wrun by OpenMarket: the full documentation bundle. Generated by scripts/build-llms-txt.ts; do not edit. The page index is https://openmarket.xyz/wrun/llms.txt. # What is wrun **wrun is OpenMarket's engine for your own market logic.** Write an indicator in TypeScript syntax, and wrun runs it on every bar of the real market, compiled to WebAssembly, right in your browser. - **Compiled, not interpreted.** A 20-bar average over 10,000 bars computes in under a millisecond. - **Live without the lag.** A live update re-runs one bar, never your whole history. - **Nothing new to learn.** TypeScript syntax your AI already writes, checked by a real compiler before anything runs. - **The whole market.** The order book, trade tape, liquidations and options chains, plus 78 built-in indicators and stats to build on. ## How it works ![How wrun works: market data the chart serves (candles, trades, the order book, options and more) goes into your indicator, one file in TypeScript syntax that runs on every bar, and your chart draws what it writes: lines, boxes, labels and alerts](/wrun/images/diagrams/intro-overview.svg) 1. **Market data.** The file reads the data the chart serves, for the market on screen or another one ([What you can read](../core-concepts/what-you-can-read.md)). 2. **Your indicator.** One file, written in the chart's editor. There is nothing to install: **Run** compiles it in your browser, and its `onBar()` runs once per bar. 3. **Your chart.** The chart draws whatever the file writes ([What the chart shows](../presentation/overview.md)). Once published, anything it draws can trigger an alert. A strategy also places simulated orders in the Strategy Tester; wrun never places real ones. ## What a file looks like ![The file beside what it makes: param adds the Period setting and output adds the sma line; a variable outside a function holds state between bars; onStart() runs once, and onBar() runs on every bar and writes the value the chart draws](/wrun/images/diagrams/intro-anatomy.svg) 1. **Declarations** come first. `param` adds a setting, here the **Period** field; `output` adds something to draw, here the `sma` line and its legend entry. 2. **State** is any variable outside a function. It keeps its value from one bar to the next. 3. **`onStart()`** runs once, before the first bar. Here it sizes the average from the Period setting. 4. **`onBar()`** runs once per bar. Here it feeds the close into the average and writes the result. The whole file, ready to paste into the editor: ```typescript param("period", 20, { min: 1, max: 200 }); output("sma", line, overlay, { color: "#2563eb", width: 2, description: "20-period simple moving average" }); let sma = new Sma(20); function onStart(): void { sma = new Sma(i32(p_period())); } function onBar(): void { out_sma(sma.update(bar.close())); } ``` - **The kit.** `Sma` comes from the kit, the library of ready-made pieces every file can use by name with nothing to import: `Rsi`, `Cvd`, levels, market structure and more ([Quick reference](../reference/quick-reference.md)). - **The language.** TypeScript syntax over typed numbers (`f64`, `i32`); the dialect is AssemblyScript, so there is no `any` and no npm. If you can follow this file, you can write one: the **Moving Average** starter is this file with a comment on every line. ## Bar by bar ![Bar by bar, on graph paper: onBar() has run once for every bar, oldest first, and each run added one point to the average under the candles; the circled forming bar at the right runs again as new data arrives](/wrun/images/diagrams/intro-bar-by-bar.svg) 1. **`onStart()` runs once**, before any bar. 2. **`onBar()` runs once per bar**, oldest first, over every bar the chart has loaded. 3. **State carries forward.** Each call sees only its own bar. To use an earlier bar, keep it in a variable. 4. **Each call writes one value**, and the chart joins the values into the line. A bar where nothing is written stays blank. 5. **The forming bar re-runs** as new data arrives. Each run starts from the state of the last closed bar, so nothing counts twice. ## Where it runs ![Where an indicator runs: Run draws a draft in your browser; published with Compiled or Open source code, it runs in each reader's browser, and a padlock marks both as sandboxed; published as Protected, it runs on OpenMarket's servers; alerts on a published indicator run in OpenMarket's cloud](/wrun/images/diagrams/intro-where-it-runs.svg) 1. **Run** draws a draft on your chart for this session. It runs in a sandbox in your browser: no internet, no files, no access to your account. 2. **Publish** with **Compiled** or **Open source** code gives the indicator a name (`@yourname/name`) and a version, and it runs in the same sandbox in each reader's browser, so adding someone else's indicator is safe. Compiled keeps the code to you: readers get a compiled module, never the source. 3. **Publish** as **Protected** runs it on OpenMarket's servers. The code never leaves them; the chart shows the result. 4. **Alerts** on a published indicator run in OpenMarket's cloud, wherever the indicator runs. A published version never changes, and you choose who can use it: Everyone, Invite only, or Just me ([Sharing](../sharing/overview.md)). ## Next - **New here?** [Your first indicator](primer-first-indicator.md): from the Moving Average starter to a published indicator with an alert. - **Coming from Pine?** [wrun vs Pine](wrun-vs-pine.md): what wrun reads and draws that Pine can't, in one table. - **Writing with an AI?** [Write it with AI](write-with-ai.md): Copy for LLM gives any assistant the rules, the kit's API and your code in one paste. - **Want the tour?** [The editor](the-editor.md) lists every starter and every control. # Your first indicator ![Two moving averages with marks on each cross](/wrun/images/first-indicator.png) **From an empty editor to a published indicator with an alert, in six steps.** Run the Moving Average starter, grow it into an EMA crossover with a mark on every cross, then publish it and let OpenMarket's cloud watch it for you. ## Before you start - **Sign in.** The editor asks a signed-out visitor to sign in, and your account needs a verified email. - **Pick a market that is not CME.** Run is paused on CME markets. ## 1. Open the editor and run the starter 1. On the chart toolbar, press **Editor** (the code icon). When it is not pinned, it is in the **Panels** menu. 2. In the editor's Explorer, press **New indicator** and pick **Moving Average** under **Starters**. 3. Press **Run**. A 20-bar average draws on the chart. The tab's first line is the language marker, `//@lang=wrun-ts`, and the starter sits under it, a comment on every statement: ```typescript // A simple moving average, declared and computed in one file. Read it top to bottom. // Nothing is imported: every word below (param, output, Sma, bar, out_sma) is already there. // A param is a setting the chart user can change, declared by its kind: a whole number here, with its name, its default, then the allowed range and the label the dialog shows. param.int("period", 20, { min: 1, max: 200, label: "Period in bars" }); // An output is a per-bar number the Indicator sends back, here drawn as a line on the price panel under the label the legend shows. // output() returns a handle; bind it with a const only when a box() or segment() needs to name it. output("sma", line, overlay, { label: "Moving average" }); let sma = new Sma(20); // a moving-average helper that keeps its own window; onStart() rebuilds it at the chosen period // onStart() runs once before the first bar: read each setting through its p_ reader (i32() = whole bars). function onStart(): void { sma = new Sma(i32(p_period())); } // onBar() runs once per bar: bar.close() is this candle's close, and out_sma() writes the output. // Until the window holds period bars the average is NaN, and a NaN is not drawn. function onBar(): void { out_sma(sma.update(bar.close())); } ``` - **The parts** are the ones from [What is wrun](introduction.md): declarations, state, `onStart()` and `onBar()`. - **The first bars are empty.** The average is `NaN` until its window fills, and `NaN` draws nothing. ## 2. Swap in an EMA Replace the file under the marker with this, and press **Run**: ```typescript param("period", 20, { min: 1, max: 200, description: "EMA length in bars" }); output("avg", line, overlay, { color: "#2563eb", width: 2, description: "EMA of the close" }); let ema = new Ema(20); function onStart(): void { ema = new Ema(i32(p_period())); } function onBar(): void { out_avg(ema.update(bar.close())); } ``` - **`Ema`** weights recent bars more than `Sma`, so it turns sooner. - **The look is declared:** `color` and `width` style the line, and each `description` labels a setting or a line. ## 3. Add a faster average ```typescript param("fast", 9, { min: 1, max: 200, description: "Fast EMA length" }); param("slow", 21, { min: 2, max: 400, description: "Slow EMA length" }); output("fast", line, overlay, { color: "#2563eb", width: 2, description: "Fast EMA of the close" }); output("slow", line, overlay, { color: "#f97316", width: 2, description: "Slow EMA of the close" }); let fast = new Ema(9); let slow = new Ema(21); function onStart(): void { fast = new Ema(i32(p_fast())); slow = new Ema(i32(p_slow())); } function onBar(): void { const close = bar.close(); const fastValue = fast.update(close); const slowValue = slow.update(close); if (isNaN(slowValue)) return; out_fast(fastValue); out_slow(slowValue); } ``` - **A new `param`** comes with a `p_()` reader, and **a new `output`** with an `out_()` writer. - **`onBar()` returns early** until the slow average has a value, so both lines start on the same bar. ## 4. Mark every cross ```typescript // 1. Two settings. param("fast", 9, { min: 1, max: 200, description: "Fast EMA length" }); param("slow", 21, { min: 2, max: 400, description: "Slow EMA length" }); // 2. Two lines on the price pane. output("fast", line, overlay, { color: "#2563eb", width: 2, description: "Fast EMA of the close" }); output("slow", line, overlay, { color: "#f97316", width: 2, description: "Slow EMA of the close" }); // 3. A mark at the bar's low, drawn only where the gate is 1. output("cross_up", shape, overlay, { color: "#16a34a", shape_where: "is_cross_up", description: "Fast EMA crossed above slow EMA" }); const isCrossUp = output("is_cross_up", none); // 4. A signal the alert dialog offers once the indicator is published. alert("crossed_up", { when: isCrossUp, message: "{{symbol}}: fast EMA crossed above the slow EMA at {{close}}", description: "Fast EMA crossed above slow EMA" }); let fast = new Ema(9); let slow = new Ema(21); let cross = new Cross(); function onStart(): void { fast = new Ema(i32(p_fast())); slow = new Ema(i32(p_slow())); cross = new Cross(); } function onBar(): void { // The chart's own close for the averages, and its low to place the mark. const close = bar.close(); const low = bar.low(); const fastValue = fast.update(close); const slowValue = slow.update(close); const crossed = cross.update(fastValue, slowValue); if (isNaN(slowValue)) return; out_fast(fastValue); out_slow(slowValue); out_cross_up(low); out_is_cross_up(crossed == 1 ? 1.0 : 0.0); } ``` - **`Cross`** answers `1` on the bar the fast average crosses above the slow one, `-1` on a cross down, `0` otherwise. - **`is_cross_up`** (plot kind `none`) holds that decision without drawing it; **`cross_up`** (plot kind `shape`) draws a mark at the bar's low only where `shape_where` says so. - **`alert(...)`** turns the decision into a signal the alert dialog offers once the indicator is published. ## 5. Change a setting Open the indicator's settings on the chart. Each `param` is a row labelled by its `description`: set **Fast EMA length** to 12 and **Slow EMA length** to 26, and the chart reruns without compiling again. ## 6. Publish it and add an alert 1. Press **Publish**, type a name after `@yourname/` (for example `ema-cross`), and publish. The first version is 0.1.0 ([Publishing](../functions/publishing.md)). 2. On the receipt, press **Open on chart**. 3. On that published indicator, open the alert dialog from its bell in the legend, pick the signal the file declared ("Fast EMA crossed above slow EMA"), and save. OpenMarket's cloud evaluates the alert and notifies you when it fires ([Alerts](../functions/alerts.md)). A draft can't carry one, so publish first. ## If something goes wrong - **Run stops.** The Console names the problem and a squiggle marks the line: fix the first one and press Run again ([Common errors](../faq/common-errors.md)). - **Nothing draws.** It is usually warm-up, and the Console says so: "Output 'sma' is NaN on all N ready bars, so nothing is drawn for it." ## Next - **Mark the crosses down too:** a second gate on `crossed == -1` and a second `shape` output at the bar's high. - **Build something real:** the [Cookbook](../cookbook/overview.md) has complete recipes to read and adapt. - **Read more than candles:** [What you can read](../core-concepts/what-you-can-read.md). - **See how it runs:** [Execution model](../core-concepts/execution-model.md). # The editor ![The editor beside the chart: the Explorer lists your scripts, each script opens in its own tab, the toolbar holds Run and Publish above the code, and the Console under it prints the run's receipt; Run draws the result on the chart at the left](/wrun/images/diagrams/editor-anatomy.svg) The chart's editor is where you write a wrun indicator: **New indicator** starts a file in TypeScript syntax, **Run** compiles it in your browser and draws it on the chart, the **Console** shows what stopped a run and what your file printed, and **Publish** puts a version on the OpenMarket registry. This page walks through every control, in the order you meet them. - The Explorer on the left: **New indicator**, the **Templates** icon beside it, and the tab's draft listed under them. - The tab strip, then the toolbar: the **Run on** chip (Browser), the save icon, **Publish**, **Docs** and **Run**. - The buffer with the Moving Average starter, the status bar under it (slots, line and column), and the **Console** with the run's receipt (Added "Moving Average" to the chart) and the prompt line. - On the chart, the drawn line on the price pane. ## Open it 1. On the chart toolbar, press **Editor** (the code icon). When it is not pinned to the toolbar, it is in the **Panels** menu. The editor needs a signed-in account with a verified email. 2. The toolbar's **Indicators** button opens the **Indicators** dialog. Its **Indicators** tab lists what you can add straight to the chart (**OpenMarket**, **Community**, and **Mine** when you are signed in). The lightning icon in the dialog, **Build**, closes it and opens the editor. ## New indicator and the template picker The editor's Explorer lists your indicators. **New indicator** at its top opens a new tab with the **New indicator** list over it ("Pick a starter, or begin with a blank tab."): pick a card and its complete file fills the tab, or pick **Blank indicator** for a tab that holds only the language marker. The **Templates** icon beside **New indicator** ("Browse starter templates") opens the same list on any tab you can edit. A pick fills an untouched new tab in place; on any other tab it opens in a new tab. The search box filters the cards by title and by the line under it, which says what the card draws. ![The New indicator list: the search box, then the Starters group with Blank indicator, Convert from Pine, Moving Average, Typed inputs tour and SMA with a box, then the On price group](/wrun/images/editor-templates.png) - The search box filters every group at once. - Starters lead: **Blank indicator** for the marker alone, **Convert from Pine** for a TradingView script, then the three starter files. - Each row names what the card draws on its second line; the groups below follow the table's order. | Group | Cards | | --- | --- | | Starters | Blank indicator, Convert from Pine, Moving Average (`sma-codefirst`), Typed inputs tour (`typed-inputs-tour`), SMA with a box (`sma-box`) | | On price | [Session map](../cookbook/session-map.md) (`session-map`), [Volume heat hours](../cookbook/volume-heat-hours.md) (`volume-heat-hours`), [Supply and demand zones](../cookbook/supply-demand-zones.md) (`supply-demand-zones`), [Trend alignment](../cookbook/trend-alignment.md) (`trend-alignment`), [Trend candles](../cookbook/trend-candles.md) (`trend-candles`), [Strike matrix](../cookbook/strike-matrix.md) (`strike-matrix`), [Session OI levels](../cookbook/session-oi-levels.md) (`session-oi-levels`) | | Order flow | [CVD divergence](../cookbook/cvd-divergence.md) (`cvd-divergence`), [Positioning regimes](../cookbook/positioning-regimes.md) (`positioning-regimes`), [Absorption](../cookbook/absorption.md) (`absorption`), [Liquidation bursts](../cookbook/liquidation-bursts.md) (`liquidation-bursts`), [Book heat](../cookbook/book-heat.md) (`book-heat`), [Liquidation heat](../cookbook/liquidation-heat.md) (`liquidation-heat`), [OI liquidation heat](../cookbook/liquidation-heat-oi.md) (`liquidation-heat-oi`), [Volume footprint](../cookbook/volume-footprint.md) (`volume-footprint`), [TPO letters](../cookbook/tpo-letters.md) (`tpo-letters`), [Session volume profile](../cookbook/session-volume-profile.md) (`session-volume-profile`), VP Buy Share (`vp-buy-share-codefirst`) | | Prediction markets | [Odds vs price](../cookbook/odds-vs-price.md) (`odds-vs-price`), Polymarket YES Odds (`polymarket-odds`), BTC Threshold Conviction (`conviction-score`), Event Asset Divergence (`event-asset-divergence`) | | HUDs | [Market HUD](../cookbook/market-hud.md) (`market-hud`), [Decision board](../cookbook/decision-board.md) (`decision-board`), [Order flow HUD](../cookbook/order-flow-hud.md) (`order-flow-hud`), [Order book HUD](../cookbook/order-book-hud.md) (`order-book-hud`), [Session HUD](../cookbook/session-hud.md) (`session-hud`), [Gamma map](../cookbook/gamma-map.md) (`gamma-map`) | | Beyond the time axis | [Volume profile and value area](../cookbook/volume-profile-value-area.md) (`volume-profile-value-area`), [Volatility term structure](../cookbook/vol-term-structure.md) (`vol-term-structure`), [Options dashboard](../cookbook/options-dashboard.md) (`options-dashboard`), [ETF flows](../cookbook/etf-flows.md) (`etf-flows`), [Liquidation map](../cookbook/liquidation-map.md) (`liquidation-map`), [Options odds cloud](../cookbook/options-odds-cloud.md) (`options-odds-cloud`), [Who is trading](../cookbook/whos-trading.md) (`whos-trading`), [Rotation map](../cookbook/rotation-map.md) (`rotation-map`), [Volatility smile](../cookbook/vol-smile.md) (`vol-smile`), [Correlation matrix](../cookbook/correlation-matrix.md) (`correlation-matrix`), [Venue share](../cookbook/venue-share.md) (`venue-share`), [Volatility surface](../cookbook/iv-surface.md) (`iv-surface`), [Seasonality grid](../cookbook/seasonality-grid.md) (`seasonality-grid`), [Depth curve](../cookbook/depth-curve.md) (`depth-curve`), [Yield curve](../cookbook/yield-curve.md) (`yield-curve`) | | Strategies | [Moving-average cross](../cookbook/strategy-ma-cross.md) (`strategy-ma-cross`), [Risk-sized reversion](../cookbook/strategy-risk-reversion.md) (`strategy-risk-reversion`) | Moving Average is a complete moving average with a comment on every statement, and **New indicator** seeds a new tab with it. The Strategies group shows where the chart offers the Strategy Tester. A linked card has a worked page in the [Cookbook](../cookbook/overview.md). The three Polymarket cards (Polymarket YES Odds, BTC Threshold Conviction, Event Asset Divergence) ship with a placeholder market: Run refuses until you paste the market's condition id (`0x` followed by 64 hex characters) into the odds input's `symbol`. ## The language marker The first non-blank line of an indicator tab is the language marker. A card or **Blank indicator** writes it, and it is how the editor knows the tab holds a wrun indicator; leave it there and write under it: ```text //@lang=wrun-ts ``` One indicator is one file: a `//@file=` line is refused ("One file per indicator for now.", with the file to move into the tab). There is nothing to import: the kit's classes and the readers and writers generated from your declarations are there by name, and an `import` line you write anyway resolves only to the kit's `./sdk/...` modules and the generated `./gen/...` accessors ([Reuse and libraries](../functions/publishing.md#reuse-and-libraries)). ## Write As you type, the editor compiles the file in the background and marks each problem with a squiggle. It completes names, shows types on hover and signature help while you fill in arguments, and **F12** jumps to a definition, into the kit's own read-only files when the name lives there. **Docs** in the toolbar opens these docs in a new tab, on the page for the word under the cursor when it has one: a TA class opens the TA library, `param` or `output` the script definition, a drawing word the drawing guide. ## Run **Run** compiles the file in a Web Worker in your browser with AssemblyScript 0.27.37, the compiler the kit pins, and reuses the compile that already ran as you typed; builds are cached in the browser and reused while the kit and the compiler stay the same. The module runs in a sandboxed worker, and the computation never leaves your browser: the only network traffic is the data your inputs declare (the candles come from what the chart already loaded) and a one-time download of the compiler. A compile gets 15 s and a run 20 s before the editor stops it. Where the draft goes: - On the price pane when any drawn output declares `overlay`; a strategy always goes there. - Otherwise in its own pane when the first output declares `lower`. - An indicator on the price pane with a drawn `lower` output also gets a pane below. Running again replaces the tab's previous overlay. A draft overlay lasts for the session and is not saved with the layout (Run again after a reload), and it takes one of the chart's indicator slots. On the live bar it refreshes at most about once a second: updates in between are folded together, and the last one always lands. The **Run on** chip in the toolbar picks **Browser** (the default) or **Cloud**. **Cloud** runs the published indicator on OpenMarket's servers, and it is offered only for an indicator published as **Never leaves OpenMarket** whose published version matches the file; otherwise it says "Publish first", "Publish the current version first", or "Runs in the browser. Only a Protected Indicator runs in the cloud." On a CME market Run is paused ("Run is paused on CME markets. Switch to a non-CME symbol to run this script."). A file that declares `strategy(...)` labels the button **Backtest**: the draft goes on the price pane, and the chart's Strategy Tester shows its trades and stats ([Strategies overview](../strategies/overview.md)). ## Settings Every setting is a row in the overlay's settings dialog, drawn as the control its kind names: a number field for `param(...)` and `param.int`, a toggle for `param.bool`, a menu for `param.choice`, a color picker for `param.color`, a market button for `param.symbol`, and so on, labelled by its `label` (else its `description`, else its name) and bounded by the `min` and `max` you declared. `page(...)` and `section(...)` lay the rows out as pages in the dialog's rail, and `presets(...)` adds a strip of named settings sets ([The settings dialog](../settings/overview.md)). Changing one reruns the compiled module over the loaded bars without compiling again; to change what a row offers, edit the declaration and Run again. An output's color, width, opacity and line style come from its declaration, and the dialog's **Style** page lets the user change them per output. The dialog also has **Plot Settings**, **Visibility** (by interval) and **Price marker** under a **Chart** caption. ## The Console When Run stops before the chart changes, the reason is in the editor's **Console**, tagged with the stage that produced it, and one problem hides the ones behind it. The status bar counts errors and warnings (click the count, **Open Problems**, to open the Console), and a blocked Run says how many errors must be fixed first. | Stage | What it checks | Typical message | | --- | --- | --- | | `declarations` | the `param` / `input` / `output` / shape statements, read from the text | `This indicator declares nothing. Add param(...), input(...), and output(...) statements (imported from ./sdk/declare) at the top level of the source; the metadata sheet is derived from them.` | | `lint` | raw positional slot literals in your source | `Raw slot literal 0 passed to getFloat(): raw positional slots rebind silently when the sheet changes` | | `metadata` | the sheet derived from your declarations, against the schema | `Output 'my value' has a name the sheet refuses: use letters, digits, dots, dashes and underscores, starting with a letter or digit (no spaces).` | | `compile` | the AssemblyScript compiler, with a hint beside its message | `Conversion from type 'f64' to 'i32' requires an explicit cast.` | | `validate` | the built module against the four-export contract and the sandbox | `console.* is not available in an Indicator (it runs in a sandbox with no console). Log through the debug output instead: ...` | | `compute` | after Run, what the run produced | `Output 'sma' is NaN on all 480 ready bars, so nothing is drawn for it.` | ![A blocked Run: the underlined line in the buffer, the error count in the status bar, and the Console with the compile message, its hint, the blocked-run line and the prompt](/wrun/images/editor-console-error.png) - The buffer underlines the expression the compiler refused; the Console prints the same line number in front of the message. - The hint after the message names the fix, and the link opens [Common errors](../faq/common-errors.md). - "Cannot run: 1 error(s) must be fixed first." is the blocked Run; the circled count at the right of the status bar is **Open Problems**, and the **current** block under the run is what it shows. - The prompt line stays under the Console. Fix the first one and Run again; [Common errors](../faq/common-errors.md) lists the messages with their fixes. A run that reaches the chart and draws nothing is usually not an error: an output left `NaN` on every bar (a warm-up that never ends), an output declared `none`, or a source the chart's market does not serve, for which the legend shows a **Could not load** chip with the reason and **Retry**, and the Console shows the same sentence ([Debugging](../faq/debugging.md)). To print from the module, declare a text slot named `debug` (`string("debug", { max_bytes: 256 })`) and write it from `onBar()` through the generated `str_debug(text)` sender: the Console prints each non-empty line after the bar's time in ISO 8601 UTC ([Debugging](../faq/debugging.md)). The prompt under the Console ("Type an expression, an output name, or / for commands") compiles one expression into your current file and runs it over the chart's candles without drawing anything. It also reads your last run: `rows`, `params`, `outputs`, `sheet`, an output by name, `[-n]` for n bars back, `last N `, and `at `. `/help` lists its commands. A strategy, or a file with an input other than candles and time, is refused at the prompt. ## Save and copy The editor saves a changed tab to your account about 1.2 s after an edit, or at once with the save icon (**Save now**). The first save creates a private draft in your account, which counts against your plan's limit of saved scripts ([Plans](../reference/limits.md#plans)). Your indicators are listed in the Explorer, where a published one carries a package icon ("Published as" and its name), and under **Mine** in the Indicators dialog, where a draft reads "Draft: publish it from the editor". - **Copy for LLM** (right-click the tab, or the command palette) copies one Markdown block: a short rules primer, the kit's API one line per class, your numbered source, every problem with its stage, the last run's status with up to 40 debug lines, and the sheet from the last good build ([Write it with AI](write-with-ai.md)). ## Publish and alerts **Publish** opens the **Publish Indicator** dialog once the current code has no errors; you need to be signed in, and the draft is saved first. You type the name after `@yourname/`, the first version is 0.1.0, and you choose **Who can use it** (Everyone, Invite only, Just me) and **Code** (Protected, Compiled, Open source), which also decides whether the indicator runs on OpenMarket's servers or in each reader's browser. On the receipt, **Open on chart** adds the published version to the chart. From then on the button reads **Publish a new version**: pick **Patch**, **Minor** or **Major** and write one line on what changed ([Publishing](../functions/publishing.md)). On an Invite only indicator, **People** beside **Publish** opens its People panel, where you invite people and see who has access ([Sharing](../sharing/overview.md)). A published indicator on the chart can carry alerts: open the alert dialog from its bell in the legend, the right-click menu, or **Alert on** > **Indicators** in the sidebar, pick an output or a signal the file declares with `alert(...)`, and a condition. OpenMarket's alerts engine evaluates it in the cloud with the overlay's settings; a draft is refused with "Publish the Indicator before adding an alert." ([Alerts](../functions/alerts.md)). # Write it with AI **Any AI assistant can write wrun.** It is TypeScript syntax, which assistants have read a great deal of, and the editor's compiler checks every draft before anything runs. ## The loop ![The AI loop: you describe your idea to any AI assistant together with Copy for LLM from the editor; you paste the file it writes into the editor and press Run; if the compiler names errors, Copy for LLM sends the code and every error back; when it compiles, it draws on your chart](/wrun/images/diagrams/ai-loop.svg) 1. **Open a tab.** In the editor, press **New indicator**, or open the indicator you want to change. 2. **Copy for LLM.** Right-click the editor tab and pick **Copy for LLM**. One paste carries the language rules, the kit's API, your numbered code, every error and your last run. 3. **Ask.** Paste it into any assistant and say what you want, naming everything it should draw (an example is below). 4. **Run.** Put the complete file it returns into the tab and press **Run**. The compiler names every mistake with a line and column. 5. **Repeat until it compiles.** Copy for LLM again and paste it back. When it compiles, it draws on your chart. ## Better results A request that names everything it wants drawn: ```text An RSI(14) in its own pane with bands at 30 and 70, a dot on every cross back inside the bands, and an alert on each cross. ``` - **Name everything you want drawn.** Assistants keep the lines and tend to drop labels, per-bar colors, fills, boxes, tables and alerts. List them in your request, then check the legend and the chart against it. - **Build long scripts in parts.** A few hundred lines can outgrow one answer. Ask for the settings and the plotted lines first and press **Run**; then ask for the drawings, then the alerts, each time with a fresh Copy for LLM. - **Check the numbers before you publish.** Compare the result with a version you trust, on the same market and timeframe. ## Porting from Pine Paste your Pine script with a **Copy for LLM** and ask for a port; the loop above takes it from there. [wrun vs Pine](wrun-vs-pine.md#bring-your-pine-scripts) shows one indicator in both languages, line for line. # wrun vs Pine Pine works from candles: it runs on TradingView's servers and draws lines, labels and boxes on the chart. A wrun indicator gets the market under the candles. Every level of the order book, buy and sell volume as the exchange tagged each trade, and the live options chain with its greeks arrive as numbers your code can do arithmetic on. It compiles to WebAssembly, runs in your browser, and draws its answer in whatever form fits: a line, a profile on the price axis, a heatmap below the chart, a card in the corner. ![How deep each one reads, one row per kind of market data: Pine reads candles, other markets and liquidations, funding and open interest in full, buy and sell volume by price and the order book only with limits, and stops before the options chain; wrun reads every row, and the options chain, implied volatility and skew, prediction odds and ETF flows only wrun reads](/wrun/images/diagrams/sees-depth.svg) **Indicators you can build in wrun and not in Pine:** - **Real delta.** Buy minus sell volume from the exchange's own trade tags ([Order flow HUD](../cookbook/order-flow-hud.md)). Pine's footprint infers the side from price moves, on Premium plans and up, one per script. - **Book walls.** The biggest resting bid and ask near price on every bar, and how the pressure between the two sides shifts ([Order book HUD](../cookbook/order-book-hud.md)). - **A live gamma map.** Gamma exposure at every strike, the zero-gamma flip and the call and put walls, drawn against price from the options chain ([Gamma map](../cookbook/gamma-map.md)). - **Who is trading.** Each bar's volume split by trade size, from trades under $1K to trades of $10M and up, buyers and sellers apart. - **Pictures, not just lines.** A volume profile docked on the price axis, an hour-by-weekday heatmap, a HUD of sparklines and gauges ([Volume heat hours](../cookbook/volume-heat-hours.md), [Market HUD](../cookbook/market-hud.md)). | | Pine | wrun | | --- | --- | --- | | Language | Pine Script, TradingView's own | TypeScript syntax, compiled to WebAssembly | | Where it runs | TradingView's servers only | Your browser, sandboxed; OpenMarket's servers when the code is protected | | Order book | Best bid and ask, on 1-tick charts only | Every level, up to 500 a side, on every bar | | Trades | Sides estimated from price moves | The exchange's own buy and sell tags, by price and by trade size | | Options | No chain, greeks or implied volatility | Every listed contract with greeks; implied volatility and skew by tenor | | Drawing | Lines, labels and boxes (500 each), polylines (100), tables | All of that, plus pies, scatters, curves, heatmaps and tiles below the chart, profiles on the price axis, HUD and hover cards | | History | Lookahead is a switch; TradingView estimates over 95% of indicators repaint | No lookahead to switch on: history shows what you would have seen live | | Limits | Memory is limited; the number is not published | Every limit is published, and a run that hits one stops and says which | **Already have Pine scripts?** Any AI [ports them](#bring-your-pine-scripts), and the compiler checks every line before anything runs. ## The whole order book **Walls, gaps and the pressure between bids and asks, on every bar.** ![The order book at one moment: Pine reads one bid and one ask, the best of each, on 1-tick charts only, while the rest of the book stays faint; wrun reads every level on both sides, up to 500 a side on every bar, so the wall of bids six levels down is in view](/wrun/images/diagrams/beyond-book.svg) 1. **Pine**: two rows, the touch. The faint rest is the book it never reads. 2. **wrun**: every row, bids and asks. 3. **The wall**: six levels down, a size your code can find and draw. Crypto charts, spot and perpetual. Imbalance over the nearest levels, plus the biggest bid and ask within 1% of price: ```typescript param.int("levels", 20, { min: 1, max: 500, label: "Levels per side" }); input("close", ohlcv.close); // the chart's candles input("book", book.cells, { max_cells: 1000, block_size: 10 }); // the whole book output("imbalance", line, lower, { color: "#64748b", label: "Imbalance", format: "0.00" }); output("bid_wall", line, overlay, { color: "#16a34a", label: "Bid wall", format: "price" }); output("ask_wall", line, overlay, { color: "#dc2626", label: "Ask wall", format: "price" }); let depth = new BookImbalance(20); function onStart(): void { depth = new BookImbalance(i32(p_levels())); } function onBar(): void { const n = in_book_cells(); if (n <= 0) return; // no book on this bar const cells = in_book_view(); // [price, size, side] per level const close = bar.close(); let bidWall = NaN; let bidMax = 0.0; let askWall = NaN; let askMax = 0.0; depth.begin(); for (let i = 0; i + 2 < n; i += 3) { const price = cells[i]; const qty = cells[i + 1]; const side = i32(cells[i + 2]); // +1 a bid, -1 an ask depth.add(price, qty, side); if (Math.abs(price - close) > close * 0.01) continue; if (side > 0 && qty > bidMax) { bidMax = qty; bidWall = price; } if (side < 0 && qty > askMax) { askMax = qty; askWall = price; } } depth.end(); out_imbalance(depth.imbalance()); // (bids - asks) / (bids + asks) out_bid_wall(bidWall); out_ask_wall(askWall); } ``` More: [Order flow](../functions/order-flow-kit.md#book-imbalance). ## Who actually traded **Delta from the exchange's own tags, split by price and by trade size.** ![A footprint, three bars wide: each bar is sliced by price, sell volume drawn to the left of the bar's line and buy volume to the right, each side as the exchange recorded it, with each bar's delta, buy minus sell, written underneath](/wrun/images/diagrams/beyond-footprint.svg) 1. **Each bar**, sliced by price into buckets with real bounds. 2. **Sellers**, left of the bar's line. 3. **Buyers**, right of it. 4. **Delta**, under each bar: buy minus sell. Summed bar after bar, it is CVD. `trades.volume`, `volume_profile` and `trade_volume_by_size`; sizes run in seven buckets, from under 1K to 10M and up, in USD. Big trades against small ones, as two running sums of buy minus sell split at 100K: ```typescript input("close", ohlcv.close); // the chart's candles input("sizes", trade_volume_by_size.cells, { max_cells: 7 }); output("large", line, lower, { color: "#c2410c", width: 2, label: "100K and up", format: "si" }); output("small", line, lower, { color: "#94a3b8", label: "Under 100K", format: "si" }); let large = 0.0; // buy minus sell in USD, trades of 100K and up let small = 0.0; // the same for smaller trades function onBar(): void { const n = in_sizes_cells(); const cells = in_sizes_view(); // [bucket, buy_usd, sell_usd, buy_count, sell_count] per bucket for (let i = 0; i + 4 < n; i += 5) { const net = cells[i + 1] - cells[i + 2]; // buy minus sell if (cells[i] >= 4.0) large += net; // bucket 4 starts at 100K else small += net; } out_large(large); out_small(small); } ``` When the lines part, the big trades and the small ones are on opposite sides. More: [Order flow](../functions/order-flow-kit.md#volume-profile). ## The options chain **Gamma by strike, the flip and the walls, from the live chain, in one call.** ![The options chain beside price: gamma by strike docked on the price axis, shaded where net gamma is positive and open where it is negative; the call wall above price, the put wall below it, and the gamma flip, dashed, where net gamma crosses zero](/wrun/images/diagrams/beyond-gamma.svg) 1. **Price**: the chart's candles, last close marked on the axis. 2. **Gamma by strike**, docked on the price axis: shaded where net gamma is positive, open where it is negative. 3. **Call wall**: the strike above price with the most call open interest. 4. **Gamma flip**: where net gamma, summed up from the lowest strike, crosses zero. 5. **Put wall**: the strike below price with the most put open interest. Any coin an options venue lists. Implied volatility and skew by tenor, one week to six months, are inputs on every bar of BTC and ETH charts. The levels and max pain, on a card: ```typescript input("close", ohlcv.close); // spot is the chart's close input("chain", options_chain.cells, { max_cells: 4000 }); output("flip", none, overlay, { label: "Gamma flip" }); output("call_wall", none, overlay, { label: "Call wall" }); output("put_wall", none, overlay, { label: "Put wall" }); output("max_pain", none, overlay, { label: "Max pain" }); render.hud("chain_card", { position: "top_right", title: "Options chain", columns: 2, tiles: [ tile.value("Call wall", "call_wall", { format: "price" }), tile.value("Gamma flip", "flip", { format: "price" }), tile.value("Put wall", "put_wall", { format: "price" }), tile.value("Max pain", "max_pain", { format: "price" }), ], }); let chain = new OptionsChain(1); function onStart(): void { chain = new OptionsChain(512); } function onBar(): void { if (bar.isLast()) { // the chain arrives on the live bar const n = in_chain_cells(); const now = bar.time() * 1000.0; // the clock in milliseconds if (n > 0) chain.load(in_chain_view(), n, now, bar.close()); } out_flip(chain.zeroGamma()); // net gamma crosses zero here out_call_wall(chain.callWall()); // most call open interest above out_put_wall(chain.putWall()); // most put open interest below out_max_pain(chain.maxPain()); // pays option holders the least } ``` The [Gamma map](../cookbook/gamma-map.md) recipe docks the profile as drawn. More: [Options kit](../functions/options-kit.md). ## Off the time axis **Some answers are not lines. A pie, a curve over strikes or a heatmap gets its own pane.** ![Five panel kinds, each in a pane of its own below the chart: a pie of the bar's volume by trade size, a scatter of one value against another, a curve such as implied volatility by strike, a heatmap such as volume by hour and weekday, and tiles of big numbers with sparklines](/wrun/images/diagrams/beyond-panels.svg) 1. **Pie**: shares of one whole, such as this bar's volume by trade size. 2. **Scatter**: one dot per pair of values, x against y. 3. **Curve**: a line over any axis, such as implied volatility by strike. 4. **Heatmap**: a grid of cells, such as volume by hour and weekday. 5. **Tiles**: big numbers, each with a caption and a sparkline. This bar's volume by trade size as a pie, rebuilt on the live bar. The share of trades of 100K and up runs as a line through history: ```typescript input("close", ohlcv.close); input("sizes", trade_volume_by_size.cells, { max_cells: 7 }); output("share", line, lower, { color: "#c2410c", label: "100K and up", format: "%" }); const mix = frame("mix", { max_bytes: 1024 }); // the pie's slices panel.pie({ name: "size_mix", title: "This bar by trade size", x: "category", place: "below", frame: mix, hole: 0.5 }); const NAMES: StaticArray = ["Under 1K", "1K to 10K", "10K to 100K", "100K to 500K", "500K to 1M", "1M to 10M", "10M and up"]; const COLORS: StaticArray = ["#e2e8f0", "#cbd5e1", "#94a3b8", "#fdba74", "#fb923c", "#ea580c", "#9a3412"]; function onBar(): void { const n = in_sizes_cells(); const cells = in_sizes_view(); const live = bar.isLast(); let total = 0.0; let large = 0.0; if (live) { fb_clear(); fb_text('{"rows":['); } for (let i = 0; i + 4 < n; i += 5) { const usd = cells[i + 1] + cells[i + 2]; const k = i32(cells[i]) - 1; // buckets run 1 to 7 total += usd; if (k >= 3) large += usd; if (live) { // one slice per bucket: [name, value, color] if (i > 0) fb_text(","); fb_text("["); fb_str(NAMES[k]); fb_text(","); fb_f64(usd, 0); fb_text(","); fb_str(COLORS[k]); fb_text("]"); } } if (live) { fb_text("]}"); writeFrameBuffer(FRAME_MIX); } out_share(total > 0.0 ? (100.0 * large) / total : NaN); } ``` `fb_*` builds the frame without allocating: the sandbox refuses memory growth once the bars start. More: [Cards, frames and panels](../presentation/cards-frames-panels.md), [Volume profile and value area](../cookbook/volume-profile-value-area.md), [Volatility term structure](../cookbook/vol-term-structure.md). ## Cards, not tables **Your code writes numbers and words. The chart draws the card.** ![Three answers on one chart: the legend names the basis line, its value and a word colored for this bar; a HUD card in the corner holds four typed tiles, a value with its change, a sparkline, a gauge and a meter; and a hover card opens where the cursor rests on the line](/wrun/images/diagrams/beyond-cards.svg) 1. **The legend** carries words as well as values, each word colored for its bar. 2. **A HUD card** sits at one of nine anchors and holds typed tiles: a value with its change, a sparkline, a gauge, a meter. 3. **A hover card** opens where the cursor rests on a line, a legend entry or a tile. The options card above is one; [Market HUD](../cookbook/market-hud.md) builds a full board. Every block kind: [What the chart shows](../presentation/overview.md). ## Settings **Declare the kind. The dialog draws the control.** - **Presets**: named sets of values; one click fills every setting they name ([Presets](../settings/presets.md)). - **Ranges, multi-selects, lists**: a slider with two handles, a pick of weekdays, an editable list of lookbacks ([Setting kinds](../settings/kinds.md)). - **Units**: an offset in percent of price or in ATRs, picked beside the number ([Sessions and units](../settings/sessions-and-units.md)). - **Pages**: settings on pages and sections, with a filter box past twelve ([Pages, sections, dividers, notes](../settings/layout.md)). ## No lookahead **`onBar()` gets one bar. There is no next bar to peek at.** ![Higher timeframes, bar by bar: a 4h candle spans four 1h bars; with lookahead on, a Pine script on history sees the new 4h close from the first of those bars, while in wrun every 1h bar sees the previous close and the new one arrives on the bar after the 4h candle closes](/wrun/images/diagrams/repainting.svg) 1. **One 4h candle** spans four 1h bars. 2. **Pine, lookahead on**: history sees the new 4h close from the first of those bars, before it happened. It is off by default; an author turns it on. 3. **wrun**: no switch. Every 1h bar sees the last closed 4h candle; the new close arrives on the bar after. The forming bar replays from the last closed bar on every update. No `varip`, no `barstate.isrealtime` ([Repainting](../core-concepts/repainting.md)). ## Bring your Pine scripts **Hover a Pine line. See the wrun line that replaces it.** ```pine pair title="Pine" map="1 2 3 4 5 6 7 8 9 10 10 11" //@version=6 indicator("MA Cross", overlay=true) fastLen = input.int(9, "Fast") slowLen = input.int(21, "Slow") fast = ta.ema(close, fastLen) slow = ta.sma(close, slowLen) plot(fast, color=color.orange) plot(slow, color=color.blue) up = ta.crossover(fast, slow) plotshape(up, shape.triangleup, location.belowbar) alertcondition(up, "Golden cross") ``` ```typescript pair title="wrun" map="1 3 4 7,2 8,2 10 10 10 11 11 - 5 6 9 9" //@lang=wrun-ts param.int("fast_len", 9, { min: 1 }); param.int("slow_len", 21, { min: 1 }); output("fast", line, overlay); output("slow", line, overlay); render.shape("cross_up_mark", { output: "low", shape: "triangle_up", where: "crossed_up" }); alert("golden_cross", { when: crossedUp }); … fast = new Ema(i32(p_fast_len())); slow = new Sma(i32(p_slow_len())); const crossed = cross.update( fastValue, slowValue); ``` In the editor, **Copy for LLM** hands any AI your code, the build's rules, the kit's API and every compiler error ([Write it with AI](write-with-ai.md)). # Execution model A wrun indicator is a function the host calls once per bar, oldest bar first, with state that lives between calls in module-level variables. This page is the mental model behind every recipe: what runs when, why the first bars of a line are empty, what "no lookahead" means when you write the code, how to know the bar's index and whether it is the newest, and where an indicator runs: in your browser, on OpenMarket's servers, on OpenMarket's alerts engine, and in the Strategy Tester. ## The bar loop ![Bar by bar: onStart() runs once; then onBar() runs once for each loaded bar, oldest first, hands its state to the next call and writes one value per bar, which the chart joins into the line; the forming bar at the right runs again as new data arrives](/wrun/images/diagrams/intro-bar-by-bar.svg) An indicator runs `onBar()` once per bar, oldest first, walking forward to the newest. Module-level variables stay alive across the whole walk, and the file defines two hooks with fixed roles (`onStart()` is optional): | Function | Called | Reads | Writes | | --- | --- | --- | --- | | `onStart()` | once, before the first bar | params via `p_()` | nothing; size your averages and buffers here | | `onBar()` | once per bar, oldest first | this bar's candle via `bar.()`, every declared input via `in_()`, params | your module-level state; every output via `out_(value)`, string slots, drawing handles, strategy orders | The hooks are plain top-level functions with no arguments that return nothing: `function onStart(): void { ... }` and `function onBar(): void { ... }`. Anything else (an argument, a missing or different return type, an arrow) is refused on the hook's own line: `onBar must be a function with no arguments that returns nothing: write function onBar(): void { ... } (the build calls it once per bar)`. Params are readable from `onStart()` on, anywhere in the file (at module scope, before it, a `p_()` reads `NaN`). Inputs and the bar's fields are readable in `onBar()` and the helpers it calls (in `onStart()` they read `NaN`). The readers return values cached for the bar, so reading one twice costs nothing. The host walks the loaded history in order. On the chart that history is exactly the candles the chart has loaded, every source is fetched over the same window, and panning back runs the indicator again over the longer window. For each bar the host calls `onBar()`, then emits the bar's row with whatever `onBar()` wrote. Every bar has a row: an output `onBar()` did not write is `NaN` on that bar, and nothing is drawn there. Nothing in the file has to send the row: each `out_(value)` keeps its value for the bar (the last write wins), and the row reaches the host in one step after `onBar()` returns, so an `emitRow()` of your own does nothing. ## The bar's index, the first bar, the newest bar There are no bar-state globals. The bar's index is a module-level counter you increment in `onBar()`, and the first bar is that counter at `0` (one-time setup that needs no bar belongs in `onStart()`). The newest bar is `bar.isLast()`: true exactly when the bar being evaluated is the newest bar the host holds for this run, false on every earlier bar. Run-level renderers and declared drawings still evaluate the newest ready row on their own without it; the signal is for a handle drawing that should exist on the newest bar only, or reach past it. [Variables](core-variables.md) has the counter idioms in a compiled example, and the bar's own fields beside them. The signal is a function of the run's window, so a replay from bar 0 over the same window reproduces it: - A full run answers true on its last row only. - On the live chart it answers true on the forming bar, each time the forming bar is evaluated again. - When that bar closes, the chart evaluates it ONCE more as a closed bar, with the signal false, before the new bar is evaluated with it true. The re-run's outputs, strings, and handle drawings replace the row's. This is what keeps a chart that has been open all day identical to a fresh load: nothing the forming bar drew under `bar.isLast()` survives its close unless the closed bar draws it too. An average with a dotted projection five bars ahead, drawn on the newest bar only. The projection's far end lies past the loaded range, which a handle's absolute time coordinates allow ([Drawing objects](../presentation/drawing-objects.md)): ```typescript param("period", 20, { min: 1, max: 200 }); output("sma", line, overlay, { color: "#38bdf8", width: 2 }); handles.line({ color: "#38bdf8", width: 1, lineStyle: "dotted" }); const projection = draw.line(0); let sma = new Sma(20); let t: f64 = NaN; let prevT: f64 = NaN; function onStart(): void { sma = new Sma(i32(p_period())); } function onBar(): void { prevT = t; t = bar.time(); const value = sma.update(bar.close()); if (isNaN(value) || isNaN(prevT)) return; out_sma(value); // Only the newest bar carries the projection: five bars ahead is past the loaded // range, and an absolute x makes that legal. if (bar.isLast()) projection.set(t, value, t + 5.0 * (t - prevT), value); } ``` Over a full run the line is created on the last row and nowhere else; when that bar closes on the live chart, the closed-bar re-run draws no line, and the new forming bar creates it again one bar to the right. ## How many bars the run holds `bar.count()` is the number of bars the run holds while the bar is evaluated, the forming bar included. With your own bar counter it tells a bar whether a later bar exists, which a lagging line needs: a line drawn `shift` bars back can only show a bar's value once the bar `shift` bars later exists. ```typescript let index: i32 = 0; function onBar(): void { const later = index + 1 < bar.count(); // a bar after this one exists in the run out_settled(index + shift < bar.count() ? 1.0 : 0.0); // the bar shift bars later exists index += 1; } ``` - A full run (a fresh load) answers the run's bar count on every bar, so every bar sees the whole window, including bars after it. - On the live chart the forming bar answers the bars held so far, and a new revision of the forming bar evaluates only that bar again. - When a new bar opens, the chart runs every bar again from the first one with the new count, so each bar's reading stays current and a chart open all day matches a fresh load over the same bars. Reading `bar.count()` makes the sheet `wrun-6` ([Versions and contracts](../reference/versions.md)). It answers inside `onBar()` only; read in `onStart()` it stops the run by name (`wrun_bar_count_phase`). ## Everything travels by name Params, inputs, and outputs reach the code through generated readers and writers (`p_()`, `in_()`, `out_()`), one function per declared name, and the chart's own candle through `bar.()`. Underneath, the host passes values positionally, in declaration order. The editor regenerates the readers from the declarations on every compile, so reordering or renaming a declaration never rebinds a value silently: the compiler names the reader or writer that no longer exists. Raw positional reads (`getFloat(0)`) are refused by the editor's `lint` stage for the same reason ([Common errors](../faq/common-errors.md)). ## Warm-up: NaN until the window fills ![Warm-up: an average of 8 bars is NaN on the first 7 bars, so nothing is drawn there; from the bar where the window is full, the line draws](/wrun/images/diagrams/warm-up.svg) Every TA class returns `NaN` until it has seen enough bars: `Sma`, `Stdev`, and `Zscore` need `period` values, `Ema` seeds itself with a simple average of the first `period` values, `Rsi` is warm after `period + 1` samples, `Roc` after `period + 1`. `Cross.update()` returns `0` on any bar where either side is `NaN`. You have two ways to handle a value that is not ready yet, and both are correct: - Write nothing on the bar: `return` from `onBar()` before any write, after the state updates the bar still owes (the `update()` that folds the bar in, the close you remember). The bar's row then carries `NaN` in every output, and the chart draws nothing there. Use it when nothing on the bar is meaningful yet. - Write `NaN` to one output: the chart draws nothing for that output on that bar while the other outputs still draw. Use it when one line warms slower than another, or when a value legitimately has no answer (a session that has not completed yet, a ratio with a zero denominator). A warming TA value is already `NaN`, so writing it as it comes needs no test at all: the Moving Average starter does this (`out_sma(sma.update(bar.close()))`). Anything else written on a warming bar is drawn as if it were true. The chart does not skip a warm-up count for you: it hands the module every loaded bar and `onBar()` decides what to write. A bar-to-bar change with a smoothed line returns early twice over: on the first bar there is no previous close, then the change exists while its average does not, and only once both are real does it write them: ```typescript param("period", 10, { min: 2, max: 200, description: "EMA length over the bar-to-bar change" }); output("change_pct", line, lower, { unit: "%", color: "#94a3b8" }); output("smoothed", line, lower, { unit: "%", color: "#38bdf8", width: 2 }); let ema = new Ema(10); let prevClose: f64 = NaN; function onStart(): void { ema = new Ema(i32(p_period())); } function onBar(): void { const close = bar.close(); // Yesterday's close is whatever we kept from the previous call: there is no close[1] to read. const change = isNaN(prevClose) || prevClose == 0.0 ? NaN : ((close - prevClose) / prevClose) * 100.0; prevClose = close; if (isNaN(change)) return; const smoothed = ema.update(change); // The EMA is NaN until `period` changes have been folded in; those bars write nothing. if (isNaN(smoothed)) return; out_change_pct(change); out_smoothed(smoothed); } ``` ## History indexing An indicator has no history operator: `onBar()` sees exactly one bar, and indexing a number (`close[1]`) is a compile error (`Index signature is missing in type 'f64'`). Keep yesterday's value in a module-level variable when you see it (`prevClose` above), keep a window in a `StaticArray` ring buffer, and let a TA class keep the window a statistic needs. ## No lookahead A value can therefore only depend on bars at or before its own, and a mark that appears in history would have appeared live on the same bar. This is what no repaint means, and an indicator cannot break it by accident: there is nothing to peek at. The one deliberate exception is `bar.count()`: a value that reads it can change when a later bar arrives, which is why the chart runs every bar again when a bar opens ([How many bars the run holds](#how-many-bars-the-run-holds)). Higher timeframes follow from the same rule, in either of the chart's two forms ([Multi-timeframe](multi-timeframe.md)). Build the 4h view inside the module: bucket bars by `bar.time()`, and fold a bucket into its average only once the next bucket has started (the cookbook's regime filter does this). Or pin a secondary input to `interval: "4h"`: the chart fetches the 4h candles as their own series and hands each one to a chart bar only as of that bar's close, so a forming 4h candle never leaks into the 1h rows under it. The forming bar is the one exception to "one call per bar": the chart evaluates it again as new data arrives, at most about once a second per indicator, coalescing the updates in between and never dropping the latest. The chart keeps the compiled module alive, snapshots its state after the last closed bar (its memory, every module-level variable, and its drawing handles), and restores that snapshot before each new evaluation of the forming bar, so an update never counts the bar twice or stacks a second copy of what the forming bar drew. What can still change a bar you have already seen is on [Repainting](repainting.md). ## Declare the chart's candles first On the chart an indicator's rows are always the chart's own candles, and `onBar()` runs once per row. A file with no `input` line runs on them with no declaration at all: `bar.close()` and the other fields are its inputs, and the chart's close is its grid. A file whose first `input` line names another candle field (a lone `input("high", ohlcv.high);`) keeps that line, so the grid stays on that field instead of moving to the close; `bar.high()` and the other `bar` reads work either way, through the line or without one. Once a file declares an input, its first `input` line is the grid: it follows the chart's market and interval, so a market or interval pin on it is refused by name (a Polymarket `odds` input is the one source that may come first with a market of its own), and a `time` or celled source cannot be the first input (there is no feed behind the clock; a block needs a grid to be sliced by). So when the file declares anything else, declare the chart's own candles first even when the computation does not obviously use price: a file with a celled `volume_profile` input still declares `input("close", ohlcv.close)` before it, a `forming` view on a pinned input needs it, and a strategy's fills run against the chart's candles (with no input line a strategy gets `close` as its grid). The full rules, the reserved names and the order of the sheet are on [Data sources](data-sources.md). ## Where an indicator runs ![Where an indicator runs: Run draws a draft in your browser; published with Compiled or Open source code, it runs in each reader's browser, and a padlock marks both as sandboxed; published as Protected, it runs on OpenMarket's servers; alerts on a published indicator run in OpenMarket's cloud](/wrun/images/diagrams/intro-where-it-runs.svg) | Where | How it gets there | What it computes over | | --- | --- | --- | | Your browser | **Run** in the editor, or adding an indicator published with **Compiled** or **Open source** code | the chart's loaded bars and every source on [Data sources](data-sources.md), with pins served as [Multi-timeframe](multi-timeframe.md) and [Multi-source](multi-source.md) describe | | OpenMarket's servers | adding an indicator published as **Protected**, or **Run on** > **Cloud** in the editor for your own | the chart's candles; the module never downloads to the chart, which shows the result the servers stream | | OpenMarket's alerts engine | an alert set from the chart on a published indicator | the chart's own market, with the settings the overlay carries | | The Strategy Tester | an indicator that declares `strategy(...)` | the chart's own candles, in your browser | **Your browser.** **Run** compiles the file in your browser and runs the module in a sandboxed worker beside the chart. The computation never leaves the browser: the only network traffic is the data the declared sources need and a one-time download of the compiler. A draft from **Run** lasts for the session and is not saved with the layout; running again replaces it. A published indicator with **Compiled** or **Open source** code runs the same way, in each reader's browser: Compiled readers get a compiled module, never the source. **OpenMarket's servers.** An indicator published with the Publish dialog's **Code** choice **Protected** runs on OpenMarket's servers only: they compute it over the chart's candles and stream the result, and the code never leaves them; its settings dialog is the file's own, and a source, timeframe or symbol setting keeps its declared default there for now. In the editor, **Run on** offers **Cloud** only for a published Protected indicator whose published version matches the code in the tab; otherwise it says "Publish first", "Publish the current version first", or "Runs in the browser. Only a Protected Indicator runs in the cloud." Where an indicator runs is fixed by its first published version ([Publishing](../functions/publishing.md)). **OpenMarket's alerts engine.** An alert on an indicator runs in OpenMarket's cloud wherever the overlay itself computes: the alerts engine loads the published version the overlay carries and evaluates it on the chart's market and interval with the overlay's settings, over at most 600 bars of that interval. A draft cannot carry an alert ("Publish the Indicator before adding an alert."). The engine reads another market's candles and a coarser pinned timeframe, and refuses, by name, a pin it cannot serve ("This Indicator is pinned to a different interval than the chart.") or a data source alerts cannot evaluate yet ([Alerts](../functions/alerts.md)). **The Strategy Tester.** An indicator that declares `strategy(...)` places its orders in `onBar()`, and the Strategy Tester runs it in your browser with its own simulated broker, filling orders against the chart's own candles ([Strategies overview](../strategies/overview.md)). ## Numbers only Every output is a 64-bit float, and `NaN` means "nothing here". A decision is an output too: write `1` or `0` and let the declaration turn it into a look (`color_by` picks a palette entry per bar, `shape_where` gates a mark, `when` gates a box or a segment). A file declares at least one output; without one the build stops with `a file with onBar() needs at least one output(...) statement: declare what the Indicator draws, e.g. output("value", line, overlay), and write it in onBar() with out_value(...)`. Text reaches the chart only through string slots and renderers ([Plotting](../presentation/plotting.md)). Params reach the module as numbers too: every setting is a row in the overlay's settings dialog, drawn as the control its kind names (`param.int`, `param.bool`, `param.choice`, `param.color`, ...; plain `param(...)` is a number field labelled by its `description`, else its name, and bounded by its `min` and `max`), read once before the first bar, and changing one reruns the compiled module over the loaded bars without compiling again ([Setting kinds](../settings/kinds.md)). There is no `print()`: declare a string slot named `debug`, write it with `str_debug(...)` in `onBar()`, and each non-empty line prints in the editor's Console with its bar's time ([Debugging](../faq/debugging.md)). ## Memory and speed The module runs once per loaded bar, then again each time the forming bar updates. Allocate in `onStart()` or at module scope (a `StaticArray` sized from a param's `max`), never per bar: the module has no garbage collector, and the chart's sandbox caps its memory at 4 MiB and refuses a run whose memory grows once the bars start ("The Indicator allocated memory after init() ...: the sandbox forbids growth once the bars start."). Loops bounded by a param with a declared `max` stay cheap; the compiled module itself is a few kilobytes. A run that does not finish within 20 seconds is stopped and refused by name ("The run did not finish within 20 s.") rather than hanging the chart, and the chart keeps the last result that drew. ## The four-function form Underneath, the chart has always called four exported functions, `init()`, `state()`, `finalize()` and `reset()`. A file with `onBar()` is built into them: the build adds the four around your hooks, with import lines for every kit name, reader and writer the file uses. For the Moving Average example (an `onStart()` that sizes the average, an `onBar()` that writes it) the added functions do this: ```typescript // What the build adds around the Moving Average's onStart() and onBar(). export function init(): void { /* reads period once */ onStart(); } export function state(): i32 { /* reads this bar's close */ return 1; } export function finalize(): void { /* sma starts the bar as NaN */ onBar(); /* then the row goes out */ } export function reset(): void { /* nothing: the file has no onReset() */ } ``` A file that writes the four functions itself keeps building unchanged, with the same bytes as before; every file written before the hooks existed is one of these. The Moving Average written that way: ```typescript import { input, line, ohlcv, output, overlay, param } from "./sdk/declare"; import { in_close } from "./gen/inputs"; import { emitRow, out_sma } from "./gen/outputs"; import { p_period } from "./gen/params"; import { Sma } from "./sdk/ta"; param("period", 20, { min: 1, max: 200 }); input("close", ohlcv.close); output("sma", line, overlay); let sma = new Sma(20); let value: f64 = NaN; export function init(): void { sma = new Sma(i32(p_period())); } export function state(): i32 { value = sma.update(in_close()); return isNaN(value) ? 0 : 1; } export function finalize(): void { out_sma(value); emitRow(); } export function reset(): void { sma.reset(); value = NaN; } ``` What differs from a file with `onBar()`: - **Rows.** `state()` may return `0` to emit no row at all for that bar: nothing is drawn, no output has a value there, and `finalize()` is not called. A file with `onBar()` emits a row on every bar and leaves what it did not write `NaN`. A file that needs a bar with no row at all is written with the four functions. - **Phases.** It reads in `state()` and writes in `finalize()`: params are readable in `init()` only, inputs in `state()` only, outputs, strings, frames, handles and orders writable in `finalize()` only, so a value computed in `state()` travels to `finalize()` in a module variable, and `finalize()` calls `emitRow()` last. - **Imports.** It needs its import lines: the declaring words from `./sdk/declare`, its readers and writers from `./gen/params`, `./gen/inputs`, `./gen/outputs` (and `./gen/strings`, `./gen/draw`, `./gen/strategy` when declared), the classes from the kit modules. A file with `onBar()` has nothing to import. - **Reset.** `reset()` is its required fourth export, putting every module-level variable back to its starting value. A file with `onBar()` may define `function onReset(): void { ... }` for the same job, and the build's `reset()` calls it when the host resets the run; without one, nothing is reset. The chart in your browser never resets a run this way (it restores the snapshot of the closed bars instead), so a file without `onReset()` loses nothing there. A file is one form or the other: exporting any of the four, even one, makes it the four-function form, which then declares everything itself, imports included. The reference rows for both are on [Declarations and the sheet](../reference/declarations.md). # Named streams Multi-output indicators like Bollinger bands, MACD, and the stochastic produce several lines at once. In a wrun indicator each stream is an `output` of its own, and the computation behind them is a class whose fields you read by name after one `update()`: `bands.upper`, `macd.signal`, `stoch.k`. No positional guessing: every stream is read by name. ## Introduction Some indicators produce more than one line. Bollinger bands give you an upper band, a basis, and a lower band. MACD gives a MACD line, a signal line, and a histogram. A wrun indicator declares one output per line, and a class computes all of them from one input per bar: ```text output("bb_upper", line, overlay, { color: "#2563eb" }); output("bb_basis", line, overlay, { color: "#64748b" }); output("bb_lower", line, overlay, { color: "#2563eb" }); const bands = new Bb(20, 2.0); // in onBar(): bands.update(close) out_bb_upper(bands.upper); // then read the fields by name out_bb_basis(bands.basis); out_bb_lower(bands.lower); ``` `bands.update(close)` folds one bar; `bands.upper`, `bands.basis`, and `bands.lower` are its three lines afterwards. You name what you want. There is no "is the upper band index 0 or index 2?" guesswork, and a typo like `bands.upperr` is a compile error rather than a silently wrong line. ## How it works ![Named streams: one Bb object, updated once per bar, holds three fields; the file writes each to an output of its own, and the chart draws three lines: upper, basis and lower](/wrun/images/diagrams/named-streams.svg) A multi-output computation is a class with one `update(x)` method and one typed field per stream. `update` returns the primary stream (the MACD line, the basis) so the class also reads like a single-output one, and the other streams sit on the instance. Each field is an ordinary `f64`, so it flows straight into math, other classes, conditions, and outputs: ```text const m = new Macd(12, 26, 9); m.update(close); const risingMomentum = m.hist > prevHist; // compare bars (prevHist is remembered) const smoothSignal = signalEma.update(m.signal); // feed a stream into another class ``` The kit ships every class, single-stream and multi-stream alike, and a file uses them by name with nothing to import. Two rules keep a module honest: construct in `onStart()` (allocation once), and `update()` once per bar. ## The named streams The multi-stream classes, and the fields each one carries: | Class | Fields | | --- | --- | | `Bb` | `.basis`, `.upper`, `.lower` | | `Keltner` | `.basis`, `.upper`, `.lower` | | `Donchian` | `.basis`, `.upper`, `.lower` | | `Macd` | `.macd`, `.signal`, `.hist` | | `Stoch`, `Stochastic` | `.k`, `.d` | | `Supertrend` | `.line`, `.direction` | | `Adx` | `.adx`, `.plusDi`, `.minusDi` | | `Ichimoku` | `.tenkan`, `.kijun`, `.senkouA`, `.senkouB`, `.chikou` | Every class and its conventions: [TA library](../functions/ta-library.md). A few worth calling out: - **`Stoch`** splits into the fast `%K` (`.k`) and its smoothed `%D` (`.d`). The classic crossover is `Cross.update(stoch.k, stoch.d)`. - **`Supertrend`** carries the trailing stop level as `.line` and the trend side as `.direction` (`1` or `-1`). Use `.direction` as a `color_by` index or a gate; draw `.line`. - **`Macd.hist`** is the MACD line minus the signal line, ready for a `histogram` output. ## Reading every stream at once Two shipped classes and all six of their streams as outputs. `Macd` is the fast, slow and signal EMAs folded into one object; `Bb` is the window mean and its population standard deviation. Every stream is an output of its own, so once you publish the indicator an alert set from the chart can follow any of them ([Alerts](../functions/alerts.md)). ```typescript param("fast", 12, { min: 1, max: 200, description: "MACD fast EMA" }); param("slow", 26, { min: 2, max: 400, description: "MACD slow EMA" }); param("signal", 9, { min: 1, max: 200, description: "MACD signal EMA" }); param("bb_period", 20, { min: 2, max: 400, description: "Bollinger window" }); param("bb_mult", 2, { min: 0.5, max: 4, description: "Bollinger width in standard deviations" }); output("bb_upper", line, overlay, { color: "#2563eb", width: 2, description: "Upper Bollinger band" }); output("bb_basis", line, overlay, { color: "#64748b", width: 2, description: "Bollinger basis (the moving average)" }); output("bb_lower", line, overlay, { color: "#dc2626", width: 2, description: "Lower Bollinger band" }); output("macd", line, lower, { color: "#1d4ed8", width: 2, description: "MACD line" }); output("signal", line, lower, { color: "#ea580c", width: 2, description: "Signal line" }); output("hist", histogram, lower, { color: "#15803d", description: "MACD minus signal" }); // One update() per bar; the streams are fields you read by name afterwards. let macd = new Macd(12, 26, 9); let bands = new Bb(20, 2.0); function onStart(): void { macd = new Macd(i32(p_fast()), i32(p_slow()), i32(p_signal())); bands = new Bb(i32(p_bb_period()), p_bb_mult()); } function onBar(): void { const close = bar.close(); macd.update(close); bands.update(close); // Nothing is written until the slowest stream is warm: the MACD signal line. if (isNaN(macd.signal) || isNaN(bands.basis)) return; out_bb_upper(bands.upper); out_bb_basis(bands.basis); out_bb_lower(bands.lower); out_macd(macd.macd); out_signal(macd.signal); out_hist(macd.hist); } ``` **What to expect:** the three bands start drawing once the 20-bar window fills and track together. The MACD streams warm later: the MACD line needs the slow EMA (26 bars), the signal line nine MACD values beyond that, so nothing is drawn until the 34th bar and all six lines then start in lockstep. Bars where one stream is finite and another is not are handled in the classes (`NaN` stays `NaN`), never in `onBar()`. ## A composite class as one value The class instance is one value: pass `macd` to a helper that takes a `Macd`, keep an array of them for several settings, or read a single field inline. What you cannot do is index a stream's history (`m.hist[1]`): remember the previous value in a module-level variable, exactly as for any other number (`core-variables.md`). Single-output classes like `Rsi`, `Ema`, and `Sma` return their one value from `update()` and have no stream fields, so you write them straight to an output: `out_rsi(rsi.update(close))`. Named streams are only for the multi-line indicators above. The full catalog is the [TA library](../functions/ta-library.md). # Repainting What repainting is, why it breaks backtests, and why a wrun indicator does not repaint by construction: `onBar()` sees one bar and nothing later, a higher timeframe is folded in only after its candle closes, an `interval` pin is read as of each bar's close, and the chart replays the forming bar from a snapshot of the state the closed bars left. What can still move a bar you have already seen on the chart is the window the indicator computes over and a correction to the data, and this page shows both so neither surprises you. The guarantee comes from the shape of the model, not from a flag you remember to set. ## What repainting is ![Repainting: the values on closed bars never change; only the forming bar's value moves as new data arrives, until that bar closes](/wrun/images/diagrams/repaint-forming.svg) Repainting is when a script's historical values change after the fact. The line you see today over old bars is not the line the script drew when those bars were live. A signal that looks like it fired one bar early in backtest fires one bar late in production, or never. The chart redraws itself once the future arrives, so your backtest is reading numbers that did not exist at the time. That is the whole problem in one sentence: a repainting indicator lies about the past, so anything you measure on history (win rate, drawdown, signal timing) is fiction. You cannot trust a backtest you cannot reproduce. ## Why it happens Repainting comes from reading data that was not yet available at the bar you are computing, or from a history that is not the one you ran on. Three sources, two classic and one specific to a module with state: - **An unclosed higher-timeframe candle.** A naive 4h lookup on a 1h chart hands back the forming candle's live value; history backfills the finished number the live chart never had. - **A future-leaking series.** Anything that depends on a later bar: a centered smoother, "highest of the next N bars". History resolves it; live it does not exist yet. - **State that depends on where history starts.** A running total or a bar counter begins at the first bar loaded; load more history and every value shifts, although no bar looked ahead. The tell is the same each time: the computation saw something during the backtest that it could not have seen live, or saw something live that history will not replay. ## The good news: no lookahead by construction `onBar()` receives exactly one bar: the inputs of the bar being evaluated, oldest first. There is no history array to index forward, no `[−1]`, no way to reach the next bar. A value can only depend on bars at or before its own, so a mark that appears in history would have appeared live on the same bar. An indicator cannot break this by accident; there is nothing to peek at. The causal-prefix guarantee in one sentence: at bar *t*, every value the module holds was derived only from bars at or before *t*, so the past never changes when the future arrives. `bar.count()` is the one way out, and you opt into it by name: it tells a bar how many bars the run holds, so a value that reads it (a lagging line that waits for the bar `shift` bars later) does change when a later bar arrives. The chart keeps such a file honest by running every bar again whenever a bar opens, so the live chart always matches a fresh load over the same bars ([How many bars the run holds](execution-model.md#how-many-bars-the-run-holds)). ## Higher timeframes: confirmed by the fold ![Higher timeframes, bar by bar: a 4h candle spans four 1h bars; with lookahead on, a Pine script on history sees the new 4h close from the first of those bars, while in wrun every 1h bar sees the previous close and the new one arrives on the bar after the 4h candle closes](/wrun/images/diagrams/repainting.svg) Built inside the module, a higher timeframe comes from the chart's own bars: bucket bars by `time.bar_open_sec`, remember the running bucket's close, and fold it into the 4h statistic only when a bar from the **next** bucket arrives. A 4h candle contributes exactly once, after it closed. Rerun over history and it matches what it showed live, bar for bar. No look-ahead, no flag to remember. ```typescript param("bucket_hours", 4, { min: 1, max: 168, description: "Higher-timeframe bucket, in hours" }); // The confirmed higher-timeframe close: flat across the bucket, stepping only when a candle closes. output("h4_close", line, overlay, { color: "#7c3aed", width: 2, description: "The most recent fully closed 4h candle, no look-ahead" }); // The developing value, opt-in and clearly labeled: it moves with every bar inside the bucket. output("h4_developing", line, overlay, { color: "#16a34a", width: 1, description: "The forming 4h candle's running close (repaints by design)" }); output("close", line, overlay, { color: "#94a3b8", width: 1, description: "The chart-timeframe close for comparison" }); let bucketSec: f64 = 14400.0; let bucket: f64 = NaN; // the bucket the running candle belongs to let running: f64 = NaN; // the running candle's latest close (developing) let confirmed: f64 = NaN; // the last CLOSED candle's close function onStart(): void { bucketSec = p_bucket_hours() * 3600.0; } function onBar(): void { const close = bar.close(); const b = Math.floor(bar.time() / bucketSec); if (b != bucket) { // A bar from the next bucket has arrived: the running candle is now closed, and only now is it confirmed. if (!isNaN(bucket)) confirmed = running; bucket = b; } running = close; if (isNaN(confirmed)) return; out_h4_close(confirmed); out_h4_developing(running); out_close(close); } ``` ### Read the staircase The purple `h4_close` line is flat across four 1h bars, then steps to a new level, then holds flat again. That staircase is the visual signature of a correct, confirmed higher timeframe: the value only changes when a 4h candle actually closes, and between closes there is no new confirmed information. The green `h4_developing` line tracks the grey chart close inside each bucket: that is what a repainting value looks like, and it is drawn here on purpose so you can see the difference. A cross built on the green line would not survive into production; a cross built on the purple one reproduces exactly. ## Requesting a live value is opt-in Sometimes you genuinely want the forming bucket: a live 4h close ticking in a readout. In a module that is just the running variable, as above, and it is a deliberate choice you make by reading `running` instead of `confirmed`. A developing value is fine for a display; it is the wrong thing for a signal, because it changes as the bucket fills, so a cross or threshold built on it will not reproduce on history. Reach for it only when you want to *show* the live edge, never when you want to *act* on it. A pinned input's `forming` view is the same kind of value. ## Pins are read as of close An `interval` pin reads real coarser candles, and the chart applies the same rule for you: a pinned candle reaches a chart bar only as of that bar's close, live or historical, so a forming 4h candle never leaks into the 1h rows under it ([Multi-timeframe](multi-timeframe.md)). The cost is a value that steps once per pinned candle: the same staircase. Act on a pin through its default `confirmed` view. ## The forming bar replays from a snapshot ![The forming bar replays from a snapshot: the state after the last closed bar is saved; every update of the forming bar starts from that snapshot, so nothing counts twice; when the bar closes it runs once more and becomes the new snapshot](/wrun/images/diagrams/forming-snapshot.svg) The forming bar is the one exception to "one call per bar": the chart evaluates it again as new data arrives, at most about once a second. It does not re-run history for that. It snapshots the module after the last closed bar (its memory, every module-level variable, and its drawing handles) and restores that snapshot before each new evaluation of the forming bar, so every replay starts from exactly the state the closed bars left: an accumulator cannot count the forming bar twice, and nothing the forming bar drew is stacked. When the bar closes, the chart evaluates it once more as a closed bar, then moves on to the new one. Nothing in the file has to put its state back for this: the chart restores the snapshot, every module-level variable and every TA object included, so the forming bar never depends on a reset of your own. ## What can still change a closed bar No bar changes because it looked ahead, but on the chart two things can change a bar you have already seen: - **The window.** The chart runs an indicator over exactly the bars it has loaded, and panning back runs it again over the longer window (so does a live gap longer than 250 bars). A value that depends on where history starts shifts when more history loads: a running total from the first loaded bar, a bar counter, the open of the first bar. A value over a fixed window (an SMA, a rolling sum) or reset each session stays put once its window is loaded; an EMA, which remembers every bar it has seen, moves slightly and converges. - **Corrected data.** When the chart learns that a closed bar's data changed (a late trade folded into a closed bar, a candle the venue corrected, an ETF flow revised after the US session), it runs the indicator again from its buffers, so history matches the corrected data. ```text let cvd: f64 = 0.0; // a running sum since the first LOADED bar // Pan back and the chart runs the Indicator again over the longer window: // every closed bar's cvd shifts by the delta of the bars that just loaded, // while a delta summed over a rolling window, or reset each session, keeps // its values. ``` Run-level renderers and drawings are selected from the finished rows (the newest ready row that satisfies the kind's rule wins), and they never change an output's value. ## How to stay repaint-safe A short checklist: - **Fold higher timeframes on the next bucket.** The confirmed value is the one you act on; the running value is a readout. - **Read pins confirmed.** A pinned coarse input steps as of each bar's close; its `forming` view is a readout, not a signal. - **Do not act on the still-forming bar.** Its high, low, and close are moving until it closes. Gate confirmed signals on settled data. - **Anchor what you act on.** Act on values over a rolling window or a session, not on the level of a total that starts at the first loaded bar. Stick to confirmed folds, confirmed pins and anchored windows and your backtest will mean something: what you measured on history is what the indicator would have done live. ## See also - [Multi-timeframe](multi-timeframe.md) for the full bucket-fold and `interval`-pin story, calendar buckets included. - [Execution model](execution-model.md) for the bar loop, warm-up, and where an indicator runs. # What you can read A wrun indicator reads the market under the candles. Every level of the order book, buy and sell volume as the exchange tagged each trade, volume at each price and in each trade size, open interest, funding and liquidations, the live options chain with its greeks, ETF flows and economic series: each reaches your code as numbers, lined up bar for bar with the chart. One `input` line asks for a feed and `onBar()` reads it, with no fetch call and no network. **What you can build from these feeds:** - **Absorption.** The bars where heavy buying or selling failed to move price, from the exchange's own trade tags ([Absorption](../cookbook/absorption.md)). - **A value area.** Volume at every price, folded into a point of control and a value area on the price axis ([Volume profile and value area](../cookbook/volume-profile-value-area.md)). - **Positioning regimes.** Open interest against price, split into new longs, new shorts, short covering and long closing ([Positioning regimes](../cookbook/positioning-regimes.md)). - **Liquidation bursts.** Forced buying and selling in USD, tagged on price when one side jumps past its norm ([Liquidation bursts](../cookbook/liquidation-bursts.md)). - **A live gamma map.** Gamma at every strike, the flip and the call and put walls from the options chain, and whether dealers are long or short gamma where spot trades ([Gamma map](../cookbook/gamma-map.md)). - **Book walls.** The heavier side of the order book near the price, the dollars resting by distance from the mid and the biggest bid and ask walls, kept current on the live bar ([Order book HUD](../cookbook/order-book-hud.md)). - **ETF flows.** Each day's net spot-ETF flow under the chart, with the running total ([ETF flows](../cookbook/etf-flows.md)). | Group | What you get | Charts | | --- | --- | --- | | [Order flow](#order-flow) | Buy and sell volume, as the exchange tagged each trade | Crypto, prediction markets | | | The footprint: buy and sell at each price | Crypto, prediction markets | | | Volume by trade size | Crypto | | | The order book, up to 500 levels a side | Crypto, prediction markets | | | Long and short liquidations | Crypto perpetuals | | [Positioning](#positioning) | Open interest | Crypto perpetuals, prediction markets | | | Funding and the long/short ratio | Crypto perpetuals | | [Options and volatility](#options-and-volatility) | Implied volatility, skew and DVOL | BTC, ETH | | | Options open interest and volume | BTC, ETH | | | The options chain, with greeks | Any coin an options venue lists | | [Beyond the venue](#beyond-the-venue) | Spot-ETF flows, holdings and premium | BTC, ETH, SOL | | | Ethena collateral | BTC, ETH | | | Bitfinex margin funding | Coins Bitfinex lends | | | Token supply, economic series, Binance's treasury | Every chart | | | Polymarket odds | Crypto, prediction markets | | [Price and time](#price-and-time) | Candles, and each bar's time, trade date and session | Every chart | | [Other markets and timeframes](#other-markets-and-timeframes) | Another market's candles: crypto, stocks, forex, gold and silver | Every chart | | | A coarser timeframe, or its closed candles as a stream | Every chart | | | The finer bars inside each bar | Charts of 2 minutes and up | ## One line per feed **Name a feed once at the top of the file. Read this bar's value in `onBar()`.** ![Every feed on the chart's rows: the candles set one row per bar; buy and sell volume and open interest give each row its numbers; a daily ETF flow lands on the first bar of its day and the bars after it carry it; onBar() runs once per row and reads every feed on that row, the column circled in orange](/wrun/images/diagrams/read-lanes.svg) 1. **Candles**: the chart's own bars set the rows, one per bar. 2. **Buy and sell volume**: two numbers on every row, buyers up and sellers down. 3. **Open interest**: one number on every row. 4. **ETF flow**: one number a day. It lands on the day's first bar, and the bars after it carry it. 5. **`onBar()`** runs once per row and reads every feed on that row. ```typescript // at the top of the file: the feed, under a name you pick input("oi", oi.close); // inside onBar(): this bar's value const oi = in_oi(); ``` Knobs appear only when a feed has a choice to make: a side, a tenor, a fund, a venue. ## Order flow **Who bought, who sold, at which price and in what size, and what still rests in the book.** ```typescript // buy and sell volume, in coins input("buy", trades.volume, { side: "BUY" }); input("sell", trades.volume, { side: "SELL" }); // the footprint: [low, high, buy, sell] at each price input("profile", volume_profile.cells, { max_cells: 8192 }); // [bucket, buy_usd, sell_usd, buy_count, sell_count] per size input("sizes", trade_volume_by_size.cells, { max_cells: 7 }); // the book: [price, size, side] per level input("book", book.cells, { max_cells: 1000, block_size: 10 }); // liquidations in USD, both sides unless side picks one input("liq", liquidations.liquidations, { missing: "zero" }); ``` Buy minus sell, summed bar after bar, is CVD. Size buckets run from 1, trades under 1K USD, to 7, trades of 10M and up, each fill at its own size; a long is liquidated by a forced sell, so `side: "SELL"` reads longs. ## Positioning **Who holds the market, what holding it costs, and which way they lean.** ```typescript // open interest, in USD input("oi", oi.close, { missing: "nan" }); // the funding rate, in percent per hour input("funding", funding.rate_close, { missing: "zero" }); // longs over shorts, every account input("ls", long_short_ratio.total_account, { missing: "nan" }); ``` Funding is normalized to one hour, so venues with different funding intervals compare. The long/short ratio counts every account, or the venue's top traders with `top_trader_account` or `top_trader_position`, on charts of 5 minutes or coarser. ## Options and volatility **What the options market prices in, by tenor and by strike.** ```typescript // implied volatility and skew on Deribit, one tenor each input("iv", implied_volatility.implied_volatility, { tenor: "ONE_M" }); input("skew", skew.skew, { tenor: "ONE_M", delta: 25 }); // the volatility index, DVOL, in index points input("dvol", volatility_index.close); // put open interest on Deribit, call volume on Binance input("put_oi", options_oi.puts); input("call_vol", options_volume.calls, { venue: "binance" }); // every listed contract, on the live bar input("chain", options_chain.cells, { max_cells: 2000 }); ``` Tenors are `ONE_W`, `ONE_M`, `TWO_M`, `THREE_M` and `SIX_M`, and skew's `delta` is 5, 15, 25 or 35. The chain fills the live bar only, one `[strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega]` row per contract. ## Beyond the venue **What moves the coin from outside the exchange: ETF money, supply, rates and the odds.** ```typescript // spot-ETF flow (USD, daily), holdings (coins), premium (%) input("flow", etf_flow.flow_usd, { fund: "all", missing: "nan" }); input("held", etf_holdings.holdings, { fund: "all" }); input("premium", etf_premium.premium_rate, { fund: "IBIT" }); // Ethena collateral in USD; Bitfinex funding provided input("ethena", ethena_positions.collateral); input("provided", bitfinex_funding.funding_size); // market cap, daily; the US 10-year yield; Binance's own BTC input("mcap", token_supply.marketcap, { token: "Bitcoin" }); input("us10y", economic.value, { publisher: "FRED", series: "DGS10" }); input("treasury", treasury_balance.balance, { asset: "BTC" }); // a Polymarket market's odds, 0 to 1, by condition id input("yes", odds.close, { symbol: "0x...", outcome: "YES" }); ``` A token is named the way its series spells it (`Bitcoin`, never `BTC`), and a Polymarket market by its condition id. A day's ETF flow is known only after the US session, so on an intraday chart it shows the day, not a signal you had at the open. ## Price and time **The chart's candles and clock; the candle needs no input line.** ```typescript // this bar's candle bar.open(); bar.high(); bar.low(); bar.close(); bar.volume(); // its open, in epoch seconds UTC bar.time(); // the exchange's trade date input("day", time.trade_date); // 1 regular, 2 pre-market, 3 after-hours, 0 closed input("phase", time.session); ``` On US stocks the session follows New York's pre-market, regular and after-hours sessions; a market without sessions reads `1` on every bar. ## Other markets and timeframes **Another market beside yours, a higher timeframe, or the bars inside each bar.** ```typescript // another market's candles input("btc", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES" }); // a coarser timeframe, and its closed candles as a stream input("daily", ohlcv.close, { interval: "1d" }); input("d", candles.cells, { interval: "1d", bars: 400 }); // the finer bars inside each bar input("m1", intrabar.cells, { interval: "1m", max_cells: 60 }); ``` Only candle feeds take a market pin, `symbol` and `exchange` together: a crypto market, a US stock or ETF (`POLYGON`), a forex pair, gold or silver (`FX_OTC`, `XAU/USD`, `XAG/USD`), never a CME market. A coarser candle counts only once it closes, so history shows what you would have seen live. ## What each chart has **Perpetuals carry the most. Stocks and forex carry candles and a few series.** | Feeds | Crypto perpetual | Crypto spot | Prediction market | US stocks and forex | CME futures | | --- | --- | --- | --- | --- | --- | | Order flow | ● | ◐ no liquidations | ◐ trades, footprint, book | ○ | ○ | | Positioning | ● | ○ | ◐ open interest | ○ | ○ | | Options | ◐ by coin | ◐ by coin | ○ | ○ | ○ | | Beyond the venue | ◐ by coin | ◐ by coin | ◐ odds, supply, economic, treasury | ◐ supply, economic, treasury | ○ | | Price and time | ● | ● | ● | ● | ○ | | Other markets | ● | ● | ● | ● | ○ | On CME futures only OpenMarket's official indicators run; your drafts and other authors' indicators do not. A stock traded on a crypto venue is a crypto chart: the venue decides what a chart has, not the ticker. ## Blocks, not numbers **A `.cells` feed hands you the whole bar: every level of the book, every price of the footprint.** ![One number per bar beside a block per bar: open interest gives each bar one value, read with in_oi(); the book gives the circled bar a block, read with in_book_view() as a flat run of numbers where every three, price, size and side, make one level, so the loop steps i by three](/wrun/images/diagrams/read-blocks.svg) 1. **One number per bar**: open interest gives every bar a single value, read with `in_oi()`. 2. **A block per bar**: the book gives the circled bar a run of numbers, read with `in_book_view()`. 3. **Rows of three**: every three numbers are one level, `[price, size, side]`, with `+1` a bid and `-1` an ask. 4. **`i += 3`**: the loop steps one row at a time, up to the count `in_book_cells()` returns. ```typescript function onBar(): void { const n = in_book_cells(); // this bar's count: 0 empty, -1 none if (n <= 0) return; const cells = in_book_view(); // the block itself, no copy for (let i = 0; i + 2 < n; i += 3) { const price = cells[i]; const size = cells[i + 1]; const side = cells[i + 2]; // +1 a bid, -1 an ask } } ``` Every block reads the same way, stepping by its row's width: 4 for the footprint, 5 for size buckets, 6 for candles, 10 for the chain. Only the first `n` numbers are this bar's, so bound every loop by the count. A block larger than `max_cells` stops the run instead of being cut. The first input sets the rows and cannot be a block, so a file that reads blocks starts with `input("close", ohlcv.close);`. ## When a bar has no reading **`missing` decides what an empty bar reads: the last value, `NaN` or zero.** ```typescript // "carry", the default: the last value input("funding", funding.rate_close); // "nan": a bar without a reading reads NaN, never a guess input("oi", oi.close, { missing: "nan" }); // "zero": a quiet bar reads 0 input("liq", liquidations.liquidations, { missing: "zero" }); ``` Blocks never carry: a bar without one gets an empty block. A feed the chart cannot serve on this market stops the run and names the feed, on the indicator's legend and in the editor's Console. ## Four feeds, one file **Taker delta, the change in open interest and liquidations, in USD per bar.** ```typescript input("close", ohlcv.close); // first: it sets the rows input("buy", trades.volume, { side: "BUY" }); // in coins input("sell", trades.volume, { side: "SELL" }); input("oi", oi.close, { missing: "nan" }); // in USD input("liq", liquidations.liquidations, { missing: "zero" }); output("delta", histogram, lower, { color: "#94a3b8", label: "Delta, USD", format: "si" }); output("oi_change", line, lower, { color: "#c2410c", label: "OI change, USD", format: "si" }); output("liquidated", line, lower, { color: "#64748b", label: "Liquidated, USD", format: "si" }); let lastOi = NaN; // open interest on the last bar that had one function onBar(): void { const close = bar.close(); if (isNaN(close)) return; out_delta((in_buy() - in_sell()) * close); // coins to USD const oi = in_oi(); out_oi_change(oi - lastOi); // NaN until two readings if (!isNaN(oi)) lastOi = oi; out_liquidated(in_liq()); } ``` Paste it under the `//@lang=wrun-ts` marker and press **Run** on a crypto perpetual chart. On spot, open interest reads `NaN` and liquidations zero, as their `missing` knobs say. The `close` line stays first: it sets the rows the other feeds line up with, and `bar.close()` reads through it. Every knob, unit and edge case is on [Data sources](data-sources.md); pins are on [Multi-source](multi-source.md) and [Multi-timeframe](multi-timeframe.md). # Data sources The market data a wrun indicator can read on the chart: the sources the chart serves, the fields and knobs each one takes, what each value means, and the celled sources that hand the module a whole block of rows per bar. A source is one feed (candles, funding, a book snapshot, a footprint) declared as an `input`, or read straight off the chart's own candle through `bar`; the module itself has no network, and every number it sees arrives through one of these, fetched by the chart over the bars it has loaded. ![The same candles twice. Pine reads the bars and their volume. wrun also reads the order book's resting orders, the buyers and sellers at every price, the call and put walls from the options chain, and the long and short liquidations under the bars that forced them](/wrun/images/diagrams/sees-chart.svg) 1. **Pine** reads the candles and their volume. 2. **wrun** reads the same candles and what moved them: the resting orders in the order book and the buyers and sellers at every price ([celled sources](#celled-sources)), the call and put walls from the [options chain](#options-chain), and long and short liquidations ([feed sources](#feed-sources)). ## Loading a source One declaration per field you read beyond the chart's own candle. The source is a namespace word, the field is a member, and the options object carries the knobs: ```text input("close", ohlcv.close); // the chart's own candles input("funding", funding.rate_close); // another feed, same market input("buy", trades.volume, { side: "BUY" }); // a required knob input("iv", implied_volatility.implied_volatility, { tenor: "ONE_M" }); input("btc", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES" }); // another market's candles input("daily", ohlcv.close, { interval: "1d" }); // a coarser interval, as of its close input("liqs", liquidations.liquidations, { side: "SELL", missing: "zero" }); input("us10y", economic.value, { publisher: "FRED", series: "DGS10" }); // a series named by its own knobs input("bar_t", time.bar_open_sec); // the bar's open, epoch seconds input("profile", volume_profile.cells, { max_cells: 8192 }); // a celled source input("d", candles.cells, { interval: "1d", bars: 400 }); // closed daily candles as a stream ``` The editor derives the sheet from these declarations as it compiles: each one becomes an `inputSources` entry keyed by input name (`{ "source": "trades", "field": "volume", "side": "BUY" }`). Every value is read in `onBar()` through `in_()`, a celled block through `in__cells()` and `in__view()` ([Celled sources](#celled-sources)). ### The bar grid and the bar's own fields The chart's own candle needs no declaration: `bar.open()`, `bar.high()`, `bar.low()`, `bar.close()`, `bar.volume()` and `bar.time()` read it inside `onBar()`, and reading a field adds its input to the sheet, on the chart's own market and interval, with no options: | Member | Input it adds | Source field | | --- | --- | --- | | `bar.open()` | `open` | `ohlcv.open` | | `bar.high()` | `high` | `ohlcv.high` | | `bar.low()` | `low` | `ohlcv.low` | | `bar.close()` | `close` | `ohlcv.close` | | `bar.volume()` | `volume` | `ohlcv.volume` | | `bar.time()` | `bar_t` | `time.bar_open_sec` (the bar's open, epoch seconds UTC) | Input 0 of the sheet is the bar grid: the market and interval every row is a candle of, which every other input aligns to. The rules: - **A file with no `input` line runs on the chart's own candles.** It gets `close` as input 0 whether or not it reads `bar.close()`, then the other fields it reads (`bar.time()` alone gives `close`, `bar_t`). - **The first `input` line is the grid.** Once a file has an input line, the first one is input 0, never a bar field. A file whose first input line names another candle field (a lone `input("high", ohlcv.high);`) keeps that line, so the grid stays on `high` instead of moving to the close; `bar.high()` reads through it, and the other `bar` fields work either way. A typed scalar feed may come first (`input("fund", funding.rate_close)` then `bar.close()` gives `fund`, `close`); a celled or `time` input first is refused when the sheet is checked (`celled source class 'volume_profile' cannot be the primary input (index 0): the primary must be a fetched scalar series that defines the request grid`; `a time source cannot be the primary input (index 0); the primary defines the request grid, declare a feed or metric source first`; for `intrabar`, `wrun_intrabar_primary: input 'm1' reads intrabar cells at index 0; the primary input must be a fetched scalar series that defines the request grid the finer bars are sliced by (declare ohlcv first, intrabar as a secondary input)`; for `candles`, `wrun_candles_primary: input 'd' reads candles at index 0; the primary input must be a fetched scalar series that defines the request grid the candles are delivered on (declare ohlcv first, candles as a secondary input)`), so a file with a celled input keeps a scalar line in front of it (`input("close", ohlcv.close);`). - **Declared inputs keep their order and indexes.** The fields the file reads through `bar` that no declared input serves are appended after them, in the fixed order `open`, `high`, `low`, `close`, `volume`, `bar_t`. - **A declared input serves its field.** When an input is exactly that feed (a scalar input with that source and field and no option other than `description`), `bar.close()` reads through it, under its own name and index: `input("price", ohlcv.close)` serves `bar.close()`, and `in_price()` and `bar.close()` are then the same value. A `symbol`, `exchange`, `interval`, `views`, `missing` or any other option, a `param.source` pick or a derived view makes it another feed, and the field is added as an input of its own. - **The bar's names are reserved.** An input named `open`, `high`, `low`, `close`, `volume` or `bar_t` with another feed, in a file that reads that field through `bar`, is refused on the line of the first read: `bar.close() reads the chart's own ohlcv.close through an input named 'close', and input 'close' is already declared with another feed; rename the declared input (open, high, low, close, volume and bar_t are the bar's own names)`. The same name is fine when the file never reads that field through `bar`. ### Symbol and exchange are literals Every option is a literal, read from the text without running it. An input without a pin follows the chart's market. A pinned input names `symbol` **and** `exchange` together, written in the chart's own ids (`BTCUSDT` on `BINANCE_FUTURES`): symbols are venue-native, so the editor refuses half a pin, which would name a market that does not exist. Leaving the pin off is how an input follows the chart. A pin may name a stock or ETF (`POLYGON`), a forex pair, gold or silver (`FX_OTC`: `EUR/USD`, `XAU/USD`, `XAG/USD`) beside the crypto venues ([Stocks, forex and gold](multi-source.md#stocks-forex-and-gold)); a secondary input pinned to the CME Group family (`CME`, `CBOT`, `NYMEX`, `COMEX`, `GLOBEX`) is refused by name, because that market data cannot be read from another market's script (the first input is the package's own market, and whether a host serves a CME market is the host's call). Which inputs may pin a market or an interval is on [Multi-source](multi-source.md) and [Multi-timeframe](multi-timeframe.md); the venue ids are on [Symbol format](../reference/symbol-format.md). ## Feed sources A feed source serves one number per bar. Every one needs its `field`, and each reads the chart's own market or its coin: only a secondary `ohlcv` input may name another market, an `odds` input names its own Polymarket market, and a few series are named by a knob of their own (a fund, a publisher and a series id, a token). The tables group them the way a chart does: the market itself, the options of its coin, and the series from beyond the venue. ### The chart's market | Source | Fields | Knobs | What the chart serves | | --- | --- | --- | --- | | `ohlcv` | `open`, `high`, `low`, `close`, `volume` | none | The chart's own candles, history plus the live bar. Close prices are `ohlcv` + `close`; there is no "market" or "price" source. A secondary input may pin another market or a coarser interval. | | `trades` | `volume` | `side`: `BUY` or `SELL` (required); `currency`: `USD` or `Coin` (optional) | One side's traded volume per bar, in the base asset (coins), history plus live. `currency: "USD"` reads it in dollars instead (each trade's notional), `"Coin"` in coins; two inputs differing only in `currency` are two lanes, so a package with a units setting declares both and picks one. Any other field is refused by name. | | `oi` | `open`, `high`, `low`, `close` | none | Open interest in USD, refreshed by polling. May pin a coarser interval. | | `liquidations` | `liquidations` | `side` (optional): `BUY` or `SELL` | Liquidation volume in USD, history plus live. With `side` the value is that side's; without it, the bar's total over both sides. Rows exist only where liquidations happened, so declare `missing: "zero"` when a quiet bar should read 0. | | `funding` | `rate_close` | none | The funding rate in percent, normalized to a one-hour interval, history plus live. The other funding fields are refused by name. May pin a coarser interval. | | `long_short_ratio` | `total_account`, `top_trader_account`, `top_trader_position` | none | The market's long/short ratios, plain ratios of longs over shorts: every account, the venue's top traders counted by account, and the top traders counted by position size. Refreshed by polling. A value at or below 0 reads `NaN`, and the chart must be 5 minutes or coarser. | | `odds` | `open`, `high`, `low`, `close` (default), `volume` | `symbol`: the market's condition id (required); `outcome`: `YES` (default) or `NO` | A Polymarket market's YES probability, 0 to 1, refreshed by polling. Details below. | | `time` | `bar_open_sec`, `trade_date`, `session` | none | The bar's open time in epoch seconds, its exchange trade date, and its session phase (below). | ### Options, by the chart's coin | Source | Fields | Knobs | What the chart serves | | --- | --- | --- | --- | | `implied_volatility` | `implied_volatility` | `tenor`: `ONE_W`, `ONE_M`, `TWO_M`, `THREE_M` or `SIX_M` (required) | Deribit's implied volatility for the chart's coin at that tenor, refreshed by polling. `ONE_D`, `THREE_D` and `ONE_Y` are refused by name: the data carries none of them. | | `skew` | `skew` | `tenor`, as above (required); `delta`: `5`, `15`, `25` (the default) or `35` | Deribit's skew for the chart's coin at that tenor and delta, refreshed by polling. | | `volatility_index` | `open`, `high`, `low`, `close` | none | Deribit's volatility index (DVOL) for the chart's coin, in index points, refreshed by polling. BTC and ETH. | | `options_oi` | `puts`, `calls` | `venue`: `deribit` (the default) or `binance` | Put and call open interest of the chart's coin on that venue, in the venue's own units (contracts on Deribit, the base coin on Binance), refreshed by polling. BTC and ETH; another coin is refused by name. | | `options_volume` | `puts`, `calls` | `venue`, as above | Put and call volume of the chart's coin on that venue, in contracts, refreshed by polling. BTC and ETH; another coin is refused by name. | These are per-bar summaries. The chain itself, contract by contract, is the celled `options_chain` source ([Options chain](#options-chain)). ### Beyond the chart's venue | Source | Fields | Knobs | What the chart serves | | --- | --- | --- | --- | | `etf_flow` | `flow_usd` | `fund`: an ETF ticker (`IBIT`, `FBTC`, `ETHA`, ...) or `all` (required) | Daily net spot-ETF flow in USD, on a chart of BTC, ETH or SOL; another coin, or a ticker the coin does not list, is refused by name. | | `etf_holdings` | `holdings` | `fund`: an ETF ticker or `all` (required) | The fund's holdings, in coins of its underlying; `all` sums every fund listed for the chart's coin. A coin without listed funds, or a ticker the coin does not list, is refused by name. | | `etf_premium` | `premium_rate` | `fund`: one ETF ticker (required) | The fund's premium to its net asset value, in percent, daily. `all` is refused by name: a rate does not sum across funds. | | `ethena_positions` | `collateral` | none | Ethena's collateral held against the chart's coin, in USD, summed over the venues that hold it. BTC and ETH; another coin is refused by name. | | `bitfinex_funding` | `funding_size`, `credit_size`, `active_credit_size`, `margin_rate` | none | Bitfinex margin funding for the chart's coin: the total funding provided, the funding used in positions and the active credits, each in the funding currency, and the margin rate as an APR in percent. | | `treasury_balance` | `balance` | `asset` (optional): an upper-case ticker, the chart's coin when absent | Binance's own balance of that coin (its liability minus its customer liability), in coins, published monthly. | | `token_supply` | `marketcap`, `first_marketcap`, `marketcap_dominance_percent`, `circulating_supply`, `total_supply`, `max_supply`, `total_value_locked`, `fully_diluted_valuation`, `cg_marketcap_rank`, `total_volume`, `usd_price` | `token`: the token's display name (required) | A token's supply and market-cap series, daily. The token is named the way the series spells it (`Bitcoin`, `Ethereum`, `Solana`), never by its ticker; a name the chart does not list is refused by name, and so is a one-minute chart. | | `economic` | `value` | `publisher` and `series` (both required) | One economic series in the publisher's own unit, daily or slower: `{ publisher: "FRED", series: "DGS10" }` is the US 10-year Treasury yield in percent. A publisher the chart does not know is refused by name. | Every feed in the last two tables is refreshed by polling, and none takes a market or an interval pin: each follows the chart's coin or the series its knobs name. A few behaviors to plan for: - **Funding exists on perpetuals only.** On a spot, FX or prediction market, a `funding` input that declares `missing: "nan"` or `"zero"` reads that fill on every bar; on the default `carry` policy the run stops with a message naming the funding rate. - **ETF flows are daily.** On an intraday chart the day's value lands on the first bar of its day and the later bars of that day follow the `missing` policy (`carry` repeats it across the day, `nan` and `zero` leave a one-bar spike); on a daily chart it joins row for row; on a coarser chart each bar sums its days. A day's flow is known after the US session, so on an intraday chart it is a same-day display, never a signal that was available at the day's open. - **Daily levels land the same way, and never sum.** `etf_premium`, `economic` and `treasury_balance` are daily or slower: a chart bar reads the newest value at or before its day. On an intraday chart the day's value lands on the first bar of its day and the later bars follow the `missing` policy; on a daily chart it joins row for row; on a coarser chart a bar reads its last day. A slower series (a monthly balance, a weekly print) carries between its prints under `carry`. - **Polymarket odds.** Pin the market by its condition id (`0x` followed by 64 hex characters) in `symbol`. The exchange is implied (declaring one is refused), and the chart has no market picker, so a `binding` is refused. `outcome: "NO"` reads 1 minus the close. An `odds` input may be the first input (the rows are still the chart's candles), and it needs a chart of one minute or coarser. The prediction-market starters in the template picker ship a placeholder market: **Run** refuses them until you paste a real condition id into the input's `symbol`. - **Required and optional sources.** A source the chart must have (volume profile, implied volatility, skew, the options chain, ETF flows, funding on the default policy, the candles of a pinned or `intrabar` input) that answers empty stops the run with a toast naming it. Trades, open interest, liquidations and the book may legitimately be empty for a stretch, so they read their `missing` fill (the book an empty block) instead. Every other feed of the options and beyond-the-venue tables, and `long_short_ratio`, follows the funding rule: an empty answer stops the run with a message naming the feed, unless every input that reads it declares `missing: "nan"` or `"zero"`, in which case those inputs read that fill. A volume profile declared `missing: "empty"` is optional: on a market that serves none (a stock, an index), or a stretch with no profile, every bar reads an empty block (`in__cells()` is `0`) and the run goes on, so the parts of the indicator that read it blank while the rest draws as usual. A bar whose profile has more buckets than the input's `max_cells` reads the same empty block on an optional profile (on a required one it stops the run: a block is never truncated). `"empty"` is the one word a celled input takes, and only a volume profile takes it. A code-first file that exercises the per-source requirements together (a required `side`, a required `tenor`, an optional `side` with a `missing` policy) and puts the units side by side: ```typescript input("close", ohlcv.close); input("buy_volume", trades.volume, { side: "BUY" }); input("iv_1m", implied_volatility.implied_volatility, { tenor: "ONE_M" }); input("sell_liqs", liquidations.liquidations, { side: "SELL", missing: "zero", description: "SELL-side liquidations, 0 on bars without any" }); output("stress", line, lower, { description: "SELL liquidations per dollar of buy volume, scaled by one-month IV" }); function onBar(): void { // trades volume is in coins; liquidations are already in USD. const buyUsd = in_buy_volume() * bar.close(); const iv = in_iv_1m(); if (isNaN(buyUsd) || buyUsd <= 0.0 || isNaN(iv)) return; out_stress((in_sell_liqs() / buyUsd) * iv); } ``` The option summaries read the same way. This one puts five of them side by side on a BTC or ETH perpetual chart of 5 minutes or coarser: put/call ratios by open interest (Deribit, the default venue) and by volume (Binance), the volatility index, a one-week skew at 5 delta, and the top traders' long/short ratio: ```typescript input("close", ohlcv.close); input("put_oi", options_oi.puts); // venue absent: Deribit, in contracts input("call_oi", options_oi.calls); input("put_volume", options_volume.puts, { venue: "binance" }); input("call_volume", options_volume.calls, { venue: "binance" }); input("dvol", volatility_index.close); input("wing_skew", skew.skew, { tenor: "ONE_W", delta: 5 }); input("long_short", long_short_ratio.top_trader_position, { missing: "nan", description: "Top traders' long/short ratio by position, NaN on a bar without a print" }); output("put_call_oi", line, lower, { description: "Puts over calls by open interest, Deribit" }); output("put_call_volume", line, lower, { description: "Puts over calls by volume, Binance" }); output("dvol", line, lower, { description: "Deribit volatility index, index points" }); output("wing_skew", line, lower, { description: "One-week skew at 5 delta" }); output("long_short", line, lower, { description: "Top traders' long/short ratio by position" }); function onBar(): void { if (isNaN(bar.close())) return; // A ratio needs a positive denominator: NaN before the first print, never a division by zero. const callOi = in_call_oi(); const callVolume = in_call_volume(); out_put_call_oi(callOi > 0.0 ? in_put_oi() / callOi : NaN); out_put_call_volume(callVolume > 0.0 ? in_put_volume() / callVolume : NaN); out_dvol(in_dvol()); out_wing_skew(in_wing_skew()); out_long_short(in_long_short()); } ``` And the series from beyond the venue, on a BTC chart. Holdings and the treasury balance arrive in coins, so the chart's close prices them; the ETF premium, the yield and the token's market cap arrive in their own units and are plotted as they are: ```typescript input("close", ohlcv.close); input("etf_coins", etf_holdings.holdings, { fund: "all" }); // coins held by every listed fund input("marketcap", token_supply.marketcap, { token: "Bitcoin" }); // USD; the token by its display name input("premium", etf_premium.premium_rate, { fund: "IBIT" }); // percent, daily input("us10y", economic.value, { publisher: "FRED", series: "DGS10" }); // the publisher's unit: percent input("treasury", treasury_balance.balance, { asset: "BTC" }); // coins; monthly input("ethena", ethena_positions.collateral); input("provided", bitfinex_funding.funding_size); // funding currency input("lent", bitfinex_funding.credit_size); // the part in use output("etf_share", line, lower, { unit: "%", description: "Spot-ETF holdings as a share of the market cap" }); output("premium", line, lower, { unit: "%", description: "IBIT premium to net asset value" }); output("us10y", line, lower, { unit: "%", description: "US 10-year Treasury yield" }); output("treasury_usd", line, lower, { description: "Binance's own BTC balance, in USD" }); output("ethena", line, lower, { description: "Ethena collateral in the chart's coin" }); output("utilization", line, lower, { unit: "%", description: "Bitfinex funding in use, as a share of funding provided" }); function onBar(): void { const close = bar.close(); if (isNaN(close)) return; // Holdings and the treasury balance are coins: the close turns them into USD. const cap = in_marketcap(); out_etf_share(cap > 0.0 ? (100.0 * in_etf_coins() * close) / cap : NaN); out_premium(in_premium()); out_us10y(in_us10y()); out_treasury_usd(in_treasury() * close); out_ethena(in_ethena()); const provided = in_provided(); out_utilization(provided > 0.0 ? (100.0 * in_lent()) / provided : NaN); } ``` ### Alignment and the `missing` policy The rows are the chart's own candles: the first input follows the chart's market and interval, and every other input is aligned onto those rows. Sources at the chart's interval join by bar open, row for row; a coarser `interval` pin contributes to a row only as of that row's close, so a forming 4h candle never leaks into the 1h rows under it ([Multi-timeframe](multi-timeframe.md)). A scalar source with no observation on a bar delivers, by policy, the latest value carried forward (`missing: "carry"`, the default; `NaN` before the first observation), `NaN` (`"nan"`), or `0` (`"zero"`). Celled blocks never carry: a bar with no observation gets an empty block. ## The `time` source `time.bar_open_sec` carries the bar's open timestamp (epoch seconds, UTC), taken from the chart's own candle, so a module can do session and calendar math deterministically ([Time and sessions](time-and-sessions.md)); `bar.time()` reads it with no declaration. Two more fields carry the bar's market facts, so an indicator never has to know the venue's calendar: - `time.trade_date`: the bar's exchange trade date, as epoch seconds at 00:00 UTC of that date. On CME futures the trade date starts at the 17:00 Chicago open, so an evening bar belongs to the next date; on US stocks it is the New York calendar date; on crypto, forex, HIP-3 and Polymarket markets it is the UTC date. - `time.session`: `1` regular, `2` pre-market, `3` after-hours, `0` closed. US stocks follow the New York pre-market, regular (09:30 to 16:00) and after-hours sessions; CME futures read `1` inside the contract's regular trading hours (all session long for a contract without them), `2` before and `3` after them within the same trade date, and `0` while the market is closed; a market without sessions reads `1` on every bar. A bar of one day or longer (a 1D, 3D, 1W or 1M chart) is a whole trading day or more, so it takes the date of its own stamp and `session` `1` on every market: the chart stamps such bars at 00:00 UTC of their trade date. Both fields are facts of the market, the bar and its interval alone: switching the chart between regular and extended hours never changes them. Every field is filled by the chart (there is no feed behind it), every other knob is refused, and a `time` input is never the first input. ## Celled sources A feed source serves one number per bar. A celled source serves a whole BLOCK of rows per bar, which is what footprint-style indicators need: a footprint is the same candle sliced by price, one `[low, high, buy, sell]` row per price bucket, so "did buyers or sellers do the volume, and at which prices" is answerable inside one bar instead of only as a per-bar total. Celled inputs are declared as `input(name, .cells, { max_cells })`, which moves the derived sheet to at least the second contract (`abi_version: "wrun-2"`); the module-side accessors are in [TA library](../functions/ta-library.md): | Source | One tuple per | What the chart serves | Knobs | | --- | --- | --- | --- | | `volume_profile` | price bucket: `[low, high, buy, sell]` | the chart's own volume profile for each bar, with real bucket bounds, in ascending price order; history plus live | `max_cells`; `ticks_per_bar` (1 to 500 of the market's buckets merged into one, or `"@"` naming a `param.int` whose value it takes); `currency` (`"USD"` for dollar volumes, `"Coin"` by default); `missing: "empty"` (the profile optional: below) | | `book` | level: `[price, size, side]`, side `+1` bid, `-1` ask | the chart's own order book for each bar, up to 500 levels a side: bids first, then asks from the highest price down; history plus live | `max_cells` (1000 holds both sides); `block_size` (the declaration requires one, but the chart reads its own order-book feed and does not use it or `max_depth`) | | `intrabar` | closed finer bar: `[offset_ms, open, high, low, close, volume]`, `offset_ms` the finer open minus the bar's open | the finer candles inside each bar, of the chart's market or a pinned one | `interval` (required), `max_cells`, `symbol` + `exchange` | | `candles` | closed candle of the stream's interval: `[offset_ms, open, high, low, close, volume]`, `offset_ms` the candle's open minus the bar's open | a stream of closed candles: the backlog on the first bar, then each candle on the bar it closes with, of the chart's market or a pinned one | `interval` (required, any word), `bars` (1 to 5000), `max_cells` (the editor fills it when left out), `symbol` + `exchange` | | `trade_volume_by_size` | USD trade-size bucket: `[bucket, buy_usd, sell_usd, buy_count, sell_count]` | the chart market's traded volume split by the size of each trade (each fill bucketed by its own dollar value), one tuple per bucket that traded in the bar, in ascending bucket order | `max_cells` (7 holds a full bar) | | `options_chain` | listed contract: `[strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega]` | the newest option chain, on the newest bar only | `venue`, `expiries`, `max_cells` | Every celled source reads the chart's own market (a market pin is refused) except `intrabar` and `candles`, which may name another market with `symbol` and `exchange` together. A block over `max_cells` refuses the run instead of being truncated, except on a volume profile declared `missing: "empty"`, where that bar reads an empty block. Order-book depth figures (the summed bid size, the largest ask, ...) are loops over the `book` block ([Order flow](../functions/order-flow-kit.md#depth-window-scans-by-hand)); the `intrabar` and `candles` rules are on [Multi-timeframe](multi-timeframe.md). ### Reading a block A celled input gets three readers and two constants, and the build owns one buffer per celled input, allocated at module start (`max_cells` x the tuple width, so 8192 volume profile tuples are 262,144 bytes): - `in__cells(): i32`: the f64 values in this bar's block; `-1` when the bar carries none, `0` for an empty block. - `in__view(): StaticArray`: the build's own buffer, no copy. Only the first `in__cells()` values belong to this bar: on an absent or empty bar the buffer still holds the previous block, so always bound the loop by the count. - `in__read(ptr: i32): i32`: copies the block to memory at `ptr` and returns the bytes written (count x 8), `-1` absent, `0` empty. It is for code that owns a buffer of its own, which then holds the block twice; prefer the view. - `in__max_cells` and `in__capacity` (`max_cells` x tuple width): constants. Read the block where you write, bounded by the count: ```typescript function onBar(): void { const n = in_profile_cells(); if (n <= 0) return; const cells = in_profile_view(); let buy = 0.0; let sell = 0.0; for (let i = 0; i + 3 < n; i += 4) { buy += cells[i + 2]; sell += cells[i + 3]; } if (buy + sell > 0.0) out_buy_share((100.0 * buy) / (buy + sell)); } ``` ### Volume by trade size `trade_volume_by_size` answers "who traded this bar": the same volume split by how large each trade was. A trade is one fill, bucketed by its own dollar value, so an order that fills in pieces lands in smaller buckets: read the large buckets as a floor for what large traders did. `bucket` runs from 1 to 7 by the USD value of one trade: | `bucket` | Trade size, USD | | --- | --- | | 1 | under 1K | | 2 | 1K to 10K | | 3 | 10K to 100K | | 4 | 100K to 500K | | 5 | 500K to 1M | | 6 | 1M to 10M | | 7 | 10M and up | `buy_usd` and `sell_usd` are USD notional, `buy_count` and `sell_count` are trade counts. A bucket that did not trade has no tuple, and a bar with no trades is an empty block. The share of a bar's volume that arrived in trades of 100K and up: ```typescript input("close", ohlcv.close); input("sizes", trade_volume_by_size.cells, { max_cells: 7 }); // seven buckets hold a full bar output("large_share", line, lower, { unit: "%", description: "Share of the bar's USD volume in trades of 100K and up" }); function onBar(): void { const n = in_sizes_cells(); if (n <= 0) return; const cells = in_sizes_view(); let total = 0.0; let large = 0.0; // One [bucket, buy_usd, sell_usd, buy_count, sell_count] row per bucket that traded. for (let i = 0; i + 4 < n; i += 5) { const usd = cells[i + 1] + cells[i + 2]; total += usd; if (cells[i] >= 4.0) large += usd; // bucket 4 starts at 100K } out_large_share(total > 0.0 ? (100.0 * large) / total : NaN); } ``` ### What the chart does not serve - **Live individual trades** (the `tape` source): the declaration compiles, and **Run** refuses it by name. Per-bar buy and sell volume is `trades`; the buy/sell split by price is `volume_profile`; the split by trade size is `trade_volume_by_size`. - **Another indicator's outputs, or a saved series**: the editor has no declaration for either; an indicator reads market data only. - **CME open interest**: no source serves it. Open interest on the venues the chart serves is `oi`. There is no `cvd` source: cumulative volume delta is buy minus sell `trades` volume, accumulated in the module. ## Options chain `options_chain` is the celled source behind a gamma map: the chart market's option chain, one tuple per listed contract, so exposure by strike is a loop inside the indicator instead of a server aggregate. Declare the chart's candles first, then the chain input: ```typescript input("close", ohlcv.close); input("chain", options_chain.cells, { max_cells: 2000 }); ``` Each contract occupies ten f64 values: `[strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega]`. `expiry_ms` is the expiry in epoch milliseconds; `side` is `+1` for a call and `-1` for a put; `oi` is the open interest in the venue's amount units; `gamma` and `delta` are the venue's served greeks; `mark_iv` is the mark implied volatility; `underlying` is the chain's underlying price; `multiplier` is the USD value of one price unit per OI unit (`1` where the OI already carries it, the CME point value on a CME chain); `vega` is the venue's vega, the price change per one IV point (`0` where the venue serves none). Gamma exposure per contract is `gamma * oi * multiplier * underlying * underlying * 0.01`, summed by strike with the sign of `side`; vega exposure is `vega * oi * multiplier`, summed the same way. The chart serves the LATEST chain, not a history of chains, so the block is present on the newest bar only: every older bar is an empty block (`in_chain_cells()` reads `0`), and the indicator reads the chain when `bar.isLast()` is true. The chart refreshes it from the same 30-second snapshot its options panes use. `venue: "auto"` (the default) reads the chart's own market when it is an options venue (a CME futures chart reads the CME chain of its root), else the coin's Deribit chain. Every other word pins one venue's chain: `"deribit"`, `"cme"`, `"binance"`, `"okx"`, `"bybit"`, `"bullish"` or `"derive"`. A pinned venue is refused by name on a chart it cannot serve: a coin that venue does not list, or a venue the chart does not offer you. `expiries` is `"all"` (the default) or `"nearest:N"`, the N nearest live expiries. `max_cells` counts contracts: a chain over the cap refuses the run by name, never truncated, so size it for the venue (a full BTC chain is about 1,500 contracts; 2000 leaves headroom). A market pin, an unknown venue word and an expiries selector outside that grammar are refused by name. ## The worked footprint example The `vp-buy-share-codefirst` template is the footprint loop end to end: a celled `volume_profile` input, the buy share of each bar's profile as a numeric output, and a text renderer fed from a string slot on the newest bar. The whole file (code-first, so the sheet is derived): ```typescript input("close", ohlcv.close); // the first input is the bar grid: the chart's own candles input("profile", volume_profile.cells, { max_cells: 8192 }); output("buy_share", line, lower, { label: "Buy share", format: "%", decimals: 1 }); string("summary", { max_bytes: 64 }); render.text("flow", { y: "buy_share", text: "summary" }); function onBar(): void { const n = in_profile_cells(); if (n <= 0) return; // no profile on this bar: nothing is drawn const cells = in_profile_view(); // this bar's cells, four numbers each: low, high, buy, sell let buy = 0.0; let sell = 0.0; for (let i = 0; i + 3 < n; i += 4) { buy += cells[i + 2]; sell += cells[i + 3]; } if (buy + sell <= 0.0) return; const share = (100.0 * buy) / (buy + sell); out_buy_share(share); if (!bar.isLast()) return; // the text rides the newest bar only, so the pane stays readable sb_clear(); sb_text("buy "); sb_f64(share, 1); sb_text("%"); str_summary_sb(); } ``` To run it, open the editor, click the **Templates** icon ("Browse starter templates") in the Explorer, pick **VP Buy Share** under **Order flow**, and press **Run**. A bar whose profile splits 60/40 to the buy side computes `buy_share = 60`; only the newest bar also gets its text, such as `buy 60.0%`, so the pane stays readable. A book-driven variant loops over `book.cells` the same way. ## How many sources you can open On the chart a wrun indicator you add counts against your plan's per-chart indicator limit by the number of distinct sources it reads, where a source is the source name plus its pinned market: close, high and volume of the chart's own candles count once, and a pinned `ohlcv` input on another market counts again. Inside the indicator a derived value (an average of an input) costs nothing, since it is your arithmetic, and two inputs with the same source, market and knobs are two declarations over one feed. The practical cost of each extra source is fetch time over the loaded window (a coarse interval pin widens its fetch by two of its candles, and `bars` by as many as it asks). The one hard ceiling inside the module is on the other side: it may write at most 256 output slots. ## Availability The editor accepts any declared source and field the kit knows; whether the chart can serve it on the market in front of you is answered when you press **Run**, by name, never with silent empty data. A source the chart cannot serve there shows a **Could not load** chip on the indicator's legend with the reason and a **Retry** button, and the editor's Console shows the same sentence. On a one-second chart, sources the chart does not serve at one second are declined. Where each part of an indicator runs, and what an alert can evaluate, is on [Execution model](execution-model.md). # Multi-timeframe Read higher timeframes from any wrun indicator on the chart, three ways: fold the chart's own bars into buckets on `time.bar_open_sec`, confirming a candle only after it closes; pin a secondary input to a coarser `interval`, which the chart fetches as real candles and hands to each bar as of that bar's close; or read a whole stretch of a timeframe's closed candles as a `candles` stream, reaching back as far as you ask, whatever the chart has loaded. Rolling buckets, calendar buckets (day, Monday week, month, quarter, year), confirmed and developing values, and the offset-N form all fall out of the same integer math. Finer bars inside each chart bar come from the `intrabar` celled source. ## Introduction An indicator is not locked to its chart's timeframe. From a 1h chart you can read the 4h trend, pull a daily level, or reset a sum on the Monday open. Three forms cover it: - **The bucket fold** (any input): bucket bars by their open time, keep the running bucket's values, and fold a bucket into the higher-timeframe statistic only when the next bucket's first bar arrives. It works on every input, including the ones that cannot pin an interval (trades, liquidations, implied volatility). - **The `interval` pin** (a secondary `ohlcv` input, and `funding` or `oi` on the chart's own market): `input("h4", ohlcv.close, { interval: "4h" })` reads real 4h candles, and the chart hands each one to a chart bar only as of that bar's close. The no-repaint discipline is built into the data path. - **The `candles` stream** (a celled input): `input("d", candles.cells, { interval: "1d", bars: 400 })` reaches back 400 closed days. The first bar receives those that had closed by its own close, and each later bar the day that closed with it. The `./sdk/candles` kit turns that stream into calendar months, quarters and years, or a list of the newest candles. The headline property is shared by all three: none of them repaints. ## The bucket fold is confirmed by default In most charting languages a higher-timeframe lookup is a repaint trap: you ask for the 4h close on a 1h chart and, while the 4h candle is still forming, get its live, not-yet-final value. Your signal looks perfect in backtest and fires a bar early in production, because history got a value the live chart never had. The fold closes that trap. The 4h reading on any 1h bar uses only the 4h candles that had already closed by that bar: the module remembers the last close it saw in the current bucket, and when a bar from the NEXT bucket arrives it folds that remembered close into the 4h statistic. The value you see in history is the value the module saw live. A 4h regime filter, on any chart interval: ```typescript param("ema_len", 20, { min: 2, max: 200, description: "EMA length in 4h candles" }); output("h4_close", line, overlay, { color: "#7c3aed", width: 1, description: "Confirmed 4h close" }); output("h4_ema", line, overlay, { color: "#2563eb", width: 2, description: "EMA of confirmed 4h closes: the trend baseline" }); output("regime", none, overlay, { description: "1 when the confirmed 4h close is above its 4h EMA, 0 otherwise" }); output("bull_dot", shape, overlay, { color: "#16a34a", shape_where: "is_bullish", description: "Bullish 4h regime" }); output("bear_dot", shape, overlay, { color: "#dc2626", shape_where: "is_bearish", description: "Bearish 4h regime" }); output("is_bullish", none); output("is_bearish", none); const H4: f64 = 14400.0; // 4h in seconds; buckets are floored from the epoch, like a rolling "4h" token let ema = new Ema(20); let bucket: f64 = NaN; let bucketClose: f64 = NaN; // the running bucket's latest close let h4Close: f64 = NaN; // the last CLOSED bucket's close let h4Ema: f64 = NaN; function onStart(): void { ema = new Ema(i32(p_ema_len())); } function onBar(): void { const close = bar.close(); const b = Math.floor(bar.time() / H4); if (b != bucket) { // The bucket changed: what we remembered is a CLOSED 4h candle. Fold it now, never earlier. if (!isNaN(bucketClose)) { h4Close = bucketClose; h4Ema = ema.update(bucketClose); } bucket = b; } bucketClose = close; if (isNaN(h4Ema)) return; const regime = h4Close > h4Ema ? 1.0 : 0.0; out_h4_close(h4Close); out_h4_ema(h4Ema); out_regime(regime); out_bull_dot(bar.low()); out_bear_dot(bar.high()); out_is_bullish(regime == 1.0 ? 1.0 : 0.0); out_is_bearish(regime == 0.0 ? 1.0 : 0.0); } ``` The 4h EMA only steps when a 4h candle closes, so it draws as a staircase across four 1h bars. That flat-then-step shape is the visual signature of a correct, confirmed higher timeframe ([Repainting](repainting.md)). The fold works on any chart interval that divides the bucket: a 15m chart contributes sixteen bars per bucket, a 1h chart four, and a 4h chart one (the fold then confirms each bar on the next). ### When you do want the live value Sometimes you genuinely want the forming bucket, for example a live 4h close ticking inside the current period. In a fold that value is already in your hands: it is `bucketClose`, the running variable. Write it to an output of its own, label it as developing, and never build a signal on it: developing values change as the bucket fills, so a cross built on them will not reproduce. Confirmed is what you get by reading the folded variable instead. ### Offset N: the bucket before that To read the completed period before the most recent one, keep a small ring of closed-bucket values: the last few `h4Close` values in a `StaticArray` you rotate at each bucket change, with entry `1` one bucket back ([Variables](core-variables.md)). A pinned input reads the same thing with one option, `offset: 1` ([Offset](#offset-the-candle-before-that)). ## Timeframe tokens: rolling versus calendar Timeframe tokens come in two flavours, and the difference is where the bucket boundaries fall. Both are one line of integer math over the bar's open time in epoch seconds: **Rolling buckets** are `N` seconds wide, floored from the Unix epoch: `Math.floor(bar_t / N)`. `"4h"` is `N = 14400`, `"1d"` is `N = 86400` (and its buckets start at 00:00 UTC purely because that is where epoch days fall), `"7d"` is `N = 604800`, whose week boundaries land on Thursdays. **Calendar buckets** anchor to real UTC calendar boundaries: | Token | Bucket index | UTC anchor | | --- | --- | --- | | `"1D"` | `dayIndex = floor(bar_t / 86400)` | `00:00` | | `"1W"` | `floor((dayIndex + 3) / 7)` | **Monday** `00:00` (epoch day 0 was a Thursday; `+ 3` shifts the week start) | | `"1M"` | `civilYear * 12 + civilMonth` | the first of the month | | `"1Q"` | `civilYear * 4 + (civilMonth - 1) / 3` | Jan / Apr / Jul / Oct 1 | | `"1Y"` | `civilYear` | Jan 1 | `civilYear` and `civilMonth` come from the days-to-civil function on [Time and sessions](time-and-sessions.md). A Monday-anchored `"1W"` and a rolling `"7d"` produce **different** closes over the same chart, because their week boundaries fall on different days; write the one you mean. Multi-count calendar tokens (`"2W"`, `"3M"`) are just a different index (`weekIndex / 2`), so there is nothing to reject. The cookbook's anchored VWAP and key levels are day and Monday-week folds; the regime filter above is a rolling 4h fold. The `./sdk/resample` module writes this fold for you on the built-ins' timeframes (5m to 4h rolling, 1D and 1W on the exchange trade date), with averages and EMAs over the closed candles, confirmed or developing ([Higher-timeframe kit](../functions/higher-timeframe-kit.md#from-the-chart-bars-resampler)). ## The `interval` pin: a real coarser feed The second form reads real coarser candles, and it is the one that also reaches **another symbol**. A secondary `ohlcv` input pinned to a coarser `interval`, on the chart's market or on a market named by `symbol` and `exchange`, is fetched as its own series of closed candles, and each chart bar reads the latest of them that had closed by that bar's close. ```typescript // The first input: the chart's own market and interval. input("close", ohlcv.close); // A coarser interval of the same market: each daily candle arrives as of its close. input("daily", ohlcv.close, { interval: "1d", description: "Prior daily close, as of close" }); // Another symbol at a coarser interval: the request() form, pinned by symbol and exchange together. input("eth_4h", ohlcv.close, { symbol: "ETHUSDT", exchange: "BINANCE_FUTURES", interval: "4h", description: "ETH 4h close, as of close" }); output("daily", line, overlay, { color: "#f59e0b", description: "Daily close, stepping once per day" }); output("eth_4h", line, lower, { color: "#7c3aed", description: "ETH 4h close" }); output("ratio_to_daily", line, lower, { color: "#2563eb", unit: "%", description: "This bar's close relative to the last daily close" }); function onBar(): void { const close = bar.close(); const daily = in_daily(); const eth = in_eth_4h(); if (isNaN(daily) || isNaN(eth)) return; out_daily(daily); out_eth_4h(eth); out_ratio_to_daily(daily == 0.0 ? NaN : ((close - daily) / daily) * 100.0); } ``` Add it to a 1h chart: `daily` steps once a day, `eth_4h` reads the ETHUSDT perpetual on Binance at 4h whatever market the chart shows, and `ratio_to_daily` compares every hourly close with the last closed daily candle. The chart checks every pin before it fetches anything and refuses, by name, a pin it cannot serve. ### When a pinned candle arrives On a 1h chart pinned to 4h, the 00:00 to 04:00 candle belongs to the 03:00 bar, the bar whose close is 04:00: while that bar is still forming it reads the previous 4h candle, and once the bar has closed the chart evaluates it again with the new candle in place. The bucket fold above shows the same candle from the 04:00 bar, one bar later. Both are causal: neither reads a candle before it closed. A pin reads the `confirmed` view unless the input declares another one with `view`, or lists extra views with `views`, each read through its own accessor: ```text input("h4", ohlcv.close, { interval: "4h", views: "forming,is_new_period" }); // in_h4() confirmed: the latest 4h candle closed by this bar's close, carried forward // in_h4_forming() forming: this bar's own 4h bucket so far, a developing value // in_h4_is_new_period() is_new_period: 1 on the bar where the held 4h candle changes, else 0 ``` `forming` folds the chart's own bars (or, for a pinned market, that market's bars at the chart's interval), goes up to `1w`, and needs the chart's own candles as the first input; like the fold's `bucketClose`, it is for display, not for signals. `is_new_period` reads 0 on the forming bar until it closes. ### Offset: the candle before that `offset: N` reads the pinned candle N candles before the one `confirmed` holds, counted in the pin's own candles: ```text input("wk_high", ohlcv.high, { interval: "1w" }); // the last closed week's high input("wk_high_before", ohlcv.high, { interval: "1w", offset: 1 }); // the week before that one ``` N runs from 1 to 500, and the editor writes the `offset` view into the sheet for you. Until N + 1 candles have closed the input reads `NaN` (0 under `missing: "zero"`), and the bar still runs. `offset: 0` would be the confirmed reading itself, so leave the option off for it. A calendar offset (the quarter before last) comes from the `candles` stream below. ### What the chart serves | Input | Interval pin | Market pin | | --- | --- | --- | | a secondary `ohlcv` input | coarser than the chart and a whole multiple of its interval | `symbol` + `exchange`, at the chart's interval or with a coarser one | | `funding` (`rate_close`), `oi` | coarser, on the chart's own market (bucketed from their own rows) | refused | | `odds` | coarser, `confirmed` and `is_new_period` views only | the market's condition id in `symbol`, always | | `trades`, `liquidations`, `implied_volatility`, `skew`, `etf_flow`, `time` | refused | refused | | `candles` (celled) | required: any interval word, finer than, equal to or coarser than the chart's | `symbol` + `exchange` | | `intrabar` (celled) | required: finer than the chart's and dividing it evenly | `symbol` + `exchange` | | other celled inputs (`book`, `volume_profile`, `options_chain`) | refused | refused | | the first input | refused: it follows the chart's interval | refused: it follows the chart's market (an `odds` first input is the exception) | For the feed pins above, a finer interval is refused, and so is one that is not a whole multiple of the chart's (a `5m` pin on a `3m` chart), a view without an interval, and a `forming` view above `1w`. The interval words are `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, `1w` (`MINUTE`, `FIVE_MINUTES`, `FIFTEEN_MINUTES`, `THIRTY_MINUTES`, `HOUR`, `FOUR_HOURS`, `DAY` and `WEEK` work too). An interval pin equal to the chart's own interval is no pin at all: the input reads bars at the chart's interval. What a coarse pin costs: the chart fetches the pinned candles from two of them before the first loaded bar (N more for `offset: N`, and further back with `bars`, below), and the value steps once per pinned candle. Keep the default `missing: "carry"` on a coarse pin: it carries the latest closed candle across the bars in between, while `nan` and `zero` give those bars the fill and only the bar that admits a new candle reads it. ### The chart's custom timeframes The chart's custom timeframes are interval words too: `2m`, `3m`, `10m`, `45m`, `2h`, `6h`, `8h`, `12h` and `3d` (`TWO_MINUTES`, `THREE_MINUTES`, `TEN_MINUTES`, `FORTY_FIVE_MINUTES`, `TWO_HOURS`, `SIX_HOURS`, `EIGHT_HOURS`, `TWELVE_HOURS` and `THREE_DAYS` work too). They work everywhere a pin does: a pin and its views, `offset`, `bars`, a `candles` stream and `intrabar`, by the same rules as the eight above. They are chart only. The chart serves them; anywhere else an Indicator that pins one is refused by name, `wrun_custom_interval_unsupported`, rather than reading a nearby interval in its place. Every custom bar opens on the UTC grid of its own length counted from 1970-01-01, the grid the chart draws them on, so the whole-multiple rule decides what nests: | Chart | Pin | Why | | --- | --- | --- | | `45m` | `1d` works, `1h` is refused | `45m` divides a day (32 bars, restarting at midnight UTC) but not an hour | | `1d` | `3d` works | three whole days; a `3d` bar opens every third day from 1970-01-01, so its weekday drifts | | `3d` | `1w` is refused | three days never divide a week, whose bars open on Mondays | | `2h` | `6h`, `8h`, `12h` and `1d` work, `45m` is refused | `45m` is finer than the chart: read it as a `candles` stream instead | A future follows its exchange's session instead: a CME future's bars open on the 17:00 Chicago session, and its `3d` bar holds three trade dates, so one can span a weekend or a holiday and close days after its open plus three days. A pin and its views, a `candles` stream and `intrabar` read such a bar only once it closes, never while it is still forming. ### Stocks, forex and gold A pin on a stock, forex or gold market, and any pin on a stock or forex chart, is served like a crypto pin, its candles joined onto the chart's bars. A `3d` pin on those markets is refused and an index pin reads `NaN`; the session rules are on [Multi-source](multi-source.md#stocks-forex-and-gold). ## History: the `candles` stream A pin hands each bar one candle. When the indicator needs many candles at once (every day since January 1st, last quarter's range, the last 20 weekly candles), read the timeframe as a stream of its closed candles: ```text input("close", ohlcv.close); // the first input stays a scalar: it is the bar grid input("d", candles.cells, { interval: "1d", bars: 400 }); // the newest 400 closed days, then each day as it closes ``` - **The tuple.** Six numbers per candle, oldest first: `[offset_ms, open, high, low, close, volume]`. `offset_ms` is the candle's open minus the bar's open, negative for history, so the candle opened at `bar.time() + offset_ms / 1000` epoch seconds. - **When candles arrive.** The first bar carries the backlog: every closed candle from the start of the stream up to that bar. Each later bar carries the candles that closed since the bar before it, which on most bars is none. A candle arrives on the first bar whose close is at or after its own, so a forming candle never arrives and none arrives twice. - **`interval`** is required and may be any interval word: coarser than the chart's (daily candles on a 1h chart), equal to it, or finer. `symbol` and `exchange` together read another market. - **`bars`** (1 to 5000) sets the depth: the stream starts at least `bars` candles before the forming one (earlier when the chart has loaded more), so by the newest bar the indicator has seen at least the newest `bars` closed candles. Without it the stream starts one or two candles before the first bar the indicator runs. - **`max_cells`** may be left out: the editor then declares the larger of `bars` and 2. A finer interval also needs room for the candles one bar can close, the chart's interval over the stream's plus one (61 for `1m` candles on a 1h chart), and Run names the number when the cap is short. A block over the cap stops the run; it is never cut short. - **`view: "forming"`** turns an input into the timeframe's live candle: `input("d_live", candles.cells, { interval: "1d", view: "forming" })` carries the day still forming, as the market serves it so far, as one tuple on the live bar (its `offset_ms` is that candle's open minus the bar's) and an empty block on every other bar, any interval, finer than the chart's or coarser. It takes no `bars`, and `max_cells` 1 holds it. The closed stream and this candle together are the newest candles through now. - **`view: "forming_open"`** turns an input into the open of the candle each bar sits in, on every bar: `input("d_open", candles.cells, { interval: "1d", view: "forming_open" })` hands every bar one tuple `[offset_ms, open, NaN, NaN, NaN, NaN]`, the open of the day holding that bar, today's still-forming day included. A candle's open is settled once the candle starts trading, so the value never looks ahead and never changes; the day's high, low, close and volume as of the bar are not known, so they read `NaN`. This is the running open of a higher timeframe, which the stream (a candle only once it closes) cannot give a bar in the middle of a day: Woodie pivots, for one, read the new period's open on its first bar even when the chart's first loaded bar is 21:00. A bar no candle holds (a gap in that timeframe) gets an empty block. Any interval, no `bars`, and `max_cells` 1 holds it. The `./sdk/candles` kit reads the stream: a `Periods` folds it into calendar days, weeks, months, quarters or years, and a `CandleList` keeps the newest candles. A `Periods` takes a `1d` or finer stream, or a `1w` one told so; a `3d` stream is not a calendar unit, so read it through `CandleList`. Feed it every bar, empty blocks included, because each bar moves its clock ([Calendar periods and candle lists](../functions/higher-timeframe-kit.md#calendar-periods-and-candle-lists)). ### A yearly VWAP The volume-weighted average price of every day closed since January 1st, on any chart interval: ```typescript input("close", ohlcv.close); // a scalar first: the first input is the bar grid input("d", candles.cells, { interval: "1d", bars: 400, description: "The newest 400 closed days" }); output("yvwap", line, overlay, { color: "#f59e0b", width: 2, description: "VWAP of this year's closed days" }); const year = new Periods(period.YEAR); function onBar(): void { // Every bar feeds its block, an empty one too: the bar moves the period clock. year.load(in_d_view(), in_d_cells(), bar.time()); out_yvwap(year.developing().vwap()); } ``` On a 1h chart the first bar receives the backlog, every day closed from 400 days back up to that bar, and each later bar receives the day that closed with it (the 23:00 bar receives that day) or nothing. `Periods` files each day under its calendar year, which starts January 1st at 00:00 UTC, and keeps the year's VWAP sums, so `developing().vwap()` is the VWAP of this year's closed days and steps once a day. 400 days always reach back past January 1st, so the current year is whole. A year the stream does not reach back to the start of reads `NaN`, never a partial VWAP. For a line that also moves inside the day, add the chart's own bars of the forming day to the period's `pv` and `pvVolume` sums. ### A grid of timeframes A matrix of small candle charts, one per timeframe, pinned to a corner of the pane: for each timeframe a `candles` stream into a `CandleList` and its live candle, and the `draw.minicharts` widget, whose frame the indicator rewrites on the live bar: ```typescript input("close", ohlcv.close); // a scalar first: the first input is the bar grid input("m15", candles.cells, { interval: "15m", bars: 18, max_cells: 100 }); // a 1d chart closes 96 of them per bar input("m15_live", candles.cells, { interval: "15m", view: "forming" }); // the live 15m candle, on the live bar input("h1", candles.cells, { interval: "1h", bars: 18, max_cells: 25 }); input("h1_live", candles.cells, { interval: "1h", view: "forming" }); input("h4", candles.cells, { interval: "4h", bars: 18 }); input("h4_live", candles.cells, { interval: "4h", view: "forming" }); input("d1", candles.cells, { interval: "1d", bars: 18 }); input("d1_live", candles.cells, { interval: "1d", view: "forming" }); output("held", none, lower, { description: "Closed daily candles the grid holds" }); // data-only: a reading a watch can use // The settings: each "@" in the grid below binds one of them to a look option. param.color("panelBg", "#f7f3e8ee", { label: "Panel background", group: "Style" }); param.color("borderColor", "#c8c1b3", { label: "Border color", group: "Style" }); param.color("textColor", "#18202b", { label: "Text color", group: "Style" }); // the text and the wicks param.color("bullColor", "#13a983", { label: "Bull candle", group: "Style" }); param.color("bearColor", "#e04f5f", { label: "Bear candle", group: "Style" }); param.bool("showFastMA", true, { label: "Show fast MA", group: "Moving averages" }); param.int("fastMALen", 5, { label: "Fast MA length", min: 2, max: 30, group: "Moving averages" }); param.color("fastMAColor", "#f59e0b", { label: "Fast MA color", group: "Moving averages" }); param.bool("showSlowMA", true, { label: "Show slow MA", group: "Moving averages" }); param.int("slowMALen", 12, { label: "Slow MA length", min: 3, max: 60, group: "Moving averages" }); param.color("slowMAColor", "#2563eb", { label: "Slow MA color", group: "Moving averages" }); const grid = frame("grid", { max_bytes: 16384 }); draw.minicharts({ name: "matrix", frame: grid, x: -84, y: 42, columns: 2, panel_width: 250, panel_height: 144, background_color: "@panelBg", border_color: "@borderColor", text_color: "@textColor", wick_color: "@textColor", bull_color: "@bullColor", bear_color: "@bearColor", show_fast_ma: "@showFastMA", ma_fast_length: "@fastMALen", ma_fast_color: "@fastMAColor", show_slow_ma: "@showSlowMA", ma_slow_length: "@slowMALen", ma_slow_color: "@slowMAColor", ma_width: 1, show_change: true, show_volume: true, }); const m15 = new CandleList(18); const h1 = new CandleList(18); const h4 = new CandleList(18); const d1 = new CandleList(18); function onBar(): void { // Every bar feeds every stream, an empty block too: the bar moves each list's clock. m15.load(in_m15_view(), in_m15_cells(), bar.time()); h1.load(in_h1_view(), in_h1_cells(), bar.time()); h4.load(in_h4_view(), in_h4_cells(), bar.time()); d1.load(in_d1_view(), in_d1_cells(), bar.time()); out_held(f64(d1.count())); if (!bar.isLast()) return; // Each panel: the newest closed candles, then the timeframe's live one, 18 in all. mc_begin("{{symbol}}"); mc_leg("15m", m15, 18, in_m15_live_view(), in_m15_live_cells(), bar.time()); mc_leg("1h", h1, 18, in_h1_live_view(), in_h1_live_cells(), bar.time()); mc_leg("4h", h4, 18, in_h4_live_view(), in_h4_live_cells(), bar.time()); mc_leg("1d", d1, 18, in_d1_live_view(), in_d1_live_cells(), bar.time()); writeMiniCharts(FRAME_GRID); } ``` The declaration holds the grid's look: `anchor` with `x` and `y` pixel offsets from it, `columns`, the panel size and `gap`, the colours, `candle_style`, the two moving averages and the change badge and volume strip, which the chart draws from the candles ([Cards, frames and panels](../presentation/cards-frames-panels.md)). A look option takes a literal, or an `"@"` reference where a setting should change it: a colour takes a `param.color`, a switch (`show_fast_ma`, `show_slow_ma`, `show_change`, `show_volume`) a `param.bool`, an average's length a `param.int` whose `min` is 1 or more, `ma_width` a number setting kept within 0.5 to 5, and `candle_style` a `param.choice` over the style words. The setting's default draws until someone changes it, and one setting may drive several options: here `textColor` colours the text and the wicks. A reference to a setting of another kind stops the build with the option's name. A grid that binds nothing gets settings of its own instead: the panel, border, text, candle and wick colours, the candle style, each declared average's switch, length and colour, the average width and the change and volume switches, labelled and defaulting to the values written in the declaration. The frame holds what changes: the title and each panel's label and candles, written with `mc_begin` and `mc_leg` (or `mc_candles`, `mc_panel` and `mc_bar` for candles of your own) and sent with `writeMiniCharts`. The chart writes the market's name in place of `{{symbol}}`. On the live bar each panel holds its newest closed candles and ends with the timeframe's live candle, whatever the chart's interval: the `view: "forming"` inputs carry it, finer than the chart's or coarser. On every other bar those inputs are empty, so a panel shows closed candles only. ### How far back history reaches `bars` asks; the data decides. On the chart a stream or a pin reaches back 15,000 of its own candles (25,000 on `1h`, 50,000 on `30m`). Where history stops sooner, as it does at about a year (365 days) on some data plans, the run is not refused: the stream starts where history is served, and a warning names that start. About a year of history still holds the current quarter, and the current year until its last day or two. ### Two things to plan for - **Keep a scalar input first.** The first `input` line is the bar grid, so a file that opens with the stream is refused when it runs (`wrun_candles_primary: input 'd' reads candles at index 0; ...`). Put `input("close", ohlcv.close);` above it. - **A stream across a weekend.** On a market that pauses (a CME future breaks daily and closes for the weekend), a stream pinned to a market that trades around the clock (BTC) hands the first bar after a pause every candle that closed during it. On an intraday interval that block can exceed `max_cells` and stop the run: raise `max_cells` to hold a weekend of candles, or, for candles finer than the chart's, read them through `intrabar`, which slices each bar instead. ## Lower timeframes The `intrabar` celled source attaches the finer bars inside each chart bar as cells: ```typescript input("close", ohlcv.close); input("m1", intrabar.cells, { interval: "1m", max_cells: 60 }); ``` Each chart bar carries one `[offset_ms, open, high, low, close, volume]` tuple per CLOSED finer bar it contains, ascending by open, `offset_ms` the finer open minus the bar's open (the finer bar's own open in epoch milliseconds is the chart bar's open in seconds times 1000 plus `offset_ms`). The forming finer bar never ships; a covered bar with no finer bars is a present empty block (`in_m1_cells()` = 0), a bar the finer history does not reach reads -1, and blocks are never truncated (declare `max_cells` at least the finer bars per chart bar: 60 for 1m under 1h, or **Run** refuses `wrun_intrabar_ratio_over_cap`). The interval is REQUIRED, finer than the chart's, a whole divisor of it and an interval word, the custom timeframes included (`3m` slices a `45m` chart into 15 bars, while `10m` would straddle its bars and is refused; `wrun_intrabar_leg_not_finer`, `wrun_intrabar_leg_off_grid`), so the finest is `1m` and the chart must be `2m` or coarser. The source reads the chart's own market, or another one named by `symbol` and `exchange` together, and can never be the first input. On a closed bar with full coverage, folding the block (first open, max high, min low, last close, summed volume) is the bar's own candle; on a pinned market it is that market's candle over the same span. The other celled sources slice the same bar by price (`volume_profile`) or level (`book`; [Data sources](data-sources.md)). ## What the fold can see A fold is computed from the bars the chart loaded: a bucket already running when the loaded history starts is incomplete, so keep its statistic `NaN` until the first boundary passes (the cookbook's anchored VWAP does this). The chart applies the same rule to a `forming` view: a bucket that opened before the first loaded bar reads `NaN` until panning back loads the rest. A coarse pin is not limited this way: it fetches its own candles, so a daily level resolves from the first loaded 1m bar without loading a day of minutes. Neither is a `candles` stream: it reaches `bars` candles back whatever the chart has loaded, which is how a 1m chart reads this year's days. ## Boundaries The chart refuses, by name, every pin it cannot serve (the table above), so a pinned file never silently reads the chart's own bars in place of the candles it asked for. Test a new fold by writing its bucket index to a data-only output and reading it back at the editor's Console prompt (`last 20 bucket` prints the last twenty values of an output named `bucket`). An alert reads pins within 600 bars of the chart's interval: another market's candles on a secondary `ohlcv` input, and a coarser pin of `1m` to `1w` that is a whole multiple of the chart's interval, read once its candle has closed. It refuses a custom timeframe ("This Indicator is pinned to a different interval than the chart."), an `offset` view, `intrabar` cells and `candles` streams. The 600 chart bars hold 600 / (leg / chart) candles of a pin, 37 of a 4h pin on a 15m chart and 12 on 5m, so an average of 20 4h candles fills on 15m and arms but stays empty on 5m: arm such alerts on a coarser chart. A fold of the chart's own bars has no pin to size the alert by: declare `warmup()` with the bars its window spans, or the alert can arm and stay empty ([Alerts](../functions/alerts.md)). ## From `htf()`, `request()`, `requestBars()` and `ltf()` If you have written chart scripts with these four functions, each call maps to one declaration here: | The function | Here | | --- | --- | | `htf(src, "4h")`, or `request(sym, "4h")` for another market: the last closed candle | `input("h4", ohlcv.close, { interval: "4h" })`, the `confirmed` view, with `symbol` and `exchange` for another market | | `htf(src, "4h", { mode: "developing" })` | `view: "forming"`, or `views: "forming"` beside the confirmed reading | | `htf(src, "4h", { offset: N })` | `offset: N` | | `request(sym, "4h", { bars: N })` | `bars: N` on the pin | | `request(sym, "1W")`, `"1M"`, `"1Q"`, `"1Y"` | a `1d` `candles` stream read through `Periods(period.WEEK)`, `MONTH`, `QUARTER` or `YEAR`: `confirmed(0)` is the last closed period, `confirmed(1)` the one before (with `keep` 2), `developing()` the current one | | `request(sym, "1Q")` and `"1Y"`, value for value | the same `Periods` fed a `1w` stream and told so, `new Periods(period.QUARTER, 4, period.WEEK)`: a week that straddles two periods counts in the one it opens in, as those two tokens count it | | `vwap(anchor="quarter")` or `"year"` | `Periods(period.QUARTER)` or `YEAR` over a `1d` stream: `developing().vwap()`, plus the forming day's own bars for a live value | | `requestBars(sym, "1d", { bars: N + 1 })` | a `1d` `candles` stream with `bars: N`, read through `CandleList(N)`: the same rows without the last one, the forming candle, which a second `1d` `candles` input declared `view: "forming"` carries on the live bar (or the `forming` view of a scalar pin) | | `plotMiniChartGrid(panels, title, position, x, y, ...)` over `requestBars(..., { bars: N, anchor: "latest" })` panels | `draw.minicharts({ name, frame, ... })` with the same look in snake case (`position` is `anchor`, `panelWidth` is `panel_width`), and per panel a `candles` stream read through `CandleList(N)` plus its `view: "forming"` twin, written with `mc_leg` (the live candle last) and sent with `writeMiniCharts`; the look's inputs are settings bound with `"@"` references ([A grid of timeframes](#a-grid-of-timeframes)) | | `ltf("1m")` | `input("m1", intrabar.cells, { interval: "1m", max_cells: 60 })` | | `ltf("1m", "ohlcv", sym, exchange)` | the same with `symbol` and `exchange` | # Multi-source and aggregation One indicator can read several data types and markets at once: the other feeds of the chart's market, other markets' candles, and a sum across venues. Other markets come in through `symbol` + `exchange` pins, side-split flow through `side`, sparse feeds through `missing`, and aggregation is ordinary arithmetic across inputs. Everything rides `input` declarations, and the finer bars inside each chart bar come from the `intrabar` celled source. ## Introduction An indicator is not limited to its chart's candles. On the chart it can declare the other data types the chart serves for the chart's own market (open interest, funding, side-split trades, liquidations, a book snapshot, a volume profile) and the candles of other markets and venues, and combine them with ordinary math. "Aggregated" indicators (one quantity summed across venues) are not a special feature; they fall out of this plus a few lines of arithmetic. ```text input("close", ohlcv.close); // the first input: the chart's own candles input("btc", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES" }); // a pinned reference market // in onBar(): out_ratio(bar.close() / in_btc()); ``` ## How multiple sources coexist Three rules make cross-source math safe by default: 1. **One grid: the chart's candles.** The rows are the chart's own candles; the first input follows the chart's market and interval, and every other input is aligned onto those rows. Every input is readable on every bar. 2. **Values align by policy.** A source at the chart's interval joins by bar open. A coarser pin contributes only as of each bar's close ([Multi-timeframe](multi-timeframe.md)). A scalar source with no observation on a bar delivers the latest value carried forward (`missing: "carry"`, the default), `NaN` (`"nan"`), or `0` (`"zero"`), and you choose per input. Funding against 1h candles just works. 3. **Events never fabricate.** Every comparison with `NaN` is false, and `Cross.update()` answers `0` when either side is `NaN`, so a venue that has not reported cannot invent a signal. ## Multi-symbol A secondary `ohlcv` input can name a market other than the chart's. The two halves pin together (the editor refuses a lone `symbol` or a lone `exchange`), written in the chart's own ids, and everything downstream is source-agnostic: classes, folds, gates, shapes. The ETH/BTC ratio and the relative-strength reading between them: ```typescript param("period", 14, { min: 2, max: 200, description: "Lookback for the ratio average and the leader's RSI" }); // The first input follows the chart (ETHUSDT on an ETH chart); the reference is pinned. input("close", ohlcv.close); input("btc", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES", description: "BTC close, the reference market" }); output("ratio", line, lower, { color: "#2563eb", width: 2, description: "The chart's close divided by the BTC close" }); output("ratio_sma", line, lower, { color: "#94a3b8", description: "Average of the ratio" }); output("lead_rsi", line, lower, { color: "#f59e0b", description: "RSI of the reference market: the leader's momentum" }); let sma = new Sma(14); let rsi = new Rsi(14); function onStart(): void { sma = new Sma(i32(p_period())); rsi = new Rsi(i32(p_period())); } function onBar(): void { const close = bar.close(); const btc = in_btc(); const ratio = btc > 0.0 ? close / btc : NaN; const ratioSma = isNaN(ratio) ? NaN : sma.update(ratio); const leadRsi = rsi.update(btc); if (isNaN(ratioSma) || isNaN(leadRsi)) return; out_ratio(ratio); out_ratio_sma(ratioSma); out_lead_rsi(leadRsi); } ``` On the chart the pinned market's candles are joined onto the chart's rows by timestamp, at the chart's interval, under the input's `missing` policy, and kept live from that market's own candle feed, so the forming bar reads the reference market's forming candle. For another market at a coarser interval, the declaration adds `interval` to the pin: `input("eth_4h", ohlcv.close, { symbol: "ETHUSDT", exchange: "BINANCE_FUTURES", interval: "4h" })`, read as of each bar's close ([Multi-timeframe](multi-timeframe.md)). Cross-symbol arithmetic is only dimensionally sane under a shared quote currency. Only a secondary `ohlcv` input may name another market. The chart refuses, by name and before it fetches anything, a market pin on the first input, on any other feed (trades, open interest, funding, liquidations, implied volatility, skew, ETF flows, time) and on every celled source: those always read the chart's own market. A Polymarket `odds` input is the one exception, since it always names its own market by condition id ([Data sources](data-sources.md)). Stocks, ETFs, forex, gold and silver pin the same way, on any chart (next section). ## Stocks, forex and gold An input can read a stock, an ETF, a forex pair, gold or silver beside the chart's own market. Pin it with the market's exchange id and symbol, as you pin any other market. ```typescript // The chart's market measured against a stock ETF, a forex pair and gold, each read from its own market's daily candles. // The first input is the indicator's own market: leave it unpinned so it follows the chart. input("close", ohlcv.close); input("spy", ohlcv.close, { symbol: "SPY/USD", exchange: "POLYGON", interval: "1d" }); input("eurusd", ohlcv.close, { symbol: "EUR/USD", exchange: "FX_OTC", interval: "1d" }); input("gold", ohlcv.close, { symbol: "XAU/USD", exchange: "FX_OTC", interval: "1d" }); output("vs_spy", line, lower, { color: "#2563eb", description: "The close divided by SPY's last daily close" }); output("in_euro", line, lower, { color: "#16a34a", description: "The close in euros" }); output("in_ounces", line, lower, { color: "#eab308", description: "The close in ounces of gold" }); function onBar(): void { const close = bar.close(); if (isNaN(close)) return; const spy = in_spy(); const eurusd = in_eurusd(); const gold = in_gold(); // A pinned input reads NaN until its first daily candle has closed. if (isFinite(spy) && spy > 0) out_vs_spy(close / spy); if (isFinite(eurusd) && eurusd > 0) out_in_euro(close / eurusd); if (isFinite(gold) && gold > 0) out_in_ounces(close / gold); } ``` - Keep the chart's own input first. The first input is the indicator's own market, and every pin lines up with its bars. - Each pin reads its market's last closed daily candle, so run this on a chart finer than one day. - A pinned input is `NaN` until its first candle closes. Check before you divide. ### Markets you can pin | Market | `exchange` | `symbol` | What you get | | --- | --- | --- | --- | | US stocks and ETFs | `POLYGON` | `AAPL/USD`, `SPY/USD`, `QQQ/USD`, `GLD/USD` | candles with volume, in regular trading hours unless the chart is a US stock chart with extended hours on | | Forex | `FX_OTC` | `EUR/USD`, `GBP/USD`, `USD/JPY` | quote candles: open, high, low and close; `volume` reads 0 | | Gold and silver | `FX_OTC` | `XAU/USD`, `XAG/USD` | quote candles, as forex | | Crypto | the venue's id | the venue's spelling | [Exchange and symbol format](../reference/symbol-format.md) | Stocks and forex trade in sessions, so their bars do not always open where a crypto chart's bars open. Pin an interval coarser than the chart's, as the example does: a pin at the chart's own interval lines up bar by bar on exact times, and a stock's hourly bars (13:30 UTC, 14:30 UTC, ...) never meet a crypto chart's hourly bars. ### What you cannot pin | Pin | What happens | | --- | --- | | a CME Group market (`CME`, `CBOT`, `NYMEX`, `COMEX`, `GLOBEX`) | the build refuses it: `input 'es' pins exchange CME: wrun_pin_venue_refused: CME is CME Group market data and cannot be read from another market's script`. A `param.symbol` default on one is refused the same way. | | more than one market in a pin | the build refuses it: `input 'spy' pins symbol "SPY/USD + CME\|ES1!": wrun_pin_symbol_literal: symbol "SPY/USD + CME\|ES1!" is not one market's spelling (letters, digits and . _ - / : @ ! only; no spaces, no \| and no operators)` | | an index (S&P 500, Nasdaq 100, VIX, the dollar index) | the build accepts `POLYGON_INDICES`, but index series are not served yet. On the chart the input reads `NaN` on every bar and the legend says why; an alert on the indicator is refused. Pin an ETF on the same market instead (`SPY/USD`, `QQQ/USD`). | | a `3d` pin on a stock, forex or gold market | the chart refuses it before it fetches: a three-day candle there groups trading days and would count as closed too early. Pin `1d` or `1w`. | The chart's own market may still be a CME market on a chart entitled to it: the rule is about reading CME data from another market's script. The chart's words for the last two rows: - `Input 'spx' pins SPX on POLYGON_INDICES; index series (SPX, NDX, VIX, DJI) are not served yet: the gateway holds POLYGON_INDICES out of the market directory until the backend opens it, so the input reads its missing fill (NaN) on every bar.` - `Input 'spy' pins interval '3d' on POLYGON/SPY/USD: a multi-day candle on a session venue groups trading days (a Friday bar runs into Monday) and would be confirmed at its open plus the span, early and still forming (wrun_pin_session_interval_unserved); pin 1d or 1w instead` ### In alerts An alert evaluates pinned stock, forex and gold candles like any other pinned market. It refuses an indicator that pins an index ("This Indicator reads a market alerts cannot evaluate yet (an index).") or a CME Group market ("Indicator alerts are not allowed on this venue."). Alerts read stock candles with extended hours, while the chart reads regular hours unless its own market is a US stock with extended hours on, so a stock leg can differ slightly between the chart and its alert. ## Multi-venue aggregation The flagship pattern: one quantity, summed across exchanges. On the chart the venues come in through their candles, so the natural aggregates are candle ones: volume across venues, a cross-venue price spread, a volume-weighted price. Each venue is one pinned `ohlcv` input declared with `missing: "nan"`, so a venue with no candle on a bar reads `NaN`, counts as not reporting, and contributes nothing: ```typescript param("smooth", 21, { min: 2, max: 200, description: "EMA length over the aggregated volume" }); // The chart's own candles are the rows; each venue's candles join onto them by timestamp. input("close", ohlcv.close); input("vol_binance", ohlcv.volume, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES", missing: "nan", description: "Binance BTC perpetual volume" }); input("vol_bybit", ohlcv.volume, { symbol: "BTCUSDT", exchange: "BYBIT", missing: "nan", description: "Bybit BTC perpetual volume" }); output("agg_volume", histogram, lower, { color: "#2563eb", description: "Volume summed over the venues that reported" }); output("volume_ema", line, lower, { color: "#94a3b8", width: 2, description: "Smoothed aggregated volume" }); output("binance_share", line, lower, { color: "#f59e0b", unit: "%", description: "Binance share of the two venues' volume" }); let ema = new Ema(21); // A venue with no candle on this bar reads NaN (missing: "nan") and contributes nothing. function reported(value: f64): f64 { return isNaN(value) ? 0.0 : value; } function onStart(): void { ema = new Ema(i32(p_smooth())); } function onBar(): void { const binance = in_vol_binance(); const bybit = in_vol_bybit(); if (isNaN(binance) && isNaN(bybit)) return; const agg = reported(binance) + reported(bybit); const smoothed = ema.update(agg); // Venue dominance: NaN rather than a fake 100% when only one venue reported. const share = isNaN(binance) || isNaN(bybit) || agg <= 0.0 ? NaN : (100.0 * binance) / agg; out_agg_volume(agg); out_volume_ema(smoothed); out_binance_share(share); } ``` **Venue dominance** is the last output: one venue's share of the aggregate, `NaN` rather than a fake `100%` when only one venue reported. The same shape covers a spread (subtract instead of add) or a volume weighted price (sum `close * volume` and divide by the summed volume): one pinned input per venue, one term per input, and a `missing` policy that says what a silent venue means. Check that the venues count volume in the same unit before you add them. Package the helpers as a class and the whole family is one paste. Flow and derivative feeds (side-split trades, open interest, funding, liquidations) always read the chart's own market, so a cross-venue sum of them is not available on the chart; accumulate the chart's own market instead. ## Higher-timeframe views of aggregates A fold composes with any input: bucket the aggregated series by `time.bar_open_sec` and fold it on the next bucket, and the 4h view of a cross-venue sum is confirmed and repaint-free exactly like a single-source one ([Multi-timeframe](multi-timeframe.md)). ## Lower-timeframe data and raw bars The `intrabar` celled source delivers the finer bars inside each chart bar as cells, for the chart's own market ([Multi-timeframe](multi-timeframe.md)). There is no raw-bar request that returns the last N native bars of another market as an array for drawing. The celled sources slice the same bar by price (`volume_profile`), by level (`book`), or by time (`intrabar`), and a drawing's coordinates come from values the module computes on the chart's rows ([Drawing objects](../presentation/drawing-objects.md)). To mark the last twenty daily highs, keep them in a ring buffer from a daily fold or a `1d` pin and draw them as segments. ## Sparse flow sources align to the grid Some market-flow feeds are naturally sparse: a liquidation, a funding print, or a side-split trade bucket may simply have no event for a candle. The `missing` policy is how you say what that means, per input: | Policy | A bar with no observation reads | Use it for | | --- | --- | --- | | `"carry"` (default) | the latest value, carried forward | levels and states: open interest, a coarse close, a funding rate between prints | | `"nan"` | `NaN` | "did this venue report?" logic: a `NaN` counts as not live and never fires a comparison | | `"zero"` | `0` | flows to be summed: liquidation volume, side-split volume, where nothing happened means zero | The rows stay the chart's candles whatever the policy, so a sparse series such as liquidations reads its fill on the bars where nothing happened. The practical rule: keep an `ohlcv` spine as the first input in a multi-source flow package, then read the flow sources beside it. The candle grid stays stable, sparse "nothing happened" bars become `0` or `NaN` as you choose, and truly unavailable history stays distinguishable from zero activity. ## Budgets and good citizenship On the chart a wrun indicator counts against your plan's per-chart indicator limit by the number of distinct sources it reads (a source name plus its pinned market), and the practical cost of each one is fetch time over the loaded window ([Data sources](data-sources.md)). Declare the inputs the computation needs, pin `symbol` and `exchange` together, keep the chart's own candles first, and put the sparse feeds on the policy that matches their meaning. ## Availability Whether the chart can serve a feed on the market in front of you is answered by name when you press **Run**, never with silent empty data ([Data sources](data-sources.md)). ## Next - **Multi-timeframe:** a pin at a coarser interval, read as of each bar's close ([Multi-timeframe](multi-timeframe.md)) - **Data sources:** every feed, its fields and its options ([Data sources](data-sources.md)) - **Exchange and symbol format:** the venue ids and symbol spellings a pin takes ([Exchange and symbol format](../reference/symbol-format.md)) - **Alerts:** which pinned markets an alert can evaluate ([Alerts](../functions/alerts.md#what-an-alert-can-evaluate)) # Time and sessions Read the current bar's clock and trading session from the `time` source: hour, day of week, day of month, month, year, and session predicates, all in UTC, all as small functions you paste into the file. A wrun indicator reads one number per bar, the bar's open time in epoch seconds, and derives everything from it with integer math. The `./sdk/clock` module does this for you ([Clock and sessions kit](../functions/time-and-sessions-kit.md)); this page shows the math underneath. ## Introduction Every bar happens at a moment in time, and that moment is often a signal in itself: London opening, the Asian session winding down, a fresh day, a weekend gap. An indicator reads that moment with one call: ```text bar.time() // the bar's open time, seconds since 1970-01-01 00:00 UTC ``` Three things to keep in mind: - **It is seconds, not milliseconds.** `bar.time()` is seconds. Multiply by 1000 for a millisecond value, and keep the math in `i64` (a 32-bit integer cannot hold an epoch second past 2038 comfortably, and it cannot hold milliseconds at all). - **It is the bar's open.** Not the close, not "now". There is no wall clock inside the module: the newest bar is the live edge, and the host tells you nothing about the clock on the wall. - **Times are UTC.** Every clock value and every session boundary below is in UTC. There is no local-timezone surprise, but it does mean you compare against UTC hours when you write a rule, and daylight-saving shifts in a venue's local session are yours to encode. A session the user should pick is the one exception: `param.session("rth", "09:30-16:00", { tz: "America/New_York" })` draws the window in the settings dialog, and `inSession(barOpenSec, start, end, zone)` applies the zone's daylight-saving rule per bar for you ([Sessions and units](../settings/sessions-and-units.md)). Everything on this page is that integer math, pasted into your file: it is what the kit computes underneath, shown so you can read it, and it is enough on its own for a rule stated in UTC. When the rule lives in a venue's own clock (the 09:30 cash open in New York, a daylight-saving shift, the first bar of the week in Sydney), the `./sdk/clock` module on the [Clock and sessions kit](../functions/time-and-sessions-kit.md) page keeps the calendar for you: a `Clock` constructed with a zone name answers hour, weekday, day of month and is-new-day, -week, -month with the zone's daylight rule applied, and a `Session` answers whether the bar sits inside an `0930-1600` window on the weekdays you list, half-open and wrapping midnight. The same `Clock` counts the bar index and infers the interval from the gap between opens, so the previous-`bar.time()` subtraction below becomes one `intervalSec()` call. ## Lead example: the clock and the session This file computes every clock value from the bar's open time, classifies the bar into a session, and tints the close line by session so you can sanity-check the math against the chart's time axis. ```typescript // The close, tinted by session: 0 off-hours grey, 1 Asia blue, 2 Europe amber, 3 America green. output("close", line, overlay, { width: 2, color_by: "session", colors: ["#94a3b8", "#3b82f6", "#f59e0b", "#22c55e"], description: "Close, colored by the session the bar opened in" }); output("session", none, overlay, { description: "0 off-hours, 1 Asian, 2 European, 3 American (UTC)" }); output("hour", line, lower, { color: "#2563eb", description: "Hour of the bar, 0 to 23 UTC" }); output("day_of_week", line, lower, { color: "#7c3aed", description: "0 Sunday to 6 Saturday" }); output("day_of_month", line, lower, { color: "#16a34a", description: "Day of the month, 1 to 31" }); output("month", line, lower, { color: "#f59e0b", description: "Month, 1 to 12" }); output("year", line, lower, { color: "#94a3b8", description: "Four-digit year" }); // ---- Clock helpers over epoch seconds (UTC). Paste these into any file. ---- const DAY: i64 = 86400; function hourOf(t: i64): i32 { return i32((t % DAY) / 3600); } function minuteOf(t: i64): i32 { return i32((t % 3600) / 60); } function dayIndex(t: i64): i64 { return t / DAY; // days since 1970-01-01 } function dayOfWeek(t: i64): i32 { return i32((dayIndex(t) + 4) % 7); // 1970-01-01 was a Thursday: 0 Sunday .. 6 Saturday } // Civil date from a day index (integer math only; exact for every date after 1970). let civilYear: i32 = 0; let civilMonth: i32 = 0; let civilDay: i32 = 0; function civilFromDays(days: i64): void { const z = days + 719468; const era = (z >= 0 ? z : z - 146096) / 146097; const doe = z - era * 146097; const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; const y = yoe + era * 400; const doy = doe - (365 * yoe + yoe / 4 - yoe / 100); const mp = (5 * doy + 2) / 153; const d = doy - (153 * mp + 2) / 5 + 1; const m = mp < 10 ? mp + 3 : mp - 9; civilYear = i32(m <= 2 ? y + 1 : y); civilMonth = i32(m); civilDay = i32(d); } // ---- Sessions (UTC). Edit the boundaries to taste; they are ordinary comparisons. ---- function isAsianSession(h: i32): bool { return h >= 0 && h < 8; } function isEuropeanSession(h: i32): bool { return h >= 7 && h < 16; } function isAmericanSession(h: i32): bool { return h >= 13 && h < 21; } function currentSession(h: i32): f64 { if (isAmericanSession(h)) return 3.0; // overlaps resolve to the later session if (isEuropeanSession(h)) return 2.0; if (isAsianSession(h)) return 1.0; return 0.0; } function onBar(): void { const t = i64(bar.time()); const hour = hourOf(t); const dow = dayOfWeek(t); civilFromDays(dayIndex(t)); const session = currentSession(hour); out_close(bar.close()); out_session(session); out_hour(f64(hour)); out_day_of_week(f64(dow)); out_day_of_month(f64(civilDay)); out_month(f64(civilMonth)); out_year(f64(civilYear)); } ``` Each session predicate is a plain `bool` over the hour, so it drops straight into an `if` or a gate. The close line picks up a blue, amber, or green tint depending on which session the bar opened in, and the lower pane shows the raw clock values so you can check them against the axis. ## Function reference Every helper takes the bar's open time as an `i64` of epoch seconds and returns the value for the **current bar**. ### Clock | Helper | Returns | | --- | --- | | `hourOf(t)` | Hour of the bar, `0`..`23` (UTC) | | `minuteOf(t)` | Minute of the bar, `0`..`59` | | `i32(t % 60)` | Second of the bar, `0`..`59` (always `0` on candle grids) | | `dayOfWeek(t)` | `0` Sunday .. `6` Saturday | | `civilDay` after `civilFromDays(dayIndex(t))` | Day of the month, `1`..`31` | | `civilMonth` | Month, `1`..`12` | | `civilYear` | Four-digit year | `civilFromDays` is the standard days-to-civil algorithm in integer math, exact for every date from 1970 onward; it writes three module-level variables because a function returns one value. Prefer `dayIndex(t)` for "is this a new day" tests; the civil fields are for calendar rules and labels. ### Timestamps | Need | wrun | | --- | --- | | the bar's open as a timestamp | `bar.time()` (seconds); `i64(bar.time()) * 1000` for milliseconds | | the wall clock | none: no wall clock reaches the module. The newest bar is the live edge, and a run-level renderer or drawing evaluates it on its own | The bar's interval is the difference between consecutive open times: keep the previous `bar.time()` in a module-level variable and subtract. ### Sessions | Helper | Returns | | --- | --- | | `isAsianSession(h)` | `true` in the Asian session (00:00 to 08:00 UTC in the example) | | `isEuropeanSession(h)` | `true` in the European session (07:00 to 16:00 UTC) | | `isAmericanSession(h)` | `true` in the American session (13:00 to 21:00 UTC) | | `currentSession(h)` | `0` off-hours, `1`, `2`, `3`, as a number an output or a `color_by` ladder can carry | The boundaries are ordinary comparisons in your file, so they are yours: an indicator states them where you can read and change them. Sessions overlap (London and New York share the 13:00 to 16:00 UTC hours), which is why the predicates are independent and `currentSession` picks one by priority. ## The market's own calendar The integer math above answers questions in UTC. Two more `time` fields carry the market's own calendar, so an indicator can ask what the venue says about a bar without knowing its schedule: ```text input("day", time.trade_date); // the bar's trade date: epoch seconds at 00:00 UTC of that date input("phase", time.session); // 1 regular, 2 pre-market, 3 after-hours, 0 closed ``` The trade date is the exchange's: on CME futures it starts at the 17:00 Chicago open, so an evening bar already belongs to the next date; on US stocks it is the New York date; on markets without sessions (crypto, forex, HIP-3, Polymarket) it is the UTC date and every bar reads `1`, regular. On a chart of one day or longer every bar takes its own stamp's date and reads `1` on every market, since the chart stamps daily, weekly and monthly bars at 00:00 UTC of their trade date. Both are facts of the market, the bar and its interval alone, so switching the chart to extended hours never changes them ([Data sources](data-sources.md)). `MarketSession` from `./sdk/clock` folds the two facts once per bar and answers the checks a Pine script reads from `session.*` ([Clock and sessions kit](../functions/time-and-sessions-kit.md)): ```text const ms = new MarketSession(); // made once, at module level or in onStart() // onBar(): ms.update(in_day(), in_phase()); if (ms.isFirstRegularBar()) { // the cash open: anchor a VWAP, start the day's range } ``` Group daily, weekly and monthly views by `tradingDayKey()`, `tradingWeekKey()` and `tradingMonthKey()` instead of the UTC day index: on a CME chart the UTC day cuts the evening session off the trade date it belongs to. ### Pine equivalents | Pine | wrun | | --- | --- | | `session.ismarket` | `ms.isRegular()` | | `session.ispremarket` | `ms.isPremarket()` | | `session.ispostmarket` | `ms.isAfterHours()` | | `session.isfirstbar` | `ms.isNewTradingDay()` | | `session.isfirstbar_regular` | `ms.isFirstRegularBar()` | | `time_tradingday` | `in_day()` from a `time.trade_date` input, in seconds (Pine's is milliseconds: multiply by 1000) | | `timeframe.change("D")` | `ms.isNewTradingDay()` | `ms.isExtended()` is pre-market or after-hours, and `ms.isClosed()` marks a bar printed while the market is closed. Like every check on this page, they read the bar the module is on: on the first bar the chart loads, a session already running counts as new. ## Common patterns **Only signal during a session.** Wrap your trigger in a session predicate so it can only fire when the market you care about is active. The predicate becomes a data-only gate and the mark's `shape_where`: ```text const brokeOut = close > prevHigh && isEuropeanSession(hour); out_london_break(high); // a shape output at the bar's high out_is_london_break(brokeOut ? 1.0 : 0.0); // its shape_where gate ``` **Gate by session instead of weekday.** `dayOfWeek(t)` works here, so both are available: `dow >= 1 && dow <= 5` is weekdays, and `isEuropeanSession(hour) || isAmericanSession(hour)` is "skip the quiet Asian hours." Combine them freely. **Once-per-day reset.** Detect a new day by watching the day index change from the previous bar, then reset whatever daily accumulator you keep. The cookbook's anchored VWAP and key levels are built on exactly this: ```text const day = dayIndex(t); if (day != dayIdx) { // the day that just closed is complete; the running sums start over dayIdx = day; dayPv = 0.0; dayVol = 0.0; } ``` The same idea works for a new week (`(dayIndex(t) + 3) / 7` changes; the `+ 3` makes weeks start on Monday 00:00 UTC), a new month (`civilMonth` changes), or a new year (`civilYear` changes). Hold the previous index in a module-level variable so you can compare, and remember that a period already running when the loaded history starts is incomplete: keep its values `NaN` until the first boundary passes. **Weekend gaps.** Crypto trades through; a venue with a session calendar simply has no bars off-hours, and the `time` source follows the primary grid, so there is nothing to skip. The bucket fold that turns these day and hour indexes into higher-timeframe views is `multi-timeframe.md`; the per-bar candle tint is `render.barcolor`, and the background tint beside it is `render.bgcolor` (`presentation/plotting.md`). # Data types The types a wrun indicator is built from. The file is AssemblyScript, TypeScript syntax over fixed-width numbers, so every value has a declared width and the compiler names a mismatch before anything runs. An indicator has two numeric widths, a boolean, module-internal strings, fixed-size arrays, and classes, and a missing value is simply `NaN`. ## Primitive types ### `f64` A 64-bit float: every param, input, and output crosses the host boundary as one. Prices, volumes, scores, ratios, and decisions (`0.0` or `1.0`) are all `f64`. A literal with a decimal point is an `f64`; a whole-number literal is not, so annotate or write the point. ```text let price: f64 = 45000.5; let volume: f64 = 1000.0; // 1000 alone would be an i32 let value: f64 = NaN; // "no value yet" ``` ### `i32` A 32-bit integer: a period, a ring-buffer cursor, a loop counter, and what `Cross.update()` answers. Params arrive as `f64`, so a period becomes an integer with an explicit cast: `new Sma(i32(p_period()))`. Integer division truncates: `7 / 2` on two `i32` values is `3`. ```text let period: i32 = 20; let cursor: i32 = 0; const bars = i32(p_period()); // f64 to i32, explicitly ``` ### `bool` `true` or `false`, from comparisons (`<`, `>`, `==`, `!=`) and logical operators (`&&`, `||`, `!`). A `bool` never leaves the module on its own: a decision becomes an output by turning it into a number, usually with a ternary. ```text const isUptrend: bool = fast > slow; out_is_uptrend(isUptrend ? 1.0 : 0.0); ``` ### `string` Text lives inside the module: labels you build for a text renderer, keys in a `Map`, comparisons of two literals. Strings never become params, inputs, or outputs; the only way out is a declared string slot written in `onBar()` (`presentation/plotting.md`). Building a string allocates, and the module never frees memory, so per-bar text goes through the allocation-free line builder (`sb_text`, `sb_f64`) rather than `+`. Concatenating a number needs `.toString()`: `"close " + close` is refused with `Type 'f64' is not assignable to type 'String'`. ### `NaN` is the missing value There is no missing-value type. A missing `f64` is `NaN`: every TA class returns it until its window is warm, and writing it to an output draws a gap. Test it with `isNaN(x)`; `x == NaN` is always false. Arithmetic with `NaN` stays `NaN` (`na-and-scalar-types.md`). ```text out_filtered(condition ? value : NaN); // a gap where the condition fails ``` ## Core wrun types ### The per-bar value There is no series type with a whole history behind it. An input is a reader function that returns this bar's value, and the host calls `onBar()` once per bar, oldest first. History is whatever you keep: - a **remembered value** (`prevClose`) for the previous bar; - a **TA class** (`Sma`, `Ema`, `Rsi`, ...) for anything windowed, since each one keeps its own window; - a **ring buffer** when you need the last N values yourself: `History` is one ready-made ([Stats, history and lists](../functions/stats-history-lists.md#history-xn-from-pine)), and by hand it is a `StaticArray` plus a cursor. Inputs are read-only by construction (`bar.close()` or `in_()` returns a number), and they are read in `onBar()`. The full model is `core-concepts/execution-model.md`. ### `StaticArray` A fixed-size array allocated once, the workhorse for windows. Size it from a param's declared `max` at module scope or in `onStart()`, never per bar. `Array` (growable) and `Map` exist too (`collections.md`). ```text const MAX_BARS = 200; const window = new StaticArray(MAX_BARS); ``` ### `class` Your own typed structs with methods (`user-defined-types.md`). ## Input and configuration types ### Params reach the module as numbers or words Every setting reaches the module through its generated reader, readable from `onStart()` on, whatever control the dialog draws for it; the kind is the declaration ([Setting kinds](../settings/kinds.md)). Every kind reads as an `f64` except text, whose words `pt_()` reads as a `string`: | Setting you want | wrun form | | --- | --- | | a number or a slider | `param.number("mult", 2.0, { min: 0.5, max: 5, step: 0.1 })` or `param.int("period", 20, { min: 1, max: 200 })`, `slider: true` for the slider; plain `param(...)` is the number field | | a toggle | `param.bool("show_open", true)`, read as a `bool` through `pb_show_open()` | | a choice among options | `param.choice("kind", ["line", "bar"], "line")`, read as the index (`0` = line, `1` = bar) and switched on in the code | | a color | `param.color("fast", "#3b82f6")` bound to the output with `color: "@fast"`; the reader hands the code the color packed into one number | | a symbol | `param.symbol("pair", "BINANCE_FUTURES:ETHUSDT")` bound to an input with `symbol: "@pair"` | | words | `param.text("label", "Session average")`, read once in `onStart()` with `pt_label()` as a `string` | ### Data sources are members An indicator names a source as a member of a source namespace, and the field is part of the reference: | Identifier | Description | | --- | --- | | `ohlcv.close` (and `open`, `high`, `low`, `volume`) | Price and volume; the chart's own candle needs no declaration (`bar.close()` and the other `bar` fields read it) | | `funding.rate_close` (and `rate_open`, `predicted_close`, ...) | Funding rates | | `liquidations.liquidations` | Liquidation volume, optionally by `side` | | `oi.close` | Open interest | | `trades.volume` with `side: "BUY"` or `"SELL"` | Side-split trade volume | | `time.bar_open_sec` | The bar's open time in epoch seconds | The complete catalog, celled classes included, is `data-sources.md`. ### Placement is per output An indicator places each output: `overlay` (the price pane) or `lower` (its own pane), as the third argument of `output(...)`. ## Visual and plotting types ### Color A color is a string literal in a declaration: `#rrggbb`, `#rrggbbaa`, `rgb()`, `hsl()`, or a named color on an output. It is never a runtime value; there is no color variable, and a per-bar color is a data-only output indexing a declared `colors` palette through `color_by` (`color-constants.md`). ```text output("fast", line, overlay, { color: "#FF6B35" }); output("mid", line, overlay, { color_by: "regime", colors: ["#ef4444", "#22c55e"] }); ``` ### Plot and shape kinds The second argument of `output(...)` is the plot kind: `line`, `bar`, `area`, `histogram`, `candle`, `shape`, `scatter`, or `none` (data-only). A `shape` output draws a mark at its value; the mark's kind is the host's default, and `render.shape` picks one of `circle`, `cross`, `triangle_up`, `triangle_down`, `diamond`, `arrow_up`, `arrow_down`, `flag`, `square` (`presentation/plotting.md`). ## Practical examples ### Colors for multi-line plots An indicator declares each line's color on its output, and the palette lives in the file's declarations: ```typescript output("sma10", line, overlay, { color: "#FF6B35", width: 2, description: "10-period simple moving average" }); output("sma20", line, overlay, { color: "#3B82F6", width: 2, description: "20-period simple moving average" }); output("sma50", line, overlay, { color: "#10B981", width: 2, description: "50-period simple moving average" }); let sma10 = new Sma(10); let sma20 = new Sma(20); let sma50 = new Sma(50); function onBar(): void { const close = bar.close(); // Each line starts when its own window is warm: the shorter averages draw first. out_sma10(sma10.update(close)); out_sma20(sma20.update(close)); out_sma50(sma50.update(close)); } ``` The three periods are fixed here, so there is no `onStart()`; each line draws as soon as its own average is warm, the slower two writing `NaN` (a gap) until their own windows fill. ### Conditional plotting with `NaN` Combine a condition with `NaN` to draw a value only when the condition holds: ```typescript param("limit", 50, { min: 0, max: 1000000, description: "Volume a bar must exceed to be drawn" }); output("filtered", line, overlay, { color: "#dc2626", width: 2, description: "Close, only on bars whose volume exceeds the limit" }); output("volume", histogram, lower, { color: "#94a3b8", description: "Volume" }); let limit: f64 = 50.0; function onStart(): void { limit = p_limit(); } function onBar(): void { const close = bar.close(); const volume = bar.volume(); // NaN draws nothing: the line breaks wherever the condition fails, and the volume pane still draws. out_filtered(volume > limit ? close : NaN); out_volume(volume); } ``` Writing `NaN` to one output leaves the other outputs drawing; returning early from `onBar()` before any write leaves the whole bar blank. Both are correct; pick by whether anything on the bar is meaningful. ## Type conversion and indexing A candle is not one value with indexed columns. An indicator reads the chart's own candle one field at a time (`bar.open()`, `bar.high()`, `bar.low()`, `bar.close()`, `bar.volume()`) and the timestamp through `bar.time()` (seconds, not milliseconds). There is no `priceIndex` argument anywhere: a class takes the number you hand it. Casts are explicit (`i32(x)`, `f64(n)`) and indexing a number is refused (`type-system.md` lists the messages). ## Best practices - **Name by what it is.** `prevClose`, `windowHigh`, `barsSeen`: a module- level variable's name should say what it carries across bars. - **Annotate module-level state.** `let value: f64 = NaN;` and `let cursor: i32 = 0;` read as documentation and stop an integer literal from silently making a variable an `i32`. - **Keep decisions as outputs.** A `bool` you want to draw or alert on is a `0.0` / `1.0` output declared `none`; the sheet turns it into a look. - **Let `NaN` mean missing.** Never write `0` for "not ready"; a zero is drawn and alerted on as a real value. # Type system A comprehensive guide to the wrun type system: static typing, how types are inferred from literals, explicit casts, the per-bar value (there is no series type), and the messages the compiler prints when a type does not fit. A wrun indicator is AssemblyScript: every value has a fixed width, nothing converts implicitly, and there is no `any`. ## Introduction The compiler type-checks the whole file before it runs. There are no implicit conversions between numeric widths, no coercion between strings and numbers, and no union types. That strictness is the point: a mismatch is a compile error with a line and column, never a wrong number on bar 1,400. At its core the system has two categories of values: **primitives** (`f64`, `i32`, `bool`, `string`) and **references** (arrays, maps, class instances). What it does not have is a series type, a value with a whole history behind it; an indicator's central idea is one value per bar, read through a function, with history kept explicitly. This page covers declarations and inference, the primitives, the per-bar model, collections, conversion rules, and a complete example. ## Variable declarations and type inference `let` and `const` declare variables, as in TypeScript. The type comes from the annotation if there is one, otherwise from the initializer, and the rule to remember is the literal rule: **a whole-number literal is an `i32`, a literal with a decimal point is an `f64`.** ```text let price = 45000.5; // f64 let count = 0; // i32, because the literal has no point let flag = true; // bool let name = "BTCUSDT"; // string let value: f64 = 0; // annotated: f64 (the literal widens) let cursor: i32 = 0; // annotated: i32 ``` Once inferred, the type is fixed. `let count = 0; count = 2.5;` is refused with `Conversion from type 'f64' to 'i32' requires an explicit cast`, which is also the message you get when a param (always `f64`) meets a class constructor (always `i32`): write `new Sma(i32(p_period()))`. `const` fixes the binding, not the contents: a `const window = new StaticArray(50)` is filled and overwritten freely; only `window = ...` is refused. Module-level TA objects are `let`, because `onStart()` rebuilds them at the chosen period. The three lifetimes (a local, a module-level variable, a value with history) are in `core-variables.md`; this page is about what the values are. ## Primitive types **`f64`** is the 64-bit float and the type of everything that crosses the host boundary. Arithmetic on two `f64` values is an `f64`; dividing by zero gives `Infinity` or `NaN`, never an error, so guard denominators. `NaN` is the missing value: `isNaN(x)` tests it, `x == x` is false when `x` is `NaN`, and every comparison with `NaN` is false. **`i32`** is the 32-bit integer: periods, counters, cursors, loop bounds, `Cross.update()`'s answer. Integer division truncates (`7 / 2` is `3`). `i64` exists for epoch-second math (`time-and-sessions.md`). **`bool`** comes from comparisons and logical operators. A bare number in an `if` compiles (nonzero is true), but write the comparison out: `x > 0.0` says what you mean, and `!isNaN(x)` is the only reliable test for a missing value. **`string`** is immutable text with `length`, `charCodeAt`, `indexOf`, `startsWith`, `+` between strings, and `==` / `<` by code unit. Strings stay inside the module and reach the chart only through a declared string slot; building one allocates, so per-bar text goes through the line builder (`sb_clear`, `sb_text`, `sb_f64`). There is no number-to-string coercion: `"close " + close` is refused with `Type 'f64' is not assignable to type 'String'`; write `close.toString()`. ## The per-bar value An indicator input is a function: `bar.close()` returns this bar's close (and `in_()` a declared input's), inside `onBar()`. The host calls `onBar()` once per bar, oldest first, so a line you write once still produces a value at every point in time, but the past is not addressable. What holds history: | Need | Form | | --- | --- | | the previous bar's close | a module-level `prevClose` assigned at the end of `onBar()` | | a 20-bar average, a 14-bar RSI | `new Sma(20)`, `new Rsi(14)`: the class keeps the window, `update(x)` folds one value | | the highest high of 20 bars, the close 5 bars back | a `History` per series, read with `max()` and `ago(5)` ([Stats, history and lists](../functions/stats-history-lists.md#history-xn-from-pine)); by hand, a `StaticArray` ring buffer of 20 with a cursor | | a running total | a module-level accumulator | Immutability is built in (an input has no setter), and module scope is where history lives: anything that must survive a call is declared there, because a local is gone when `onBar()` returns. ### OHLCV data structure A candle is not one value with six columns. The chart's own candle is read one field at a time through `bar`, with no declaration: ```text bar.open(); bar.high(); bar.low(); bar.close(); bar.volume(); bar.time(); // the timestamp, epoch seconds ``` A file with no `input` line runs on the chart's own candles. When a file declares inputs (another feed, a pinned market or interval), the first `input` is the primary: it defines the grid (market and interval) every other input aligns to ([Data sources](data-sources.md)). There is no `priceIndex`: a class takes whichever number you hand it, so `sma.update(bar.close())` and `sma.update((bar.high() + bar.low()) / 2.0)` are the same call over different sources. ## Arrays and collections Arrays are typed and homogeneous. `StaticArray` is a fixed-size block allocated once; `Array` grows with `push`; `f64[]` and `string[]` literals work; `Map` is a key-value store. The element type is fixed at declaration and enforced: pushing a string onto an `Array` is refused with `Type 'String' is not assignable to type 'f64'`. Arrays are references: passing one to a function passes the same storage, so a function that writes into it writes into yours. The allocation rule applies to all of them: build them in `onStart()` or at module scope, never per bar (`collections.md`). ```text const periods: i32[] = [10, 20, 50]; const window = new StaticArray(200); const weights = new Map(); ``` ## Data sources An indicator reads every feed through one form, `input(name, source.field, options)`, where `source` is a source namespace and `field` one of its members (the chart's own candle needs no declaration: `bar.close()` and the other `bar` fields read it); every input is an `f64`, and the source decides what the number means. The catalog, the `missing` policy for sparse feeds, and the named refusals for feeds a host cannot fetch are in `data-sources.md`. ## Type conversion and casting Nothing converts on its own. The rules: - **`f64` to `i32`:** `i32(x)` truncates toward zero. Periods, cursors, and loop bounds derived from a param need it. - **`i32` to `f64`:** `f64(n)`. Writing a counter to an output needs it, and so does mixing an `i32` into float arithmetic. - **`bool` to a number:** a ternary, `flag ? 1.0 : 0.0`. - **A number to a string:** `.toString()`, and only for module-internal text. - **Never implicit:** `f64 * i32` is refused; cast one side. ```text const period = i32(p_period()); // f64 to i32 out_count(f64(hits)); // i32 to f64 out_is_long(fast > slow ? 1.0 : 0.0); // bool to f64 const label = "n=" + hits.toString(); // i32 to string, inside the module ``` **Type mismatch errors** name the rule. The ones you will meet: | Message | Cause | Fix | | --- | --- | --- | | `AS200: Conversion from type 'f64' to 'i32' requires an explicit cast.` | a param or float where an integer goes | `i32(...)` | | `TS2322: Type 'bool' is not assignable to type 'i32'.` | a comparator returning a comparison | return `a > b ? 1 : a < b ? -1 : 0` | | `TS2322: Type 'String' is not assignable to type 'f64'.` | a string pushed onto a numeric array | keep the array homogeneous | | `TS2329: Index signature is missing in type 'f64'.` | `close[1]` on a number | keep the previous value in a variable | | `TS1110: Type expected.` | a function without a return type | annotate: `function f(x: f64): f64` | | `AS100: Not implemented: Closures` | an arrow function reading a local of the enclosing function | read module-level state instead (`lambdas-and-reducers.md`) | | `AS100: Not implemented: Iterators` | `for (const x of xs)` | an index loop | ## Best practices Annotate every module-level `let` and every function signature (the annotation is documentation, and it stops the literal rule from making a float an integer); feed inputs straight into the classes that keep the windows; keep params, inputs, and outputs at the top as top-level statements, state under them, `onStart()` and `onBar()` last. ## Complete example: multi-source momentum A momentum indicator over three sources: the close for an RSI, the funding rate averaged over 24 bars, and liquidations (sparse, so `missing: "zero"` makes a quiet bar a `0`). The extreme condition is a data-only gate and a mark drawn only where it holds. ```typescript // Configuration: the RSI length, the RSI level, and the funding level. param("rsi_period", 14, { min: 2, max: 100, description: "RSI period" }); param("extreme_rsi", 30, { min: 1, max: 99, description: "RSI at or below this is oversold" }); param("funding_threshold", 0.01, { min: 0, max: 100, description: "Funding below minus this means shorts are paying, in the feed's own units" }); // Data sources: price, funding, liquidations. input("close", ohlcv.close); input("funding", funding.rate_close, { description: "Funding rate at the bar's close" }); input("liqs", liquidations.liquidations, { missing: "zero", description: "Liquidation volume, 0 on quiet bars" }); // Visualization: the RSI and its level in one pane, a mark only on extreme bars. output("rsi", line, lower, { color: "#2962FF", width: 2, description: "Relative Strength Index" }); output("extreme_rsi", line, lower, { color: "#FF6B35", width: 1, description: "Extreme RSI level" }); output("extreme", shape, lower, { color: "#00BA88", shape_where: "is_extreme", description: "Oversold, shorts paying, liquidations printing" }); output("is_extreme", none, lower); let rsi = new Rsi(14); let avgFunding = new Sma(24); // 24 bars of funding, one day on a 1h chart let liqAvg = new Sma(6); // six bars of liquidation volume let extremeLevel: f64 = 30.0; let fundingThreshold: f64 = 0.01; function onStart(): void { rsi = new Rsi(i32(p_rsi_period())); extremeLevel = p_extreme_rsi(); fundingThreshold = p_funding_threshold(); } function onBar(): void { const rsiValue = rsi.update(bar.close()); // f64 in, f64 out const fundingAvg = avgFunding.update(in_funding()); const liqRecent = liqAvg.update(in_liqs()); // Three bools, combined; NaN on any side makes its comparison false, so a warming source never fires. const oversold: bool = rsiValue <= extremeLevel; const shortsPaying: bool = fundingAvg < -fundingThreshold; const liquidating: bool = liqRecent > 0.0; const extreme: bool = oversold && shortsPaying && liquidating; if (isNaN(rsiValue)) return; out_rsi(rsiValue); out_extreme_rsi(extremeLevel); out_extreme(rsiValue); // the mark sits on the RSI line out_is_extreme(extreme ? 1.0 : 0.0); // bool to f64 } ``` Watch the types flow: three `f64` inputs feed three classes that return `f64`, those numbers feed comparisons that return `bool`, the bools combine into one `bool`, and the ternary turns it into the `f64` gate output that the `shape` output reads through `shape_where`. Nothing converts on its own, and every step is checked before the first bar runs. # Variables: locals, module state, and history Variable declaration and bar-state behavior in a wrun indicator: what a local in `onBar()` is, what a module-level variable is, and how a value with history is kept. An indicator has the ordinary scoping of TypeScript syntax plus two hooks: `onStart()` once before the first bar, `onBar()` once per bar. ## Declaration roles | Lifetime | wrun | | --- | --- | | this bar only | a `const` or `let` inside `onBar()` | | carried across bars | a module-level `let`, initialized once (at module scope or in `onStart()`) | | a value with history | a `History` ([Stats, history and lists](../functions/stats-history-lists.md#history-xn-from-pine)), a TA class, or by hand a module-level "previous" variable or a ring buffer | A **local** is born when `onBar()` is called for a bar and gone when it returns. It is right for a scalar you compute and use immediately: this bar's typical price, a ratio, a comparison. A **module-level `let`** lives outside the hooks and keeps its value from one call to the next. A counter incremented on each bar keeps its running total for the whole run; an accumulator like a cumulative delta is one of these; so is any TA object, since its window is state too. The chart replays the forming bar from a snapshot of every module-level variable ([Repainting](repainting.md)). A **value with history** is something an indicator keeps on purpose. For the previous bar's value, store the value in a module-level variable when you see it and read it on the next bar. For `[n]` over a window, a `StaticArray` used as a ring buffer. For a windowed statistic, a TA class holds the window for you. `History` packages the first two: `push()` the value once per bar and read `ago(n)` ([Stats, history and lists](../functions/stats-history-lists.md#history-xn-from-pine)). ## The bar's index, the first bar, the newest bar There are no bar-state globals in an indicator; each need has a plain form: | Need | wrun | | --- | --- | | the bar's index | a module-level counter you increment in `onBar()` | | the first bar | that counter at `0` (or a `first` flag `onStart()` sets and `onBar()` clears) | | is this bar closed | no flag: on the live chart the newest bar is the forming one; the chart evaluates it again as new data arrives, from a snapshot of the state the closed bars left, and once more as a closed bar when it closes | | the newest bar | `bar.isLast()`, true on the newest bar the run holds ([Execution model](execution-model.md)); renderers and drawings evaluate the newest ready row on their own | | does a later bar exist | your counter + 1 below `bar.count()`, the bars the run holds ([Execution model](execution-model.md#how-many-bars-the-run-holds)) | `bar` also carries the chart's own candle, with no `input` line: reading a field adds that input to the sheet, on the chart's own market and timeframe ([Data sources](data-sources.md)). Every member is this bar's value inside `onBar()`; in `onStart()` the fields read `NaN`. | Member | Type | What it reads | | --- | --- | --- | | `bar.open()` | `f64` | the chart candle's open (`ohlcv.open`) | | `bar.high()` | `f64` | its high (`ohlcv.high`) | | `bar.low()` | `f64` | its low (`ohlcv.low`) | | `bar.close()` | `f64` | its close (`ohlcv.close`) | | `bar.volume()` | `f64` | its volume (`ohlcv.volume`) | | `bar.time()` | `f64` | its open time in epoch seconds, UTC (`time.bar_open_sec`) | | `bar.isLast()` | `bool` | no input: true on the newest bar the run holds, the last row of a full run or the forming bar live | | `bar.count()` | `i32` | no input: how many bars the run holds while this bar is evaluated, the forming bar included; every bar of a full run reads the run's bar count | One-time initialization is `onStart()` itself: it runs once before the first bar and reads the params. Logic that must run on the first bar of data (seeding a level from the first open) is the counter test. ## No tuples A multi-output computation is a class whose fields you read by name after `update()` (`named-streams.md`); there are no tuples to destructure. ## History example The close, the one-bar change (a remembered previous close), and a moving average whose class holds the window. Nothing here is indexed; everything is kept. ```typescript param("period", 20, { min: 1, max: 200, description: "SMA length" }); output("moving_average", line, overlay, { color: "#dc2626", width: 2, description: "SMA of the close (the class keeps the window)" }); output("change", line, lower, { color: "#2563eb", width: 2, description: "Close minus the previous close (one remembered value)" }); let sma = new Sma(20); let prevClose: f64 = NaN; // stands in for closeSeries[1] function onStart(): void { sma = new Sma(i32(p_period())); } function onBar(): void { const close = bar.close(); // a local: this bar's value, gone after the call const change = isNaN(prevClose) ? NaN : close - prevClose; prevClose = close; // remember it for the next bar const average = sma.update(close); if (isNaN(change)) return; out_change(change); out_moving_average(average); } ``` On the first bar there is no previous close, so `change` is `NaN` and the file returns before writing anything; from the second bar on the change draws, and the average joins it once its window is full (writing `NaN` until then is a gap on that one line, not a blank bar). ## Persist example A bar counter that keeps its running total, plus a bar index and a first-bar seed in the same file. ```typescript output("carried_score", line, lower, { color: "#16a34a", width: 2, description: "Bars seen plus this bar's body: a value carried across bars" }); output("bar_index", line, lower, { color: "#94a3b8", description: "0 on the oldest bar, counting up" }); output("first_open", line, lower, { color: "#f59e0b", description: "The open of the first bar of loaded history, held" }); let barsSeen: f64 = 0.0; // module state: survives every bar let barIndex: i32 = 0; // counts bars from the oldest loaded one let firstOpen: f64 = NaN; // seeded once, on the first bar function onBar(): void { const open = bar.open(); const close = bar.close(); if (barIndex == 0) firstOpen = open; // the first bar of the run barsSeen += 1.0; const score = barsSeen + (close - open); barIndex += 1; out_carried_score(score); out_bar_index(f64(barIndex - 1)); out_first_open(firstOpen); } ``` `barsSeen` is the carried counter; `barIndex` is the same idea kept as an `i32` and written to an output as `f64(barIndex - 1)` (the increment happens before the write). The counter says `0` on the oldest loaded bar, not on the first bar the market ever traded: history depth is whatever the chart loaded, and panning back, which runs the indicator again over the longer window, renumbers every bar ([Repainting](repainting.md)). ## Boundaries **Indexing a number is refused.** `const previous = closeNow[1];` on an `f64` fails to compile with `Index signature is missing in type 'f64'`. There is nothing to index: `push()` the value into a `History` and read `ago(1)` ([Stats, history and lists](../functions/stats-history-lists.md#history-xn-from-pine)), or promote it to a remembered variable or a ring buffer. **A local does not survive the call.** A `let` declared inside `onBar()` starts over on every bar. If you meant to carry it, move the declaration to module level. **Nothing in the file puts module state back.** The chart does not need it to replay the forming bar: it restores a snapshot of the module taken after the last closed bar, every module-level variable and every TA object included ([Repainting](repainting.md)). **Allocate once.** A module-level `new StaticArray(n)` or `new Sma(n)` is built in `onStart()` or at module scope, never per bar: the module has no garbage collector, so memory only grows, and the chart refuses a run whose memory grows once the bars start ([Collections](collections.md)). # Missing values (NaN) `NaN` is a wrun indicator's "no value here": a TA class that has not warmed up yet, a sparse source with no observation on this bar, a ratio with a zero denominator. This page is how to test for it, fill it, hold the last good value across it, and what it does in arithmetic and on the chart. ## Why NaN matters The important rule is that **`NaN` is contagious in arithmetic**: any expression touching a `NaN` becomes `NaN`. Add `5` to a not-yet-warm SMA and you get `NaN`, and a `NaN` written to an output draws as a gap, not a zero. So the skill is detecting `NaN` and deciding what to do about it before it poisons a calculation or blanks a line. The helpers, and what they are in an indicator: | Helper | Use it to | | --- | --- | | `isNaN(x)` | branch on whether a value is missing | | `isFinite(x)` (`!isNaN(x)` when infinities are impossible) | the inverse: is this a usable number | | `nz`: `isNaN(x) ? fallback : x` | replace a missing value with a fallback | | `fixnan`: a module-level `held` updated only when the new value is finite | hold the last good value forward over gaps | | `isNaN(x)`; never `x == NaN` | `NaN` is not equal to anything, itself included | Comparisons with `NaN` are always false: `NaN > 0.0`, `NaN < 0.0`, and `NaN == NaN` all evaluate `false`. That is why a condition built over a warming input never fires by accident, and why `isNaN` is the only test that works. ## Filling and forward-holding `nz` is the everyday tool and it is one ternary: if `x` is `NaN`, take the fallback, otherwise take `x`. It is how you keep a plot continuous or keep arithmetic finite. `fixnan` is the series-level cousin: wherever the series is `NaN`, substitute the most recent finite value, so a gappy series becomes a stepped, continuous one. In an indicator that is a module-level variable you overwrite only when the new value is finite. Both, plus the two ways a row can be missing, in one file. The `propagated` output is deliberately `NaN` for the first 20 bars: the SMA has not warmed up, and adding `5` to `NaN` stays `NaN`. `filled` patches those bars with the close, and `held` carries the last finite liquidation reading across quiet bars (`missing: "nan"` keeps the source honest; the holding is the module's decision). ```typescript param("period", 20, { min: 1, max: 200, description: "SMA length" }); input("close", ohlcv.close); input("liqs", liquidations.liquidations, { missing: "nan", description: "Liquidation volume, NaN on bars without any" }); output("propagated", line, overlay, { color: "#94a3b8", description: "SMA + 5: NaN until the window is warm, and NaN stays NaN" }); output("filled", line, overlay, { color: "rgb(37, 99, 235)", width: 2, description: "The same series with the close as a fallback while warming (nz)" }); output("held", line, lower, { color: "#7c3aed", description: "The last finite liquidation reading, held across quiet bars (fixnan)" }); const bandHi = output("band_hi", line, overlay, { color: "#2563eb66", description: "Filled series plus 1%" }); const bandLo = output("band_lo", line, overlay, { color: "#2563eb66", description: "Filled series minus 1%" }); // A box fill takes the opacity, so its color is hex, rgb(), or hsl(): a named color is refused here. box("band", { top: bandHi, bottom: bandLo, color: "hsl(221, 83%, 53%)", opacity: 0.1, borderWidth: 0 }); let sma = new Sma(20); let held: f64 = NaN; // the fixnan state: overwritten only by a finite value function nz(x: f64, fallback: f64): f64 { return isNaN(x) ? fallback : x; } function onStart(): void { sma = new Sma(i32(p_period())); } function onBar(): void { const close = bar.close(); const propagated = sma.update(close) + 5.0; // NaN + 5 is NaN: the contagion const liqs = in_liqs(); if (isFinite(liqs)) held = liqs; // fixnan: hold the last good value forward const filled = nz(propagated, close); out_propagated(propagated); out_filled(filled); out_held(held); out_band_hi(filled * 1.01); out_band_lo(filled * 0.99); } ``` Walking the key lines: - `propagated = sma.update(close) + 5.0` is `NaN` for the first 20 bars. Written to its output, those bars are a gap on the grey line, while the other outputs still draw. - `nz(propagated, close)` patches the early bars with the close, so the blue line is continuous from bar 0 instead of starting blank at bar 20. - `if (isFinite(liqs)) held = liqs;` is `fixnan`: a bar with no liquidations reads `NaN` under `missing: "nan"`, and the held value steps only when a real reading arrives. Before the first reading `held` is `NaN`, an honest gap rather than an invented zero. What you'll see: a grey line that starts at bar 20 and a blue one that starts at bar 0, a faint band around the blue one, and a stepped purple line in the lower pane. ### Abstaining is the other option Writing `NaN` to one output leaves the other outputs drawing. Returning early from `onBar()` before any write leaves the whole bar blank: nothing is drawn and no output has a value there. Use `NaN` when one line warms slower than another; return early when nothing on the bar is meaningful yet ([Execution model](execution-model.md)). A color is a declaration, not a value, so `NaN` never reaches one directly. What a `NaN` index on a `color_by` output draws, and every other color rule, is on [Colors](../functions/colors-kit.md#per-bar-color). # Collections Arrays and maps in a wrun indicator, the loops that do the work of the reducer methods, sorting with a comparator, and the two limits that keep a module safe: memory is allocated once and never freed, and every array has one element type. An indicator gives you AssemblyScript's `StaticArray`, `Array`, and `Map`, and you write the loop. ## What you get - **`StaticArray`** is a fixed-size block, allocated once. It is the right shape for a window: size it from a param's declared `max`, index it with a cursor, and it never grows. - **`Array`** is a growable list with `push`, `pop`, `length`, and the familiar methods. Grow it in `onStart()`, not per bar. - **`Map`** is a key-value store with `set`, `get`, `has`, `delete`, `size`, `keys()`, and `values()`. - **Loops** (`for`, `while`, `do`) are how you iterate. The reducer methods exist on `Array`, but each `map` or `filter` allocates a new array, so they belong in `onStart()`, not in the per-bar path (`lambdas-and-reducers.md`). The rule under all three: the module has no garbage collector. Memory that is allocated stays allocated until the run ends, and the host caps a module at 4 MiB. Allocate at module scope or in `onStart()`, and the per-bar path stays flat. ## Quick reference ```text const xs = new StaticArray(50); // fixed size, allocated once xs[0] = 1.0; xs[0]; xs.length; // write, read, count xs.fill(NaN); // fill every slot xs.sort(); // numeric ascending, in place const ys = new Array(); // growable ys.push(4.0); ys.pop(); // append, remove last ys[0]; ys.length; // read by index, count ys.slice(0, 2); ys.reverse(); // copy a range (allocates), reverse in place ys.includes(1.0); ys.indexOf(2.0); // search ys.shift(); ys.unshift(0.0); // remove first, prepend ys.sort((a: f64, b: f64): i32 => (a > b ? 1 : a < b ? -1 : 0)); const weights: f64[] = [0.6, 0.4]; // typed literal const names: string[] = ["binance", "bybit"]; const m = new Map(); // key-value store m.set("k", 10.0); m.get("k"); m.has("k"); m.delete("k"); m.size; m.keys(); m.values(); // count, key array, value array ``` `Array` also has `map`, `filter`, `reduce`, `forEach`, `some`, `every`, and `findIndex` (there is no `find`); each callback is a non-capturing function. There is no `xs.avg()` or `xs.median()`: the numeric reducers are loops, and the worked example below is all of them. ## A worked example: window statistics A `Window` class over a ring buffer with the numeric reducers as methods: `sum`, `avg`, `min`, `max`, `range`, `variance`, `stdev` (population, dividing by `n`), and `median` through an allocation-free insertion sort into a scratch buffer. Everything is allocated once, at module scope. ```typescript param("bars", 20, { min: 2, max: 200, description: "Bars in the window" }); output("avg", line, lower, { color: "#2563eb", description: "Window mean" }); output("median", line, lower, { color: "#16a34a", description: "Window median" }); output("stdev", line, lower, { color: "#f59e0b", description: "Population standard deviation" }); output("range", line, lower, { color: "#94a3b8", description: "Max minus min" }); class Window { values: StaticArray; scratch: StaticArray; size: i32; cursor: i32 = 0; count: i32 = 0; constructor(capacity: i32) { this.values = new StaticArray(capacity); this.scratch = new StaticArray(capacity); this.size = capacity; } resize(n: i32): void { this.size = n < 1 ? 1 : n > this.values.length ? this.values.length : n; this.reset(); } push(x: f64): void { this.values[this.cursor] = x; this.cursor = (this.cursor + 1) % this.size; if (this.count < this.size) this.count += 1; } full(): bool { return this.count == this.size; } sum(): f64 { let s = 0.0; for (let i = 0; i < this.count; i++) s += this.values[i]; return s; } avg(): f64 { return this.count == 0 ? NaN : this.sum() / f64(this.count); } min(): f64 { let m = Infinity; for (let i = 0; i < this.count; i++) if (this.values[i] < m) m = this.values[i]; return this.count == 0 ? NaN : m; } max(): f64 { let m = -Infinity; for (let i = 0; i < this.count; i++) if (this.values[i] > m) m = this.values[i]; return this.count == 0 ? NaN : m; } range(): f64 { return this.max() - this.min(); } variance(): f64 { if (this.count == 0) return NaN; const mean = this.avg(); let sq = 0.0; for (let i = 0; i < this.count; i++) { const d = this.values[i] - mean; sq += d * d; } return sq / f64(this.count); // population variance: divide by n } stdev(): f64 { return Math.sqrt(this.variance()); } median(): f64 { if (this.count == 0) return NaN; // Copy into the scratch buffer and insertion-sort the first count slots: no allocation. for (let i = 0; i < this.count; i++) this.scratch[i] = this.values[i]; for (let i = 1; i < this.count; i++) { const x = this.scratch[i]; let j = i - 1; while (j >= 0 && this.scratch[j] > x) { this.scratch[j + 1] = this.scratch[j]; j -= 1; } this.scratch[j + 1] = x; } const mid = this.count / 2; return this.count % 2 == 1 ? this.scratch[mid] : (this.scratch[mid - 1] + this.scratch[mid]) / 2.0; } reset(): void { this.cursor = 0; this.count = 0; } } const MAX_BARS = 200; const window = new Window(MAX_BARS); // allocated once, sized from the param's max function onStart(): void { window.resize(i32(p_bars())); } function onBar(): void { window.push(bar.close()); if (!window.full()) return; out_avg(window.avg()); out_median(window.median()); out_stdev(window.stdev()); out_range(window.range()); } ``` A few idioms worth lifting out: - `this.values[this.cursor] = x; this.cursor = (this.cursor + 1) % this.size;` is the ring buffer: the oldest slot is overwritten and nothing shifts. - `median()` sorts a **copy** in a preallocated scratch buffer, so a per-bar median allocates nothing. Sorting `values` in place would destroy the ring order. - Empty-window reducers return `NaN`, and `onBar()` returns before writing until the window is full anyway. - `resize()` clamps to the capacity allocated from the param's `max`, so a setting change never asks for memory that was not reserved. ## Maps A `Map` is right for a small keyed table: weights per venue, a running total per hour of day, a level per session name. Allocate the map at module scope and insert its keys in `onStart()`; from then on `set` on an existing key allocates nothing. Volume by hour of the day, from the `time` source: ```typescript input("volume", ohlcv.volume); input("bar_t", time.bar_open_sec); output("hour_total", histogram, lower, { color: "#2563eb", description: "Cumulative volume traded in this bar's UTC hour, over the loaded history" }); output("hour", line, lower, { color: "#94a3b8", description: "The bar's UTC hour, 0 to 23" }); const byHour = new Map(); function onStart(): void { for (let h = 0; h < 24; h++) byHour.set(h, 0.0); // every key exists before the first bar } function onBar(): void { const hour = i32((i64(bar.time()) % 86400) / 3600); // epoch seconds to the UTC hour const total = byHour.get(hour) + bar.volume(); byHour.set(hour, total); // an existing key: no allocation out_hour_total(total); out_hour(f64(hour)); } ``` `get` on a missing key traps at runtime, so guard with `has` when a key might not exist, or insert every key up front as this file does. ## Sorting `sort()` orders an array **in place** and returns it, on `Array` and `StaticArray` alike. With no argument the order is numeric ascending: `[30, 4, 100, 25]` comes out `[4, 25, 30, 100]`. ### Comparators `sort` takes one optional comparator, a function `(a: T, b: T) => i32` in the JavaScript convention: a negative result puts `a` first, a positive result puts `b` first. ```text xs.sort((a: f64, b: f64): i32 => (a > b ? 1 : a < b ? -1 : 0)); // ascending xs.sort((a: f64, b: f64): i32 => (a < b ? 1 : a > b ? -1 : 0)); // descending ``` The comparator is an ordinary non-capturing function, so it may read module-level state but not a local of the enclosing function (`lambdas-and-reducers.md`). Sorting a copy is `xs.slice(0, xs.length).sort(...)`, and `slice` allocates, so do it in `onStart()` or use a scratch buffer as the window example does. ### Structs, strings, and `NaN` An array of class instances sorts by a comparator over a field (`(a: Level, b: Level): i32 => (a.price > b.price ? 1 : a.price < b.price ? -1 : 0)`); strings compare code unit by code unit, so `a < b` orders them directly. `NaN > x` and `NaN < x` are both false, so a `NaN` element compares equal to everything and lands wherever the algorithm leaves it: drop `NaN` values before sorting, or map them to `Infinity` in the comparator when they should sort last. ### What sort rejects The comparator's return type is `i32`. The JavaScript habit of returning a boolean, `(a, b) => a > b`, is refused at compile time with `Type 'bool' is not assignable to type 'i32'`. Return the difference or the three-way ternary. A comparator with untyped parameters, or without a return type, is refused too (`Type expected.`): annotate both. ## The two limits Collections are bounded so a module cannot exhaust memory or silently mis-type a list. ### Memory is allocated once There is no garbage collector in the module. Every `new` takes memory that is never returned, the chart caps a module at 4 MiB, and it refuses a run whose memory grows once the bars start ("The Indicator allocated memory after init() ...: the sandbox forbids growth once the bars start."), which a module that allocates on every bar does sooner or later. So: - allocate at module scope or in `onStart()`, sized from a param's `max`; - never `push` without bound, never `slice` or `map` per bar, never build a string with `+` per bar; - prefer a `StaticArray` you overwrite to an `Array` you refill. The limit is not a count the runtime enforces, but a budget you allocate against, once. ### Arrays are typed An array has one element type, fixed when it is declared. Mixing types is refused at compile time: pushing a string onto an `Array` fails with `Type 'String' is not assignable to type 'f64'`. If you genuinely need mixed data per row, declare a `class` with typed fields (`user-defined-types.md`) and keep an array of those. # User-defined types Define your own typed classes with fields and methods, construct them, call methods, and mutate state through `this`. Model trading state in the shape of the problem. A wrun indicator uses an AssemblyScript `class`, with typed fields, a constructor, and methods, allocated once and reset in place. ## Introduction A `class` is a struct you define: a named bundle of typed fields, optionally with methods that operate on those fields. Instead of tracking a supply zone as three loose variables (`zoneTop`, `zoneBottom`, `zoneTouches`), you describe it once as a `Zone` and work with whole zones. ```text class Zone { top: f64 = NaN; bottom: f64 = NaN; touches: i32 = 0; } ``` Each field is declared with a name, a type, and (best practice) an initializer. Once a class exists, you create instances of it, read and write their fields, and pass them around like any other value. ## Construct with a constructor You build an instance with `new`, and a constructor sets the fields from its arguments: ```text class Zone { top: f64; bottom: f64; touches: i32 = 0; constructor(top: f64, bottom: f64) { this.top = top; this.bottom = bottom; } } const z = new Zone(high, low); const h = z.top - z.bottom; ``` Arguments are positional, so name the parameters well and keep the order obvious (top before bottom, open before close). A field the class does not declare cannot be set: `z.label = ...` is a compile error, so a typo is caught before the file runs. A field left without an initializer or a constructor assignment starts at zero (or `null` for a reference); give numeric fields an explicit `NaN` when "not set yet" must be distinguishable from `0`. ## Add behavior with methods A class can carry methods. Declare them inside the class, and they read and mutate the instance through `this`: ```text class Zone { top: f64; bottom: f64; touches: i32 = 0; constructor(top: f64, bottom: f64) { this.top = top; this.bottom = bottom; } height(): f64 { return this.top - this.bottom; } registerTouch(price: f64): bool { if (price >= this.bottom && price <= this.top) { this.touches += 1; return true; } return false; } } ``` Now the data and the logic that maintains it live together. `height()` derives a value from the fields. `registerTouch(price)` is a self-updating operation: ask the zone whether the current price touched it, and it answers while bumping its own `touches` counter. The zone manages its own state. ```text const inZone = z.registerTouch(close); // true or false, and z.touches updates itself const tall = z.height(); ``` This is why classes matter for indicators. A "supply zone that counts its own touches and retires after the third," an "order block that tracks whether price mitigated it," a "trailing-stop level that ratchets": each becomes one class whose methods enforce its rules, instead of bookkeeping smeared across the whole file. Hold one in a module-level variable to track one, or in a preallocated `StaticArray` to manage a fixed set of them across bars (`collections.md`). ## Two rules a class must follow here **Allocate once.** `new Zone(...)` takes memory the module never gives back, so a class instance is built at module scope or in `onStart()`, never per bar. When a zone is replaced, mutate the instance you have (a `set(top, bottom)` method) rather than constructing a new one. A fixed set of zones is a `StaticArray` filled in `onStart()`; a slot that may be empty is a `StaticArray` and is tested with `!== null` before use. **Give it a `reset()`.** A class that carries state across bars gets its own `reset()` method that puts every field back to its start, so one call puts the whole zone back instead of three assignments spread through the file. (The chart replays the forming bar from a snapshot of the whole module, so nothing of yours has to run for that: [Repainting](repainting.md).) ## A complete typed class This file declares `Zone` with both methods and a `set()`, keeps one instance at module scope, re-anchors it to the previous bar's candle body whenever price leaves it, and lets the methods mutate `touches` through `this`. Three outputs show the zone's height, its running touch count, and whether this bar touched it. ```typescript param("max_touches", 3, { min: 1, max: 20, description: "Touches after which the zone retires and re-anchors" }); input("open", ohlcv.open); input("high", ohlcv.high); input("low", ohlcv.low); input("close", ohlcv.close); output("zone_top", line, overlay, { color: "#7c3aed", width: 1, description: "Top of the tracked zone" }); output("zone_bottom", line, overlay, { color: "#7c3aed", width: 1, description: "Bottom of the tracked zone" }); output("height", line, lower, { color: "#2563eb", description: "Zone height (a method over the fields)" }); output("touches", line, lower, { color: "#16a34a", description: "Touches registered on the current zone" }); output("touch", shape, overlay, { color: "#16a34a", shape_where: "touched", description: "This bar's close touched the zone" }); output("touched", none); class Zone { top: f64 = NaN; bottom: f64 = NaN; touches: i32 = 0; set(top: f64, bottom: f64): void { this.top = top > bottom ? top : bottom; this.bottom = top > bottom ? bottom : top; this.touches = 0; } height(): f64 { return this.top - this.bottom; } contains(price: f64): bool { return price >= this.bottom && price <= this.top; } registerTouch(price: f64): bool { if (this.contains(price)) { this.touches += 1; return true; } return false; } reset(): void { this.top = NaN; this.bottom = NaN; this.touches = 0; } } const zone = new Zone(); // one instance, allocated once at module scope let maxTouches: i32 = 3; let prevOpen: f64 = NaN; let prevClose: f64 = NaN; function onStart(): void { maxTouches = i32(p_max_touches()); } function onBar(): void { const open = bar.open(); const close = bar.close(); // Anchor the zone to the previous bar's body when there is none yet or the current one has retired. if (isNaN(zone.top) && !isNaN(prevOpen)) zone.set(prevOpen, prevClose); const touched = !isNaN(zone.top) && zone.registerTouch(close); if (zone.touches >= maxTouches) zone.set(open, close); // retire: re-anchor to this bar's body prevOpen = open; prevClose = close; if (isNaN(zone.top)) return; out_zone_top(zone.top); out_zone_bottom(zone.bottom); out_height(zone.height()); out_touches(f64(zone.touches)); out_touch(close); out_touched(touched ? 1.0 : 0.0); } ``` **What to expect:** `zone.height()` returns the band width, `registerTouch(close)` reports whether the close fell inside the band and increments `zone.touches` when it did, and after `max_touches` touches the zone re-anchors to the current bar's body. The whole zone is one value carrying its own data and behavior, and `zone.reset()` puts it back in one call. ## Rules and gotchas A few constraints the compiler enforces, each at compile time with a precise line and column. **Fields are typed.** `top: f64;` is required; an untyped field is a parse error (`Type expected.`), and assigning an `i32` literal to an `f64` field needs the point (`0.0`) or a cast. **Constructors are positional.** There are no named arguments; `new Zone(top, bottom)` takes the arguments in the constructor's order. A class without a constructor is built with `new Zone()` and its initializers. **`this` only works inside methods.** It refers to the current instance and is meaningless at module scope or inside a free function. **Unknown fields are refused.** `z.label` on a class without `label` fails with `Property 'label' does not exist on type 'src/indicator/Zone'` (the compiler prints a class by its file path). **A class is a reference.** Assigning an instance to another variable aliases it; two zones need two instances, allocated once each. **Nullable slots need a test.** A `StaticArray` element is read into a local and tested with `!== null` before its fields are touched; using it as a `Zone` without the test fails with `Type 'src/indicator/Zone | null' is not assignable to type 'src/indicator/Zone'`. Classes are part of the broader type system: `type-system.md` covers how `f64`, `i32`, `bool`, and `string` fields fit together, and `collections.md` covers arrays of instances. # User-defined functions Create custom, reusable functions with the `function` keyword: typed parameters, a typed return, default values, functions as values, and the rules that keep them honest in a per-bar module. A wrun indicator's functions are plain TypeScript syntax, with every type written out and module-level state in place of closures. ## Overview | Feature | Description | | --- | --- | | `function` | The keyword; declares a named function with typed parameters and return | | Any logic | Encapsulate a calculation, a predicate, a candle pattern, a session test | | Module state | Functions read and write module-level variables; that is how state reaches them | | Values | A non-capturing function is a value you can store and pass | ## Function declaration ### Basic syntax ```text function functionName(parameter1: f64, parameter2: f64): f64 { // body return result; } ``` Every parameter carries a type and the return type is written after the parameter list. Leaving the return type off is a parse error (`Type expected.`), so annotate even `void`. ### Calling Calls are positional, in the declared order. There are no keyword arguments; readable names and a stable order do the same job, and a function with many settings takes a class instance instead of a long argument list. Parameters may carry defaults: ```text function calculate(base: f64, multiplier: f64, offset: f64 = 0.0): f64 { return base * multiplier + offset; } const a = calculate(10.0, 2.0, 5.0); // 25 const b = calculate(10.0, 2.0); // 20, offset defaulted ``` ### Syntax details **Function name:** standard identifier rules (letters, digits, underscore; not starting with a digit). The two names `onStart` and `onBar` are the hooks the build calls; everything else is yours, and nothing needs an `export`. **Parameters:** typed, positional, optionally defaulted. A parameter is an `f64` for a price or a value, an `i32` for a count, a `StaticArray` for a window, a class for a struct, or a function type for a callback. **Return statement:** every path returns a value of the declared type (`void` returns nothing). The compiler refuses a missing return. ## Function examples ### Safe division ```text function safeDiv(a: f64, b: f64): f64 { return b == 0.0 ? 0.0 : a / b; } const ratio = safeDiv(10.0, 2.0); // 5 const safe = safeDiv(10.0, 0.0); // 0 ``` ### Average of two values ```text function average(a: f64, b: f64): f64 { return (a + b) / 2.0; } const mid = average(close, prevClose); ``` ### Custom pattern logic A candle-pattern test takes this bar's and the previous bar's open and close. An indicator remembers the previous bar's values in module-level variables that `onBar()` updates on every bar: ```typescript input("open", ohlcv.open); input("close", ohlcv.close); input("low", ohlcv.low); output("engulfing", shape, overlay, { color: "#16a34a", shape_where: "is_engulfing", description: "Bullish engulfing candle" }); output("is_engulfing", none); output("body_ratio", line, lower, { color: "#94a3b8", description: "This body divided by the previous body" }); function safeDiv(a: f64, b: f64): f64 { return b == 0.0 ? 0.0 : a / b; } function isGreenCandle(openPrice: f64, closePrice: f64): bool { return closePrice > openPrice; } function isBullishEngulfing(prevOpen: f64, prevClose: f64, currOpen: f64, currClose: f64): bool { const prevWasRed = prevClose < prevOpen; const currIsGreen = isGreenCandle(currOpen, currClose); const engulfs = currOpen < prevClose && currClose > prevOpen; return prevWasRed && currIsGreen && engulfs; } let prevOpen: f64 = NaN; let prevClose: f64 = NaN; function onBar(): void { const open = bar.open(); const close = bar.close(); const ready = !isNaN(prevOpen); const engulfing = ready && isBullishEngulfing(prevOpen, prevClose, open, close); const bodyRatio = ready ? safeDiv(Math.abs(close - open), Math.abs(prevClose - prevOpen)) : NaN; prevOpen = open; prevClose = close; if (!ready) return; out_engulfing(bar.low()); // the mark sits under the candle out_is_engulfing(engulfing ? 1.0 : 0.0); out_body_ratio(bodyRatio); } ``` The three helpers are pure: they take numbers and return a number or a `bool`, and `onBar()` supplies the remembered previous bar. That keeps the pattern testable in isolation and the per-bar function short. ## Functions as values A function that captures no local variable is a value. Store it in a variable typed with its signature, pass it to a loop, keep a small table of them: ```text function aboveMean(x: f64): bool { return x > mean; } // mean is module-level let pred: (x: f64) => bool = aboveMean; const hits = countWhere(window, n, pred); ``` Arrow functions work the same way, and nested functions (declared inside another function) are allowed as long as they read nothing from the enclosing function's locals. The full treatment, with the loop that takes a predicate, is `lambdas-and-reducers.md`. ## Constraints and rules ### No declarations inside functions `param(...)`, `input(...)`, `output(...)`, `box(...)`, and the rest are top-level statements of the file; the build reads them without running the code. One inside a function is a named build error: ```text output(...) declarations must be top-level statements, not inside a function, class, or expression ``` Pass an input's value in as an argument instead: ```text // Invalid function bad(): f64 { input("close", ohlcv.close); // a declaration cannot live here return 0.0; } // Valid: declare at the top level, pass the value function good(close: f64, avg: f64): f64 { return close - avg; } ``` ### No locals from the outside A function reads its parameters and module-level state. An inner function or arrow that reads a local of the function around it is refused with `AS100: Not implemented: Closures`. Move the value to module scope or pass it as a parameter. ### Allocation is once, not per call A function called in `onBar()` runs on every bar. A `new` inside it (a class, an array, a string built with `+`) allocates memory the module never frees. Helpers on the per-bar path take and return numbers, or write into buffers allocated at module scope. ### Types everywhere Parameter types and the return type are required; there is no inference from usage. A function meant for both a price and a count is written for `f64`, and the caller casts (`f64(count)`). ### Phase rules still apply inside functions `p_()` reads its value from `onStart()` on (at module start it returns `NaN`), `in_()` and `bar.*()` from `onBar()` on (`NaN` before the first bar), and an `out_()` write reaches the row only from `onBar()`; a helper inherits the phase of its caller, so keep helpers pure and let the hooks read and write. ## Best practices - **Keep functions focused.** One thing per function: `rsiZone(rsiValue: f64): f64` returns `0`, `1`, or `2`; the output write stays in `onBar()`. - **Use descriptive names.** `isOverbought(rsiValue)`, `percentChange(oldValue, newValue)`; not `calc(a, b)` or `check(x)`. - **Prefer parameters to module state for pure math.** A function that takes everything it needs is reusable across indicators by pasting; a function that reaches into module state is tied to this file. Reserve module-state reads for predicates you pass as values. - **Turn a family of helpers into a class** when they share state: a `Window` with `sum()`, `avg()`, and `median()` (`collections.md`), a `Macd` with `update()` (`named-streams.md`). Helpers that share nothing stay functions. # Lambdas and reducers Arrow functions, function values, and the loops that do the work of the reducer methods (`map`, `filter`, `reduce`, `forEach`, `some`, `every`). A wrun indicator has arrow functions, with one rule that changes how you use them: a function cannot capture a local variable, so the "closure" is module-level state and a reducer is a plain loop over a buffer. ## Lambda syntax An arrow function has typed parameters and a typed return, in either the expression or the block form. It is a value: store it in a variable typed `(x: f64) => f64`, pass it to a function, call it. ```text const double = (x: f64): f64 => x * 2.0; const label = (side: f64): string => { return side > 0.0 ? "bid" : "ask"; }; let pick: (x: f64) => bool = above; // a named function is a value too const y = double(21.0); // 42 ``` Both forms need every type written out: an untyped parameter or a missing return type is a parse error (`Type expected.`). ## The one rule: no captured locals The JavaScript habit is a lambda that closes over the surrounding scope: `const threshold = high * 0.99; prices.filter((p) => p > threshold)`. In an indicator that exact shape, an arrow function inside `onBar()` reading a `const` of `onBar()`, is refused at compile time: ```text ERROR AS100: Not implemented: Closures ``` The compiler cannot build a function that carries a copy of another function's locals. What a function **can** read is module-level state, which is where an indicator keeps everything that matters anyway. So the pattern is: put the threshold in a module-level `let`, write the predicate as a named function (or an arrow at module scope), and pass it. ```typescript param("bars", 20, { min: 1, max: 200, description: "Bars in the window" }); output("above_count", line, lower, { color: "#16a34a", description: "Bars in the window closing above the window mean" }); output("near_high_count", line, lower, { color: "#f59e0b", description: "Bars in the window within 1% of the window high" }); const MAX_BARS = 200; const closes = new StaticArray(MAX_BARS); let n: i32 = 20; let cursor: i32 = 0; let count: i32 = 0; let mean: f64 = NaN; // module-level: the "closure" every predicate reads let nearHighLevel: f64 = NaN; // Predicates are named functions over module-level state, never over a local. function aboveMean(x: f64): bool { return x > mean; } function nearHigh(x: f64): bool { return x >= nearHighLevel; } // A reducer is a loop that takes the predicate as a value. function countWhere(xs: StaticArray, len: i32, pred: (x: f64) => bool): i32 { let hits = 0; for (let i = 0; i < len; i++) if (pred(xs[i])) hits += 1; return hits; } function windowMax(xs: StaticArray, len: i32): f64 { let m = -Infinity; for (let i = 0; i < len; i++) if (xs[i] > m) m = xs[i]; return m; } function windowMean(xs: StaticArray, len: i32): f64 { let s = 0.0; for (let i = 0; i < len; i++) s += xs[i]; return s / f64(len); } function onStart(): void { n = i32(p_bars()); } function onBar(): void { closes[cursor] = bar.close(); cursor = (cursor + 1) % n; if (count < n) count += 1; if (count < n) return; mean = windowMean(closes, n); nearHighLevel = windowMax(closes, n) * 0.99; out_above_count(f64(countWhere(closes, n, aboveMean))); out_near_high_count(f64(countWhere(closes, n, nearHigh))); } ``` `countWhere` is the reducer; `aboveMean` and `nearHigh` are the lambdas, reading `mean` and `nearHighLevel` from module scope instead of capturing them. The same function value can be passed to any loop that takes a `(x: f64) => bool`. ## The reducer methods `Array` has the familiar methods, and each callback is a non-capturing function with typed parameters (trailing parameters may be omitted): | Method | Callback | Returns | Allocates? | | --- | --- | --- | --- | | `map(fn)` | `(value: f64, index?: i32) => U` | a new array | yes | | `filter(fn)` | `(value: f64, index?: i32) => bool` | a new array | yes | | `reduce(fn, initial)` | `(acc: U, value: f64, index?: i32) => U` | the final accumulator | no | | `forEach(fn)` | `(value: f64, index?: i32) => void` | nothing | no | | `findIndex(fn)` | `(value: f64, index?: i32) => bool` | the first matching index, or `-1` | no | | `some(fn)` | `(value: f64, index?: i32) => bool` | `true` if any match | no | | `every(fn)` | `(value: f64, index?: i32) => bool` | `true` if all match | no | There is no `find`: use `findIndex` and read the element. `map` and `filter` return a **new** array every call, and the module never frees memory, so they belong in `onStart()` (building a lookup table once) and not in `onBar()`. On the per-bar path, write the loop over a `StaticArray` you allocated once; the window example above is the template, and `collections.md` has every numeric reducer as a method. ```text // onStart(): fine, once const doubled = periods.map((p: f64): f64 => p * 2.0); // onBar(): a loop over a preallocated buffer, no allocation let sum = 0.0; for (let i = 0; i < n; i++) sum += window[i]; ``` ## The microstructure idiom Where you would reach for a `map` and a `reduce` over order-flow rows (buy minus sell per bucket, summed), an indicator reads the bar's cells (one `[low, high, buy, sell]` row per price bucket) through the input's view and loops over them: the net delta of the bar, and the price of its largest-volume bucket (the point of control). ```typescript input("close", ohlcv.close); input("profile", volume_profile.cells, { max_cells: 8192 }); output("delta", line, lower, { color: "#2563eb", description: "Net per-bar delta: buy minus sell volume across every price bucket" }); output("poc", line, overlay, { color: "#f59e0b", description: "Price of the bucket that traded the most volume" }); function onBar(): void { const n = in_profile_cells(); if (n <= 0) return; // no block this bar const cells = in_profile_view(); // max_cells tuples of 4 f64; only the first n values are this bar's let net = 0.0; let best = -1.0; let bestPrice = NaN; for (let i = 0; i + 3 < n; i += 4) { const buy = cells[i + 2]; const sell = cells[i + 3]; net += buy - sell; // the map + reduce, as one pass if (buy + sell > best) { best = buy + sell; bestPrice = (cells[i] + cells[i + 1]) / 2.0; // the bucket's midpoint } } out_delta(net); out_poc(bestPrice); } ``` Every windowed class in the TA kit accepts any number, so `rsi.update(delta)` is a delta-RSI in one line. The celled input, its accessors, and the `max_cells` contract are in `data-sources.md`. ## Loops still exist, and you bound them `for`, `while`, and `do` are the iteration tools (`for...of` is not implemented: `Not implemented: Iterators`, so index loops it is). There is no per-loop ceiling inside the module; size every loop by a param with a declared `max`. A loop that runs away is stopped by the host's execution timeout and the evaluation is refused: it cannot hang the chart, and it cannot produce a value either. # The settings dialog Declare a setting by its kind and the dialog draws the right control: one page per thing you can declare, in the order you meet them. ## One rule A setting is one declaration users can change without editing code. `param.(name, default, options?)` names the kind of value, and the overlay's settings dialog on the chart draws the control that kind calls for: a number field, a toggle, a menu, a color picker, a market button, a session strip. The value you read once in `onStart()` drives your logic, and changing it reruns the indicator over the loaded bars without recompiling. This is how you ship a configurable indicator: an adjustable period, a threshold, a multiplier, an on/off switch, a picked market. ```typescript param.int("length", 20, { min: 2, max: 200 }); param.bool("show_bands", true); let n: i32 = 20; let bands = true; function onStart(): void { n = i32(p_length()); bands = pb_show_bands(); } ``` Every kind keeps the contract of plain `param(name, default, { required, min, max, description })`: a top-level statement, a literal default, and a generated reader, readable from `onStart()` on. Plain `param(...)` still declares a number field, and a file that uses it runs exactly as before. ## The dialog, labelled ![The settings dialog on the first page of the tour: the rail on the left, the presets strip, then the rows with their controls](/wrun/images/wrun-dialog.svg) ![The second page of the tour's dialog: a section that carries its toggle in the header and a folded section with its row count](/wrun/images/wrun-dialog-bands.svg) The tour's dialog on its two pages. On the left, the rail: one entry per `page(...)`, then a **Chart** caption with the chart's own entries. Across the top of a page, the presets strip. Below it the rows, grouped by sections: one carries its toggle in its header, one starts folded with its row count in the header. Each row is the label, a hint glyph when the declaration has one, a dot and a reset button once the value differs from its default, and the control the kind names. ## Pages on the rail `page(title)` starts a page. Pages are entries in the dialog's left rail, in order, above a **Chart** caption that holds the chart's own entries: the plot settings, visibility and price marker pages follow the pages you declare. Settings declared before the first `page(...)` land on **General**. A file with no layout word and no `group` keeps one flat list. The layout words are on [Pages, sections, dividers, notes](layout.md). ## Rows and their controls Every `param.` you declare is a row, drawn as the control its kind names ([Setting kinds](kinds.md) has all fifteen). The row's name is its `label`; without one the row shows `description`, else the name read as words (`show_bands` reads "Show Bands"). A `hint` puts words behind a small glyph after the label, shown on hover. Rows that name the same `row` word share one line of the dialog, and a row whose `when` toggle is off is dimmed, or hidden with `hide` ([Options on a setting](options.md)). The dialog adds the rest by itself. A row that differs from its default shows a dot and a reset button that names the default, and a dialog with more than twelve settings gets a filter box over every page. ## The presets strip `presets({ Name: { setting: value, ... }, ... })` ships named sets of values with the indicator. The dialog offers them as chips after **Default**; picking one fills every setting it names in one step, and editing a row afterwards shows **Custom** ([Presets](presets.md)). ## The Style page Every output you draw gets a row on the dialog's **Style** page without a declaration: visible, width, line style and color for a line; opacity beside them for an area, and for a column with a static color; visible, width and color for a mark. A spot the file binds to a setting (`color: "@basis_color"`) is that setting's row instead, so a look never has two controls ([The Style page](style-page.md)). ## Browser or cloud Most settings are numbers the module reads, and every place that runs an indicator hands them to `onStart()` the same way: your browser, OpenMarket's servers for an indicator published as **Protected**, and the alerts engine. A setting's value on the chart is the overlay's own, read once in `onStart()`: the chart runs every setting from the dialog, or from the default when none is set, and changing one reruns the indicator over the loaded bars. An alert on a published indicator runs with the settings of the overlay you set it from ([Alerts](../functions/alerts.md)). Three kinds are applied by the chart instead: `param.source` picks the field an input reads, and `param.timeframe` and `param.symbol` pin an input. For now the chart applies them only when the indicator runs in your browser. | Where the indicator runs | A number, toggle, menu, color, time, price, range, multi, list or session | A `source`, `timeframe` or `symbol` setting | | --- | --- | --- | | your browser | read in `onStart()` from the dialog | applied by the chart | | OpenMarket's servers | read in `onStart()` from the dialog | keeps the declared default; the dialog marks the row "Browser lane only for now" | | the alerts engine | the settings of the overlay you set the alert from | the alert refuses to arm while one is off its default | An indicator that runs on OpenMarket's servers has no Style page either. The lanes in full, with the picks and the 128-setting cap: [Picks, lanes, the cap](picks-and-lanes.md). ## Where to go next - [Setting kinds](kinds.md): the fifteen kinds, the control each one draws, the reader each one generates - [Options on a setting](options.md): `label`, `hint`, `step`, `row`, `when`, `slider`, `confirm`, and which kind takes which - [Pages, sections, dividers, notes](layout.md): the words that lay the dialog out - [Presets](presets.md): named sets of values and the chip strip - [The Style page](style-page.md): the rows every drawn output gets, and `"@name"` bindings - [Sessions and units](sessions-and-units.md): a window in its zone, a number with a unit picker - [Picks, lanes, the cap](picks-and-lanes.md): time and price picks, the settings the chart applies, the 128-setting cap - [What the build checks](checks.md): every refusal, on the declaration's line # Setting kinds Fifteen kinds, one row each: the declaration, the control the dialog draws, the reader your code calls. ## What it is Every kind keeps the contract of plain `param(name, default, { required, min, max, description })`: a top-level statement, a literal default, and a generated reader, readable from `onStart()` on. Plain `param(...)` still declares a number field, and a file that uses it runs exactly as before. What a menu chooses at runtime is still a number in the module: two moving-average kinds are two classes, and the `param.choice` index picks which one writes the output. ## Declare it ```typescript // a number field, a toggle, a menu, two handles on one slider, a text field param.int("length", 20, { min: 2, max: 500 }); param.text("label", "Session VWAP", { max_bytes: 64 }); param.bool("show_bands", true); param.choice("kind", ["Simple", "Exponential"], "Simple"); param.range("band_pct", [0.5, 2.0], { min: 0, max: 10 }); ``` | Kind | Declare it as | The dialog draws | | --- | --- | --- | | `int` | `param.int(name, 20, { min, max })` | a number field that steps by 1 | | `number` | `param.number(name, 2.0, { min, max, step })` | a number field that steps by `step` | | `bool` | `param.bool(name, true)` | an on/off toggle | | `choice` | `param.choice(name, ["Simple", "Exp"], "Simple")` | a menu of the labels | | `color` | `param.color(name, "#2962ff")` | a color picker | | `time` | `param.time(name, "2024-01-01 00:00")` | a date and time field with **Pick**: the next click on the chart sets it | | `price` | `param.price(name, 0.0)` | a number field with **Pick**: the next click on the chart sets the price | | `range` | `param.range(name, [0.5, 2.0], { min, max })` | one slider with two handles | | `multi` | `param.multi(name, ["Mon", "Tue"], ["Mon"])` | a chip per option up to four options, a multi-select menu past that | | `list` | `param.list(name, [5.0, 20.0], { max })` | an editable list of numbers with **Add**, up to `max` long | | `source` | `param.source(name, ohlcv.close)` | a menu of `open`, `high`, `low`, `close`, `hl2`, `hlc3`, `ohlc4`, `volume`, `hlcc4` | | `timeframe` | `param.timeframe(name, "chart")` | a menu of `chart`, `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, `1w` | | `symbol` | `param.symbol(name, "BINANCE:ETHUSDT")` | a market button that opens the symbol search | | `session` | `param.session(name, "09:30-16:00", { tz })` | a day strip with the window shaded, a start and an end on the 24-hour clock, and a zone menu | | `text` | `param.text(name, "Session VWAP", { max_bytes })`, or `param.text_area(name, ...)` for several lines | a text field (a text area for `text_area`) with a "never paste keys or passwords" hint | What `onStart()` reads, per kind (the reader takes the setting's name): | Kind | `onStart()` reads | | --- | --- | | `int` | `p_length()`, a whole number | | `number` | `p_mult()` | | `bool` | `pb_show_bands()`, a `bool` | | `choice` | `p_kind()`, the picked index (0 for the first label) | | `color` | `p_basis_color()`, the color packed into one number; most files bind it to an output instead ([The Style page](style-page.md)) | | `time` | `p_since()`, epoch seconds | | `price` | `p_floor()` | | `range` | `p_band_pct_lo()` and `p_band_pct_hi()` | | `multi` | `p_days()`, a bitmask (bit 0 is the first option); `multiHas(mask, i)` tests one | | `list` | `p_lookbacks()`, an `f64[]` of the filled slots | | `source` | nothing: it declares the input too, and `in_src()` reads the picked field per bar | | `timeframe` | nothing: `{ interval: "@htf" }` on an input pins that input to the pick | | `symbol` | nothing: `{ symbol: "@pair" }` on an input pins that input to the pick | | `session` | `p_rth_start()` and `p_rth_end()` (minutes from midnight), `p_rth_tz()` (the zone's index) | | `text` | `pt_label()`, the typed words as a `string` (`p_label()` reads their UTF-8 byte count) | The defaults are literals, read from the text without running it: a number, `true` or `false`, a string, an array of literals, or for `param.source` an `ohlcv.` reference. A `param.time` default is `"YYYY-MM-DD HH:MM"` in UTC or a whole number of epoch seconds. A `param.symbol` default is `"EXCHANGE:SYMBOL"` in the chart's own ids. A `param.source` default is any word of its menu, a blend such as `hl2` included ([A blended default](picks-and-lanes.md#a-blended-default)). A `param.multi` takes at most 53 options (one bit each), and a `param.range` needs `min` and `max`. ## By the setting you need | Setting you need | wrun form | | --- | --- | | a number, a float | `param.number`, or plain `param` | | an integer | `param.int`, read whole | | a slider | `param.int` or `param.number` with `min`, `max` and `slider: true` | | an on/off toggle | `param.bool`, read with `pb_()` | | a dropdown | `param.choice`, read as the picked index | | a multi-select | `param.multi`, read as a bitmask | | a color picker | `param.color`, bound to an output with `color: "@name"` | | a palette the user picks | one `param.color` per entry, each bound into a `colors` palette by `"@name"`; a `color_by` ladder then indexes the user's colors ([Styling](../presentation/styling.md)) | | a source dropdown | `param.source`, which declares the input too; a second feed is a second input | | a timeframe field | `param.timeframe` bound to an input with `interval: "@name"`; an `interval` pin written on the input needs no setting | | a symbol field | `param.symbol` bound to an input with `symbol: "@name"`; a `symbol` + `exchange` pin written on the input needs no setting | | a session field | `param.session` and `inSession(...)` per bar; a window fixed in the file is a `Session` from `./sdk/clock` ([Clock and sessions kit](../functions/time-and-sessions-kit.md)), and the integer math underneath is on [Time and sessions](../core-concepts/time-and-sessions.md) | | a text field | `param.text` (one line) or `param.text_area` (several lines), read with `pt_()`; the words are plain text within `max_bytes` | | a label, a step, a group, a tooltip | the options `label`, `step`, `group`, `hint` ([Options on a setting](options.md)) | ## What the dialog draws ![a whole-number field that steps by one, with a hint glyph after its label](/wrun/images/wrun-settings-int.svg) ![a number field with a unit picker beside it](/wrun/images/wrun-settings-number-unit.svg) ![a bounded whole number drawn as a slider with its readout](/wrun/images/wrun-settings-slider.svg) ![a toggle and a timeframe menu sharing one line of the dialog](/wrun/images/wrun-settings-bool-row.svg) ![a menu of the labels a choice lists](/wrun/images/wrun-settings-choice.svg) ![a color picker](/wrun/images/wrun-settings-color.svg) ![a date and time field with its Pick button](/wrun/images/wrun-settings-time.svg) ![a price field with its Pick button](/wrun/images/wrun-settings-price.svg) ![one slider with two handles](/wrun/images/wrun-settings-range.svg) ![a chip per option of a multi-select](/wrun/images/wrun-settings-multi.svg) ![an editable list of numbers with an Add button](/wrun/images/wrun-settings-list.svg) ![a menu of the price fields a source can read](/wrun/images/wrun-settings-source.svg) ![a market button that opens the symbol search](/wrun/images/wrun-settings-symbol.svg) ![a day strip with the window shaded, a start and an end on the 24-hour clock, and a zone menu](/wrun/images/wrun-settings-session.svg) A row that differs from its default shows a dot and a reset button that names the default. ## Read it in onStart() Every reader works from `onStart()` on: the chart serves the settings there, before the first bar, and a reader called later, in `onBar()` or a helper, returns the same cached value. Read each setting into a module variable in `onStart()` and use the variable per bar, or read it where you use it. ```typescript function onStart(): void { const n = i32(p_length()); sma = new Sma(n); useEma = p_kind() == 1.0; bandsOn = pb_show_bands(); lowPct = p_band_pct_lo(); highPct = p_band_pct_hi(); } ``` | Kind | Readers in `./gen/params` | | --- | --- | | `int`, `number`, `price`, `time`, `choice`, `multi`, `color`, plain `param` | `p_(): f64` | | `bool` | `pb_(): bool` (and `p_()`, 1 or 0) | | `range` | `p__lo()`, `p__hi()` | | `list` | `p_(): f64[]`, the filled slots in order | | `session` | `p__start()`, `p__end()`, `p__tz()` | | `text` | `pt_(): string`, the words (and `p_()`, their UTF-8 byte count) | | a `unit` picker | `p_()` and `p__unit()`, the unit's code | | `market.tick_size()`, `market.price_precision()` | `p_market_tick_size()`, `p_market_price_precision()` | | `market.kind()`, `market.point_value()`, `market.zone()`, `market.quote_is_usd()` | `p_market_kind()`, `p_market_point_value()`, `p_market_zone()`, `p_market_quote_is_usd()` ([The market's facts](sessions-and-units.md#the-markets-facts)) | | `source`, `timeframe`, `symbol` | none to call: the chart applies the pick | ## Text settings A text setting is a field the user types words into: a label, a note, a list of tickers. Declare it like any other setting, and read the words once, in `onStart()`, with `pt_()`. ```typescript // A moving average with a corner readout that says what you typed in the settings. param.int("length", 20, { min: 1, max: 500 }); // One line of text, at most 64 bytes. param.text("label", "Session average", { max_bytes: 64, hint: "Shown in the corner of the chart" }); // Several lines of text. The readout shows the first line. param.text_area("notes", "Buy the dip above it.\nStand aside below it.", { max_bytes: 512 }); // A preset may set a text setting like any other. presets({ Fast: { length: 9, label: "Fast average" }, Slow: { length: 50, label: "Slow average" } }); output("average", line, overlay, { color: "#2563eb" }); // Sized for the label's 64 bytes plus a space and a number. string("tag", { max_bytes: 96 }); string("note", { max_bytes: 512 }); render.table("corner", { rows: 2, cols: 1, cells: ["tag", "note"], position: "top_left" }); let average = new Sma(20); let label: string = ""; let firstNote: string = ""; // Text settings are read once, here, through pt_(). function onStart(): void { average = new Sma(i32(p_length())); label = pt_label(); firstNote = pt_notes().split("\n")[0]; } function onBar(): void { const value = average.update(bar.close()); if (isNaN(value)) return; out_average(value); sb_clear(); sb_text(label); sb_text(" "); sb_auto(value); str_tag_sb(); str_note(firstNote); } ``` - `param.text` draws a one-line field, and `param.text_area` a box for several lines. - `pt_label()` returns the typed words as a `string`. Read it in `onStart()` and keep it in a module variable. - The `tag` slot holds the label, a space and a number, so it is sized for the label's 64 bytes plus the rest. A line longer than its slot is refused, never cut. - A preset sets a text setting with its words: `Fast: { label: "Fast average" }`. ### Options | Option | What it does | Default | | --- | --- | --- | | `max_bytes` | the most UTF-8 bytes the field takes (a plain letter is 1 byte, an accented one 2, most CJK characters 3) | 256, at most 4096 | | `multiline` | keeps newlines in a `param.text` field (`param.text_area` always does) | `false` | | `label`, `hint`, `group`, `row`, `when`, `hide`, `confirm`, `required`, `description` | the words every setting takes ([Options on a setting](options.md)) | | `pt_()` reads the words. `p_()` reads their length in bytes. ### What the user can type Plain text. Before your code reads the words, the chart and every other place the indicator runs clean them the same way: - Invisible characters go: right-to-left overrides, zero-width characters and the other format marks. A "Binance" disguised with them reads as plain "Binance". - Control characters go. On a one-line field each newline becomes a space. - Words longer than `max_bytes` are refused, never cut. The dialog says "Too long: 70 of 64 bytes. Shorten the text to apply it." The words are never shown as a link or as markup, and they never fill a placeholder: a typed `{{close}}` stays those eight characters. Every text field carries the hint "Never paste keys or passwords here." The words are saved like any other setting and are never sent anywhere shared. ### Limits and refusals | Rule | What the build says | | --- | --- | | the default is a string | `param.text 'label' default must be a string literal (expected a string literal)` | | `max_bytes` is 1 to 4096 | `param.text 'label' max_bytes takes an integer from 1 to 4096, not 5000` | | the default fits `max_bytes` | `param.text 'label' default is 15 UTF-8 bytes, over its max_bytes 4` | | the default is plain text | `param.text 'label' default carries a newline, a control or an invisible character; typed text is stripped of bidi controls, zero-width characters and control characters, so a default cannot carry them` | | a text field takes no number options | `param.text 'label' takes no min option (its value is not a number field)` | | a text area is always several lines | `param.text_area 'notes' is multi-line by definition; drop multiline: false (param.text takes the flag)` | | a preset sets words | `preset 'Fast' value for 'label' takes a string literal (a text setting), not 5` | | a preset fits `max_bytes` | `preset 'Fast' value for 'label' is 70 UTF-8 bytes, over the setting's max_bytes 64` | | history is sized from numbers | `warmup term names 'label', a text setting: a window is sized from numbers, so name a numeric param (length)` | A text setting counts as one of the 128 settings. Declaring one moves the file to the `wrun-5` contract (or keeps it on a later one) when you build ([Versions and contracts](../reference/versions.md)); a place that cannot read text settings yet refuses the indicator by name instead of running it without the words. ## Fourteen kinds in one module An average of a picked price field with percent bands, an offset in a picked unit, a reference close on a picked timeframe, a ratio against a picked market, a floor line and a session filter over picked weekdays. Every setting is read in `onStart()` and kept in a module variable. Text, the fifteenth kind, has its own module under [Text settings](#text-settings). ```typescript // int: a whole number. param.int("length", 20, { min: 2, max: 500 }); // choice: a menu; the reader returns the picked index, 0 for Simple. param.choice("kind", ["Simple", "Exponential"], "Simple", { label: "Average" }); // source: declares the primary input too; in_src() reads the picked field. param.source("src", ohlcv.close); // bool: a toggle, read as a bool. param.bool("show_bands", true); // range: a low..high pair on one slider. param.range("band_pct", [0.5, 2.0], { min: 0, max: 10, step: 0.1, label: "Band width %" }); // number with a unit picker: the value and the unit's code. param.number("offset", 0.0, { min: -100, max: 100, unit: ["price", "ticks", "%", "atr"], unit_default: "%" }); // A bounded int drawn as a slider: the ATR an atr-unit offset scales by. param.int("atr_length", 14, { min: 2, max: 50, slider: true, label: "ATR length" }); // color: painted onto the basis line below, by name. param.color("basis_color", "#2962ff"); // timeframe and symbol: each pins the input that names it. param.timeframe("htf", "chart", { label: "Reference timeframe" }); input("close_htf", ohlcv.close, { interval: "@htf" }); param.symbol("pair", "BINANCE_FUTURES:ETHUSDT", { label: "Ratio against" }); input("close_pair", ohlcv.close, { symbol: "@pair" }); // list: up to four momentum lookbacks. param.list("lookbacks", [5.0, 20.0], { max: 4 }); // time and price: both can be picked on the chart. param.time("since", "2024-01-01 00:00", { label: "Start" }); param.price("floor", 0.0, { hint: "0 draws no floor" }); // session and multi: a window in its zone, and the weekdays it applies on. param.session("rth", "09:30-16:00", { tz: "America/New_York", label: "Active hours" }); param.multi("days", ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], ["Mon", "Tue", "Wed", "Thu", "Fri"]); // The tick size, written by the chart into a hidden setting. market.tick_size(); output("basis", line, overlay, { color: "@basis_color", width: 2 }); output("upper", line, overlay, { color: "#26a69a" }); output("lower", line, overlay, { color: "#ef5350" }); output("reference", line, overlay, { color: "#94a3b8", line_style: "dashed" }); output("floor", line, overlay, { color: "#f59e0b" }); output("momentum", line, lower, { color: "#8b5cf6" }); output("ratio", line, lower, { color: "#0ea5e9" }); let sma = new Sma(20); let ema = new Ema(20); let atr = new Atr(14); let rocs: Roc[] = []; let useEma = false; let bandsOn = true; let lowPct = 0.5; let highPct = 2.0; let offset = 0.0; let offsetUnit = 0; let tick = 0.0; let since = 0.0; let floorPrice = 0.0; let dayMask = 0.0; let sessionStart = 570.0; let sessionEnd = 960.0; let zone = 0; // Every setting is read here, once, and kept in module state. function onStart(): void { const n = i32(p_length()); sma = new Sma(n); ema = new Ema(n); useEma = p_kind() == 1.0; bandsOn = pb_show_bands(); lowPct = p_band_pct_lo(); highPct = p_band_pct_hi(); offset = p_offset(); offsetUnit = i32(p_offset_unit()); atr = new Atr(i32(p_atr_length())); tick = p_market_tick_size(); since = p_since(); floorPrice = p_floor(); dayMask = p_days(); sessionStart = p_rth_start(); sessionEnd = p_rth_end(); zone = i32(p_rth_tz()); const lookbacks = p_lookbacks(); rocs = []; for (let i = 0; i < lookbacks.length; i += 1) rocs.push(new Roc(i32(lookbacks[i]))); } function onBar(): void { const t = bar.time(); const close = in_src(); const reference = in_close_htf(); const pair = in_close_pair(); const simple = sma.update(close); const exponential = ema.update(close); let basis = useEma ? exponential : simple; const atrValue = atr.update(bar.high(), bar.low(), close); // The offset in the unit the dialog picked, as a price distance. if (isFinite(basis)) basis += unitToPrice(offset, offsetUnit, close, tick, atrValue); const ratio = isFinite(pair) && pair != 0.0 ? close / pair : NaN; let sum = 0.0; let count = 0; for (let i = 0; i < rocs.length; i += 1) { const r = rocs[i].update(close); if (isFinite(r)) { sum += r; count += 1; } } const momentum = count > 0 ? sum / f64(count) : NaN; // 0 = Monday, from the bar's UTC day: the bit the days setting is tested by. const weekday = i32((Math.floor(t / 86400.0) + 3.0) % 7.0); const active = inSession(t, sessionStart, sessionEnd, zone) && multiHas(dayMask, weekday); if (!isFinite(basis) || t < since) return; out_basis(basis); // A hidden line is NaN: nothing is drawn. out_upper(bandsOn ? basis * (1.0 + highPct / 100.0) : NaN); out_lower(bandsOn ? basis * (1.0 - lowPct / 100.0) : NaN); out_reference(reference); out_floor(floorPrice > 0.0 ? floorPrice : NaN); // Momentum draws inside the session window only. out_momentum(active ? momentum : NaN); out_ratio(ratio); } ``` Both averages update on every bar whatever the menu says, so the choice only picks which one writes `basis`; changing it in the settings dialog reruns the indicator over the loaded bars. The **Typed inputs tour** card in the editor's template list (`typed-inputs-tour`) is this module with a two-page layout, presets, a hover card, a HUD and legend entries declared beside it ([Plotting](../presentation/plotting.md)). ## Gotchas - A kind that takes no option refuses it by name, on the declaration's line: `param.bool 'x' takes no min option (its value is not a number field)`. - Each kind checks its own default's shape: a whole number for `param.int`, hex (`"#rrggbb"` or `"#rrggbbaa"`) for `param.color`, one of the options for `param.choice`. - A setting outside `min`..`max`, or off its `step` grid (counted from `min`, or from 0 without one), is refused by name before the module runs: `params.length must align to step (1) from min (1), got 14.5`. The default is checked the same way, so keep it on the grid. The grid allows float rounding: a value a hair off a step (1002.000001 on a step of 1) passes as typed, so read a whole number with `i32(p_length())`. - A `param.range` needs `min` and `max`, and a `param.list` needs `max`, the most items the dialog may hold. - A `param.source` default may be a blend (`hl2`, `hlc3`, `ohlc4`, `hlcc4`): every place the indicator runs blends the bar's candle the same way. - `source`, `timeframe` and `symbol` are applied by the chart, in your browser only for now; an indicator that runs on OpenMarket's servers keeps the declared default ([Picks, lanes, the cap](picks-and-lanes.md)). - Every refusal, with the words the Console prints: [What the build checks](checks.md). ## Related - [Options on a setting](options.md): the keys every kind takes, and which kind takes which - [Pages, sections, dividers, notes](layout.md): where a row lands - [Sessions and units](sessions-and-units.md): the session strip and the unit picker in full - [What the build checks](checks.md): the refusal table # Options on a setting Every kind takes one options object, every key optional, and a key the kind cannot use is refused by name, on the declaration's line. ## What it is An option is one of the dialog's words on a declaration: the row's name, its stepper, a glyph with a tooltip, which rows share a line, which toggle dims it, where it lands, and the bounds of a number field. Plain `param(...)` keeps its four keys, `required`, `min`, `max` and `description`; the dialog's words belong to the typed settings, `param.int(...)`, `param.bool(...)` and the rest. ## Declare it ```typescript // Two fields on one line of the dialog, the first with a hint. param.int("fast", 9, { min: 1, max: 200, row: "lengths", hint: "Bars in the fast average" }); param.int("slow", 21, { min: 2, max: 400, row: "lengths" }); param.color("slow_color", "#f59e0b", { label: "Color" }); param.bool("smooth", false); // Dimmed until the toggle it names is on. param.number("smoothing", 0.3, { min: 0, max: 1, step: 0.05, slider: true, when: "smooth" }); ``` | Option | What it does | | --- | --- | | `label` | the row's name in the dialog. Without one the row shows `description`, else the name read as words (`show_bands` reads "Show Bands") | | `description` | the long text of the setting, recorded in the sheet | | `min`, `max` | bounds of a number field: a setting outside them is refused by name before the module runs; required by `slider` and by `param.range`; on `param.list`, `max` is the most items the list may hold | | `step` | the stepper's increment and, on `int`, `number` and `range`, the grid a setting sits on, counted from `min` (or 0 without one): a value off it is refused by name before the module runs. A `param.int` steps by 1 unless you say otherwise; on `price`, `step` is the stepper alone | | `slider` | `true` draws a bounded `param.int` or `param.number` as a slider | | `hint` | words behind a small glyph after the label, shown on hover | | `row` | rows that name the same word share one line of the dialog | | `when` | the NAME of a `param.bool`: the row is dimmed while that toggle is off | | `hide` | with `when`: hide the row instead of dimming it | | `group` | `"Page"` or `"Page/Section"`: where the row lands, shorthand for the layout words ([Pages, sections, dividers, notes](layout.md)) | | `unit`, `unit_default` | a unit picker beside a number ([Sessions and units](sessions-and-units.md)) | | `confirm` | marks a setting whose change is worth confirming before it applies (one that refetches); recorded in the sheet, and the dialog applies edits as you make them for now | | `required` | recorded in the sheet; on the chart a setting always has a value | | `tz` | `param.session` only: the zone the window is written in | ## Which kind takes which option | Option | Kinds that take it | | --- | --- | | `label`, `description`, `group`, `row`, `hint`, `when`, `hide`, `confirm`, `required` | every typed kind | | `min`, `max`, `step`, `slider` | the number fields: `int`, `number`, `price`, `range` | | `max` | also `param.list`, as the most items the list may hold | | `unit`, `unit_default` | one number field: `int`, `number`, `price` (a range's two ends have no unit picker) | | `tz` | `param.session` only | | `required`, `min`, `max`, `description` | plain `param`, which takes no other key | On any other kind they are refused. ## What the dialog draws ![a toggle and a timeframe menu sharing one line of the dialog](/wrun/images/wrun-settings-bool-row.svg) ![the hint glyph after a label, with its words shown on hover](/wrun/images/wrun-layout-hint.svg) ![a bounded whole number drawn as a slider with its readout](/wrun/images/wrun-settings-slider.svg) The dialog adds the rest by itself. A row that differs from its default shows a dot and a reset button that names the default, and a dialog with more than twelve settings gets a filter box over every page. ## Gotchas - `when` on a setting takes the NAME of a `param.bool`; `toggle` and `when` on a `section(...)` take the toggle's HANDLE ([Pages, sections, dividers, notes](layout.md)). - A key the kind does not take: `param.int options accept only { required, min, max, description, label, step, group, row, hint, when, hide, slider, unit, unit_default, confirm }, not 'tooltip'`. - A typed key on plain `param`: `param options accept only { required, min, max, description }, not 'label'`. - A number-field key on a toggle: `param.bool 'x' takes no min option (its value is not a number field)`. - A unit picker on a range: `param.range 'r' takes no unit option (a unit picker sits beside one number field, not a range)`. - `hide` without `when`: `param.int 'x' declares hide without when; hide names what happens when the when param is off, so declare when beside it`. - `when` naming a setting that is not a toggle: `param.int 'x' when names 'y', which is not a param.bool (when takes the NAME of a param.bool, e.g. when: "show_bands")`. - A slider without both bounds: `param.int 'x' declares slider without min and max (a slider needs both bounds)`. ## Related - [Setting kinds](kinds.md): the fifteen kinds the options sit on - [Pages, sections, dividers, notes](layout.md): `group` written out as layout words - [Sessions and units](sessions-and-units.md): `unit`, `unit_default` and `tz` in full - [What the build checks](checks.md): the refusal table # Pages, sections, dividers, notes The layout words are top-level statements, and source order is the layout: each word applies to the settings declared after it. ## What it is Four words lay the dialog out, top-level statements like the settings themselves: `page(title)`, `section(title, { toggle?, collapsed?, when? })`, `divider()` and `note(text)`. They are sheet-only, compiled to nothing: the module receives the same numbers whether or not the file lays its dialog out. A file with no layout word and no `group` keeps one flat list. ## Declare it - `page(title)` starts a page. Pages are entries in the dialog's left rail, in order, above a **Chart** caption that holds the chart's own entries. Settings declared before the first `page(...)` land on **General**. - `section(title, { toggle?, collapsed?, when? })` starts a section on the current page. `toggle` puts a `param.bool` in the section's header and dims its rows while the toggle is off; `collapsed: true` starts the section folded, with its row count in the header; `when` dims the section by a toggle declared elsewhere. `toggle` and `when` take the toggle's HANDLE: bind it with `const show = param.bool(...)` and pass `show`. - `divider()` draws a rule between rows. - `note(text)` puts a line of words between rows; `**bold**` is the one markup. `{ group: "Signals/Labels" }` on a setting is the one-line form: it opens the page and the section it names when they are not already open. A moving-average pair with two pages, a row of two fields, a section that carries its toggle, a folded section and a gated slider: ```typescript page("Lengths"); // Two fields on one line of the dialog, the first with a hint. param.int("fast", 9, { min: 1, max: 200, row: "lengths", hint: "Bars in the fast average" }); param.int("slow", 21, { min: 2, max: 400, row: "lengths" }); note("Keep the fast length **below** the slow one."); page("Look"); // The handle lets the section below carry this toggle in its header. const showSlow = param.bool("show_slow", true, { label: "Slow line" }); section("Slow line", { toggle: showSlow }); param.color("slow_color", "#f59e0b", { label: "Color" }); param.choice("slow_style", ["solid", "dashed", "dotted"], "dashed", { label: "Line style" }); divider(); section("Fine tuning", { collapsed: true }); param.bool("smooth", false); // Dimmed until the toggle it names is on. param.number("smoothing", 0.3, { min: 0, max: 1, step: 0.05, slider: true, when: "smooth" }); output("fast", line, overlay, { color: "#3b82f6", width: 2 }); // The color and the line style follow the two settings above, by name. output("slow", line, overlay, { color: "@slow_color", line_style: "@slow_style", width: 2 }); ``` ## What the dialog draws ![the rail of the dialog: one entry per page, then the Chart caption with the chart's own entries](/wrun/images/wrun-layout-rail.svg) ![a section header carrying its own toggle](/wrun/images/wrun-layout-section-toggle.svg) ![a folded section with its row count in the header](/wrun/images/wrun-layout-section-collapsed.svg) ![the same section open, its rows showing](/wrun/images/wrun-layout-section-open.svg) ![a rule between rows, and a note with one bold word](/wrun/images/wrun-layout-divider-note.svg) A section's toggle is drawn in the header and never as a row of its own; it stays a declared setting, so its value is saved like any other. Rows that name the same `row` word render as a pair, and dividers and notes render between rows. The pages you declare come first on the rail, then the **Style** page the dialog derives from your outputs ([The Style page](style-page.md)), then the chart's own entries under **Chart**. ## Gotchas - `toggle` and `when` on a section take the toggle's HANDLE, bound with a top-level `const`; `when` on a setting takes the toggle's NAME ([Options on a setting](options.md)). - `group` alone is enough to open pages: `{ group: "Page/Section" }` on a setting writes the same layout the words do, and only a file with no layout word and no `group` keeps one flat list. - `**bold**` is the one markup a `note` takes. - A composite (a range, a list, a session, a number with a unit) is placed once, where its declaration sits; a hidden `market.*` setting takes no place in the layout. ## Related - [The settings dialog](overview.md): the dialog with every region named - [Options on a setting](options.md): `group` and `row`, the one-line forms - [Presets](presets.md): the chip strip across the top of a page - [The Style page](style-page.md): the page the dialog derives from your outputs # Presets `presets({ Name: { setting: value, ... }, ... })` ships named sets of values with the indicator. ## What it is The dialog offers the presets as chips after **Default**; picking one fills every setting it names in one step, and editing a row afterwards shows **Custom**. Like the layout words, `presets(...)` is a top-level statement, sheet-only and compiled to nothing: the module still receives the same numbers in the same order, and `onStart()` reads them. The dialog reads the keys; `onStart()` reads the numbers. ## Declare it Write each value as you declared it: a number within the setting's bounds, `true` or `false`, a choice's label, a color string, a time in either spelling, a market as `"EXCHANGE:SYMBOL"`, a text setting's words within its `max_bytes`. A range, list, session or unit is set through its parts (`band_pct_lo`, `rth_start`, `offset_unit`, by the dialog's words for a session zone or a unit). A value the setting cannot take is refused on the `presets` line. ```typescript page("Signal"); param.int("period", 14, { min: 2, max: 200, label: "Length" }); param.choice("kind", ["Simple", "Exponential"], "Simple"); const show = param.bool("show_band", true); section("Band", { toggle: show }); param.range("band_pct", [0.5, 2.0], { min: 0, max: 10, step: 0.1, label: "Band width %" }); param.color("band_color", "#94a3b8"); // The range is set through its two ends. presets({ Tight: { band_pct_lo: 0.2, band_pct_hi: 1 } }); ``` A moving-average pair ships two presets, each naming a different mix of its settings; picking one fills every setting it names: ```typescript presets({ Scalp: { fast: 5, slow: 13, show_slow: true }, Swing: { fast: 20, slow: 50, slow_style: "solid" } }); ``` The sheet records `presets` as `[{name, values: {param: number | boolean | string}}]`, as declared; the chart turns each value into the setting's own form when a preset is picked. ## What the dialog draws ![the presets strip across the top of a page: a Default chip, then one chip per preset](/wrun/images/wrun-layout-presets.svg) ![the strip after an edit: the Custom chip lit](/wrun/images/wrun-layout-presets-custom.svg) ![a changed row with its dot and the reset arrow that names the default](/wrun/images/wrun-layout-changed-reset.svg) The strip holds a **Default** chip, one chip per preset, and a **Custom** chip once the values leave every preset. A row that differs from its default shows a dot and a reset button that names the default. ## Gotchas - A preset names a composite's members, never the composite: `preset 'A' sets 'band', which is not a declared param on the sheet (a composite is set by its members, e.g. band_lo)`. - A value the setting cannot take is refused on the `presets` line: `preset 'A' value for 'mode' takes one of its options as a string (fast, slow), not "Zed"`. - A choice is set by its label, a market by `"EXCHANGE:SYMBOL"`, a time in either spelling, a session zone or a unit by the dialog's words for it. - A text setting takes words that fit its `max_bytes`: `preset 'Fast' value for 'label' takes a string literal (a text setting), not 5` and `preset 'Fast' value for 'label' is 70 UTF-8 bytes, over the setting's max_bytes 64`. ## Related - [The settings dialog](overview.md): where the strip sits on a page - [Setting kinds](kinds.md): the value each kind takes - [Sessions and units](sessions-and-units.md): the parts a session and a unit are set through - [What the build checks](checks.md): the refusal table # The Style page Every output you draw gets a row on the dialog's **Style** page without a declaration. ## What it is The Style page draws visible, width, line style and color for a line; opacity beside them for an area, and for a column with a static color; visible, width and color for a mark. A per-bar ladder keeps its spot: an output colored by `color_by` has no color row, one widened by `width_by` no width row, and a `color_by` palette stays the way to switch looks per bar. The user's changes repaint the output and are saved with the overlay; the file's values stay the defaults. A spot the file binds to a setting (`color: "@basis_color"`, `line_style: "@style"`) is that setting's row instead, so a look never has two controls. Every `render.hud` card of the indicator adds a Look row (unless its `look` is bound to a setting), and every heatmap whose colour scale reads the data a Sensitivity row (below). ## Declare it A color the user should pick is a `param.color` bound by name; a line style is a `param.choice` over `solid`, `dashed`, `dotted`: ```typescript param.color("fast_color", "#3b82f6", { label: "Fast" }); param.choice("style", ["solid", "dashed", "dotted"], "solid", { label: "Line style" }); output("fast", line, overlay, { color: "@fast_color", line_style: "@style", width: 2 }); ``` A `colors` palette takes a setting per entry (`colors: ["@up", "@down"]`), so a `color_by` ladder's colors can be the user's too. The same reference reaches a band, a fill, a box, a segment and a docked profile: ```typescript param.color("band_ink", "#94a3b8", { label: "Band" }); param.choice("side", ["right", "left"], "right", { label: "Profile side" }); param.number("width", 0.12, { min: 0.05, max: 0.5, label: "Profile width" }); param.bool("show_poc", false, { label: "Point of control" }); range("band_hi", "band_lo", { color: "@band_ink", edge_line_style: "@style" }); box("zone", { top: hi, bottom: lo, color: "@band_ink", borderStyle: "@style" }); plot.levels({ name: "profile", frame: profile_rows, dock: "@side", color: "@band_ink", width_frac: "@width", poc: "@show_poc" }); ``` An output's other colour words bind the same way: a split line's `up_color` and `down_color`, an area's `fill_color`, and the entries of a candle group's `border_colors` and `wick_colors` each take a `param.color`, so one pair of inks can drive several looks at once. ```typescript param.color("up_ink", "#22c55e", { label: "Up" }); param.color("down_ink", "#ef4444", { label: "Down" }); param.color("fill_ink", "#f59e0b", { label: "Fill" }); // One pair of inks drives the split line and the candle group's borders and wicks; the fill ink drives the area's edge and fill. output("osc", line, lower, { split: "slope", up_color: "@up_ink", down_color: "@down_ink" }); output("cvd", area, lower, { color: "@fill_ink", fill_color: "@fill_ink", fill_opacity: 0.3 }); output("ha_open", candle, overlay, { colors: ["@up_ink", "@down_ink"], border_colors: ["@up_ink", "@down_ink"], wick_colors: ["@up_ink", "@down_ink"] }); output("ha_high", candle, overlay); output("ha_low", candle, overlay); output("ha_close", candle, overlay); ``` A string that starts with `@` is a reference to a setting by name. The build writes the setting's default in its place before it compiles, so the source compiles to the same module as one that wrote the default literally, and the sheet records which setting controls the spot. The places that paint are below; two more pin an input ([Picks, lanes, the cap](picks-and-lanes.md)). | Where | Names | What the chart does | | --- | --- | --- | | `color: "@name"` on an output, a text or label renderer, or a legend entry; an entry of a `colors` palette | a `param.color` | paints with the picked color | | `line_style: "@name"` on an output | a `param.choice` over `solid`, `dashed`, `dotted` | draws the picked style | | an output's other colour words: `fill_color`, an entry of `fill_colors`, `fill_gradient` or `gradient`, `up_color`, `down_color`, an entry of `border_colors` or `wick_colors` | a `param.color` | paints the fill, the stroke, the split or the candle part with the picked color | | `color`, an entry of `colors` or `gradient`, `edge_line_style` on a `range`; `color`, an entry of `colors` on a `fill` | a `param.color`; a `param.choice` over the three line styles | paints the band or the fill, dashes its edges | | `color`, `borderColor`, an entry of `colors` or `borderColors`, `borderStyle` on a `box`; `color`, `lineStyle` on a `segment` | a `param.color`; a `param.choice` over the three line styles | paints the per-bar shape, dashes its border or stroke | | a style key of `plot.levels` (`color`, a `series[].color`, a `gradient` stop, `dock`, `width_frac`, `poc`, `thickness_px`, `font_size`, `label_place` and the rest) | a colour key takes a `param.color`; a word key a `param.choice` whose every choice is a legal word of that key; a number key a `param.number` or `param.int` (an integer key `param.int` only) whose `min..max` lies inside the key's range; a boolean key a `param.bool` | redraws the docked profile with the pick; a structural key (`name`, `frame`, `panel`, `beside`, `offset`, `unit`, a series name) refuses a reference | | every colour word of a `panel.*` declaration (a series' `color` and `fill_color`, `positive_color`, `negative_color`, the `fill_*_color` pair, `palette` entries and the rest, [Panel words](../presentation/cards-frames-panels.md#panel-words)) | a `param.color` | paints the panel with the picked color; a title or a series name keeps an `@` as text | | `look` on a `render.hud` card | a `param.choice` whose every choice is a look | draws the card in the picked look; the card gets no Look row ([Looks](../presentation/hud-and-hover-cards.md#looks)) | One setting may control several spots. A data-only output (`none`) has nothing to paint and refuses a reference (a `barcolor` output, which paints the candles, takes one), and so do a `draw.*` object, a `handles.*` default, a tile or block and a HUD card's colours: write a literal there. Width, opacity and visibility need no setting: the Style page draws a row for them per output, and a look with no setting bound to it stays the declaration's (`color`, `width`, `opacity` and `line_style` on the output are set in the file). Every other setting is compute-affecting by construction: it reaches `onStart()`, and the indicator reruns over the loaded bars when it changes; a toggle that hides a line does so by writing `NaN` to it while it is off. ## A setting at an alpha A colour drawn translucent keeps its translucency when the user picks a new colour: write the alpha after the setting's name, a decimal from 0 to 1. ```typescript param.color("bull", "#22c55e", { label: "Bull" }); // The zone's edges at 60 percent and its interior at 20, all from one setting. output("zone_hi", line, overlay, { color: "@bull/0.6" }); output("zone_lo", line, overlay, { color: "@bull/0.6" }); range("zone_hi", "zone_lo", { color: "@bull/0.2" }); ``` The build writes the setting's default at that alpha in the reference's place, the colour's own alpha replaced, and records the alpha beside the spot, so the chart paints every pick at 20 percent there. The default takes the form the spot holds: `rgba(34, 197, 94, 0.2)` where the colour takes `rgba()` (an output's `color` and `colors`, a text, label, shape, bgcolor or barcolor renderer's colours, a legend entry, a range's colours and gradient, a fill's `color`, a box's `color` and `borderColor`, a segment's `color`, a mini-chart grid's colours), and `#22c55e33` where it takes a colour word (an area's `fill_color`, a gradient stop, the candle and split colours, a fill's or a box's ladder, a stats row, a docked profile or a price canvas, a `plot.levels` colour, a panel's colours). A panel series colour is the one spot that binds without an alpha: the chart reads it as `#rrggbb`, `#rrggbbaa` or a theme token and refuses the panel on the `rgba()` a recolour at an alpha writes, so write the plain reference there (a translucent default is `#rrggbbaa`). ## The Look row Every `render.hud` card of the indicator gets a Look row, a list of the twelve looks with "As made" first (the author's own `look`, or today's card when the declaration has none). The pick is the viewer's, saved with the overlay and never written into the sheet; it sits between the author's `look` and the words declared beside it, so a declared `corner_radius` still wins over the picked look's radius. A second HUD in the same indicator keeps its own row ([HUD cards](../presentation/hud-and-hover-cards.md#looks)). A card whose `look` the file bound to a setting (`look: "@card_look"`) gets no Look row: the setting's own row is the look's one control. ## The Position row Under each card's Look row, a Position row says where the viewer moved the card on the chart ("From the script" until they move it), with a Reset that puts it back where `position` and `offset` place it. Like the Look pick, the place is the viewer's, saved with the overlay and never written into the sheet ([Moving a card](../presentation/hud-and-hover-cards.md#moving-a-card)). ## The Sensitivity row Every `plot.heatmap` whose colour scale reads the data gets a Sensitivity row: a slider from Subtle to Vivid for how quickly the colours saturate. Untouched, it sits where the declaration's `auto_quantile` puts it (0.98 when absent) and draws exactly that; toward Vivid the top of the scale comes down so smaller values reach the bright end, toward Subtle full colour is kept for the largest. The chart recolours while the slider moves, with no rerun. Like the Look pick, the setting is the viewer's, saved with the overlay and never written into the sheet. A heatmap with both ends declared, or a one-sided one (`size`, `bid`, `ask`, `total`, `buy`, `sell`) with `max` declared, has no row; one whose `auto_quantile` is bound to a setting (`auto_quantile: "@contrast"`) shows that setting's row instead ([Heatmaps](../presentation/price-canvases.md#heatmaps)). ## What the dialog draws ![the Style page: one section per drawn output with its visible, width, line style and color rows](/wrun/images/wrun-style-rows.svg) The Style page follows the pages you declare on the rail, one section per output titled by its `label`, each with its own reset. ## Gotchas - The Style page is drawn where the indicator runs in the browser; an indicator that runs on OpenMarket's servers keeps the looks the file declares. - An alpha where it cannot land: on a panel series colour, `panel.bars 'b' series[0].color references "@bull/0.5", but a panel series colour binds without an alpha`; on a line style, `an alpha belongs to a colour reference`; and `"@bull/1.5"` or `"@bull/.4"`, `whose alpha is not a decimal from 0 to 1`. - A reference to the wrong kind: `output 'h' color references "@k", which is not a param.color`; on a `plot.levels` key, a `param.choice` with a word the key refuses, a number setting whose range leaves the key's (`width_frac: "@w"` with `max: 0.9`), a colour setting on a boolean key (`poc: "@ink"`) and a reference on a structural key (`panel: "@p"`) are each refused by name. - A reference where no setting can paint (a `draw.*` object, a `handles.*` default, a tile): the Console names the declaration and asks for a colour literal there. - A `param.color` read in `onStart()` through `p_()` is the color packed into one number; most files bind it to an output instead. ## Related - [Setting kinds](kinds.md): `param.color` and `param.choice`, the two kinds that paint (`param.number`, `param.int` and `param.bool` join them on a docked profile) - [Picks, lanes, the cap](picks-and-lanes.md): the two references that pin an input, and the lanes - [Styling](../presentation/styling.md): every look an output takes, and which looks have no form - [Plotting](../presentation/plotting.md): the outputs the rows are derived from # Sessions and units `param.session` declares a window on the 24-hour clock in its zone, and `unit` on a number puts a unit picker in the field. ## What it is `param.session(name, "HH:MM-HH:MM", { tz })` declares a window on the 24-hour clock and the zone it is written in. A window may wrap midnight (`"22:00-04:00"`). Per bar, `inSession(...)` says whether the bar's open sits inside the window, with daylight saving applied per bar from the zone's own rule. `unit: ["price", "ticks", "%", "atr"]` on a number puts a unit picker in the field, listing the units in your order; `unit_default` names the one picked at first (the first listed when absent). `unitToPrice(...)` then turns the number into a price distance per bar. ## Declare it ```typescript // session: a window in its zone. param.session("rth", "09:30-16:00", { tz: "America/New_York", label: "Active hours" }); // number with a unit picker: the value and the unit's code. param.number("offset", 0.0, { min: -100, max: 100, unit: ["price", "ticks", "%", "atr"], unit_default: "%" }); // A bounded int drawn as a slider: the ATR an atr-unit offset scales by. param.int("atr_length", 14, { min: 2, max: 50, slider: true, label: "ATR length" }); // The tick size, written by the chart into a hidden setting. market.tick_size(); ``` `tz` is `param.session` only: the zone the window is written in, one of these, each with its own daylight-saving rule: | Index | `tz` | Standard offset | Daylight saving (one hour ahead) | | --- | --- | --- | --- | | 0 | `UTC` (the default) | UTC+0 | none | | 1 | `America/New_York` | UTC-5 | second Sunday of March to first Sunday of November | | 2 | `America/Chicago` | UTC-6 | second Sunday of March to first Sunday of November | | 3 | `Europe/London` | UTC+0 | last Sunday of March to last Sunday of October | | 4 | `Europe/Berlin` | UTC+1 | last Sunday of March to last Sunday of October | | 5 | `Asia/Tokyo` | UTC+9 | none | | 6 | `Asia/Hong_Kong` | UTC+8 | none | | 7 | `Asia/Singapore` | UTC+8 | none | | 8 | `Australia/Sydney` | UTC+10 | first Sunday of October to first Sunday of April | | 9 | `Asia/Kolkata` | UTC+5:30 | none | | 10 | `America/Los_Angeles` | UTC-8 | second Sunday of March to first Sunday of November | | 11 | `America/Toronto` | UTC-5 | second Sunday of March to first Sunday of November | | 12 | `America/Mexico_City` | UTC-6 | none | | 13 | `America/Sao_Paulo` | UTC-3 | none | | 14 | `America/Argentina/Buenos_Aires` | UTC-3 | none | | 15 | `Europe/Paris` | UTC+1 | last Sunday of March to last Sunday of October | | 16 | `Europe/Amsterdam` | UTC+1 | last Sunday of March to last Sunday of October | | 17 | `Europe/Zurich` | UTC+1 | last Sunday of March to last Sunday of October | | 18 | `Europe/Madrid` | UTC+1 | last Sunday of March to last Sunday of October | | 19 | `Europe/Rome` | UTC+1 | last Sunday of March to last Sunday of October | | 20 | `Europe/Stockholm` | UTC+1 | last Sunday of March to last Sunday of October | | 21 | `Europe/Oslo` | UTC+1 | last Sunday of March to last Sunday of October | | 22 | `Europe/Copenhagen` | UTC+1 | last Sunday of March to last Sunday of October | | 23 | `Europe/Warsaw` | UTC+1 | last Sunday of March to last Sunday of October | | 24 | `Europe/Helsinki` | UTC+2 | last Sunday of March to last Sunday of October | | 25 | `Europe/Athens` | UTC+2 | last Sunday of March to last Sunday of October | | 26 | `Europe/Istanbul` | UTC+3 | none | | 27 | `Europe/Moscow` | UTC+3 | none | | 28 | `Asia/Jerusalem` | UTC+2 | Friday before the last Sunday of March to last Sunday of October | | 29 | `Asia/Riyadh` | UTC+3 | none | | 30 | `Asia/Dubai` | UTC+4 | none | | 31 | `Africa/Johannesburg` | UTC+2 | none | | 32 | `Asia/Karachi` | UTC+5 | none | | 33 | `Asia/Bangkok` | UTC+7 | none | | 34 | `Asia/Jakarta` | UTC+7 | none | | 35 | `Asia/Ho_Chi_Minh` | UTC+7 | none | | 36 | `Asia/Kuala_Lumpur` | UTC+8 | none | | 37 | `Asia/Shanghai` | UTC+8 | none | | 38 | `Asia/Taipei` | UTC+8 | none | | 39 | `Asia/Manila` | UTC+8 | none | | 40 | `Asia/Seoul` | UTC+9 | none | | 41 | `Pacific/Auckland` | UTC+12 | last Sunday of September to first Sunday of April | The dialog shows each zone by its city (`SESSION_TZ_LABELS`: `New York`, `Kolkata`, `Sao Paulo`, ...). The list only grows at its end: a saved setting stores the index `p__tz()` reads, so the first nine never move and a new zone is appended. A `tz` outside the list refuses the build and names every id. `unit` and `unit_default` belong to one number field (`int`, `number`, `price`): a range's two ends have no unit picker. `market.tick_size()` declares the tick size as a hidden setting the host writes before `onStart()`; `market.price_precision()` does the same for the price decimals, and `market.zone()` for the market's own time zone as an index into this same list ([Chart context](#chart-context) says who fills what). ## What the dialog draws ![a day strip with the window shaded, a start and an end on the 24-hour clock, and a zone menu](/wrun/images/wrun-settings-session.svg) ![a number field with a unit picker beside it](/wrun/images/wrun-settings-number-unit.svg) ## Read it in onStart() A session is three readers: `p__start()` and `p__end()` (minutes from midnight) and `p__tz()` (the zone's index). The index is what `p__tz()` reads and what `inSession` takes; `SESSION_TZ_IDS` and `SESSION_TZ_LABELS` hold the ids and the dialog's words in the same order. The rules are compiled into the module, so a session reads the same in your browser, on OpenMarket's servers and in an alert. The bar's open is `bar.time()` ([Time and sessions](../core-concepts/time-and-sessions.md)). A unit pick reaches the module as a code that never changes with the order you list: `price` 0, `ticks` 1, `%` 2, `atr` 3, read through `p__unit()` beside `p_()`. The tick size is read through `p_market_tick_size()`, the price decimals through `p_market_price_precision()`. ```typescript function onStart(): void { offset = p_offset(); offsetUnit = i32(p_offset_unit()); atr = new Atr(i32(p_atr_length())); tick = p_market_tick_size(); sessionStart = p_rth_start(); sessionEnd = p_rth_end(); zone = i32(p_rth_tz()); } ``` Per bar, `inSession(barOpenSec, startMin, endMin, zone)` answers whether the bar's open sits inside the window, and `unitToPrice(value, unit, close, tick, atr)` turns the number into a price distance: `ticks` multiplies by the tick size, `%` takes that percent of `close`, `atr` multiplies by the ATR you pass, and `price` is the value as it is. ```typescript const atrValue = atr.update(bar.high(), bar.low(), close); // The offset in the unit the dialog picked, as a price distance. if (isFinite(basis)) basis += unitToPrice(offset, offsetUnit, close, tick, atrValue); active = inSession(t, sessionStart, sessionEnd, zone) && multiHas(dayMask, weekday); ``` ## Chart context Numbers about the chart and its market reach your file before the first bar: the bar interval, the price decimals, the tick size, the chart's colours, and the market's kind, point value, time zone and quote currency ([The market's facts](#the-markets-facts)). Declare the ones you need at the top level and read them in `onStart()`. Wherever the indicator runs, it gets the numbers that place can know, and 0 for the rest. ```typescript // A close line with a corner readout of the three facts the host writes before the first bar. chart.interval_sec(); market.price_precision(); market.tick_size(); output("close_line", line, overlay, { color: "#94a3b8" }); string("facts", { max_bytes: 96 }); render.table("corner", { rows: 1, cols: 1, cells: ["facts"], position: "top_left" }); let intervalSec: f64 = 0; let decimals: i32 = 0; let tick: f64 = 0; function onStart(): void { intervalSec = p_chart_interval_sec(); decimals = i32(p_market_price_precision()); tick = p_market_tick_size(); } function onBar(): void { const close = bar.close(); if (isNaN(close)) return; out_close_line(close); sb_clear(); sb_text("bar "); // 0 means the host did not fill the fact. if (intervalSec > 0) sb_duration(intervalSec); else sb_text("unknown"); sb_text(", close "); sb_f64(close, decimals); sb_text(tick > 0 ? ", tick known" : ", tick unknown"); str_facts_sb(); } ``` On a 15-minute BTC chart the readout says `bar 15m`, the close at the chart's own decimals, and `tick unknown`. ### Who fills what | Declare | Read with | On the chart | On OpenMarket's servers and in alerts | Where there is no chart | | --- | --- | --- | --- | --- | | `chart.interval_sec()` | `p_chart_interval_sec()` | the bar interval in seconds (a 15-minute chart reads 900) | the chart's number, carried with the indicator | the interval of the bars it runs on | | `market.price_precision()` | `p_market_price_precision()` | the chart's price decimals | the chart's number | the most decimals the market's candle prices carry, at most 10; 0 when it reads no candles of that market | | `market.tick_size()` | `p_market_tick_size()` | the market's tick where the market publishes one (CME markets), else 0 | the chart's number | 0, until market data carries tick sizes | | `chart.bg_color()` | `i32(p_chart_bg_color())` | the background colour, packed | the chart's number | 0 | | `chart.fg_color()` | `i32(p_chart_fg_color())` | the text colour, packed | the chart's number | 0 | | `market.kind()` | `i32(p_market_kind())` | what the chart's market trades | the chart's number | what the market directory says the market trades | | `market.point_value()` | `p_market_point_value()` | the money one point of price is worth on one contract | the chart's number | 1 for a market priced per unit (a coin, a perpetual, a stock), 0 on a CME futures market, whose multiplier is not known there | | `market.zone()` | `i32(p_market_zone())` | the market's exchange time zone | the chart's number | New York for US stocks and indices, Chicago for CME, London for the London metals, else 0 (UTC) | | `market.quote_is_usd()` | `i32(p_market_quote_is_usd())` | whether prices are in US dollars | the chart's number | from the market's quote currency | - Read 0 as "unknown". `unitToPrice` leaves a ticks value as it is when the tick is 0, and a `Bucket` refuses its span when the interval is 0. The zone is the one exception: its 0 is UTC, also where the zone is unknown. - These are hidden settings: the settings dialog never shows them, and the number the host writes always wins over anything passed under the same name. - Each one counts toward the 128 settings. ### The interval and the colours ```typescript // The bar interval and the chart's two colours, written by the chart into hidden settings. chart.interval_sec(); chart.bg_color(); chart.fg_color(); ``` - `chart.interval_sec()` is the chart's bar interval in seconds, read through `p_chart_interval_sec()` and available from the first bar (a 15-minute chart reads 900); 0 only when the interval is unknown. - `chart.bg_color()` is the chart's background, a custom background included, and `chart.fg_color()` is the chart's text colour, each as the packed colour the colours kit uses (`(r << 24) | (g << 16) | (b << 8) | a` as a signed 32-bit integer, so white reads `-1`). Read them with `i32(p_chart_bg_color())` and `i32(p_chart_fg_color())`; 0 means unavailable. ```typescript function onStart(): void { intervalSec = p_chart_interval_sec(); bg = i32(p_chart_bg_color()); fg = i32(p_chart_fg_color()); } ``` The text colour is the theme's, not the background's: on a custom background it may not contrast with the background. Choose ink from the background's brightness instead: `red(bg)`, `green(bg)` and `blue(bg)` read its channels, and [Read a color back](../functions/colors-kit.md#read-a-color-back) draws dark words on a light chart and light words on a dark one. Treat `fg_color` as the theme's hint. ### The market's facts ```typescript // What the market trades, its point value, its time zone and its quote, written by the host into hidden settings. market.kind(); market.point_value(); market.zone(); market.quote_is_usd(); ``` Each is a number, and 0 means the host does not know: | Declare | Reads | Values | | --- | --- | --- | | `market.kind()` | `i32(p_market_kind())` | 1 crypto, 2 a stock or an ETF, 3 forex, 4 a metal, 5 an index, 6 an economic series; 0 unknown, or none of these (a prediction market, an oil contract) | | `market.point_value()` | `p_market_point_value()` | the money one whole point of price is worth on one contract: a futures multiplier (50 on the E-mini S&P), 1 on a market priced per unit (a coin, a perpetual, a stock); 0 unknown | | `market.zone()` | `i32(p_market_zone())` | the market's exchange time zone as its index in the zone list above (1 New York, 2 Chicago, 3 London); 0 is UTC, also where the zone is unknown | | `market.quote_is_usd()` | `i32(p_market_quote_is_usd())` | 1 when prices are in US dollars, 2 in another currency or coin (USDT and USDC included: BTCUSDT is priced in USDT), 0 unknown | - A futures market reports what it trades: an index future reads 5, a gold future 4. - A stock that trades around the clock on a crypto venue reads 2 and its venue's zone, UTC. - The codes never change: a new kind is added at the end of the list. - The zone is an index, not an offset: pass it to `inSession(...)` or `new Clock(SESSION_TZ_IDS[zone])` and each bar gets its own daylight-saving offset. ```typescript // A listed stock's regular hours on its own exchange clock: the close drawn only between 09:30 and 16:00 there, every bar on a market that trades around the clock. market.kind(); market.zone(); market.point_value(); market.quote_is_usd(); output("session_close", line, overlay, { color: "#2962ff" }); output("point_value_usd", line, lower, { color: "#94a3b8" }); let kind: i32 = 0; let zone: i32 = 0; let pointValue: f64 = 0; let inDollars = false; function onStart(): void { kind = i32(p_market_kind()); // 0 is UTC, also where the host does not know the zone. zone = i32(p_market_zone()); pointValue = p_market_point_value(); inDollars = i32(p_market_quote_is_usd()) == 1; } function onBar(): void { const close = bar.close(); // A stock (kind 2) on an exchange clock keeps its hours, 09:30 (minute 570) to 16:00 (minute 960); // zone 0 is a venue that trades around the clock (a stock perpetual), or an unknown zone. const open = kind != 2 || zone == 0 || inSession(bar.time(), 570, 960, zone); out_session_close(open ? close : NaN); // A one-point move on one contract, in dollars where the market is priced in them. out_point_value_usd(inDollars && pointValue > 0 ? pointValue : NaN); } ``` On a US stock the line breaks outside New York's regular hours, daylight saving included, and the lower pane reads 1. On BTCUSDT, and on a stock perpetual that trades around the clock, the line runs through every bar; on BTCUSDT the lower pane stays empty, since the price is in USDT. ## Gotchas - A tick size or an ATR of 0 means unavailable, and `unitToPrice` then leaves the value as it is. - A unit picker on a range is refused: `param.range 'r' takes no unit option (a unit picker sits beside one number field, not a range)`. - A session counts as three settings and a `unit` list as one more toward the 128-setting cap ([Picks, lanes, the cap](picks-and-lanes.md)). - A preset sets a session or a unit through its parts (`rth_start`, `offset_unit`, by the dialog's words for a session zone or a unit) ([Presets](presets.md)). - A window fixed in the file needs no setting: it is a `Session` ([Clock and sessions kit](../functions/time-and-sessions-kit.md)). ## Related - [Setting kinds](kinds.md): every kind and its readers - [Options on a setting](options.md): `unit`, `unit_default` and `tz` among the other keys - [Time and sessions](../core-concepts/time-and-sessions.md): the integer math underneath a session - [Clock and sessions kit](../functions/time-and-sessions-kit.md): a window fixed in the file # Picks, lanes, the cap Two kinds are picked on the chart, three are applied by the chart rather than read by the module, and a sheet holds at most 128 settings. ## What it is `param.time` draws a date and time field with **Pick**: the next click on the chart sets it. `param.price` draws a number field with **Pick**: the next click on the chart sets the price. Three kinds are applied by the chart instead of being read by the module: `param.source` picks the field an input reads, and `param.timeframe` and `param.symbol` pin an input. OpenMarket's cloud accepts at most 128 settings per indicator. ## Declare it ```typescript // time and price: both can be picked on the chart. param.time("since", "2024-01-01 00:00", { label: "Start" }); param.price("floor", 0.0, { hint: "0 draws no floor" }); // source: declares the primary input too; in_src() reads the picked field. param.source("src", ohlcv.close); // timeframe and symbol: each pins the input that names it. param.timeframe("htf", "chart", { label: "Reference timeframe" }); input("close_htf", ohlcv.close, { interval: "@htf" }); param.symbol("pair", "BINANCE_FUTURES:ETHUSDT", { label: "Ratio against" }); input("close_pair", ohlcv.close, { symbol: "@pair" }); ``` A `param.time` default is `"YYYY-MM-DD HH:MM"` in UTC or a whole number of epoch seconds. A `param.symbol` default is `"EXCHANGE:SYMBOL"` in the chart's own ids. A `param.source` default is any word of its menu: `open`, `high`, `low`, `close`, `hl2`, `hlc3`, `ohlc4`, `volume` or `hlcc4` ([A blended default](#a-blended-default)). ### A blended default `hl2`, `hlc3`, `ohlc4` and `hlcc4` are blends of the candle, not prices stored on it: wherever the indicator runs, the bar's own candle is blended the same way, so a blend is as good a default as `close`. | Word | The bar's | | --- | --- | | `hl2` | `(high + low) / 2` | | `hlc3` | `(high + low + close) / 3` | | `ohlc4` | `(open + high + low + close) / 4` | | `hlcc4` | `(high + low + close + close) / 4` | A blend is missing on a bar where a price it reads is missing. A plain input may read one too: `input("mid", ohlcv.hl2)`. ```typescript // An EMA of the typical price by default: hlc3, a blend of each bar's candle, and any other word of the menu in the dialog. param.source("src", ohlcv.hlc3, { label: "Price" }); param.int("length", 21, { min: 2, max: 500 }); output("ema", line, overlay, { color: "#f59e0b", width: 2 }); let ema = new Ema(21); function onStart(): void { ema = new Ema(i32(p_length())); } function onBar(): void { out_ema(ema.update(in_src())); } ``` A string that starts with `@` on an input is a reference to a setting by name; the build writes the setting's default in its place before it compiles, and the sheet records which setting controls the pin: | Where | Names | What the chart does | | --- | --- | --- | | `interval: "@name"` on an input | a `param.timeframe` | pins the input to the picked timeframe; `chart` removes the pin | | `symbol: "@name"` on an input | a `param.symbol` | pins the input to the picked market, exchange included unless the input names one | | `ticks_per_bar: "@name"` on a `volume_profile.cells` input | a `param.int` whose `min..max` lies inside 1..500 | asks for the profile at the setting's bucket size and fetches again when it changes; the chart and the alerts engine both apply it ([Order flow](../functions/order-flow-kit.md#what-the-chart-serves)) | The pin rules still hold: a timeframe pick must be coarser than the chart and a whole multiple of it, which is why a default of `"chart"` runs on every interval ([Multi-timeframe](../core-concepts/multi-timeframe.md)). A `symbol` pin that starts with `@` but names no setting is the venue's own spelling and stays the literal (Hyperliquid spot markets are `"@107"`; a `param.symbol` default may carry one, `"HYPERLIQUID:@107"`). A pin the user need not change is written on the input itself, on an input after the first: both halves together for a market, `interval` on its own. The chart honors it, and a pin it cannot serve is refused by name when the indicator runs; on a 1h chart this one reads BTC's closed 4h candles ([Multi-timeframe](../core-concepts/multi-timeframe.md#the-interval-pin-a-real-coarser-feed) and [Multi-source](../core-concepts/multi-source.md#multi-symbol) have the pin rules): ```typescript input("btc_4h", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES", interval: "FOUR_HOURS" }); ``` ## What the chart draws ![the dialog dimmed while a pick is armed, with a hint naming the setting the next click on the chart sets](/wrun/images/wrun-pick-mode.svg) A time or price row's **Pick** button lets the pointer through the dialog, dims it and shows a hint, "Click the chart to set {label}". The next click on the chart sets the row: the bar's time in epoch seconds for a time, the price under the pointer for a price. A press that travels is a pan, not a pick, and Escape disarms. ## Read it in onStart() `p_since()` reads the time as epoch seconds and `p_floor()` the price. `source`, `timeframe` and `symbol` have no reader to call: the chart applies the pick, and the input bound to the setting reads the picked field, timeframe or market per bar. ```typescript function onStart(): void { since = p_since(); floorPrice = p_floor(); } function onBar(): void { const t = bar.time(); const close = in_src(); const reference = in_close_htf(); const pair = in_close_pair(); if (!isFinite(basis) || t < since) return; } ``` ## Where a setting applies Most settings are numbers the module reads, and every place that runs an indicator hands them to `onStart()` the same way: your browser, OpenMarket's servers for an indicator published as **Protected**, and the alerts engine. Three kinds are applied by the chart instead: `param.source` picks the field an input reads, and `param.timeframe` and `param.symbol` pin an input. For now the chart applies them only when the indicator runs in your browser: - an indicator that runs on OpenMarket's servers keeps the declared default, and its dialog marks the row "Browser lane only for now"; - an alert refuses to arm while one of them is off its default ("Alerts cannot use the Reference timeframe setting yet. Set it back to its default to arm."); - a `param.source` default runs everywhere, a blend included: every place blends the bar's candle the same way. An indicator that runs on OpenMarket's servers has no Style page either ([The Style page](style-page.md)). An alert on a published indicator runs with the settings of the overlay you set it from ([Alerts](../functions/alerts.md)). ## The 128-setting cap OpenMarket's cloud accepts at most 128 settings per indicator, so the build refuses a file whose settings expand past 128, on the declaration that crosses the line. Most kinds count once: | Declaration | Counts as | | --- | --- | | `int`, `number`, `bool`, `choice`, `color`, `time`, `price`, `multi`, `source`, `timeframe`, `symbol`, plain `param`, each `market.*` | 1 | | a `unit` list on a number | 1 more | | `range` | 2 | | `session` | 3 | | `list` | `max` + 1 | A list is the kind that grows: `param.list("lookbacks", [5.0, 20.0], { max: 4 })` draws an editable list of numbers with **Add**, up to `max` long, and counts `max` slots and the count. A list whose `max` alone crosses the cap is refused on its own line: `param.list 'l' max 200 would put 201 params on the sheet (max slots and the count); hosted lanes cap params at 128, so max is at most 127`. ## Gotchas - A source default off the menu: `unknown field ohlcv.vwap for param.source 's' (known: open, high, low, close, hl2, hlc3, ohlc4, volume, hlcc4)`. - More than 128 settings: `the sheet would carry 129 params after expansion (a range derives two, a list max + 1, a session three, a unit list one more); hosted lanes cap params at 128`. - In a preset, a time is written in either spelling, a price as a number within its bounds, and a market as `"EXCHANGE:SYMBOL"` ([Presets](presets.md)). ## Related - [Setting kinds](kinds.md): the five kinds on this page among the fifteen - [The Style page](style-page.md): the two references that paint - [Multi-timeframe](../core-concepts/multi-timeframe.md): why a timeframe pick must be coarser than the chart - [What the build checks](checks.md): the refusal table # What the build checks The declarations are read from the text without running it, and **Run** checks them before the compiler runs. ## What it is Declarations are literals: the extractor reads them from the text without running it, and each kind checks its own default's shape, a whole number for `param.int`, hex for `param.color`, one of the options for `param.choice`. Every kind takes one options object, every key optional, and a key the kind cannot use is refused by name. The Console shows each refusal on the declaration's line: ```text param.bool 'x' takes no min option (its value is not a number field) ``` ![A toggle declared with a min option: its line underlined in the editor, the declarations row in the Console with the refusal, the error count in the status bar, and Run blocked](/wrun/images/checks-declaration.png) - Line 7 is underlined as you type: the extractor reads the declaration before anything compiles. - The Console row is tagged `declarations` and starts with the line number; the message names the kind, the setting and the option. - The status bar counts the error, and Run answers "Cannot run: 1 error(s) must be fixed first." ## The refusal table | Mistake | What the Console says | | --- | --- | | a key the kind does not take | `param.int options accept only { required, min, max, description, label, step, group, row, hint, when, hide, slider, unit, unit_default, confirm }, not 'tooltip'` | | a typed key on plain `param` | `param options accept only { required, min, max, description }, not 'label'` | | a number-field key on a toggle | `param.bool 'x' takes no min option (its value is not a number field)` | | a unit picker on a range | `param.range 'r' takes no unit option (a unit picker sits beside one number field, not a range)` | | `hide` without `when` | `param.int 'x' declares hide without when; hide names what happens when the when param is off, so declare when beside it` | | `when` naming a setting that is not a toggle | `param.int 'x' when names 'y', which is not a param.bool (when takes the NAME of a param.bool, e.g. when: "show_bands")` | | a slider without both bounds | `param.int 'x' declares slider without min and max (a slider needs both bounds)` | | a fractional `param.int` default | `param.int 'x' default must be an integer literal, not 1.5` | | a choice default that is not an option | `param.choice 'm' default "c" is not one of its options (known: a, b)` | | a color that is not hex | `param.color 'c' default takes "#rrggbb" or "#rrggbbaa", not 'red'` | | a range without bounds | `param.range 'r' needs min and max (the two-handle slider's bounds), e.g. param.range("r", [1, 2], { min: 0, max: 100 })` | | a list without `max` | `param.list 'l' needs max (the most items the dialog may hold), e.g. param.list("l", [1,2], { max: 8 })` | | a list whose `max` alone crosses 128 | `param.list 'l' max 200 would put 201 params on the sheet (max slots and the count); hosted lanes cap params at 128, so max is at most 127` | | a source default off the menu | `unknown field ohlcv.vwap for param.source 's' (known: open, high, low, close, hl2, hlc3, ohlc4, volume, hlcc4)` | | a reference to the wrong kind | `output 'h' color references "@k", which is not a param.color` | | a reference where no setting can paint | `draw.line 'ln' color references "@c", but a param paints an output, a renderer or a legend entry only; write a colour literal here` | | a preset naming a composite | `preset 'A' sets 'band', which is not a declared param on the sheet (a composite is set by its members, e.g. band_lo)` | | a preset value the setting cannot take | `preset 'A' value for 'mode' takes one of its options as a string (fast, slow), not "Zed"` | | a name a composite already derives | `param 'band_lo' collides with the name param.range 'band' derives; rename one` | | more than 128 settings | `the sheet would carry 129 params after expansion (a range derives two, a list max + 1, a session three, a unit list one more); hosted lanes cap params at 128` | | a default above `max` | `Param 'length' (default): default above max` | | `min` above `max` | `Param 'length' (min): min must be <= max` | | a computed default (`param("n", base * 2)`) | `param 'n' default must be a numeric literal (expected a numeric literal)` | | a string default on a number field (`param("mode", "ema")`) | `param 'mode' default must be a numeric literal (expected a numeric literal)` | | a name built from a variable | `param names must be string literals (expected a string literal)` | The rows by the page that explains them: the option rows on [Options on a setting](options.md), the default rows on [Setting kinds](kinds.md), the reference rows on [The Style page](style-page.md), the preset rows on [Presets](presets.md), the source default and the cap on [Picks, lanes, the cap](picks-and-lanes.md). ## Reserved names A setting cannot take a name the chart keeps for itself: `symbol`, `exchange`, `interval`, `transformations`, `ticksPerBar`, `currency`, `runMode`, `devViewerTier`, `errorMessage`, `splineVisibility`, `customScriptId`, `subType`, `scriptSource`, `catalogSource`, `wrunRef`, `wrunRegistryRef`, `wrunRunTarget`, `wrunAlertOutputs`, `wrunDraftRef`, `isProtectedScript`, `codeVisibility`, `hasGuide`, `scriptAuthor`, or any name that starts with `__style__` ("param 'symbol' uses a reserved name ...; rename it"). Every other name is free, `smooth`, `width` and `opacity` included. ## Common errors Each message with its symptom, its cause and its fix, beside the rest of the build's refusals: [Common errors](../faq/common-errors.md). The full grammar, every key and its legal values: [Declarations and the sheet](../reference/declarations.md#the-declaration-grammar). ## Related - [Setting kinds](kinds.md): the default each kind takes - [Options on a setting](options.md): which kind takes which option - [Presets](presets.md): the values a preset may name - [Common errors](../faq/common-errors.md): symptom, cause and fix per message # Style anything ![The desk on two chart cells: on the left, BTCUSDT 5m with the GEX by expiry board docked against the price axis, teal and violet cells with signed dollars, the at-the-money row outlined, the per-strike bars left of the board and the dashed gamma flip level with its label; on the right, BTCUSDT 1h with the eight tiles, the Deribit GEX curve with its spot marker, point callouts and the long gamma badge, and the rolling CVD histogram at the bottom](/wrun/images/style-anything/overview.png) Every part a wrun indicator draws can be styled. A line, a column, a docked profile, a card in a corner, a label, a strike board and a pane each take their look as words in the declaration, and the chart draws what the words say; the code only writes numbers. This page walks one real indicator part by part, an options desk: a strike matrix docked on the price axis, a dashboard of tiles, a gamma curve and a cumulative volume delta. Each part shows its picture, the lines of the desk that style it, the words that matter most, and the page that lists every word. The two files are cookbook recipes and template cards of their own: [Strike Matrix](../cookbook/strike-matrix.md) is the left cell (the board, the per-strike bars and the levels, under **On price**) and [Options Dashboard](../cookbook/options-dashboard.md) the right (the tiles, the gamma curve and the rolling CVD, under **Beyond the time axis**); each compiles as written, on a chart of a coin with a listed options chain. The whole desk is eight declarations, two files on two chart cells; the frames, outputs and slots they read come first: ```typescript const board = frame("board", { max_bytes: 32768 }); const gexRows = frame("gex_rows", { max_bytes: 16384 }); const tileRows = frame("tile_rows", { max_bytes: 4096 }); const gexCurve = frame("curve_rows", { max_bytes: 16384 }); output("t_prev", none, overlay); output("t_last", none, overlay); output("flip_price", none, overlay); output("cvd_sign", none, lower); string("flip_text", { max_bytes: 32 }); plot.matrix({ name: "gex_board", frame: board, dock: "right", column_width: 66, row_max_px: 22, price_column: true, header: true, palette: ["theme.down", "theme.bg", "theme.up"], center: 0, scale: "sqrt", opacity: 0.95, format: "usd", signed: true, decimals: 1, font_size: 10, font_weight: "medium", align: "center", text_color: "theme.text", cell_padding: 3, grid_color: "theme.bg", grid_width: 2, grid_style: "solid", grid_lines: "all", highlight_color: "#f8c000", label: "GEX by expiry", tooltip: "{{column}}: {{value:usd}} net GEX at {{price}}" }); plot.levels({ name: "gex_by_strike", frame: gexRows, dock: "right", width_px: 100, baseline: "center", labels: false, opacity: 0.95, thickness_px: 10, format: "usd", signed: true, hover: true, offset: [340, 0] }); // offset: the board's width (4 columns of 66 px plus the price column), so the bars sit left of it instead of under it draw.line("flip_line", { x1: "t_prev", y1: "flip_price", x2: "t_last", y2: "flip_price", color: "theme.text", width: 1, line_style: "dashed", extend: "both" }); handles.label({ anchor: "right", align: "right", style: "knockout", font_weight: "medium" }); panel.tiles({ name: "tiles", title: "Options, Deribit", x: "category", place: "below", frame: tileRows, columns: 4, accent: "auto", positive_color: "theme.up", negative_color: "theme.down", format: "usd", signed: true, hover_card: true, height_frac: 0.18 }); panel.line({ name: "gex_curve", title: "Deribit GEX (Hypothetical Spot)", x: "number", place: "below", frame: gexCurve, series: [{ name: "Net GEX", color: "theme.text", width: 2 }], chrome: "grid", smooth: true, fill_mode: "signed", fill_positive_color: "theme.up", fill_negative_color: "theme.down", fill_fade: true, x_format: "usd", x_decimals: 1, format: "usd", signed: true, decimals: 0, x_title: "Spot", y_zero: true, legend_style: "none", hover_card: true, height_frac: 0.25, maximize: true, stats_row: true }); output("cvd", histogram, lower, { pane: "cvd", color_by: "cvd_sign", colors: ["theme.down", "theme.up"], width: 0.7, label: "Agg. Rolling CVD", format: "si", description: "Rolling cumulative volume delta: aggressive buys minus sells over the window" }); pane("cvd", { height_frac: 0.12, format: "si" }); // the Indicator's home pane: it carries the Indicator's name, so no title here ``` ## The map | Part | What you see | The words that matter most | Full page | | --- | --- | --- | --- | | Side charts | a small chart of its own below the candles: the gamma curve | `chrome`, `legend_style`, `smooth`, `glow`, `fill_mode`, `markers`, `badge`, `height_frac` | [Frames, panels and compact widgets](cards-frames-panels.md#frames-panels-and-compact-widgets) | | Levels and profiles | bars docked on the price axis, one per strike | `dock`, `width_px`, `baseline`, `thickness_px`, `offset`, `hover`, `poc`, `opacity` | [Docked profiles](cards-frames-panels.md#docked-profiles) | | Cards and HUDs | the dashboard: eight tiles below the chart | `columns`, `accent`, `positive_color`, `negative_color`, `format`, `look`, `chrome`, `background_color` | [Panel words](cards-frames-panels.md#panel-words), [HUD cards](hud-and-hover-cards.md#hud-cards) | | Labels and drawings | a line across the chart at the gamma flip, its label just left of the strike bars | `style`, `align`, `valign`, `font_weight`, `line_style`, `extend`, `sticky_right`, `axis_label` | [Drawing objects](drawing-objects.md) | | Lines over time | the rolling CVD as columns, one number per bar | `color_by`, `colors`, `width`, `label`, `format`, `split`, `fill_color`, `glow` | [Styling](styling.md), [Plotting](plotting.md) | | Price canvases | the strike matrix: a row per strike, a column per expiry | `palette`, `center`, `scale`, `column_width`, `highlight_color`, `grid_lines`, `format`, `font_weight` | [Price canvases](price-canvases.md) | | Panes | the tiles, the curve and the CVD stacked under the chart | `height_frac`, `format`, `title`, `place`, `scale`, `invert`, `padding`, `min` | [Panes](plotting.md#panes) | | Theme and the Style page | the same desk on a light chart, with the user's own colours | `theme.*` tokens, `"@name"`, `param.color`, `param.choice`, `contrast_guard`, `stack_handles` | [Theme colours](styling.md#theme-colours), [The Style page](../settings/style-page.md) | ## Side charts A side chart is a small chart of its own, in a pane below the candles or in the strip beside them. The desk's gamma curve is a `panel.line` over a decimal x, the hypothetical spot; its dashboard of tiles is a `panel.tiles` (the next part). Reach for one when the picture is not one value per bar: a curve over strikes, a count per weekday, a board of numbers. The module writes the rows as a frame; the look is the declaration's words, and the per-run chrome (the markers, the caption, the badge chip) rides in the frame beside the rows. ![The gamma curve panel: a smooth teal line with a halo over a dollar spot axis, its fill fading downward, the dashed Spot marker with its chip, two point callouts pinned on the curve at the trough and the peak, and the LONG GAMMA badge chip on the title row](/wrun/images/style-anything/side-charts.png) ```typescript const gexCurve = frame("curve_rows", { max_bytes: 16384 }); // The gamma curve: a smooth line in the chart's text ink over hypothetical spot, filled in the up colour above zero // and the down colour below, both axes in dollars; the spot marker, the point callouts (the flip, the peak, the trough) // and the regime badge chip are written per run into the frame. The one series needs no legend chip: the title names // it and the hover card reads it. panel.line({ name: "gex_curve", title: "Deribit GEX (Hypothetical Spot)", x: "number", place: "below", frame: gexCurve, series: [{ name: "Net GEX", color: "theme.text", width: 2 }], chrome: "grid", smooth: true, fill_mode: "signed", fill_positive_color: "theme.up", fill_negative_color: "theme.down", fill_fade: true, x_format: "usd", x_decimals: 1, format: "usd", signed: true, decimals: 0, x_title: "Spot", y_zero: true, legend_style: "none", hover_card: true, height_frac: 0.25, maximize: true, stats_row: true }); ``` The frame the module writes beside the rows, with the chrome of this run: the spot marker, three point callouts pinned on the curve, the caption and the badge chip (the flip callout is written only when the curve crosses zero): ```json { "rows": [ [76726.8, 65600], [76932.9, 787123], [93776.5, 398200000] ], "markers": [ { "x": "spot", "label": "Spot", "badge": true, "wash": true }, { "x": 76820.4, "valign": "point", "series": "Net GEX", "label": "Gamma flip", "color": "theme.text", "badge": true, "show_value": true }, { "x": 93776.5, "valign": "point", "series": "Net GEX", "label": "+$398.2M @ $93776", "color": "theme.up", "badge": true }, { "x": 76726.8, "valign": "point", "series": "Net GEX", "label": "+$65.6K @ $76726", "color": "theme.down", "badge": true } ], "caption": "live chain", "badge": { "text": "LONG GAMMA - DAMPENED", "color": "theme.up" } } ``` The words that matter: - `chrome` picks the frame: `"box"`, `"grid"` or `"none"`; the rich words of a line (`smooth`, `glow`, `animate`, the fills, `markers`) need `"grid"` or `"none"`. - `legend_style` (`"none"`, `"title"`, `"pane"`, `"chips"`) and `legend_latest` put the series names and their last values on the panel; `hover_card` is the readout under the pointer. - `height_frac` is the pane's share of the chart; `place: "side"` with `width_px` and `height_px` moves the panel into the strip beside the price pane. - `fill_mode: "signed"` with `fill_positive_color` and `fill_negative_color` shades a line around zero and `fill_fade` fades the fill away from the line; `stroke_fade` fades the stroke away from a pivot; `markers` rule off an x of note. - `badge` puts a chip on the panel's title row (`text`, a `color` that defaults to `theme.accent`, a `text_color` that defaults to whichever of light or dark reads on the fill); a marker with `valign: "point"` is a callout pinned to the curve, at the value of the declared `series` it names (the first series by default) or at an exact `y`. Both are frame keys too, so the module can rewrite the chip and point at the peak it found on every run. - `x_title`, `y_title`, `x_format` with `x_decimals`, `y_zero` and `y_min`, `y_max` dress the axes; `maximize` and `stats_row` add the fullscreen button and the strip. Every panel word, kind by kind, is on [Panel words](cards-frames-panels.md#panel-words); the strip beside the chart is on [The strip beside the chart](price-canvases.md#the-strip-beside-the-chart). ## Levels and profiles A docked profile is one bar per price along the price axis, drawn from a frame the module writes. On the desk it is net gamma by strike: zero sits mid-dock, positives grow one way and negatives the other, each row in its own colour from the frame, a hover card per row. Reach for it when a number lives at a price rather than at a time: open interest by strike, volume by price, a liquidation map. `plot.profile` draws the same shape from the chart's own volume cells with no frame. Two canvases docked on the same side overprint and no word stacks them: `offset: [340, 0]`, the board's width, pushes the bars inward so they sit left of the board instead of under it. ![The per-strike net GEX bars in a narrow column left of the board: teal bars growing one way from the centre baseline and violet bars the other, one per strike, the dashed flip level running through them](/wrun/images/style-anything/levels.png) ```typescript const gexRows = frame("gex_rows", { max_bytes: 16384 }); // the bars: net GEX per strike, the board's row sums // The per-strike net GEX bars beside the board: zero mid-dock, positives one way and negatives the other, each row // in its own colour from the frame, a hover card per row, pushed inward past the board (both dock right), 100 px wide. plot.levels({ name: "gex_by_strike", frame: gexRows, dock: "right", width_px: 100, baseline: "center", labels: false, opacity: 0.95, thickness_px: 10, format: "usd", signed: true, hover: true, offset: [340, 0] }); // offset: the board's width (4 columns of 66 px plus the price column), so the bars sit left of it instead of under it ``` The frame carries one price, one value, one colour and one hover hint per row: ```json { "prices": [84000, 84250, 84500], "values": [-1200000, 350000, 2400000], "colors": ["theme.down", "theme.up", "theme.up"], "tooltips": [ "C +$1.2M | P -$2.4M", "C +$0.9M | P -$0.5M", "C +$2.6M | P -$0.2M" ] } ``` The words that matter: - `dock` and a fixed `width_px` (or `width_frac`) place the profile; `offset` and `beside` nest one profile inside another. - `baseline: "center"` grows positive rows one way and negative the other; `series` splits a row into stacked segments, each with its colour. - `thickness_px` caps a bar's height and `step` gives every row a price band of its own (a strike ladder). - `labels`, `label_place`, `format` with `signed`, `text_color` and `font_size` print the rows; `hover` opens the chart's card on a row, with the frame's `tooltips` as its hint. - `poc`, `poc_color`, `poc_width`, `poc_line_style`, `poc_extend` and `poc_label` draw the point of control; the `value_area_*` words rule off the value area and `outside_opacity` fades the rows beyond it. - `opacity`, `gradient`, `border_color`, `shape: "outline"` and `behind_candles` change how the bars paint. Every word, with its range, is on [Docked profiles](cards-frames-panels.md#docked-profiles); a profile over the chart's own cells is on [Profiles anchored in time](price-canvases.md#profiles-anchored-in-time). ## Cards and HUDs A dashboard is a panel of tiles below the chart, or a card pinned at one of the nine anchors that never moves with price. The desk's eight tiles are a `panel.tiles`: a label, a value, a caption and a colour per tile, written per run into the frame; the panel prints signed dollars, a tile's own format word or text wins, and the accent tints a numeric tile by its sign. Reach for a tiles panel when the numbers need room; for a readout that floats over the candles, a `render.hud` card picks a whole `look` at once, and a status card (`draw.card`), a ladder, a feed and a meter take the same surface and chrome words. ![The eight tiles in two rows of four: live net GEX in teal, the gamma flip and max pain in the text colour, the dealer regime in teal, the P/C ratio and net VEX in teal, the 25D skew and the dealer delta in rose, each with a caption under its value](/wrun/images/style-anything/cards.png) ```typescript const tileRows = frame("tile_rows", { max_bytes: 4096 }); // Eight tiles in two rows of four: the panel prints signed dollars, a tile's own format word or text wins, and the // accent tints a numeric tile by its sign (the chart's up colour above zero, its down colour below); a tile with its own // colour keeps it. The tiles, the curve and the CVD pane take 0.55 of the chart, so the candles keep the rest. panel.tiles({ name: "tiles", title: "Options, Deribit", x: "category", place: "below", frame: tileRows, columns: 4, accent: "auto", positive_color: "theme.up", negative_color: "theme.down", format: "usd", signed: true, hover_card: true, height_frac: 0.18 }); ``` The rows as the module writes them, `[label, value, caption, color, spark, format]` with `null` to skip an element; the neutral values carry `theme.text`, so they read on the light chart too: ```json { "rows": [ ["Live net GEX", 235182438, "@ $85.2K | chart", null, null, "usd"], ["Gamma flip", "$87,526", "+2.7% from spot", "theme.text"], ["Max pain", "$79,000", "-7.3% from spot", "theme.text"], ["Dealer regime", "Positive Gamma", "Dips bought, rallies sold", "theme.up"], ["P/C ratio", "0.63", "Calls favored", "theme.up"], ["25D skew", "+0.1pp", "Puts bid (downside)", "theme.down"], ["Dealer delta", "-62.1K", "Net short delta, in coins", "theme.down"] ] } ``` The words that matter: - A tiles panel: `columns` (1 to 8), `accent` (`"auto"` tints a numeric tile by its sign, `"neutral"` leaves it), `positive_color` and `negative_color`, `format` with `signed`, `decimals` and `unit` for every value it prints, `hover_card`, `height_frac` and `title`; a row's own `color`, `spark` and `format` win over the panel's. - A HUD card: `look` is one of twelve presets (`glass`, `terminal`, `cockpit`, `phosphor` and the rest) and any word beside it wins; `chrome` the frame (`"card"`, `"none"`, `"brackets"`, `"rules"`, `"tag"`, `"title_bar"`, `"window"`, `"cover"`); `texture: "scanlines"` the finish. - The surface: `background_color` with `background_opacity`, or `background_gradient` with `gradient_direction`; `border_color`, `border_width`, `border_style`; `corner_radius`, `padding`, `shadow`, `opacity`. - The type and the accent: `font_family`, `text_color`, `title_text_color`, `title_case`, `label_case`, `text_glow`; `accent_color`, or `accent_color_by` with `accent_colors` so the whole card follows an output per bar. - Size and place: `width`, `offset`, `z`, `safe_area`, `mobile`; each tile's `draw`, `color` or `color_by` with `colors`, `headline`, `font_size`, `height`. The tiles words are on [Panel words](cards-frames-panels.md#panel-words) and the rows on [Panel rows, kind by kind](cards-frames-panels.md#panel-rows-kind-by-kind); every HUD word is on [HUD cards](hud-and-hover-cards.md#hud-cards) and the twelve looks on [Looks](hud-and-hover-cards.md#looks); the card, ladder, feed and meter words on [Status cards](cards-frames-panels.md#status-cards) and [Ladder, feed and meter](cards-frames-panels.md#ladder-feed-and-meter). ## Labels and drawings A drawing is one object placed from the newest bar. The desk draws each of its levels as a dashed line across the whole pane and a knockout label that ends just left of the strike bars, so it never covers a cell of the board; the gamma flip is the pair below, and the largest GEX strike, max pain and the put wall repeat it in their own colours. The line is a `draw.line`; the label is a handle the live bar places in pixels from the price axis at the level's price, its text a slot the live bar writes. Reach for `draw.*` when one declaration is one object; for a mark on every signal bar use `render.text` or `render.shape`, and for an object the module moves and deletes later, a handle. Every look word is optional, and absent keeps the object's usual look. ![The gamma flip level: a violet dashed line across the price pane with its knockout label, #3 $88K | GF $87.5K, ending at the last candle, the line running on through the board's cells](/wrun/images/style-anything/labels.png) ```typescript output("t_prev", none, overlay); output("t_last", none, overlay); output("flip_price", none, overlay); string("flip_text", { max_bytes: 32 }); // The gamma flip: one dashed line across the whole pane, placed from the newest bar. draw.line("flip_line", { x1: "t_prev", y1: "flip_price", x2: "t_last", y2: "flip_price", color: "theme.text", width: 1, line_style: "dashed", extend: "both" }); // Its knockout label: a label handle placed in pixels from the price axis (x) at the level's price (y), right-aligned // so its right edge sits just left of the bars. handles.label({ anchor: "right", align: "right", style: "knockout", font_weight: "medium" }); const tags: LabelHandle[] = [draw.label(0), draw.label(1), draw.label(2), draw.label(3)]; // the wall, the flip, max pain, the put wall; handle objects allocate once // On the live bar, with the text built in the line buffer; inset is the board's and the bars' width in pixels. tags[1].set(inset, flipPrice).text(str_flip_text_sb).color(theme.TEXT); ``` The words that matter: - A line: `width`, `line_style`, `extend` (`"none"`, `"left"`, `"right"`, `"both"`), `arrow`, `glow` with `glow_color`, `sticky_right`, `axis_label` and `opacity`. - A label: `style` from eight words (`plain`, `box`, `knockout`, `emblem`, `pill`, `badge`, `callout`, `price_label`; `knockout` hides what is behind the text), `align` (which edge of the text sits on x), `valign`, `background_color`, `border_color`, `border_width`, `corner_radius`, `padding`, `font_weight`, `font_family`, `emblem_shape` with `emblem_color`, `max_width` and `angle`. - A label handle: `handles.label(...)` takes the label words as the defaults every label handle starts from; `anchor: "right"` measures its x in pixels from the price axis while its y stays a price, and the setters (`set`, `text`, `color`) place, word and paint each one per run. - A box: `color` is the fill, then `border_*`, `shape: "ellipse"`, `gradient` with `gradient_direction`, and `text` with its `text_color`, `font_size`, `align`, `valign` and `padding`. - A renderer mark takes the same label `style` words plus `label_position`, `size`, `size_by` and a `color_by` ladder; `tooltip` is a template shown on any of them. The drawings are on [Run-level drawings](drawing-objects.md#run-level-drawings), every setter a handle takes on [Every look a handle takes](drawing-objects.md#every-look-a-handle-takes), the pixel anchors on [Pane pixel placement](cards-frames-panels.md#pane-pixel-placement), and the renderer marks on [Labels, tooltips, badges](labels-and-tooltips.md). ## Lines over time A line over time is an output: one number per bar that the chart draws as a line, an area, columns or marks. The desk's rolling CVD is a `histogram` coloured by a sign ladder, columns in the chart's down colour below zero and its up colour above, in a pane of its own that prints K and M on its axis. Reach for the output words first: they are the cheapest look, and every drawn output can carry an alert once the indicator is published. ![The rolling CVD histogram in its pane: violet columns below zero on the left turning green above zero on the right, the legend reading the indicator's name and the last value, the axis tag in green](/wrun/images/style-anything/lines.png) ```typescript input("buy", trades.volume, { side: "BUY", missing: "zero", description: "Aggressive buy volume" }); input("sell", trades.volume, { side: "SELL", missing: "zero", description: "Aggressive sell volume" }); output("cvd_sign", none, lower, { description: "0 while the rolling CVD is negative, 1 while positive: the histogram's palette index" }); output("cvd", histogram, lower, { pane: "cvd", color_by: "cvd_sign", colors: ["theme.down", "theme.up"], width: 0.7, label: "Agg. Rolling CVD", format: "si", description: "Rolling cumulative volume delta: aggressive buys minus sells over the window" }); pane("cvd", { height_frac: 0.12, format: "si" }); // the Indicator's home pane: it carries the Indicator's name, so no title here ``` The words that matter: - The static look: `color`, `width`, `opacity`, `line_style`, `glow`, `z`; the per-bar ladders `color_by` with `colors` (or `color_packed_by`) and `width_by` with `widths`; `label` and `format` for the legend and the axis tag. - A line's shape: `smooth`, `step`, `gradient`, and `split` with `up_color`, `down_color`, `split_level`, `split_fill`, `split_base`. - An area's fill: `fill_color`, `fill_opacity`, `fill_gradient` with `gradient_mode`, or `fill_color_by` with `fill_colors`. - Columns: `width` as a share of the bar slot, `base`, `grading`, `stack`; a candle group: `candle_style`, `border_colors`, `wick_colors`, `border_width`. - Marks: `shape`, `location`, `char`, `fill`, `fill_opacity`; a level: `role: "guide"` with `align`, `pill_style`, `font_size`, `axis_label`. Every output word is on [Styling](styling.md); the looks each plot kind takes are the table on [Lines, areas, columns, dots, marks](plotting.md#lines-areas-columns-dots-marks) and [Candles](plotting.md#candles); a band between two lines is a `range()` or a `fill()` ([Decisions to looks](styling.md#decisions-to-looks)). ## Price canvases A price canvas is drawn on the price chart from cells the chart already serves or from a frame the module writes. The desk's strike matrix is a `plot.matrix`: one row per strike on the price axis, one column per expiry, each cell tinted on a diverging scale around zero. Heatmaps, footprints, TPO letters and anchored profiles are the other four. Reach for a canvas when the picture is a grid over price: a cell per bar and price row, or a cell per strike and expiry. The board sits on its strikes' prices, so strikes outside the candles' own range stay clipped until you zoom the price axis out by dragging it, as the original's author did; no word widens the range. ![The GEX by expiry board docked against the price axis: the expiries 0DTE, 06OCT, 07OCT and 08OCT across the header, the strikes down the price column, every cell a signed dollar amount tinted teal for calls and violet for puts by size, the at-the-money row outlined in amber](/wrun/images/style-anything/canvases.png) ```typescript const board = frame("board", { max_bytes: 32768 }); // the matrix: one row per strike, one column per expiry // The strike by expiry board against the price axis: a diverging palette centred on zero, puts in the chart's down // colour and calls in its up colour, the tint by the square root of the cell's size, signed dollars in every cell, the // at-the-money row outlined in amber. plot.matrix({ name: "gex_board", frame: board, dock: "right", column_width: 66, row_max_px: 22, price_column: true, header: true, palette: ["theme.down", "theme.bg", "theme.up"], center: 0, scale: "sqrt", opacity: 0.95, format: "usd", signed: true, decimals: 1, font_size: 10, font_weight: "medium", align: "center", text_color: "theme.text", cell_padding: 3, grid_color: "theme.bg", grid_width: 2, grid_style: "solid", grid_lines: "all", highlight_color: "#f8c000", label: "GEX by expiry", tooltip: "{{column}}: {{value:usd}} net GEX at {{price}}" }); ``` The frame the module sends is `{ prices, cells, cols, title, highlight }`: the strikes ascending, one cell row per strike with `null` where an expiry lists no open interest, the column names (`"0DTE"`, `"05OCT"`, ...), the board's title and the at-the-money row to outline. The words that matter: - The scale: `palette` with `min`, `max`, `center`, `auto_quantile` and `scale` (`"linear"`, `"sqrt"`, `"log"`); `opacity` over every fill. - The grid: `column_width`, `row_max_px`, `header`, `price_column`, `cell_padding`, `grid_color`, `grid_width`, `grid_style`, `grid_lines`, the `border_*` words and `highlight_color`. - The type and numbers: `font_size`, `font_family`, `font_weight`, `align`, `text_color`, `header_text_color`, `format` with `decimals`, `signed` and `unit`; `tooltip` and `label` for the readout. - A heatmap adds `row_height`, `floor`, `cell_gap`, `behind_candles` and `labels`; a footprint `mode`, the `imbalance_*` words, `hide_zero`, `cell_metric`; a TPO `display`, `color_mode`, `letter_minutes` and `single_prints_color`; an anchored profile `span`, `mode`, `naked` and `extend`. Every canvas, word by word, is on [Price canvases](price-canvases.md); the matrix on [Strike matrices](price-canvases.md#strike-matrices). ## Panes A pane is a scale of the indicator's own: below the chart for a number with its own range, or over the candles on a hidden scale with `place: "price"`. The desk's right cell stacks three under the chart: the tiles at 0.18 of the chart, the curve at 0.25 with a maximize button and a stats row, and the indicator's own CVD pane at 0.12, mounted last beside the time axis; the three take 0.55, so the candles keep the rest, and the home pane carries the indicator's name, so it takes no title. Reach for a pane when a small-magnitude series would hug the axis floor on the price pane, or when two studies need scales of their own. ![The right cell top to bottom: the candles, the BTC Options tiles panel, the Deribit GEX curve panel with the Spot marker, callouts and badge, and the Options Dashboard CVD pane next to the time axis](/wrun/images/style-anything/panes.png) ```typescript const tileRows = frame("tile_rows", { max_bytes: 4096 }); const gexCurve = frame("curve_rows", { max_bytes: 16384 }); output("regime_sign", none, overlay); output("cvd_sign", none, lower); string("regime_text", { max_bytes: 48 }); output("cvd", histogram, lower, { pane: "cvd", color_by: "cvd_sign", colors: ["theme.down", "theme.up"], width: 0.7, label: "Agg. Rolling CVD", format: "si", description: "Rolling cumulative volume delta: aggressive buys minus sells over the window" }); pane("cvd", { height_frac: 0.12, format: "si" }); // the Indicator's home pane: it carries the Indicator's name, so no title here render.legend("regime_entry", { text: "regime_text", color_by: "regime_sign", colors: ["theme.down", "theme.up"] }); panel.tiles({ name: "tiles", title: "Options, Deribit", x: "category", place: "below", frame: tileRows, columns: 4, accent: "auto", positive_color: "theme.up", negative_color: "theme.down", format: "usd", signed: true, hover_card: true, height_frac: 0.18 }); panel.line({ name: "gex_curve", title: "Deribit GEX (Hypothetical Spot)", x: "number", place: "below", frame: gexCurve, series: [{ name: "Net GEX", color: "theme.text", width: 2 }], chrome: "grid", smooth: true, fill_mode: "signed", fill_positive_color: "theme.up", fill_negative_color: "theme.down", fill_fade: true, x_format: "usd", x_decimals: 1, format: "usd", signed: true, decimals: 0, x_title: "Spot", y_zero: true, legend_style: "none", hover_card: true, height_frac: 0.25, maximize: true, stats_row: true }); ``` The words that matter: - `title` heads the pane's legend, and `{{name}}` reads a setting; the indicator's home pane carries its name instead. - `place: "below"` stacks the pane under the chart in declaration order; `place: "price"` draws its outputs over the candles on a hidden scale. - `height_frac` is the pane's share of the chart, on a pane and on a panel alike; `padding: [top, bottom]` sets the autoscale margins; `min` and `max` hold either end. - `scale` is `"linear"`, `"log"`, `"percent"` or `"indexed"`, and `invert: true` flips the axis. - `format`, `decimals`, `signed` and `unit` print the axis ticks; `pane: ""` on an output puts it in the pane. Every pane word is on [Panes](plotting.md#panes). ## Theme and the Style page Every colour word takes a theme token beside a hex colour, and a token follows the chart: the board's zero cell takes `theme.bg`, the legend entry `theme.muted` and the neutral tile values `theme.text`, so the desk reads on a dark and a light chart alike, re-resolved on a theme switch with no rerun, while every hex colour stays as written on both. A `"@name"` in place of a colour, a line style or a docked profile's key hands that spot to a setting, and the dialog's Style page draws the user's own controls for every drawn output without a declaration. Two chart-level flags finish the set. ![The same eight tiles on a dark chart and on a light chart side by side: the panel surface follows the theme, the neutral values read white on dark and black on light, and the teal and rose values keep their colours](/wrun/images/style-anything/theme.png) ```typescript string("spot_text", { max_bytes: 32 }); const board = frame("board", { max_bytes: 32768 }); const tileRows = frame("tile_rows", { max_bytes: 4096 }); // The words that follow the chart's theme: theme.muted on the legend entry; theme.down and theme.up at the palette's // ends around theme.bg (the board's zero cell takes the chart background, dark or light); theme.up and theme.down on // the tiles' signed values, theme.text on the neutral ones in the frame. render.legend("spot_entry", { text: "spot_text", color: "theme.muted" }); plot.matrix({ name: "gex_board", frame: board, dock: "right", column_width: 66, row_max_px: 22, price_column: true, header: true, palette: ["theme.down", "theme.bg", "theme.up"], center: 0, scale: "sqrt", opacity: 0.95, format: "usd", signed: true, decimals: 1, font_size: 10, font_weight: "medium", align: "center", text_color: "theme.text", cell_padding: 3, grid_color: "theme.bg", grid_width: 2, grid_style: "solid", grid_lines: "all", highlight_color: "#f8c000", label: "GEX by expiry", tooltip: "{{column}}: {{value:usd}} net GEX at {{price}}" }); panel.tiles({ name: "tiles", title: "Options, Deribit", x: "category", place: "below", frame: tileRows, columns: 4, accent: "auto", positive_color: "theme.up", negative_color: "theme.down", format: "usd", signed: true, hover_card: true, height_frac: 0.18 }); ``` The badge chip in the curve's frame takes a theme word per run, `{ "text": "SHORT GAMMA - AMPLIFIED", "color": "theme.down" }` or `{ "text": "LONG GAMMA - DAMPENED", "color": "theme.up" }`, and the chart picks the ink that reads on it. The words that matter: - The seven tokens: `theme.up`, `theme.down`, `theme.text`, `theme.muted`, `theme.bg`, `theme.grid`, `theme.accent`; every colour key on this page takes one, and so does a colour string inside a frame. - `"@name"` binds a spot to a setting: a `param.color` behind a colour, a `param.choice` behind a line style or a docked profile's word key, a `param.number`, `param.int` or `param.bool` behind a profile's number or switch. - `label`, `format`, `legend` and `visible` on an output shape its row on the Style page; every HUD gets a Look row of its own. - `chart.contrast_guard(false)` keeps the author's colours as written on a light chart; `chart.stack_handles(true)` stacks the indicator's corner groups under other indicators' groups. The tokens are on [Theme colours](styling.md#theme-colours), the bindings and the rows on [The Style page](../settings/style-page.md), and the packed form a module writes on [Colors kit](../functions/colors-kit.md#theme-tokens). ## Every word The deep pages list every word with its range and what refuses: - [Plotting](plotting.md) and [Styling](styling.md): outputs, panes, ranges, fills, boxes and segments - [Drawing objects](drawing-objects.md) and [Labels, tooltips, badges](labels-and-tooltips.md): drawings, handles and renderer marks - [Cards, frames and panels](cards-frames-panels.md): tables, cards, panels, docked profiles, ladders, feeds and meters - [HUD and hover cards](hud-and-hover-cards.md) and [Legend](legend.md): the three surfaces that read the newest values - [Price canvases](price-canvases.md): heatmaps, footprints, TPO letters, anchored profiles and strike matrices # What the chart shows Three surfaces read the indicator's newest values and are drawn by the chart from declarations alone: the legend entry, a HUD card at one of the nine anchors, and the card that opens when the cursor rests on a legend entry, a HUD tile or a line. ## What it is The three share one vocabulary of blocks and one rule for text: a template in double braces, the alert placeholder style. The blocks are seven kinds, `value`, `rows`, `spark`, `gauge`, `meter`, `pill` and `chips`; on a HUD card they are the `tile.*` constructors, on a hover card the same constructors under the `block.*` name ([Blocks and tiles](hud-and-hover-cards.md#blocks-and-tiles)). Every tile and block reads the newest row, so a data-only output written per bar is the way to carry a delta or a regime onto the card. The declarations are erased before the compiler and ride the sheet alone: the module writes numbers and words per bar and never mentions a tile. | Home | What it shows | Declared with | | --- | --- | --- | | the legend entry | each output's `label` and its value in the output's `format`; words after the indicator's name; a slot's words or an output's value as an extra entry | `label`, `format`, `legend` on the output; `legend({ title })`; `render.legend` | | the hover card | the blocks the cursor opens on a legend entry, a HUD tile or a line; without one, the output's `tooltip`, else its label and value | `hover(handle, [block.*])`, or `hover: [...]` on an output, a `render.text` or a `render.label` | | the HUD card | a card of typed tiles at one of the nine anchors, one or two columns | `render.hud(name, { position, title?, columns?, tiles })` | ## Declare it - `legend({ title })`: the words after the indicator's name, `(14)` style; `{{length}}` reads a setting by name ([Legend](legend.md)). - `render.legend(name, { text?, value?, format?, color?, color_by?, colors? })`: an entry in the legend, a string slot's words (`text`) or an output's formatted value (`value`), in a static `color` or colored per bar by a ladder ([Legend](legend.md)). - `render.hud(name, { position, title?, columns?, tiles })`: a card at one of the nine anchors, one or two columns, its tiles from the kit ([HUD cards](hud-and-hover-cards.md#hud-cards)). - `hover: [...]` on an output, `render.text` or `render.label`, or `hover(handle, [...])` at the top level: the card the cursor opens ([Hover cards](hud-and-hover-cards.md#hover-cards)). `badges` names another output, a ladder whose labels become chips on the card. A basis line with a titled legend, a regime word in the legend, a four-tile HUD and a hover card with four block kinds: ```typescript param.int("length", 20, { min: 2, max: 500 }); param.int("rsi_length", 14, { min: 2, max: 200, label: "RSI length" }); // label, format and tooltip shape the legend entry; the handle names the output for hover() below. const basis = output("basis", line, overlay, { color: "#2962ff", width: 2, label: "Basis", format: "price", tooltip: "{{label}} {{value:price}}" }); output("rsi", line, lower, { color: "#8b5cf6", label: "RSI", format: "0.0" }); // Data-only: the delta a tile shows beside the basis, and the regime ladder (0 under, 1 over). output("basis_chg", none); output("state", none); string("regime", { max_bytes: 16 }); // The legend reads " (20)" from the length setting; the entry below it is the slot's word, colored by the ladder. legend({ title: "({{length}})" }); render.legend("regime_entry", { text: "regime", color_by: "state", colors: ["#ef5350", "#26a69a"] }); // A card of four tiles in the top-right corner. render.hud("board", { position: "top_right", title: "Basis", columns: 2, tiles: [tile.value("Basis", "basis", { format: "price", delta: "basis_chg" }), tile.spark("RSI", "rsi", { bars: 32 }), tile.gauge("RSI level", "rsi", { min: 0, max: 100, format: "int" }), tile.pill("Regime", "regime", { color_by: "state", colors: ["#ef5350", "#26a69a"] })] }); // The card the basis line opens on hover: a lead value, rows, a meter, chips. hover(basis, [block.value("Basis", "basis", { format: "price", delta: "basis_chg" }), block.rows([["RSI", "rsi", "0.0"], ["Regime", "regime"]]), block.meter("RSI", "rsi", { min: 0, max: 100, marks: [30, 70] }), block.chips("regime")]); let sma = new Sma(20); let rsi = new Rsi(14); let basisValue: f64 = NaN; let prevBasis: f64 = NaN; function onStart(): void { sma = new Sma(i32(p_length())); rsi = new Rsi(i32(p_rsi_length())); } function onBar(): void { const close = bar.close(); prevBasis = basisValue; basisValue = sma.update(close); const rsiValue = rsi.update(close); if (isNaN(basisValue) || isNaN(rsiValue)) return; out_basis(basisValue); out_basis_chg(basisValue - prevBasis); out_rsi(rsiValue); const over = close > basisValue; out_state(over ? 1.0 : 0.0); sb_clear(); sb_text(over ? "over" : "under"); str_regime_sb(); } ``` ## What the chart draws ![the legend row: the name with its title suffix, then two labelled values](/wrun/images/wrun-legend.svg) ![a card of four tiles in the top-right corner: a value with its change, a sparkline, a dial and a chip](/wrun/images/wrun-hud.svg) ![the card a tile opens under the cursor: a value block with its delta](/wrun/images/wrun-hover-card.svg) The module above writes five numbers and one word per bar: the legend reads "Basis 20", then "Basis 64,120.5", "RSI 61.3" and the regime word in the ladder's color; the card in the corner shows the basis with its change, a sparkline, a dial and the regime chip; resting the cursor on the basis line, its legend entry or its tile opens the hover card. Which home answers which question: | You want | Use | Not | | --- | --- | --- | | a word or a value in the legend | `label` and `format` on the output, `render.legend` for a slot's words | a text renderer in the corner | | an answer when the cursor rests on a line or a tile | `tooltip` on the output, or a `hover` block list | a table the user has to read across | | a corner readout that does not move with price | a `render.hud` tile, or a one-cell `render.table` with a `position` | a label, which sits at its `x` and `y` | | a dashboard | `render.hud` with typed tiles, or `render.table` for a grid of words | many labels | ## Gotchas What a card cannot do: - A block takes its kind's options and nothing else, and a spark's `color` or a pill's `colors` is a literal: a setting paints an output, a renderer or a legend entry only ([Blocks and tiles](hud-and-hover-cards.md#blocks-and-tiles)). - A block or tile takes no `size`: the pixel size is a `render.text` and `render.label` option, and renderer text has no alignment option ([Labels, tooltips, badges](labels-and-tooltips.md)). - A presentation name used twice is refused. - The legend, the HUD and the hover card are drawn where the indicator runs in the browser. ## Related - [Style anything](style-anything.md): every part a wrun indicator draws can be styled, one real indicator part by part - [Legend](legend.md), [Hover cards](hud-and-hover-cards.md#hover-cards), [HUD cards](hud-and-hover-cards.md#hud-cards), [Labels, tooltips, badges](labels-and-tooltips.md), [Styling](styling.md), [Blocks and tiles](hud-and-hover-cards.md#blocks-and-tiles) - [Plotting](plotting.md): the outputs and renderers that draw on the time axis - [The settings dialog](../settings/overview.md): the dialog the same file lays out # Plotting A wrun indicator draws by declaring, not by calling: it declares `output(name, plot, panel, options)` once at the top of the file and writes one number per bar, and the chart draws that number as a line, a column, a mark, or a tint from the declaration. That is why the module never touches a color, and why every drawn output is a value an alert can follow once the indicator is published ([Alerts](../functions/alerts.md)). This page maps everything you might want to draw to its wrun form, and compiles the forms that exist. ## The map | You want | wrun form | Notes | | --- | --- | --- | | a plotted series | `output(name, line \| bar \| scatter \| candle, panel)` | the plot kind is the declaration, not a runtime argument | | a line | `output(name, line, panel, { color, width, line_style, opacity, glow, step, smooth, gradient, split })` | a filled line is `area`; `glow` is a soft halo in pixels ([Styling](styling.md)); `smooth` bends it into curves, `step` draws it as horizontal and vertical segments, `gradient` shades the stroke by height, `split` colours it above and below a level or by slope | | columns | `output(name, bar, panel, { width, base, grading, stack })` | one value per bar; `width` is a share of the bar slot (1 = columns touch), `base` the level they grow from, `grading` fades them by magnitude, `stack` stacks them on their siblings | | a histogram | `output(name, histogram, panel, { color_by, colors, corner })` | columns grow from zero (or from `base`); a sign ladder colors them; `corner` rounds them, in pixels | | candles | four consecutive `candle` outputs (open, high, low, close), or a box and a segment per bar | the chart draws the four as one candle; `candle_style` on the first output draws them hollow, as OHLC bars or as high-low bars; see below | | a mark on some bars | `output(name, shape, panel, { shape, shape_where, location, char, fill, fill_opacity, glow })` or `render.shape` | either picks the mark from ten shapes (`char` draws one character); `location` sits it above or below the bar or at the pane's edge | | several marks or a pie per bar | none: one thing per bar per renderer | a one-summary pie is a `panel.pie` snapshot; a per-level picture is a `plot.levels` frame ([Cards, frames and panels](cards-frames-panels.md)) | | text at a price | `render.text(name, { y, text, color, size, style?, align?, valign?, font_weight?, font_family? })` | one mark per bar whose slot was written; a `style` other than plain text draws each as a tag (`price_label`, `pill`, `callout`, `badge`, `box`, `knockout`, `emblem`) | | a price tag | `render.label(name, { x, y, text, style: "price_label" })` for one tag at the newest bar, or `render.text` with the same `style` for a tag per bar | a label's `style` is also `plain`, `pill`, `callout`, `badge`, `box`, `knockout` or `emblem` | | a corner readout | `render.label(name, { position, text, offset? })` pinned to one of the nine anchors, a one-cell `render.table(name, { rows: 1, cols: 1, cells, position })`, or a `render.hud` card with one tile | fixed position at one of nine anchors; the newest bar that wrote the slot wins | | an output on its own scale over the candles | `pane(name, { place: "price" })` and `output(name, plot, overlay, { pane: name })` | open interest or a cumulative delta drawn over price on a hidden scale of its own ([Panes](#panes)) | | several panes below the chart | `pane(name, { title, height_frac, scale, min, max, format })` and `output(name, plot, lower, { pane: name })` | up to four named panes, stacked in declaration order ([Panes](#panes)) | | a table | `render.table(name, { rows, cols, cells, position })` for a grid of words, styled by the same literal (widths, fills, lines, per-cell colours that follow an output; [Styled tables](cards-frames-panels.md#styled-tables)); `render.hud(name, { position, tiles })` for a card of typed tiles | cells are string slots; tiles read outputs and slots | | a rectangle | `box(...)` per bar, `draw.box` for one object, or a box handle | [Drawing objects](drawing-objects.md) | | a grid of mini charts pinned to the viewport | none | a dashboard is a `render.table` or a `render.hud` card; a snapshot panel below the chart is a `panel.*` frame ([Cards, frames and panels](cards-frames-panels.md)) | | a statistics-strip row | `render.stats_row(name, { output, title, format, polarity })` | a strip row over an output | | a heatmap, a curve over a category or an index, tiles | `panel.heatmap`, `panel.line` over a category or index x, `panel.tiles` | a frame snapshot in its own pane below the chart ([Cards, frames and panels](cards-frames-panels.md)) | | a horizontal level | `output(name, line, panel, { role: "guide", align?, pill_style?, font_size?, axis_label? })` written to the constant every bar | one full-width line at the output's last value, out of the legend; `align` puts its label on the line, `axis_label: true` on the axis; or a segment `from: 0, to: 1` | | a background tint | `render.bgcolor(name, { where, color, color_by, colors, width?, line_style? })` | a tint per bar where the gate is nonzero; with `width` a vertical line per gated bar instead of a band | | colored candles | `render.barcolor(name, { where, color, color_by, colors })` | the bar's own candle tinted where the gate is nonzero | | a fill between two lines | `range("a", "b", { color })` between two drawn outputs for a band with edges, `fill("a", "b", { color, opacity })` for the interior only, or a `box` per bar | the chart draws the range as a band with its two edges and the fill as shading with none ([Styling](styling.md)) | | a name and a description | the output `name` and `description` | names are unique per family by construction | ## Outputs `output(name, plot, panel, options)` returns a handle a `box` or `segment` can name; a bare statement discards it. - `plot`: `line`, `bar`, `area`, `histogram`, `candle`, `shape`, `scatter`, or `none` for a data-only output (computed, never drawn: the building block for gates, palettes, and shape coordinates). - `panel`: `overlay` (the price pane) or `lower` (its own pane). Put small-magnitude series (oscillators, percentages, counts) in `lower`; a z-score on the price axis hugs the axis floor. - Static style: `color`, `width`, `opacity` (0..1), `line_style` (`"solid"`, `"dashed"`, `"dotted"`), `glow` (a halo in pixels on a line, an area, columns, candles or marks), `z` (the paint order inside the pane, an integer -10..10), `description` (documents the output). A colour is `"#rrggbb"`, `"#rrggbbaa"` or a theme token such as `"theme.up"` ([Styling](styling.md#theme-colours)). - Numbers: `format` (`price`, `%`, `si`, `int`, `0`, `0.0`, `0.00`, `0.000`, `usd`, `auto`), `decimals` (0..8), `signed` and `unit` (printed after the value; unbounded on an output, as it always was) shape the legend, the hover card and the axis tag; `decimals` and `signed` need `format`, and a `unit` prints only beside one ([Styling](styling.md#placement-per-output)). - Per-bar color: `color_by: ""` plus `colors` (at least two entries). Each bar's value indexes the palette (floored); a finite value outside the palette takes entry 0, and a non-finite one draws the static color. `color_packed_by: ""` reads a packed colour the module computes per bar instead (never beside `color_by`). A `shape` output takes either ladder too: each mark in its bar's colour. - Per-bar width: `width_by` plus `widths` (1..10 entries, each 0.5..20), the same ladder for line width. On `bar` and `histogram` outputs `width` and `widths` are a share of the bar slot (`widths` entries from 0.05): 1 means the columns touch, 0.5 half the slot, 2 overlap. - Gated marks: `plot: shape` with `shape_where: ""` draws a mark at the output's value only on bars where the gate is nonzero. - Axis tags: `axis_label: true` tags this output's last value on the price axis (absent, only the first mounted output is tagged); `axis_name: true` adds the indicator's name to that tag (plus the output's label when several outputs are tagged); `price_line: true` draws a dotted line across the pane at the last value. The three are refused on a data-only output. - `pane: ""` puts the output in a named pane, declared with `pane(...)` below; absent, a `lower` output lands in the default pane. - `displacement_bars` (an integer in -500..500) makes the chart draw the series shifted left or right without changing its values; past the loaded edge it extrapolates at the bar spacing. `displacement_bars_by: { param: "", scale?: 1, offset?: 0 }` reads the shift from a setting instead: `round(scale * value + offset)` bars, clamped to -500..500, which wins over `displacement_bars` while the setting is a finite number. An output cannot color, widen, or gate itself: the palette index and the gate must be different outputs. The rest of the vocabulary is on the [Styling](styling.md) page. The legend shows each output's `label`, or its name read as words when there is none (`bb_upper` reads "Bb upper"), and its value in the output's `format`; `legend: false` keeps an output out of the legend, and `legend({ title: "..." })` adds words after the indicator's name in the legend ([Legend](legend.md)). ## Panes `pane(name, options)` declares a pane of the indicator's own, at most four per indicator, and `pane: ""` on an output puts the output in it. The name `"lower"` styles the default pane; any other name is a new pane below the chart, stacked in declaration order, the default pane first unless it is declared later. These time-series panes sit at the bottom of the indicator's stack, next to the time axis; the indicator's panels (`panel.line`, `panel.bars` and the rest, on [Cards, frames and panels](cards-frames-panels.md#panel-words)) stack above them. A declared pane with nothing drawn in it is refused, and so is a pane beside a declared overlay, with one exception: beside `display({ overlay: "offchart" })` the indicator lives in the default pane, so `pane("lower", { format: "0.00" })` formats that pane's axis (`format`, `decimals`, `signed` and `unit` only; any other pane or word is refused). The options, every one optional: ```typescript pane("rsi", { title: "RSI {{period}}", height_frac: 0.2, min: 0, max: 100 }); pane("oi", { place: "price", scale: "log", format: "si" }); output("rsi", line, lower, { pane: "rsi", color: "#7c3aed" }); output("oi", line, overlay, { pane: "oi", color: "#f59e0b" }); ``` - `title` (1..40 characters, `{{param}}` reads a setting): the pane's legend title. An indicator that homes below the chart (no drawn output on the price pane) keeps its own name on the pane holding its first drawn output, so that pane takes no title. - `place`: `"below"` (the default) or `"price"`. A pane placed on price draws its outputs over the candles on a hidden scale of their own (open interest or a cumulative delta on price); its outputs declare `overlay`, a pane placed below takes `lower` outputs, and the pair must match. - `height_frac` (0.05..0.6): the pane's share of the chart height, refused on a pane placed on price. Absent, a pane below an on-price indicator takes 0.22 and an indicator that homes below keeps the chart's default pane size. - `scale`: `"linear"` (the default), `"log"`, `"percent"` or `"indexed"`; `invert: true` flips the axis; `padding: [top, bottom]` (each 0..0.4) sets the autoscale margins; `min` and `max` hold either end of the autoscale (RSI: `min: 0, max: 100`, `min` below `max`; a log scale refuses `min` at or below 0). Dragging the axis by hand still works. - `format`, `decimals`, `signed`, `unit`: the pane's axis ticks, the same four words an output takes (`decimals`, `signed` and `unit` need `format`). Absent, a lower pane prints K, M and B from 1,000 and its own precision below that. An inverted pane turns a loss into something that hangs: write a drawdown as a positive percent and flip the axis, so zero sits at the top of the pane and the deepest loss reaches lowest, with the running peak drawn over price above it. The pane keeps a title because the indicator homes on the price pane. ```typescript output("peak", line, overlay, { color: "theme.muted", line_style: "dotted" }); pane("dd", { title: "Drawdown", height_frac: 0.18, invert: true, min: 0, padding: [0.02, 0.1], format: "%", decimals: 1 }); output("drawdown", area, lower, { pane: "dd", color: "#ef4444", fill_color: "#ef4444", fill_opacity: 0.25 }); ``` Ranges, fills, boxes, segments and renderers follow the pane of the output they anchor to, and a card, feed or meter with `panel: "lower"` sits in the indicator's lower pane too. ## Lines, areas, columns, dots, marks A line is a `line` output; a filled line is `area`. Columns are `bar`, and a `histogram` is columns grown from zero, colored per bar through a `color_by` ladder over a sign output (`value >= 0 ? 1 : 0`); `base` grows the columns from another level instead (`colors[0]` at or above it, `colors[1]` below). Dots are `scatter`. A mark on some bars is a `shape` output plus a `shape_where` gate: write the price every bar and let the gate decide which bars draw. A level such as 70 is a guide: a `line` output with `role: "guide"` written to `70` on every bar, drawn as one full-width line at its last finite value, kept out of the legend and inside the pane's autoscale (an alert can still follow it). `align` (`"left"`, `"center"`, `"right"`) puts the output's label on the line, `pill_style` (`"filled"`, the default, or `"outlined"`) and `font_size` (6..64) dress that label, and `axis_label: true` puts the value and the label on the axis instead (never both). A guide takes no `color_by`, `color_packed_by`, `width_by` or `price_line`. The looks a line, an area, a column and a mark take beyond colour and width (the words are opt-in; absent, the output draws as it always has): | Plot | Words | What they draw | | --- | --- | --- | | `line` | `step: true` | horizontal then vertical segments (trailing stops, funding steps); refused beside `smooth` | | `line`, `area` | `smooth: true` | the line (and an area's edge) bent into curves | | `line` | `gradient: [...]` | the stroke shaded by height, 2 to 8 colours listed from the top of the pane to the bottom; never beside `color_by`, `color_packed_by` or `split` | | `line` | `split: "level" \| "slope"`, `up_color`, `down_color`, `split_level?`, `split_fill?`, `split_base?` | `"level"`: `up_color` above `split_level` (default 0), `down_color` below, and `split_fill: true` shades down to `split_base` (default `split_level`); `"slope"`: `up_color` while rising, `down_color` while falling; both colours are required with `split` | | `area` | `fill_color`, `fill_opacity`, `fill_gradient: [...]`, `gradient_mode` | the fill's own colour (default the line colour) at `fill_opacity` (0.4 on a flat fill, 1 on gradient stops), or 2 to 8 stops top to bottom laid over `"pane"` (the default), `"fill"` or `"line"`; `fill_color` and `fill_gradient` exclude each other | | `area` | `fill_color_by` + `fill_colors`, or `fill_color_packed_by` | the fill coloured per bar by its own ladder (name the line's `color_by` output to follow the line); `fill_opacity` and `opacity` multiply each colour | | `bar`, `histogram` | `width`, `base`, `grading: "linear" \| "square"`, `stack: ""` | `width` as a share of the bar slot; `base` the level the columns grow from; `grading` fades each column by its magnitude within the visible range; bars sharing a `stack` name stack in declaration order, positives above 0 and negatives below (never beside `base`, and every member on one pane) | | `shape`, `scatter` | `shape`, `location`, `char`, `font_family`, `fill`, `fill_opacity` | `shape` picks the mark (`circle`, the default, `cross`, `triangle_up`, `triangle_down`, `diamond`, `arrow_up`, `arrow_down`, `flag`, `square`, `char`); `location` is `"absolute"` (the default), `"above_bar"`, `"below_bar"`, `"top"` or `"bottom"`; `shape: "char"` with `char` (one character) draws that character in `font_family` (`ui`, `mono`, `serif`, `rounded`); `fill: false` draws the outline only and `fill_opacity` fades the interior, neither on a `char` mark | | every drawn plot | `glow`, `opacity`, `z` | a halo in pixels; the opacity, which now fades every colour the output draws (ladders, packed colours, a column's sign pair, a candle's colours, marks, gradient stops); the paint order inside the pane | `triangle_down` points down (apex at the bottom), `triangle_up` up. ```typescript param("fast", 9, { min: 1, max: 200 }); param("slow", 21, { min: 2, max: 400 }); output("fast", line, overlay, { color: "#2563eb", width: 2, description: "Fast average, a line" }); output("slow", area, overlay, { color: "#94a3b8", opacity: 0.3, description: "Slow average, drawn as an area" }); output("cross_mark", shape, overlay, { color: "#16a34a", shape_where: "crossed", description: "A mark on the low of each bullish-cross bar" }); output("crossed", none, overlay, { description: "1 on the bar the fast average crosses above the slow: the mark's gate" }); output("volume", bar, lower, { color: "#4ecdc4", description: "Volume as bars" }); output("delta", histogram, lower, { color_by: "delta_sign", colors: ["#ef5350", "#26a69a"], description: "Fast minus slow as columns, colored by sign" }); output("delta_sign", none, lower, { description: "0 negative, 1 positive: the histogram's palette index" }); output("spread", scatter, lower, { color: "#f97316", description: "Close minus fast, as dots" }); output("rsi", line, lower, { color: "#7c3aed", width: 2, description: "RSI" }); output("overbought", line, lower, { color: "#ff0000", width: 1, line_style: "dashed", description: "The 70 level, written on every bar" }); let fast = new Sma(9); let slow = new Sma(21); let rsi = new Rsi(14); const cross = new Cross(); function onStart(): void { fast = new Sma(i32(p_fast())); slow = new Sma(i32(p_slow())); rsi = new Rsi(14); } function onBar(): void { const close = bar.close(); const low = bar.low(); const volume = bar.volume(); const fastValue = fast.update(close); const slowValue = slow.update(close); const rsiValue = rsi.update(close); const crossed = cross.update(fastValue, slowValue); if (isNaN(slowValue)) return; const delta = fastValue - slowValue; out_fast(fastValue); out_slow(slowValue); out_cross_mark(low); out_crossed(crossed == 1 ? 1.0 : 0.0); out_volume(volume); out_delta(delta); out_delta_sign(delta >= 0.0 ? 1.0 : 0.0); out_spread(close - fastValue); out_rsi(rsiValue); out_overbought(70.0); } ``` `cross_mark` is written on every bar (the bar's low) and drawn only where `crossed` is `1`. The gate is itself an output, so a declared `alert` can fire on it ([Alerts](../functions/alerts.md)). A two-line fill colored by sign (a momentum fill) is this `delta` histogram, or a `range()` between the two lines with a two-entry `colors` sign palette ([Styling](styling.md)). The other looks in the table, one coherent look per declaration. An area's fill can fade: list the stops top to bottom and lay them over the fill's own extent, so the colour is strongest at the line and gone at the floor; `axis_name` puts the indicator's name on the axis tag beside the value. ```typescript // Cumulative delta as an area: amber at the line, nothing at the pane floor, the indicator's name on the axis tag. output("cvd", area, lower, { color: "#f59e0b", width: 2, smooth: true, fill_gradient: ["#f59e0b", "#f59e0b00"], gradient_mode: "fill", fill_opacity: 0.9, axis_label: true, axis_name: true, format: "si" }); ``` When the line already follows a ladder, the fill can follow the same one: name the ladder output again on `fill_color_by` and give `fill_colors` a rung per colour, so the edge and the fill switch together. ```typescript // RSI as an area: oversold, neutral and overbought are the three rungs of one ladder, read by the edge and by the fill. output("rsi", area, lower, { color_by: "zone", colors: ["theme.up", "theme.muted", "theme.down"], fill_color_by: "zone", fill_colors: ["theme.up", "theme.muted", "theme.down"], fill_opacity: 0.3, smooth: true }); output("zone", none, lower, { description: "0 under 30, 1 between, 2 over 70: the index both ladders read" }); ``` A split line colours itself around a level or by slope, and `split_fill` shades between the line and `split_base`, so an oscillator reads as a two-tone area that reaches the floor instead of stopping at the split level. ```typescript // A money flow oscillator on a 0..100 pane: green above 50, red below, the shading hanging from the line down to 0. output("mfi", line, lower, { width: 2, smooth: true, split: "level", split_level: 50, up_color: "theme.up", down_color: "theme.down", split_fill: true, split_base: 0 }); ``` Columns that share a `stack` name pile up in declaration order, so two volumes become one column per bar, the second share on top of the first; `grading` fades the small columns. ```typescript // Buy and sell volume piled into one column per bar. output("buy_volume", bar, lower, { color: "theme.up", width: 0.8, stack: "volume", grading: "linear" }); output("sell_volume", bar, lower, { color: "theme.down", width: 0.8, stack: "volume", grading: "linear" }); ``` A mark can be softened and lit: `fill_opacity` thins its interior and `glow` puts a halo around it (`render.shape` takes the same two words), which keeps a signal visible without hiding the candle under it. ```typescript // A faded diamond with a halo above each signal bar. output("signal_mark", shape, overlay, { shape: "diamond", shape_where: "fired", location: "above_bar", color: "#f59e0b", fill_opacity: 0.4, glow: 6 }); output("fired", none, overlay, { description: "1 on the bars that fire the signal: the mark's gate" }); ``` A mark can also be a character: `shape: "char"` with `char` draws that one character in `font_family`, here under each bar that crosses a displaced average whose shift follows a setting through `displacement_bars_by` ([Outputs](#outputs)). ```typescript param.int("shift", 5, { min: -50, max: 50, label: "Shift (bars)" }); // A displaced average, and a character mark under each bar that crosses it. output("dma", line, overlay, { color: "#38bdf8", width: 2, displacement_bars_by: { param: "shift" } }); output("cross_mark", shape, overlay, { shape: "char", char: "✕", font_family: "mono", shape_where: "crossed", location: "below_bar", color: "theme.text" }); output("crossed", none, overlay, { description: "1 on the bar the close crosses the displaced average: the mark's gate" }); ``` Columns can grow from a level other than zero, and a guide's on-line label can be an outlined pill rather than a filled one: RSI as columns from 50, the sign pair colouring them above and below it, with the 70 level labelled on the left. ```typescript // RSI as columns grown from 50, and the 70 level as a guide with an outlined pill. output("rsi_bars", histogram, lower, { base: 50, colors: ["theme.up", "theme.down"], width: 0.6, grading: "square" }); output("overbought", line, lower, { role: "guide", align: "left", pill_style: "outlined", font_size: 10, color: "theme.muted", line_style: "dashed" }); ``` ## Candles An output is one number per bar, so a candle takes four: declare four consecutive `candle` outputs in the order open, high, low, close, and the chart draws them as one candle, colored by the first output's `colors` (bullish, then bearish) or its `color_by` ladder. A `candle` output that does not start four consecutive candle outputs is refused when the chart draws it ("candle output '' must start four consecutive candle outputs (open, high, low, close)"). A derived candle series (Heikin Ashi, a synthetic bar) works either way. As four candle outputs: ```typescript output("ha_open", candle, overlay, { colors: ["#4caf50", "#f44336"] }); output("ha_high", candle, overlay); output("ha_low", candle, overlay); output("ha_close", candle, overlay); ``` The group's first output carries the candle look, and the other three refuse these words by name: `candle_style` is `"candle"` (the default), `"hollow"` (up candles outlined), `"ohlc"` (OHLC bars) or `"high_low"` (high-low bars); `border_colors` and `wick_colors` take one colour for both directions or `[up, down]` (absent, border and wick follow the body colour); `border_width` is 0..10 (default 1); `opacity` fades every candle colour, and `glow` puts a halo on the candles. The legend lists a candle group as one entry reading its open, high, low and close ([Legend](../presentation/legend.md)). Hollow candles over the chart's own: the first output carries the look, the borders and the wicks take colours of their own, and `opacity` lets the real bars show through the group. ```typescript // Heikin Ashi drawn hollow: up candles outlined, down candles filled, one grey wick colour for both. output("ha_open", candle, overlay, { colors: ["theme.up", "theme.down"], candle_style: "hollow", border_colors: ["theme.up", "theme.down"], wick_colors: ["theme.muted"], border_width: 1.5, opacity: 0.85 }); output("ha_high", candle, overlay); output("ha_low", candle, overlay); output("ha_close", candle, overlay); ``` Or as a body box and a wick segment per bar, which draws the candle from the same four numbers and gives each part its own color: ```typescript const haOpen = output("ha_open", none, overlay, { description: "Heikin Ashi open" }); const haHigh = output("ha_high", none, overlay, { description: "Heikin Ashi high" }); const haLow = output("ha_low", none, overlay, { description: "Heikin Ashi low" }); const haClose = output("ha_close", none, overlay, { description: "Heikin Ashi close" }); const bullish = output("bullish", none, overlay, { description: "1 when the smoothed close is above the smoothed open" }); const bearish = output("bearish", none, overlay, { description: "1 otherwise" }); // The body: one box per bar between open and close, one declaration per color. box("body_up", { top: haClose, bottom: haOpen, when: bullish, color: "#4caf50", opacity: 0.9, borderWidth: 0 }); box("body_down", { top: haOpen, bottom: haClose, when: bearish, color: "#f44336", opacity: 0.9, borderWidth: 0 }); // The wick: a vertical segment from the high to the low on the same bar. segment("wick", { yFrom: haHigh, yTo: haLow, color: "#9ca3af", width: 1 }); let prevOpen: f64 = NaN; let prevClose: f64 = NaN; function onBar(): void { const o = bar.open(); const h = bar.high(); const l = bar.low(); const c = bar.close(); const close = (o + h + l + c) / 4.0; // The first bar seeds the open from the raw bar; after that it is the previous smoothed midpoint. const open = isNaN(prevOpen) ? (o + c) / 2.0 : (prevOpen + prevClose) / 2.0; const high = Math.max(h, Math.max(open, close)); const low = Math.min(l, Math.min(open, close)); prevOpen = open; prevClose = close; out_ha_open(open); out_ha_high(high); out_ha_low(low); out_ha_close(close); out_bullish(close >= open ? 1.0 : 0.0); out_bearish(close < open ? 1.0 : 0.0); } ``` The four `none` outputs draw nothing on their own; the chart shows only the boxes and wicks. A data-only output is not offered as an alert condition, so write a drawn output (or declare an `alert` over the gate) for anything you want to alert on ([Alerts](../functions/alerts.md)). The recursion the sample writes by hand ships as `HeikinAshi` in `./sdk/ta-plus`: `update(open, high, low, close)` fills the fields `open`, `high`, `low`, `close` and works over another market's candles too ([Extra indicators](../functions/extra-indicators.md)). ## Text, labels, tables, strips, tints Text does not travel as an output. A `string("name", { max_bytes })` declaration adds a byte-capped slot the module writes once per bar in `onBar()` through its generated senders (nothing to import): build a line with `sb_clear()`, `sb_text("...")`, `sb_int(n)`, `sb_f64(x, decimals)` and send it with `str__sb()`, or send a whole string with `str_("...")`. Renderers then place the text: | Renderer | Draws | | --- | --- | | `render.text(name, { y, text, color?, size?, style?, align?, valign?, font_weight?, font_family?, size_by?, color_by? + colors? \| color_packed_by?, panel? })` | one text mark per bar whose slot was written, at (bar, `y`); plain text is placed by `align` and `valign`, sized per bar by `size_by`, coloured per bar by its ladder; a tag `style` (`price_label`, `pill`, `callout`, `badge`, `box`, `knockout`, `emblem`) takes the tag words instead: `label_position`, `background_color` (or `background_color_by` + `background_colors`), `border_color`, `corner_radius`, `emblem_shape`, `emblem_color` | | `render.label(name, { x, y, text, color?, size?, style?, align?, valign?, ...look })`, or `render.label(name, { position, text, offset?, ...look })` | ONE label at (`x`, `y`), `x` an output in epoch seconds, or pinned to one of the nine anchors by `position` and nudged by `offset` (`[x, y]` px, each -200..200, pointing inward from the anchored edges: a positive `x` moves a right-anchored label left, a positive `y` moves a bottom-anchored label up); the newest bar that wrote a nonempty slot (with finite `x` and `y`, when placed by them) wins; the look words are `background_color`, `border_color`, `corner_radius`, `padding` (0..64, default 4), `font_weight`, `font_family`, `emblem_shape`, `emblem_color`, `size_by` | | `render.table(name, { rows, cols, cells, position?, ...look, styles? })` | a grid of string slots (`rows * cols` names, row-major); the newest bar where every cell was written wins; the look keys (width, column widths, fills, lines, font, alignment, header rows) and `styles` (one entry per styled cell) are on [Styled tables](cards-frames-panels.md#styled-tables) | | `render.shape(name, { output, shape, where?, color?, color_by?, colors?, width?, location?, glow?, char?, font_family?, fill?, fill_opacity?, tooltip? })` | a shaped mark per bar at the output's value where the gate is nonzero, in `color`, or in the `colors` entry a `color_by` output picks per bar; `width` (px, any positive number; the engine clamps what it paints) is the mark size, `location` sits it `"absolute"`, `"above_bar"`, `"below_bar"`, `"top"` or `"bottom"`, `glow` adds a halo, `shape: "char"` with `char` draws one character in `font_family`, `fill: false` draws the outline only, `fill_opacity` fades the interior, `tooltip` is a template shown on the mark | | `render.stats_row(name, { output, title?, format?, polarity?, color?, colors?, color_by?, color_packed_by?, priority?, visible? })` | a row in the statistics strip under the price pane, one cell per bar; `colors` is `[bull, bear]` on a diverging row or the ladder `color_by` indexes; `priority` 1..3 (default 2) keeps a row's text longest as bars narrow; `visible: false` draws no row at all | | `render.bgcolor(name, { where, color?, color_by?, colors?, width?, line_style? })` | a background tint per bar where the gate is nonzero, static or by ladder; with `width` (0.5..10 px) a vertical line per gated bar through the pane instead of a tint, dashed by `line_style` | | `render.barcolor(name, { where, color?, color_by?, colors? })` | the bar's own candle (body and wick) tinted where the gate is nonzero, static or by ladder; untinted bars keep the chart's candle colors | `size` is an integer pixel count, 6..64 (`10` reads as small text, `16` as large). A shape's `color_by` + `colors` ladder follows the `bgcolor` rules: the output's value is floored into `colors`, a finite index outside the palette takes entry 0, a non-finite value draws no mark (the static `color` never substitutes), and either half without the other is refused. `position` on a table is one of nine anchors (`top_left`, `top_center`, `top_right`, `middle_left`, `middle_center`, `middle_right`, `bottom_left`, `bottom_center`, `bottom_right`). `format` and `polarity` on a stats row ride to the chart verbatim (`si`, `signedSi`, `percent`, `price`, `raw`; `magnitude`, `diverging`, `none`). `visible` on a stats row (default `true`) takes `true`, `false` or an `"@"` reference to a `param.bool`: the setting's default draws, the setting switches the row from the settings dialog, and while it is off the row draws nothing at all, not even an empty strip line. One setting may switch several rows. The strip's own look is one sheet key beside its rows, `stats_strip`: `grading` (the population each cell's heat is ranked against: `rolling`, the default, `daily`, `weekly`, `visible` or `whole`), `label_side` (`left` or `right`, the edge the label gutter sits on) and `theme` (`candle`, the chart's candle colours, or `teal_rose`, `viridis`, `inferno`, `blue_red`, `mono`). A `param.choice` paints a key through `style_targets` (`{ "path": ["stats_strip", "grading"] }`) and `style_values` over that key's words, with its default's word written in the key; the key exists only beside a stats row. A row's `color` bound to a `param.color` paints the pick, the default included; mark the target `"unset_at_default": true` and the chart removes the colour while the setting holds its default, so the strip's theme colours the row until the user picks one. Declaring a string slot or a renderer switches the derived sheet to the second runtime contract; the numeric outputs compute exactly as before. `style` on a text or label renderer picks its look, the same eight words on both: `plain` (text alone, placed by `align` and `valign`), `price_label` (the price tag), `pill` (a rounded chip at the value), `callout` (a leader line to its bar), `badge` (a dot in the label's color), `box` (a rounded box, the handle label's default), `knockout` (a box in the chart's background colour that hides what is behind the text) and `emblem` (a small mark before the text, its shape `emblem_shape`: `dot`, `square`, `diamond`, `triangle_up`, `triangle_down`). A tag look takes `label_position`, `background_color`, `border_color` and `corner_radius`; plain text takes `align` and `valign`; the two sets never mix on one renderer. `font_weight` (`normal`, `medium`, `bold`) and `font_family` (`ui`, `mono`, `serif`, `rounded`) apply to both. The label styles, the `tooltip` template and `badges` are on [Labels, tooltips, badges](labels-and-tooltips.md). A corner readout is a `render.label` with `position` (below), a one-cell table or a HUD card ([HUD and hover cards](hud-and-hover-cards.md#hud-cards)). A per-bar readout, a live label, a dashboard, a strip row, a background tint, and a candle tint together: ```typescript param("period", 20, { min: 1, max: 200 }); param("stretch", 2, { min: 0.1, max: 20, description: "Percent from the average that counts as stretched" }); output("average", line, overlay, { color: "#38bdf8", width: 2, description: "Simple average" }); output("bar_time", none, overlay, { description: "Bar open in epoch seconds, the label's x" }); output("stretch_pct", none, overlay, { description: "Close distance from the average, percent" }); output("stretched", none, overlay, { description: "1 while the close is stretched from the average" }); output("volume", none, overlay, { description: "Volume, shown in the strip" }); string("readout", { max_bytes: 32 }); string("tag", { max_bytes: 32 }); string("stretch_label", { max_bytes: 16 }); string("stretch_text", { max_bytes: 16 }); // A text mark on every stretched bar (the slot is left unwritten on quiet bars). render.text("stretch_mark", { y: "average", text: "readout", color: "#f59e0b", size: 10 }); // One label riding the newest bar, at the average. render.label("average_tag", { x: "bar_time", y: "average", text: "tag", color: "#38bdf8", size: 11 }); // A fixed-position 1x2 dashboard: the newest bar where both cells were written wins. render.table("stats", { rows: 1, cols: 2, cells: ["stretch_label", "stretch_text"], position: "top_right" }); // Volume as a statistics-strip row. render.stats_row("volume_row", { output: "volume", title: "Volume", format: "si", polarity: "magnitude" }); // A background tint on stretched bars. render.bgcolor("stretch_tint", { where: "stretched", color: "#f59e0b22" }); // The stretched bars' own candles, body and wick, in the same amber. render.barcolor("stretch_candles", { where: "stretched", color: "#f59e0b" }); let sma = new Sma(20); let threshold: f64 = 2.0; function onStart(): void { sma = new Sma(i32(p_period())); threshold = p_stretch(); } function onBar(): void { const close = bar.close(); const volume = bar.volume(); const barTime = bar.time(); const value = sma.update(close); if (isNaN(value)) return; const stretch = value == 0.0 ? NaN : ((close - value) / value) * 100.0; const stretched = Math.abs(stretch) >= threshold; out_average(value); out_bar_time(barTime); out_stretch_pct(stretch); out_stretched(stretched ? 1.0 : 0.0); out_volume(volume); if (stretched) { sb_clear(); sb_f64(stretch, 1); sb_text("%"); str_readout_sb(); } sb_clear(); sb_text("SMA "); sb_f64(value, 2); str_tag_sb(); str_stretch_label("stretch"); sb_clear(); sb_f64(stretch, 2); sb_text("%"); str_stretch_text_sb(); } ``` Leaving `readout` unwritten on quiet bars is the whole gating story for `render.text`: a slot not written that bar is absent, and an absent slot draws nothing. A price tag on every signal bar is the same renderer with `style: "price_label"`: each written bar's text sits at `y` as a price tag. A label the module moves, re-words, or deletes on a later bar is a label handle ([Drawing objects](drawing-objects.md)). ### Mark size `width` on a `render.shape` sets the mark's size in pixels, any positive number; left out, the chart draws its own default size. Two sizes of one mark grade a signal at a glance: ```typescript // Volume spikes marked under the bar: a large dot over three times the average volume, a small one over twice. param.int("length", 20, { min: 2, max: 200, label: "Average length" }); output("low", none, overlay, { description: "The bar low, where the marks sit" }); output("big", none, overlay, { description: "1 when volume is over three times its average" }); output("small", none, overlay, { description: "1 when volume is over twice its average but not three times" }); render.shape("big_spike", { output: "low", shape: "circle", where: "big", color: "#f97316", width: 12 }); render.shape("spike", { output: "low", shape: "circle", where: "small", color: "#f97316", width: 6 }); let average = new Sma(20); function onStart(): void { average = new Sma(i32(p_length())); } function onBar(): void { const volume = bar.volume(); const mean = average.update(volume); if (isNaN(mean) || mean <= 0.0) return; const ratio = volume / mean; out_low(bar.low()); out_big(ratio > 3.0 ? 1.0 : 0.0); out_small(ratio > 2.0 && ratio <= 3.0 ? 1.0 : 0.0); } ``` - **One renderer per size.** `width` is fixed in the declaration, so a large and a small dot are two `render.shape` lines over the same output, each with its own gate. - **Gates that never overlap.** `small` is 1 only between two and three times the average, so a bar never carries both marks. ## Fixed-position text Text that sits at a viewport anchor and does not move with price is a `render.label` pinned by `position` over a string slot the module rewrites on every bar, so the newest bar decides the text: `render.label("status", { position: "top_right", text: "status_text", offset: [12, 8], style: "box", background_color: "theme.bg" })`. With `position` the label needs no `x` and `y` (declaring them beside it is refused: a corner label is nudged by `offset`, not placed by coordinates, and each number of the offset points inward from the anchored edges), and its `style` is one of `plain`, `box`, `knockout`, `pill`, `badge` or `emblem` (`price_label` and `callout` belong to a label at a price). The other corner readouts are a one-cell `render.table` at that anchor (`render.table("status", { rows: 1, cols: 1, cells: ["status_text"], position: "top_right" })`) and a `render.hud` card with a `tile.pill` or `tile.value` at the same anchor ([HUD and hover cards](hud-and-hover-cards.md#hud-cards)). A label the module owns and moves is a handle ([Drawing objects](drawing-objects.md)). ## Legend, HUD and hover Three surfaces read the indicator's newest values and are drawn by the chart from declarations alone: the legend entry, a HUD card at one of the nine anchors, and the card that opens when the cursor rests on a legend entry, a HUD tile or a line. They share one vocabulary of blocks, and the rest of this section documents them: `legend({ title })`, `label`, `format` and `render.legend` on [Legend](legend.md); `hover(handle, [block.*])` and `badges` on [Hover cards](hud-and-hover-cards.md#hover-cards); `render.hud(name, { position, title?, columns?, tiles, look?, ... })` on [HUD cards](hud-and-hover-cards.md#hud-cards), with its surface and type words and the twelve looks a card can wear; every `block.*` and `tile.*` constructor, the colour, drawing, headline and height words of a tile, on [Blocks and tiles](hud-and-hover-cards.md#blocks-and-tiles); the three together, with a complete module, on [What the chart shows](overview.md). ## Ranges and panels A rectangle between two times and two prices: per bar it is a `box` between two outputs over a bar-offset span; for one object placed from the newest bar it is `draw.box` with four coordinate outputs, two of them epoch seconds from the `time` source; for a rectangle the module keeps, grows, and deletes it is a box handle ([Drawing objects](drawing-objects.md)). A heatmap, a curve over a category or an index, tiles, and a one-summary pie are frame-backed panels: `panel.heatmap`, `panel.line` over a category or index x, `panel.tiles` and `panel.pie`, each a JSON snapshot the module writes and the chart draws in its own pane below the price chart ([Cards, frames and panels](cards-frames-panels.md)). Viewport-pinned panels of raw bars and a variable number of marks per bar have no renderer: every output is one number per bar on the chart's time axis. For a dashboard use `render.table` over string slots; for a per-level picture use a `plot.levels` frame docked on the price axis, or the chart's own footprint view beside the wrun indicator. ## The name requirement A wrun indicator's outputs are unique by construction (a duplicate `output("sma", ...)` is refused in the editor's Console, `duplicate output name 'sma'`), the name is the legend entry (read as words), and `description` is the optional long text. Names follow one grammar across outputs, boxes, segments, renderers, and drawings, and share one namespace: a renderer cannot reuse an output's name. ## When to use which | You want | Use | Not | | --- | --- | --- | | a value someone could alert on | a drawn output, or a declared `alert` over a gate | a box or a label (decorations are never alert targets) | | a mark on some bars only | `plot: shape` with `shape_where`, or `render.shape` with `where` | a text renderer on every bar | | a live readout at the newest bar | `render.label` from a string slot | `render.text` (one mark per bar) | | text on every signal bar | `render.text` from a slot written on those bars | one label per signal | | a corner readout that does not move with price | a `render.label` with a `position`, a one-cell `render.table` with a `position`, or a `render.hud` tile | a label placed by `x` and `y` | | a dashboard | `render.hud` with typed tiles (and a `look`), or `render.table` for a grid of words | many labels | | a word or a value in the legend | `label` and `format` on the output, `render.legend` for a slot's words | a text renderer in the corner | | an answer when the cursor rests on a line or a tile | `tooltip` on the output, or a `hover` block list | a table the user has to read across | | a label the module moves or deletes later | a label handle (`draw.label(id)`) | a renderer, which cannot be moved | | a per-bar tint behind the bars | `render.bgcolor` | recoloring a line | | trend or regime colored candles | `render.barcolor` | a background tint fighting the candle for contrast | | a level | a `line` output with `role: "guide"` written to the constant | a drawing | | an oscillator and a second study in panes of their own | two `pane(...)` declarations and `pane` on their outputs | one crowded lower pane | # Styling How outputs draw. The rule that keeps a wrun indicator honest: outputs are numbers, some numbers are decisions, and declarations map decisions to looks. Styling lives in the `output(...)` options and in the `range()`, `box()` and `segment()` declarations at the top of the file, never in the module's code: the chart reads the declaration and draws it, and **Run** records it in the sheet it derives. A static look is one option per output; a look that changes per bar is a ladder, a data-only output whose floored value indexes a palette. What the user may change comes from the same declarations: a `param.color` bound to an output by name, and [the Style page](../settings/style-page.md) the dialog derives from the outputs. This page is the wrun vocabulary for each look (size, tooltips, z-order, palettes, gradients), and which looks have no form. Every part a wrun indicator draws can be styled; for a part-by-part walk through one real indicator, start with [Style anything](style-anything.md) and come back here for the vocabulary. ## Placement, per output - `plot`: `line`, `bar`, `area`, `histogram`, `candle`, `shape`, `scatter`, or `none` for a data-only output: computed, never drawn, the building block for gates and palettes below. On the chart `bar` and `histogram` both draw as columns and `shape` and `scatter` as marks; `candle` takes four consecutive outputs (open, high, low, close) that draw as one candle ([Plotting](plotting.md)). - `panel`: `overlay` (the price chart) or `lower` (a pane below it). Put small-magnitude series (probabilities, oscillators) in `lower`; an odds-scale line on the price axis hugs the axis floor. The `lower` outputs of an indicator that also draws on price share one pane below the chart unless `pane: ""` sends them to a pane declared with `pane(name, { title, place, height_frac, scale, invert, padding, min, max, format })`: up to four named panes, below the chart or, with `place: "price"`, over the candles on a hidden scale of their own ([Panes](plotting.md#panes)). - `z`: the paint order inside a pane, an integer -10..10 on an output, a `range`, a `fill` or a `box`. Absent, the engine's layer applies: tints 0, fills 1, lines, columns, bands, marks and boxes 2, text 3; ties keep declaration order. - Numbers: `format` names how the output's value prints in the legend, the hover card and its axis tag: `price` (the chart's price digits, or a lower pane's own step digits on its axis), `%` (the value with a percent sign), `si` (K, M, B, T from 1,000), `int`, `0`, `0.0`, `0.00`, `0.000` (fixed digits), `usd` (`$` with two decimals under 1,000, else `$1.2K`, `$1.2M`, `$1.2B`, `$1.2T` at one decimal, the sign before the `$`) and `auto` (six significant digits with `,` thousands). `decimals` (0..8) overrides the word's digits, `signed: true` puts a `+` on positives, and `unit` prints after the value once `format` is declared (an output's `unit` is not bounded, as it never was; keep it short, a host may cut a long one); `decimals` and `signed` without `format` are refused ("decimals needs format"). A `unit` on its own is recorded in the sheet as before and never printed. Without `format` the chart formats numbers itself: a lower pane prints K, M and B from 1,000 and its own precision below that, a value on the price pane prints at the chart's price precision. A pane's axis ticks take the same four words on `pane(...)`. ## Static style, per output One option per output, read from the declaration: ```typescript output("overbought", line, lower, { color: "#ff0000", width: 1, line_style: "dashed", description: "The 70 level, written on every bar" }); output("delta", histogram, lower, { color_by: "delta_sign", colors: ["#ef5350", "#26a69a"], description: "Fast minus slow as columns, colored by sign" }); // Red while the close is under the average, green (and three times as wide) while it is over. output("mid", line, overlay, { color_by: "regime", colors: ["#ef4444", "#22c55e"], width_by: "regime", widths: [1, 3] }); // The band between the two edges: dotted edge lines and a slate interior. range("band_hi", "band_lo", { color: "#94a3b8", edge_width: 1, edge_line_style: "dotted" }); // A curved line split around zero, shaded below it; a fill between the two edges with no edge lines of its own. output("osc", line, lower, { smooth: true, split: "level", up_color: "theme.up", down_color: "theme.down", split_fill: true }); fill("band_hi", "band_lo", { color: "theme.accent", opacity: 0.15 }); // The 70 level as a guide: one full-width line, labelled on the line, out of the legend. output("upper", line, lower, { role: "guide", align: "right", color: "theme.muted" }); ``` | Look | Option | What it draws | | --- | --- | --- | | a color | `color`, or a `colors` palette | the line, column or mark color | | a halo | `glow` | a soft halo in pixels on a line, an area, columns, candles, marks and scatter dots, and on a `render.shape` mark | | rounded columns | `corner` | rounded column corners on a histogram, in pixels | | transparency | `opacity` | 0..1 on an output, fading every colour it draws: a ladder's rungs, a packed colour, a column's sign pair, a candle's body, border and wick, marks and gradient stops; per box, per fill, per handle and per card too | | thickness | `width` | the line width; `widths` the per-bar ladder, 1..10 entries, each 0.5..20; on columns a share of the bar slot, `widths` entries from 0.05 (1 = the columns touch) | | a dash | `line_style` | `solid`, `dashed`, `dotted`; lines and areas take the dash, columns do not | | curves | `smooth` | a line, or an area's edge, bent into curves | | steps | `step` | a line as horizontal then vertical segments (a trailing stop, a funding step); never beside `smooth` | | a stroke shaded by height | `gradient` on a line | 2 to 8 colours from the top of the pane to the bottom; never beside `color_by` or `split` | | two colours around a level or by slope | `split`, `up_color`, `down_color`, `split_level`, `split_fill`, `split_base` | `"level"`: `up_color` above `split_level` (default 0) and `down_color` below, `split_fill` shading down to `split_base`; `"slope"`: `up_color` while rising, `down_color` while falling | | an area's fill | `fill_color`, `fill_opacity`, `fill_gradient`, `gradient_mode`, `fill_color_by` plus `fill_colors`, `fill_color_packed_by` | the fill's own colour (default the line's) at `fill_opacity` (0.4 flat, 1 on stops), or stops top to bottom over the pane, the fill or each column, or a colour per bar from its own ladder | | a fill between two lines | `fill(a, b, { color, opacity, opacity_by, color_by, colors, color_packed_by, z })` | the interior only, no edge lines, between two drawn outputs on one pane, its opacity per bar from an output with `opacity_by` | | a band's edges | `edge_width`, `edge_line_style` on a `range()` | both edge lines at an integer width 0..10 (`0` = no edge lines), solid, dashed or dotted | | a faded band | `gradient` on a `range()` | 2 to 8 colors, listed top to bottom, shading the interior vertically instead of a flat tint | | where the stops lie | `gradientMode` | `"pane"` (the pane height, the default), `"fill"` (the band's own vertical extent) or `"line"` (per column, from `upper` down to `lower`) | | a band's legend row | `legend`, `label` on a `range()` | `legend: false` drops the row; `label` names it | | columns from a level | `base` | columns grow from `base` instead of zero, `colors[0]` at or above it and `colors[1]` below | | columns faded by size | `grading` | `"linear"` or `"square"`: each column's alpha follows its magnitude within the visible range | | stacked columns | `stack` | bars sharing a stack name stack in declaration order, positives above zero and negatives below | | a candle's style | `candle_style`, `border_colors`, `wick_colors`, `border_width` on a candle group's first output | `"candle"`, `"hollow"`, `"ohlc"` or `"high_low"`; border and wick colours as `[both]` or `[up, down]`; the border width 0..10 | | the mark | `shape`, `location`, `char`, `font_family`, `fill`, `fill_opacity` | the shape (ten words, `char` one character in `font_family`); `"absolute"`, `"above_bar"`, `"below_bar"`, `"top"` or `"bottom"`; the outline only, or a faded interior | | a level | `role: "guide"`, `align`, `pill_style`, `font_size`, `axis_label` | one full-width line at the output's last value, out of the legend; a label on the line (`"left"`, `"center"`, `"right"`; `"filled"` or `"outlined"`; 6..64 px) or on the axis | | paint order | `z` | an integer -10..10 on an output, a range, a fill or a box; absent, the engine's layer (tints, fills, lines and columns and bands and marks and boxes, text) in declaration order | | a theme colour | `"theme.up"`, `"theme.down"`, `"theme.text"`, `"theme.muted"`, `"theme.bg"`, `"theme.grid"`, `"theme.accent"` | the chart's own colour, resolved when it paints and again on a theme switch ([Theme colours](#theme-colours)) | | a color per bar | `color_by` plus `colors`, or `color_packed_by` | each bar's value indexes the palette, or carries a packed colour | | a width per bar | `width_by` plus `widths` | the same ladder for line width | | an inset strip's colours | `color`, `colors`, `color_by`, `opacity` on `out.inset` | `colors` without `color_by` is a sign pair (up at or above zero, down below); with `color_by` the bar's ladder colour | | a HUD's look | `look` on `render.hud` | one of twelve presets, every word of which a declared word overrides ([HUD cards](hud-and-hover-cards.md#looks)) | `color` (or a `colors` palette), `width`, `opacity` (0..1), `line_style` (`solid`, `dashed`, `dotted`; lines and areas take the dash, columns do not), `glow` (a soft halo in pixels on a line, an area, columns, candles, marks and scatter dots), `corner` (rounded column corners, in pixels), and `description`, which documents the output in the sheet. A colour is `"#rrggbb"`, `"#rrggbbaa"` (the alpha in the last byte), a CSS colour name as before, or a theme token (below); `opacity` multiplies every colour the output draws, a ladder's rungs, a packed colour, a column's sign pair, a candle's border and wick and a gradient's stops included. The legend's and the Style page's words for an output come from `label` (the name read as words when absent) and its numbers from `format`, `decimals`, `signed` and `unit` (above); `legend: false` keeps an output out of the legend, `visible: false` starts it hidden, `price_line: true` draws a dotted line across the pane at its last value, `axis_label: true` tags that value on the price axis and `axis_name: true` adds the indicator's name to the tag ([Plotting](plotting.md)). The looks a plot kind takes beyond these (`step`, `smooth`, `gradient` and `split` on lines; `fill_color`, `fill_opacity`, `fill_gradient` and the fill ladder on areas; `base`, `grading`, `stack` and `width` as a share of the bar slot on columns; `candle_style`, `border_colors`, `wick_colors` and `border_width` on a candle group; `shape`, `location`, `char`, `fill` and `fill_opacity` on marks; `role: "guide"` for a level) are the table on [Plotting](plotting.md#lines-areas-columns-dots-marks). The per-look reading of this vocabulary, with the decision rule, is [Decisions to looks](#decisions-to-looks). ## Theme colours Every colour word accepts a theme token beside the hex forms: `theme.up` and `theme.down` (the chart's candle colours), `theme.text` (the axis text), `theme.muted` (that text at 55 percent), `theme.bg` (the background), `theme.grid` (the grid lines) and `theme.accent` (the app's accent). The chart resolves a token when it paints and again when the theme switches, with no rerun, so a `"theme.text"` label reads on a dark and a light chart alike. A token rides every colour key of this page (outputs, ranges, fills, boxes, segments, renderers, drawings, handles, cards, HUDs, docked levels and panels), the colour strings inside a frame, and a packed colour the module writes (`toPacked(theme.UP)` from `./sdk/color`, [Colors kit](../functions/colors-kit.md#theme-colours)). A `param.color` default or preset stays a hex colour ("a colour param takes #rrggbb or #rrggbbaa; put the token on the colour key"), and a `theme.` word outside the seven is refused by name. Two chart-level flags ride beside the tokens: `chart.contrast_guard(false)` keeps the author's colours as written on a light chart (the chart otherwise darkens colours that would vanish there), and `chart.stack_handles(true)` stacks this indicator's corner-anchored handle groups below other indicators' groups instead of overprinting them ([Drawing objects](drawing-objects.md)). ## The Style page Every output you draw gets a row on the dialog's **Style** page without a declaration, and a spot the file binds to a setting (`color: "@basis_color"`, `line_style: "@style"`) is that setting's row instead, so a look never has two controls. The rows, the bindings and the browser-or-cloud rule are on [The Style page](../settings/style-page.md). ## Decisions to looks - Per-bar coloring: emit the decision as a data-only output (`none`), then on the styled output set `color_by: ""` plus a `colors` palette (at least 2 entries). Each bar's floored value indexes the palette; a finite value outside the palette takes entry 0, and a non-finite one draws the output's static color (entry 0 when the output declares no `color`). An output cannot color itself: the index expression is its own output. - Per-bar width: the color ladder's sibling. On the styled output set `width_by: ""` plus a `widths` ladder (1..10 entries, each 0.5..20); each bar's value indexes the ladder (floor; a finite out-of-range index takes entry 0). A non-finite value on either ladder draws that bar in the static style. Both halves or neither, each refused by name without the other. An output cannot set its own width. - Gated markers: a `shape` output with `shape_where: ""` renders only where the gate is nonzero. An output cannot gate itself. - Banded ranges: `range(upper, lower, options?)` names two DIFFERENT drawn outputs (by name, as strings; a `none` output is refused: "range references '', which is not a rendered output"). The chart draws a band: both edge lines at `edge_width` (an integer 0..10; `0` draws the band with no edge lines) and `edge_line_style` (`solid`, `dashed` or `dotted`), with a tinted interior in `color`. A `colors` palette plus `color_by` (a data-only output) tints the band per bar (floor; a finite out-of-range value takes entry 0); `color_by` requires `colors`. `colors` WITHOUT `color_by` is the band's sign palette (a momentum fill): entry 0 where `upper` plots above `lower`, entry 1 where it plots below. `gradient` (2 to 8 colors, listed top to bottom) shades the interior vertically instead of a flat tint, laid over `gradientMode`: `"pane"` (the pane height, the default), `"fill"` (the band's own vertical extent) or `"line"` (per column, from `upper` down to `lower`). `smooth` is a boolean smoothing hint. `legend: false` drops the band's own legend row and `label` names it (default: the upper edge's label); `z` sets its paint order. Declare as many ranges as needed; the same pair may repeat with different options. - Fills: `fill(a, b, options?)` shades the interior between two drawn outputs on one pane and draws no edge lines, so each line keeps its own look. Options: `color`, `opacity` (0..1, default 0.25), the `color_by` plus `colors` ladder or `color_packed_by` for a colour per bar (a non-finite rung draws no fill that bar), `opacity_by` for an opacity per bar, and `z`. `opacity_by` names a declared output whose value on each bar is that bar's opacity (0..1), with any colour mode; a non-finite value keeps `opacity`. The two sides must be different drawn outputs on the same pane; `colors` without `color_by`, `color_by` without `colors`, `color_packed_by` beside `color_by`, and an `opacity_by` that names no declared output are refused. A setting reaches a fill's opacity through `opacity_by`: the file writes the setting, scaled to 0..1, into a data-only output on every bar, and the fill reads it there. ```typescript param.int("cloudOpacity", 40, { min: 0, max: 100, label: "Cloud opacity" }); output("cloud_alpha", none); // the cloud's opacity on each bar, 0..1 fill("span_a", "span_b", { color_by: "cloud_side", colors: ["#22c55e", "#ef4444"], opacity_by: "cloud_alpha" }); // in onBar(): out_cloud_alpha(p_cloudOpacity() / 100.0); ``` A packed colour skips the palette: the module writes a colour per bar into a data-only output (`toPacked` from the colour kit, [Colors](../functions/colors-kit.md)) and the drawn output names it with `color_packed_by`; an area's fill reads the same number through `fill_color_packed_by`, so a heat that shifts smoothly needs no rungs. ```typescript // A heat area painted per bar from a colour the module computes: the edge and the fill both read it. output("heat", area, lower, { color_packed_by: "heat_color", fill_color_packed_by: "heat_color", fill_opacity: 0.35, width: 2 }); output("heat_color", none, lower, { description: "A packed colour per bar, written with toPacked(...) in onBar()" }); ``` A per-bar box takes the same two ladders for its fill and its border, under camelCase keys in the declaration (`colorBy`, `borderColorBy`, `borderColorPackedBy`, `borderStyle`), with output handles for the `By` keys ([Boxes](drawing-objects.md#boxes)): a zone can follow the regime with a dashed border while a second box, drawn only where the band squeezes, takes its fill and border from one packed heat colour. ```typescript const hi = output("band_hi", line, overlay); const lo = output("band_lo", line, overlay); const regime = output("regime", none, overlay); const heat = output("heat_color", none, overlay); const squeezed = output("squeezed", none, overlay); // The zone behind the last three bars: fill and border by the regime ladder, dashed, painted behind the lines. box("zone", { top: hi, bottom: lo, from: -3, to: 0, colorBy: regime, colors: ["#ef444433", "#22c55e33"], borderColorBy: regime, borderColors: ["#ef4444", "#22c55e"], borderStyle: "dashed", z: -1 }); // The squeeze box: fill and border from one packed colour, where the gate is nonzero. box("squeeze", { top: hi, bottom: lo, when: squeezed, colorPackedBy: heat, borderColorPackedBy: heat, opacity: 0.3, borderWidth: 2 }); ``` Every reference is checked as you type and when you press **Run**: `color_by`, `width_by`, `shape_where`, and each `range()` side must name a declared (and, where required, drawn) output, and none may name its own output. A refusal points at the declaration in the editor's Console and names the field (for example "Output 'mid' (color_by): ..."). Styling dresses per-output SERIES. When the indicator should also put text, tables, shaped marks, or free-standing objects (lines, boxes, polylines, labels) on the chart, that is the renderer and drawing vocabulary: `render.*` and `draw.*` declarations over named outputs and string slots ([Plotting](plotting.md), [Drawing objects](drawing-objects.md)). Shapes that repeat on EVERY bar (a zone behind the last few bars, a projection ray from each bar) are the `box()` and `segment()` declarations over output handles ([Boxes](drawing-objects.md#boxes), [Segments](drawing-objects.md#segments)). ## Tags, the legend and the candle tint Four looks belong to the indicator as a whole: whether it keeps a price axis of its own, which plots tag their value on the price axis, the order its plots join the legend, and the pane it lives in. One top-level `display({ ... })` declares them, at most once: | Key | What the chart does | Absent | | --- | --- | --- | | `axis: false` | no price axis of its own: an indicator on the price pane rides the chart's scale | its own axis | | `price_display: "per_output"` | every plot tags its value on the price axis unless its `show_price_display` is `false`; a mark tags only when its `show_price_display` is `true` | the first output alone tags | | `overlay: "offchart"` | the indicator lives in its own pane below the chart; `pane("lower", { format: "0.00" })` formats that pane's axis | the outputs' panels decide | | `mount_order: "first_value"` | plots join the legend, and paint within one layer, in the order they first draw a value | declaration order, every range after every output | Five words sit on the plots themselves: | Word | On | What it does | | --- | --- | --- | | `show_price_display` | an output, a `range`, a `render.shape` | the plot's tag on the price axis, read under `price_display: "per_output"`: `false` drops it, `true` forces one | | `show_price_display_by` | an output | a declared output whose value on the last bar switches the tag: nonzero on, zero off, `NaN` keeps `show_price_display` | | `omit_if_empty` | an output, a `range` | `true`: a run that never drew it leaves it out, with no legend row and no tag (a plot drawn under an `if`) | | `fill: false` | a `range` | the two edge lines and no interior | | `barcolor: true` | a `none` output | the candles take its colour: each bar's value (floored) picks an entry of `colors`, or `color_packed_by` names a packed colour; a `NaN` or out-of-range value leaves the candle as it is; one per indicator | Every colour bound to a setting also takes it at an alpha: `"@/0.45"` paints the setting's colour at 45 percent, and a recolour keeps the 45; a panel series colour binds without one ([The Style page](../settings/style-page.md#a-setting-at-an-alpha)). ```typescript // No price axis of its own, a tag per plot, and plots listed in the order they first draw a value. display({ axis: false, price_display: "per_output", mount_order: "first_value" }); param.int("length", 20, { min: 2, max: 200, label: "Length" }); param.color("bull", "#22c55e", { label: "Bull" }); param.color("bear", "#ef4444", { label: "Bear" }); param.bool("tag_basis", true, { label: "Tag the basis" }); // The basis tags its value while the setting is on; the edges never tag. output("basis", line, overlay, { color: "@bull", show_price_display_by: "basis_tag" }); output("upper", line, overlay, { color: "@bull/0.5", show_price_display: false }); output("lower", line, overlay, { color: "@bear/0.5", show_price_display: false }); output("basis_tag", none); // A mark on each breakout: a run with none leaves it out of the legend. output("breakout", shape, overlay, { shape: "triangle_up", location: "below_bar", color: "@bull", omit_if_empty: true }); // The two edges as lines with no interior, untagged. range("upper", "lower", { fill: false, show_price_display: false }); // The candles in the trend's colour at 45 percent: 0 below the basis, 1 above. output("tint", none, overlay, { barcolor: true, colors: ["@bear/0.45", "@bull/0.45"] }); let sma = new Sma(20); let stdev = new Stdev(20); let tag: f64 = 1.0; function onStart(): void { sma = new Sma(i32(p_length())); stdev = new Stdev(i32(p_length())); tag = pb_tag_basis() ? 1.0 : 0.0; } function onBar(): void { const close = bar.close(); const mid = sma.update(close); const sd = stdev.update(close); if (isNaN(mid) || isNaN(sd)) return; const hi = mid + 2.0 * sd; out_basis(mid); out_upper(hi); out_lower(mid - 2.0 * sd); out_basis_tag(tag); if (close > hi) out_breakout(bar.low()); out_tint(close > mid ? 1.0 : 0.0); } ``` Refused by name: a tag word on a `none` output ("show_price_display needs a drawn output"), `omit_if_empty` on one ("omit_if_empty needs an output that draws a value"), `barcolor` on a drawn output, without `colors` or `color_packed_by`, beside `color_by` (its own value is the index) or on a second output, a `range` with `fill: false` and a `gradient`, a `show_price_display_by` that names no output, a second `display(...)` and a word `display` does not take (`overlay: "onchart"`). ## Text size, tooltips, alignment, z-order, palettes The styling vocabulary, item by item: | You want | wrun form | | --- | --- | | text size | `size`, an integer pixel count 6..64, on `render.text` and `render.label`, or `size_by` for a size per bar from an output; `font_size` on a guide's label, a box's text and a card | | type | `font_weight` (`normal`, `medium`, `bold`) and `font_family` (`ui`, the app font; `mono`; `serif`; `rounded`, each a system stack, nothing downloads) on renderer text, labels, box text, cards, HUDs and docked levels | | a tooltip on a plot | `tooltip` on the output, a template over its own value and any output or string slot: `"{{label}} {{value:price}} · {{state}}"`; shown on the legend entry and when the cursor nears the line ([Labels, tooltips, badges](labels-and-tooltips.md)) | | a tooltip on a cell, label, mark or drawing | `tooltip` on `render.text`, `render.label`, `render.shape`, `draw.line`, `draw.box`, `draw.polyline` and `draw.label` (one template per declaration, shown on its marks or object); a handle's `tooltip(send)` setter; a `hover` block list on an output or a label for a full card ([HUD and hover cards](hud-and-hover-cards.md#hover-cards)); a table cell's `tooltip` in its `styles` entry, a string slot ([Styled tables](cards-frames-panels.md#styled-tables)) | | price tags, callouts, pills | `style` on `render.text` and `render.label`, eight words on both: `plain`, `price_label` (the price tag on the axis), `pill` (a rounded chip at the value), `callout` (a leader line to its bar), `badge` (a dot in the label's color), `box`, `knockout` (a box in the background colour) and `emblem` (a mark before the text); a tag look takes `label_position`, `background_color`, `border_color` and `corner_radius` ([Labels, tooltips, badges](labels-and-tooltips.md)) | | text alignment | `align` (`left`, `center`, `right`) and `valign` (`top`, `middle`, `bottom`) on plain `render.text` and `render.label`, on a handle or declared label, and on a box's text; the nine anchors (`top_left` ... `bottom_right`) are a table's, a HUD's or a corner `render.label`'s `position` ([Drawing objects](drawing-objects.md)) | | z-order | `z` (-10..10) on an output, a range, a fill or a box, else the engine's layer in declaration order; a handle's `zorder` setter on lines, boxes, labels and polylines | | a palette | a `colors` literal array on the output, box, range, fill, bgcolor, barcolor, stats row or shape renderer; bucket the driving value into its index ([Colors](../functions/colors-kit.md)); an entry may be a `param.color` by name; `color_packed_by` names an output that carries a packed colour per bar instead | | per-element glow, opacity, gradients | `glow` per output, per renderer mark and per handle (`glow_color` picks its colour); `opacity` per output (every colour it draws), per box and per handle; a `color_by` ladder per bar; a line's `gradient` by height, an area's `fill_gradient`, a `range()` band's vertical `gradient`, a box handle's `gradient`; `corner` rounds histogram columns, `corner_radius` the corners of labels, boxes, cards and HUDs | | a setting behind a look | `"@"` in place of a colour, a `colors` or `gradient` entry, or a line style on an output, a `range`, a `fill`, a `box`, a `segment`, a legend entry or a docked `plot.levels` key; `"@/"` for the setting's colour at an alpha; a `param.color` on every colour word of a panel ([Panel words](cards-frames-panels.md#panel-words)); a `param.choice` over the looks on a HUD card's `look` ([Looks](hud-and-hover-cards.md#looks)) ([The Style page](../settings/style-page.md)) | | a table's fill, frame, lines and widths | the look keys on `render.table`: fills with opacity and gradients, a frame, the lines between cells, column widths, merged cells ([Styled tables](cards-frames-panels.md#styled-tables)) | Value-driven styling, computing the style from the data per element, is the ladder: normalize the driving value, floor it into a bucket, write the bucket to a `none` output, and index a palette with it. The palette is finite, so the gradient is stepped, and the decision stays a number you can read at the Console prompt or hand to a declared alert. ## A worked example Bollinger-style bands with a regime-colored (and regime-widened) midline and a banded range. The declarations carry every look; the module computes four numbers per bar and never mentions a color: ```typescript param("period", 20, { min: 2, max: 400 }); param("band_width", 2, { min: 0.5, max: 4, description: "Stdev multiples" }); // Red while the close is under the average, green (and three times as wide) while it is over. output("mid", line, overlay, { color_by: "regime", colors: ["#ef4444", "#22c55e"], width_by: "regime", widths: [1, 3] }); output("band_hi", line, overlay, { color: "#94a3b8", opacity: 0.6 }); output("band_lo", line, overlay, { color: "#94a3b8", opacity: 0.6 }); output("regime", none, overlay, { description: "0 below the average, 1 above: the palette and width index" }); // The band between the two edges: dotted edge lines and a slate interior. range("band_hi", "band_lo", { color: "#94a3b8", edge_width: 1, edge_line_style: "dotted" }); let sma = new Sma(20); let stdev = new Stdev(20); let mult: f64 = 2.0; function onStart(): void { const period = i32(p_period()); sma = new Sma(period); stdev = new Stdev(period); mult = p_band_width(); } function onBar(): void { const close = bar.close(); const mid = sma.update(close); const sd = stdev.update(close); const regime = close > mid ? 1.0 : 0.0; if (isNaN(mid) || isNaN(sd)) return; out_mid(mid); out_band_hi(mid + mult * sd); out_band_lo(mid - mult * sd); out_regime(regime); } ``` The `regime` output computes on every bar but never draws; it exists so the palette (and the `widths` ladder) on `mid` has a decision to index. The band is one `range()` over the two drawn edges, so the chart shades it without a box per bar. The two params are number fields in the overlay's settings dialog (a `param.int` would step by whole numbers, and `param.number` with a `step` by that step), and changing one reruns the compiled module without recompiling it; the three lines each get a Style row. The `output(...)` options carry `color`, `colors`, `width`, `opacity`, `line_style`, `glow`, `corner`, `z`, `color_by`, `color_packed_by`, `shape_where`, `width_by`, `widths`, `displacement_bars`, `displacement_bars_by`, `unit`, `description`, the plot-kind looks of [Plotting](plotting.md#lines-areas-columns-dots-marks) (`step`, `smooth`, `gradient`, `split` and its four companions, `fill_color`, `fill_opacity`, `fill_gradient`, `gradient_mode`, `fill_color_by`, `fill_colors`, `fill_color_packed_by`, `base`, `grading`, `stack`, `candle_style`, `border_colors`, `wick_colors`, `border_width`, `shape`, `location`, `char`, `font_family`, `fill`, `role`, `align`, `pill_style`, `font_size`, `pane`), the presentation keys `label`, `format`, `decimals`, `signed`, `legend`, `visible`, `price_line`, `axis_label`, `axis_name`, `tooltip`, `hover`, `badges`, `hint`, and the chart words `show_price_display`, `show_price_display_by`, `omit_if_empty` and `barcolor` ([Tags, the legend and the candle tint](#tags-the-legend-and-the-candle-tint)); ranges declare as `range(upper, lower, options?)`, fills as `fill(a, b, options?)`, panes as `pane(name, options?)`, boxes and segments as `box(name, options)` / `segment(name, options)` over output handles, and declaration order sets the indexes ([Declarations and the sheet](../reference/declarations.md)). ## Where the rules live Every styling reference is validated as you type and when you press **Run**, and every refusal names the declaration and the field in the editor's Console; the messages are in [Common errors](../faq/common-errors.md). The module never draws: the chart reads the declarations, so the look is exactly what they describe, whatever the module computes. # Drawing objects A wrun indicator has three forms of drawing. A `box` or a `segment` is declared once over two outputs and evaluated on every bar; a run-level drawing declaration (`draw.label` and its kin) is placed from the newest bar's outputs; and a **handle** is an object the module creates under an integer id and moves, restyles, or deletes on a later bar. This page is the vocabulary, the form each kind of object takes, and the rules each form runs under. ## The objects | You want | wrun form | Lifecycle | | --- | --- | --- | | a line | `segment(name, { yFrom, yTo, from, to })` per bar; `draw.line(id).set(x1, y1, x2, y2)` as a handle | per bar, or a handle the module owns | | a box | `box(name, { top, bottom, from, to, when })` per bar; `draw.box(id).set(left, top, right, bottom)` as a handle | per bar, or a handle the module owns | | a label | `draw.label(id).set(x, y).text(str__sb)` as a handle; `render.label(name, { x, y, text })` for one label the newest bar wins | a handle, or one label | | a polyline | `draw.polyline(id).setPoints(points, count)` as a handle; `draw.polyline(name, { points })` declared over output pairs | a handle of up to 100,000 points, or 64 output pairs from the newest bar | | a fill between two lines | `draw.line(id).fillTo(otherId, color)` between two line handles; `fill("a", "b", { color })` between two drawn outputs ([Styling](styling.md)); a `box` per bar between two outputs | a handle the module owns, per output, or per bar | | a table | `render.table(name, { rows, cols, cells, position })` over string slots | the newest complete row wins | | a mark (a dot, a square, a diamond) | `draw.label(id).set(x, y).mark().style(LabelStyle.Emblem).emblem(Emblem.Diamond).size(10)` as a handle; `draw.box(id).shape(BoxShape.Ellipse)` for an ellipse | a label with no text, or an ellipse inscribed in a box | | a tooltip on an object | `tooltip: "slope {{slope:0.00}}"` on a declared drawing; `handle.tooltip(str__sb)` on a handle | shown when the cursor rests on the object | | to restyle, move, or delete an object later | the handle's setters (`set`, `setXy2`, `setRightBottom`, `color`, `fill`, `fillTo`, `width`, `style`, `extend`, `size`, `glow`, `arrow`, `cornerRadius`, `gradient`, `text`, ...) and `delete()` | a setter stamps the bar it ran on; `delete()` frees the id | Every piece is a decoration over numbers the module already computes: outputs are the values, and boxes, segments, drawings, and handles never change an output's value ([Execution model](../core-concepts/execution-model.md)). Two forms are both called `draw`: `draw.line(name, {...})` as a top-level statement declares a run-level drawing, and `draw.line(id)` returns a handle; a name or an options object as the first argument is what makes the call a declaration. A handle does everything a run-level declaration does (create it under `bar.isLast()`). ## Boxes A box is declared once and evaluated on every bar. On bar `i` it spans bars `i + from` to `i + to` (inclusive) and prices `min(top, bottom)` to `max(top, bottom)`. Nothing is drawn on a bar where any referenced output is `NaN`, or where the optional `when` gate is `0` or `NaN`. Coordinates are output HANDLES: `output(...)` returns one, so bind it with a top-level `const` and pass the const. `from` and `to` are bar offsets (negative = past, positive = ahead; literals in -500..500, whole or fractional, or a handle whose per-bar value is truncated to the offset; default `0`). A fraction places the edge inside its bar at that share of the bar's interval: `from: -0.5, to: 0.5` draws a box one bar wide centred on the bar, and `from: -0.15, to: 0.15` a narrow stick through it. Options: `when` (a gate handle), `panel` (`"overlay"` or `"lower"`, default: the panel of `top`'s output), `color`, `borderColor`, `opacity` (0..1, default 0.2), `borderWidth` (0..10, default 1; `0` for no border), `borderStyle` (`"solid"`, `"dashed"`, `"dotted"`), `z` (the paint order, -10..10). The fill takes the opacity, so `color` must be a hex, `rgb()`, `hsl()` or theme-token color; a named color is refused. A colour per bar comes from a ladder on either part: `colorBy` (an output handle whose floored value picks the entry) plus `colors` (1..64 colours) for the fill, `borderColorBy` plus `borderColors` for the border, or `colorPackedBy` / `borderColorPackedBy` naming an output that carries a packed colour per bar. A finite out-of-range rung clamps to entry 0, a non-finite rung (or a packed value that does not decode) draws no box on that bar, and a rung colour's own alpha multiplies `opacity`. A ladder half without the other, a packed key beside its `By` key, or a name that is not a declared output is refused. `color`, `borderColor` and `borderStyle` also take a `"@"` setting ([The Style page](../settings/style-page.md)). Every referenced output may be data-only (`none`), which is the usual shape: compute the coordinates, never draw them as lines. A box or a segment adds no data to the module (the referenced outputs already reach the chart), and **Run** records its options in the sheet under snake_case names (`x_from`, `border_color`, `border_style`, `color_by`, `y_from`, and so on). The last five bars' range, tinted only while the range is expanding: ```typescript param("bars", 5, { min: 2, max: 50, description: "Bars in the trailing range" }); input("high", ohlcv.high); const rangeHi = output("range_hi", none); const rangeLo = output("range_lo", none); const expanding = output("expanding", none); // Behind the last five bars, tinted only while the range is wider than it was one bar ago. box("range_zone", { top: rangeHi, bottom: rangeLo, from: -4, to: 0, when: expanding, color: "#f59e0b", opacity: 0.15, borderColor: "#f59e0b", borderWidth: 1 }); const MAX_BARS = 50; const highs = new StaticArray(MAX_BARS); const lows = new StaticArray(MAX_BARS); let n: i32 = 5; let cursor: i32 = 0; let count: i32 = 0; let prevWidth: f64 = NaN; let width: f64 = NaN; function onStart(): void { n = i32(p_bars()); } function onBar(): void { highs[cursor] = bar.high(); lows[cursor] = bar.low(); cursor = (cursor + 1) % n; if (count < n) count += 1; if (count < n) return; let hi = -Infinity; let lo = Infinity; for (let i = 0; i < n; i++) { if (highs[i] > hi) hi = highs[i]; if (lows[i] < lo) lo = lows[i]; } prevWidth = width; width = hi - lo; out_range_hi(hi); out_range_lo(lo); out_expanding(!isNaN(prevWidth) && width > prevWidth ? 1.0 : 0.0); } ``` Two shapes that fall out of the per-bar rule: - A shaded channel between two lines is a box on every bar with `from` and `to` left at `0`: each bar contributes a one-bar-wide slice, and the slices tile into a band. The [anchored VWAP recipe](../cookbook/anchored-vwap.md) shades its band this way. - A zone that lives until price breaks it is the same one-bar box gated by a `when` output that stays `1` while the zone is alive: the band starts at the pivot and stops on the bar that mitigates it (the [zone tracker](../cookbook/zone-tracker.md)). That is one form of "create the box at the pivot, delete it when mitigated"; the other is a box handle, below, which is the same object from creation to deletion. A fill between two lines has two forms, both drawn by the chart: `range("upper", "lower", { color })` over two DRAWN outputs draws a band with edge lines, a sign palette or a vertical gradient ([Styling](styling.md#decisions-to-looks)), and the one-bar box above, whose `color` and `opacity` are the fill's. The box reads outputs, not lines, so either side may be data-only, and a conditional fill (shading only some bars) is a `when` gate on the box. A hand-written sheet's `fills` list has no declaration form. Two boxes gated by opposite outputs, one color each; the delta sits near zero, so it and its fills live in a `lower` pane (a box follows its `top` output's panel) instead of flattening the price scale under the candles: ```typescript param("period", 20, { min: 2, max: 400 }); const delta = output("delta", line, lower, { color: "#e2e8f0", width: 1, description: "Close minus its average" }); const zero = output("zero", line, lower, { color: "#64748b", width: 1, description: "Zero baseline" }); const below = output("below", none, lower, { description: "1 while the delta is negative" }); const above = output("above", none, lower, { description: "1 while the delta is positive or zero" }); // Two fills, one per sign: each bar contributes one slice between the delta and zero, // in the delta's own pane (each box takes the panel of its `top` output). box("fill_up", { top: delta, bottom: zero, when: above, color: "#22c55e", opacity: 0.15, borderWidth: 0 }); box("fill_down", { top: zero, bottom: delta, when: below, color: "#ef4444", opacity: 0.15, borderWidth: 0 }); let sma = new Sma(20); function onStart(): void { sma = new Sma(i32(p_period())); } function onBar(): void { const close = bar.close(); const avg = sma.update(close); const value = isNaN(avg) ? NaN : close - avg; if (isNaN(value)) return; out_delta(value); out_zero(0.0); out_below(value < 0.0 ? 1.0 : 0.0); out_above(value >= 0.0 ? 1.0 : 0.0); } ``` Both boundary series here are outputs the chart draws; the module could also leave one of them `none` and the box would still tile, because a box reads outputs, not lines (a `range()` needs both sides drawn). ## Segments A segment is the straight line from `(i + from, yFrom)` to `(i + to, yTo)`, evaluated on every bar `i` with the same offset, gate, and `NaN` rules as a box. Options: `when`, `panel`, `color`, `width` (0.5..20, default 1), `lineStyle` (`"solid"`, `"dashed"`, `"dotted"`). Absent `color` and `panel` follow `yFrom`'s output; `color` and `lineStyle` take a `"@"` setting. A dotted projection from each bar's average toward where the slope points, its length decided per bar by an output-valued offset: ```typescript param("period", 20, { min: 2, max: 200 }); const anchor = output("anchor", line, overlay, { color: "#38bdf8" }); const target = output("target", none); const reach = output("reach", none); // From this bar's average to the projected level, `reach` bars ahead: the offset is an output, // so each bar decides its own length. segment("projection", { yFrom: anchor, yTo: target, from: 0, to: reach, color: "#38bdf8", width: 1, lineStyle: "dotted" }); let ema = new Ema(20); let value: f64 = NaN; let prev: f64 = NaN; function onStart(): void { ema = new Ema(i32(p_period())); } function onBar(): void { prev = value; value = ema.update(bar.close()); if (isNaN(value)) return; const slope = isNaN(prev) ? 0.0 : value - prev; const bars = slope == 0.0 ? 0.0 : 5.0; out_anchor(value); out_target(value + slope * bars); out_reach(bars); } ``` Horizontal levels are segments with `yFrom` and `yTo` on the same output and `from: 0, to: 1`: each bar draws its level to the next bar, and the pieces chain into one line that steps when the level changes (the [key levels recipe](../cookbook/key-levels.md)). Offsets past the loaded range clamp to its edge, so a segment cannot reach into empty space to the right of the newest bar; a handle's absolute coordinates can. ### A box and a segment together A moving average with a one-percent band drawn as a zone behind the last five bars while the average rises, plus a dotted ray from each bar's average to the band top three bars ahead. Both shapes take their color and panel from the outputs they reference: ```typescript param("period", 20, { min: 1, max: 200 }); const mid = output("mid", line, overlay, { color: "#38bdf8" }); const bandHi = output("band_hi", none); const bandLo = output("band_lo", none); const rising = output("rising", none); box("band_zone", { top: bandHi, bottom: bandLo, from: -4, to: 0, when: rising, opacity: 0.15, borderWidth: 0 }); segment("mid_ray", { yFrom: mid, yTo: bandHi, from: 0, to: 3, lineStyle: "dotted" }); let sma = new Sma(20); let value: f64 = NaN; let prev: f64 = NaN; function onStart(): void { sma = new Sma(i32(p_period())); } function onBar(): void { prev = value; value = sma.update(bar.close()); if (isNaN(value)) return; out_mid(value); out_band_hi(value * 1.01); out_band_lo(value * 0.99); out_rising(!isNaN(prev) && value > prev ? 1.0 : 0.0); } ``` ## Run-level drawings `draw.line`, `draw.box`, `draw.polyline`, and `draw.label` with a name as the first argument are the declared kind of object: one per declaration, its coordinates read from outputs on the NEWEST bar only. Every coordinate finite there means the object exists; any `NaN` there means no object, regardless of earlier bars. It is "draw from the last bar" as a declaration: the chart evaluates the newest bar on its own, and re-evaluates it as live data arrives. - `draw.line(name, { x1, y1, x2, y2, color?, width?, line_style?, extend?, arrow?, glow?, glow_color?, sticky_right?, axis_label?, opacity?, tooltip? })` - `draw.box(name, { left, top, right, bottom, color?, border_color?, opacity?, border_width?, border_style?, corner_radius?, extend?, shape?, gradient?, gradient_direction?, glow?, glow_color?, text?, text_color?, font_size?, font_weight?, font_family?, align?, valign?, padding?, tooltip? })`: `color` is the fill, painted at `opacity` (default 0.2), and the border too unless `border_color` is set (default `#38bdf8`, the box handle default); it takes any colour string, as it always has: a hex, `rgb()`, `hsl()` or theme-token fill takes the opacity, a named colour paints as given (only `handles.box` insists on an alpha-rewritable fill); `text` names a string slot drawn inside the box - `draw.polyline(name, { points, color?, width?, line_style?, opacity?, fill_color?, closed?, smooth?, arrow?, glow?, glow_color?, tooltip? })`, where `points` is a flat `["x0", "y0", "x1", "y1", ...]` list of output-name pairs, at most 64 pairs - `draw.label(name, { x, y, text, color?, style?, align?, valign?, background_color?, border_color?, border_width?, corner_radius?, font_weight?, font_family?, padding?, max_width?, angle?, glow?, glow_color?, emblem_shape?, emblem_color?, sticky_right?, axis_label?, opacity?, tooltip? })`, `text` a string slot X coordinates are epoch SECONDS, not milliseconds: `bar.time()` feeds them, and a point in the past is that time minus a bar count times the interval in seconds. Drawings reference outputs by NAME (strings), unlike per-bar boxes and segments, which take handles. The style words are the handle defaults' words (the table under [Handles](#handles)), spelled snake_case, and every one is optional: absent keeps the object's look as it has always been. `tooltip` is a template over the newest bar (`{{name}}`, `{{name:format}}`, `{{label}}`, `{{value}}`) shown when the cursor rests on the object. A `\n` inside a slot's text starts a new line on a label and on box text. Every declared kind at once: a high and a low line, a zone box between them, a three-point path, and a label, all placed from the newest bar, plus a per-bar horizontal level for contrast. The drawings reach two bars ahead by adding two intervals to the bar time. ```typescript param("lookback", 20, { min: 2, max: 500, description: "Bars in the zone's range" }); output("close_line", line, overlay, { color: "#2563eb", width: 2, description: "Close, the anchor line" }); const level = output("level", none, overlay, { description: "The lookback high, a stepping level" }); output("zone_high", none, overlay, { description: "Lookback high: the top line and the box top" }); output("zone_low", none, overlay, { description: "Lookback low: the bottom line and the box bottom" }); output("zone_mid", none, overlay, { description: "The midpoint, the path's middle point" }); output("t0", none, overlay, { description: "This bar's open time, epoch seconds" }); output("t1", none, overlay, { description: "One bar ahead" }); output("t2", none, overlay, { description: "Two bars ahead" }); string("note", { max_bytes: 32 }); // Per bar: the lookback high carried one bar to the right, chaining into a stepped level line. segment("level_line", { yFrom: level, yTo: level, from: 0, to: 1, color: "#94a3b8", width: 1, lineStyle: "dashed" }); // Run level, placed from the newest bar: two lines, a box, a path, and a label. draw.line("top_line", { x1: "t0", y1: "zone_high", x2: "t2", y2: "zone_high", color: "#2563eb", width: 2 }); draw.line("bottom_line", { x1: "t0", y1: "zone_low", x2: "t2", y2: "zone_low", color: "#dc2626", width: 2 }); draw.box("zone", { left: "t0", top: "zone_high", right: "t2", bottom: "zone_low", color: "#dbeafe" }); draw.polyline("path", { points: ["t0", "zone_low", "t1", "zone_mid", "t2", "zone_high"], color: "#f97316", width: 2 }); draw.label("last", { x: "t1", y: "close_line", text: "note", color: "#111827" }); const MAX_BARS = 500; const highs = new StaticArray(MAX_BARS); const lows = new StaticArray(MAX_BARS); let n: i32 = 20; let cursor: i32 = 0; let count: i32 = 0; let t: f64 = NaN; let prevT: f64 = NaN; let intervalSec: f64 = NaN; function onStart(): void { n = i32(p_lookback()); } function onBar(): void { const close = bar.close(); prevT = t; t = bar.time(); // The interval is the spacing between consecutive bar opens; NaN on the first bar. if (!isNaN(prevT)) intervalSec = t - prevT; highs[cursor] = bar.high(); lows[cursor] = bar.low(); cursor = (cursor + 1) % n; if (count < n) count += 1; if (count < n || isNaN(intervalSec)) return; let hi = -Infinity; let lo = Infinity; for (let i = 0; i < n; i++) { if (highs[i] > hi) hi = highs[i]; if (lows[i] < lo) lo = lows[i]; } out_close_line(close); out_level(hi); out_zone_high(hi); out_zone_low(lo); out_zone_mid((hi + lo) / 2.0); out_t0(t); out_t1(t + intervalSec); out_t2(t + 2.0 * intervalSec); sb_clear(); sb_text("last "); sb_f64(close, 2); str_note_sb(); } ``` After this runs you see one stepped level line (per bar), two lines, one box, one path, and one label (all from the newest bar). A declared drawing that should be REMOVED emits `NaN` through one of its coordinate outputs on the newest bar; a handle, below, has a `delete()`. ## Handles A handle is an object the module owns: it creates it, keeps its identity from bar to bar, moves or restyles it later, and deletes it. Four kinds: `line`, `box`, `label`, `polyline`. **Declare the kinds you draw** at the top of the file, once per kind, with the defaults every new handle of that kind starts from: `handles.line({ panel, color, width, line_style, extend })`, `handles.box({ panel, color, border_color, opacity, border_width })`, `handles.label({ text, panel, color, size, align })`, and `handles.polyline({ panel, color, width, line_style })`. There is no name (handles are ids the module picks) and every option is optional; `handles.box()` enables boxes with the defaults. A label's `text` names the string slot its text is read from (a label that only draws a mark needs none). A label's `align` (`left`, `center` or `right`) is the text edge that sits on its x; omitted centres the text, and (x, y) stays the point either way. The legacy spellings `borderColor`, `borderWidth` and `lineStyle` still work beside the snake_case ones (both spellings of one key at once are refused), and every look below is a default here too: `glow`, `glow_color`, `sticky_right`, `axis_label`, `arrow`, `opacity` on a line; `border_style`, `corner_radius`, `extend`, `shape`, `gradient`, `gradient_direction`, `text_color`, `font_size`, `font_weight`, `font_family`, `align`, `valign`, `padding` on a box; `style`, `background_color`, `border_color`, `border_width`, `corner_radius`, `font_weight`, `font_family`, `valign`, `padding`, `max_width`, `angle`, `emblem_shape`, `emblem_color` on a label; `fill_color`, `closed`, `smooth` on a polyline. `safe_area: true` on any kind keeps its pane-anchored handles clear of the chart's chrome (the legend, the pane action bar, the price-axis tags). Declaring any kind, or calling `bar.isLast()`, switches the derived sheet to the third runtime contract (`abi_version: "wrun-3"`); the numeric outputs compute exactly as before. **Make the objects once**, at module level or in `onStart()`: `draw.line(id)`, `draw.box(id)`, `draw.label(id)`, `draw.polyline(id)`. An id is any integer from `0` up, in ONE space across the four kinds: while a box holds id `3`, a label cannot. An object per bar would allocate per bar, which the module never does. **Draw in `onBar()`**, beside the outputs. The rules, each refused by name when broken: | Call | On an id nobody holds | On a live id of the same kind | | --- | --- | --- | | `set(...)` (`setPoints` on a polyline) | creates the handle: `createdBar` is this bar, the style is the kind's defaults | moves it: this bar becomes its `mutatedBar` | | the partial setters `setXy1`, `setXy2`, `setLeftTop`, `setRightBottom` | refused (nothing to remember the other corner from) | moves one end; the object re-sends the remembered rest | | `color`, `fill`, `fillTo`, `width`, `style`, `extend`, `opacity`, `size`, `border`, `zorder` and every other look setter below | refused (the handle does not exist) | restyles it; this bar becomes its `mutatedBar` | | `delete()` | a no-op | removes it; the id is free for a later bar (a new handle, a new creation bar) | | `set(...)` on an id a handle of ANOTHER kind holds | | refused: delete that handle first | Coordinates are absolute: `x` in epoch seconds (`bar.time()`), `y` in price. Nothing clamps: a box that should end one bar past the newest bar sets its right edge to `t + interval`, and the chart draws it there. Every coordinate must be finite. Colors on a setter are `rgba(r, g, b, a)` values (`rgb(r, g, b)` for opaque) or a theme token from `./sdk/color` (`theme.UP`, `theme.DOWN`, `theme.TEXT`, `theme.MUTED`, `theme.BG`, `theme.GRID`, `theme.ACCENT`, with `alpha(theme.BG, 0.85)` for a translucent one), which the chart resolves when it paints ([Colors kit](../functions/colors-kit.md#theme-colours)); `style` on a line takes `LineStyle.Solid`, `Dashed`, `Dotted`; `extend` takes `Extend.None`, `Left`, `Right`, `Both`. The core setters by kind: `width` and `style` on lines and polylines, `extend` and `fillTo` on lines, `border` and `fill` on boxes, `size` and `fill` on labels, `opacity` and `zorder` on all four kinds. A `width` of `0.5` draws a hairline, and a polyline sent one point draws a dot. A label's text comes from a string slot: build the line with `sb_*`, then `tag.set(x, y).text(str__sb)` sends the slot and draws the label with the bytes the slot holds on that bar (a `\n` in the text starts a new line). Call it again on a later bar to move or re-word the label. `tag.align(ALIGN_RIGHT)` (or `style.align(tag, ALIGN_RIGHT)`) makes the text end at x instead of centring on it; `ALIGN_LEFT` starts it there and `ALIGN_DEFAULT` restores centred text, clearing a declared `align` default. `tag.set(x, y).mark()` draws a label with no text (no slot is read): with `style(LabelStyle.Emblem)` and an `emblem(...)` it is a dot, a square, a diamond or a triangle, `size` pixels across, at (x, y). ### Every look a handle takes Each setter below restyles a live handle like `color` does (a setter on an id nobody holds is refused). Word arguments are the enums of `./gen/draw`: `Arrow { None, Start, End, Both }`, `AxisLabel { None, Price, Text }`, `BoxShape { Default, Rect, Ellipse }`, `GradientDirection { None, Vertical, Horizontal }`, `FontWeight { Default, Normal, Medium, Bold }`, `FontFamily { Default, Ui, Mono, Serif, Rounded }`, `LabelStyle { Default, Box, Plain, Knockout, Emblem, Pill, Badge, Callout }`, `Emblem { Default, Dot, Square, Diamond, TriangleUp, TriangleDown }`, and the `VALIGN_DEFAULT`, `VALIGN_TOP`, `VALIGN_MIDDLE`, `VALIGN_BOTTOM` constants beside `ALIGN_*`. A `Default` word clears the setter back to the declared default. | Setter | Kinds | What it does | | --- | --- | --- | | `glow(px)`, `glowColor(c)` | line, box, label, polyline | a halo of 0..32 px around the stroke, border or text, in `glowColor` (default the object's own colour); `glow(0)` removes it | | `opacity(f)`, `zorder(n)` | line, box, label, polyline | the object's opacity 0..1 (a box's fill opacity) and its paint order | | `tooltip(send)`, `tooltipString(s, send)`, `clearTooltip()` | line, box, label, polyline | the tooltip text from a string slot sender (`tooltipString` sends `s` through it first), read on this bar; `clearTooltip()` removes it | | `arrow(Arrow)` | line, polyline | an arrowhead at the start, the end or both (`Arrow.None` removes them) | | `stickyRight(on)` | line, label | the object keeps its right end (a line) or its point (a label) beside the price axis as the chart scrolls | | `axisLabel(AxisLabel)` | line, label | a pill on the price axis at the line's right endpoint price (`Price`) or the label's y (`Price`, or `Text` for the label's first line) | | `fitTo(group)`, `padding(px)` | box | the box sizes itself to the union of every label in `group` (1..65535), grown by `padding` (0..64, default 6); `fitTo(0)` releases it; anchor and extend are ignored while fitted | | `borderStyle(LineStyle)`, `cornerRadius(px)`, `extend(Extend)`, `shape(BoxShape)` | box | a dashed or dotted border, rounded corners (0..32), the box stretched to the pane's left or right edge, or the ellipse inscribed in the box | | `gradient(start, end, GradientDirection)`, `clearGradient()` | box | a two-colour fill laid top to bottom or left to right in place of the flat fill | | `text(send)`, `textString(s, send)`, `clearText()`, `textColor(c)`, `fontSize(px)`, `align(i32)`, `valign(i32)`, `padding(px)` | box | text inside the box from a string slot, in `textColor` at `fontSize` (6..64, default 12), aligned by `ALIGN_*` and `VALIGN_*` inside `padding` | | `fontWeight(FontWeight)`, `fontFamily(FontFamily)` | label, box | the text's weight and family (`Ui` is the app font, `Mono`, `Serif` and `Rounded` system stacks) | | `style(LabelStyle)` | label | the label's look: `Box` (the default rounded box), `Plain` (text alone), `Knockout` (a box in the background colour that hides what is behind the text), `Emblem` (a mark before the text), `Pill`, `Badge`, `Callout` | | `emblem(Emblem)`, `emblemColor(c)` | label | the emblem's shape (`TriangleDown` points down) and colour (default the text colour) | | `border(c)`, `borderWidth(px)`, `cornerRadius(px)` | label | the box's border colour and width (0..10) and its corner radius (0..32, default 4) | | `valign(i32)`, `padding(px)`, `maxWidth(px)`, `angle(deg)` | label | the box placed above, on or below y; its padding (0..64, default 6); text wrapped at `maxWidth` (0..2000, 0 = no wrap); the whole label rotated -90..90 degrees about (x, y) | | `group(id)`, `mark()` | label | the group a `fitTo` box measures (0 = none); a label with no text | | `fill(c)`, `closed(on)`, `smooth(on)` | polyline | the polygon fill colour; the path closed back to its first point (and filled when `fill` is set); the path curved through its points | `line.extend(Extend.Right).fillTo(otherId, c)` keeps working: the shading between two lines follows their extensions. Two chart-level flags belong beside the handle looks: `chart.contrast_guard(false)` keeps the author's colours as written on a light chart, and `chart.stack_handles(true)` stacks this indicator's corner-anchored groups below other indicators' groups at the same corner instead of overprinting them ([Styling](styling.md#theme-colours)). Both flags are statements beside the declarations they govern. Readouts pinned to the top-right corner as knockout labels (a theme-background box behind the text), kept in the author's colours on a light chart and stacked under other indicators' corner groups instead of over them: ```typescript string("tag", { max_bytes: 16 }); handles.label({ text: "tag", anchor: "top_right", color: "#fde68a", size: 11, style: "knockout", padding: 6 }); chart.contrast_guard(false); chart.stack_handles(true); ``` Every look in the table is also a default on a `handles.` declaration and a key on a declared drawing, spelled snake_case. Swing tags on a busy chart: the emblem look puts a diamond before the text, the box sits above its point inside 6 px of padding, and a long note wraps at 120 px instead of running across the candles. ```typescript string("tag", { max_bytes: 24 }); handles.label({ text: "tag", color: "#e5e7eb", size: 11, style: "emblem", emblem_shape: "diamond", emblem_color: "#f59e0b", valign: "top", padding: 6, max_width: 120 }); ``` A level the viewer should never lose: the line's right end rides beside the price axis as the chart scrolls, its price pilled onto the axis, under a 4 px halo in its own colour. ```typescript handles.line({ color: "#38bdf8", width: 1, line_style: "dashed", sticky_right: true, axis_label: "price", glow: 4, glow_color: "#38bdf8" }); ``` A zone that explains itself: a declared box with its note printed inside, a dashed border in the fill's colour and the text in the top-left corner inside 8 px of padding. ```typescript output("t0", none, overlay, { description: "The zone's first bar, epoch seconds" }); output("t1", none, overlay, { description: "Two bars past the newest bar" }); output("zone_hi", none, overlay, { description: "The zone's top" }); output("zone_lo", none, overlay, { description: "The zone's bottom" }); string("note", { max_bytes: 32 }); draw.box("supply", { left: "t0", top: "zone_hi", right: "t1", bottom: "zone_lo", color: "#ef4444", border_color: "#ef4444", border_style: "dashed", text: "note", text_color: "#fecaca", align: "left", valign: "top", padding: 8 }); ``` A pattern outline that reads as a shape rather than a line: a closed, curved path filled at 20% in its own colour, the stroke under a 6 px halo. ```typescript output("t0", none, overlay, { description: "This bar's open time, epoch seconds" }); output("t1", none, overlay, { description: "One bar ahead" }); output("t2", none, overlay, { description: "Two bars ahead" }); output("lo", none, overlay, { description: "The pattern's low" }); output("mid", none, overlay, { description: "The pattern's midpoint" }); output("hi", none, overlay, { description: "The pattern's high" }); draw.polyline("wedge", { points: ["t0", "lo", "t1", "mid", "t2", "hi"], color: "#f97316", width: 2, fill_color: "#f9731633", closed: true, smooth: true, glow: 6, glow_color: "#f97316" }); ``` A measured move: a dotted arrow from the breakout to its target, drawn half transparent so it reads as a projection, with the target's caption standing upright beside the arrowhead, the text ending at the point. ```typescript output("t0", none, overlay, { description: "The breakout bar, epoch seconds" }); output("t1", none, overlay, { description: "The target bar, ahead of the newest bar" }); output("y0", none, overlay, { description: "The breakout price" }); output("y1", none, overlay, { description: "The target price" }); string("target", { max_bytes: 24 }); draw.line("move", { x1: "t0", y1: "y0", x2: "t1", y2: "y1", color: "#a78bfa", width: 2, line_style: "dotted", arrow: "end", opacity: 0.6 }); draw.label("target_tag", { x: "t1", y: "y1", text: "target", color: "#a78bfa", style: "plain", angle: 90, align: "right" }); ``` `render.text` and `render.label` take the label words too (`style`, `emblem_shape`, `emblem_color`, and `align` and `valign` on plain text) plus three ladders of their own, each naming a declared numeric output: `background_color_by` with `background_colors` picks the tag's fill per bar, `color_packed_by` reads a packed text colour per bar, `size_by` the text size per bar. A tag to the right of every bar whose slot was written, its fill from a regime ladder, inked and sized per bar: ```typescript output("close_line", line, overlay, { color: "#2563eb", width: 2, description: "Close, the tag's anchor" }); output("regime", none, overlay, { description: "0 quiet, 1 trending, 2 stretched: picks the tag's fill" }); output("ink", none, overlay, { description: "The text colour per bar, packed with toPacked()" }); output("strength", none, overlay, { description: "The text size per bar, 8..14 px" }); string("readout", { max_bytes: 16 }); render.text("state_tag", { y: "close_line", text: "readout", style: "box", label_position: "right", background_color_by: "regime", background_colors: ["#334155", "#1d4ed8", "#b91c1c"], color_packed_by: "ink", size_by: "strength" }); ``` Session zones: a box that grows with every bar of its session, a label on it, four zones kept on the chart with the oldest deleted to make room, and the open zone stretched one bar past the newest bar: ```typescript param("bars", 12, { min: 2, max: 500, description: "Bars per zone" }); input("high", ohlcv.high); output("zone_hi", line, overlay, { color: "#38bdf8", description: "The open zone's high so far" }); output("zone_lo", line, overlay, { color: "#38bdf8", description: "The open zone's low so far" }); string("tag", { max_bytes: 16 }); // Enable box and label handles; every new box and label starts from these defaults. handles.box({ color: "#38bdf8", opacity: 0.2, borderWidth: 1 }); handles.label({ text: "tag", color: "#e5e7eb", size: 11 }); // Four zones stay on the chart. Handle objects are made once; ids are one space across kinds. const KEEP = 4; const zones: BoxHandle[] = [draw.box(0), draw.box(1), draw.box(2), draw.box(3)]; const tags: LabelHandle[] = [draw.label(4), draw.label(5), draw.label(6), draw.label(7)]; let bars: i32 = 12; let count: i32 = 0; let slot: i32 = 0; let serial: i32 = 0; let t: f64 = NaN; let prevT: f64 = NaN; let start: f64 = NaN; let hi: f64 = NaN; let lo: f64 = NaN; function onStart(): void { bars = i32(p_bars()); } function onBar(): void { prevT = t; t = bar.time(); if (count == 0) { start = t; hi = bar.high(); lo = bar.low(); } else { hi = Math.max(hi, bar.high()); lo = Math.min(lo, bar.low()); } count += 1; out_zone_hi(hi); out_zone_lo(lo); const zone = zones[slot]; const tag = tags[slot]; if (count == 1) { // The zone's first bar: set(...) on an id nobody holds creates the box; fill(...) tints it. serial += 1; zone.set(start, hi, t, lo).fill(rgba(56, 189, 248, 51)); } else { // Every later bar grows the same box: the id is what makes it the same box. zone.setLeftTop(start, hi).setRightBottom(t, lo); } sb_clear(); sb_text("zone "); sb_int(serial); tag.set(start, hi).text(str_tag_sb); // On the newest bar only, stretch the open zone one bar past the loaded range. if (bar.isLast() && !isNaN(prevT)) zone.setRightBottom(t + (t - prevT), lo); if (count >= bars) { // The zone is complete: move to the next slot and delete the zone that slot held. count = 0; slot = (slot + 1) % KEEP; zones[slot].delete(); tags[slot].delete(); } } ``` Run over thirty hourly bars with twelve bars per zone, this leaves three boxes and three labels: the first created on bar 0 and last moved on bar 11, the second on bars 12 and 23, the open one created on bar 24, moved on bar 29, and reaching one hour past bar 29. The `delete()` calls on the first three zone changes hit ids nobody holds and do nothing; the fourth removes the oldest zone, and its id is created again on the next bar with a new creation bar. ### Restyle later A setter on a later bar is a mutation of the same object, and the bar it ran on is recorded as the handle's last-mutation bar. The prior high as a line that extends with every bar, turns red and thick on the bar the close breaks it, stays for a while, and is deleted, freeing the id for the next level: ```typescript param("lookback", 20, { min: 2, max: 200, description: "Bars in the prior high" }); param("hold", 10, { min: 1, max: 100, description: "Bars a broken line stays before it is deleted" }); output("level", line, overlay, { color: "#94a3b8", description: "The prior high the line sits on" }); output("broke", none, overlay, { description: "1 on the bar the close breaks the line" }); handles.line({ color: "#94a3b8", width: 1, lineStyle: "dashed" }); const MAX_LOOKBACK = 200; const highs = new StaticArray(MAX_LOOKBACK); const stop = draw.line(0); let n: i32 = 20; let hold: i32 = 10; let cursor: i32 = 0; let count: i32 = 0; let level: f64 = NaN; let levelT: f64 = NaN; let fresh: bool = false; let heldBars: i32 = -1; function onStart(): void { n = i32(p_lookback()); hold = i32(p_hold()); } function onBar(): void { const t = bar.time(); const close = bar.close(); // The highest high of the previous n bars, this bar excluded. let prior: f64 = NaN; if (count >= n) { prior = -Infinity; for (let i = 0; i < n; i++) if (highs[i] > prior) prior = highs[i]; } highs[cursor] = bar.high(); cursor = (cursor + 1) % n; if (count < n) count += 1; if (isNaN(prior)) return; // A new level while no broken line is being held: the line restarts here. if (heldBars < 0 && prior != level) { level = prior; levelT = t; fresh = true; } out_level(level); if (fresh) { // Create the line (or re-create it on the same id after a delete). stop.set(levelT, level, t, level); fresh = false; } else if (heldBars < 0) { // Extend the same line to this bar: a mutation, so its mutated bar moves with it. stop.setXy2(t, level); } if (heldBars < 0 && close > level) { // The break: recolor and thicken the existing line, then hold it for a while. heldBars = 0; stop.color(rgba(239, 68, 68, 255)).width(2); } else if (heldBars >= 0) { heldBars += 1; if (heldBars >= hold) { // Delete the held line; the next bar creates a fresh one on the same id. stop.delete(); heldBars = -1; level = NaN; } } out_broke(heldBars == 0 ? 1.0 : 0.0); } ``` ### Fill between two lines `line.fillTo(otherId, color)` shades the area between this line and another line handle, the way a cloud fills between its two spans. The color is an `rgba(...)` value whose alpha is the fill's opacity, and `fillTo(-1, color)` removes the fill. The fill follows both lines as they move, so set it once and keep moving the lines; it stops showing when the other line is deleted. Handle coordinates are absolute times, so both lines, and the fill between them, can reach past the newest bar: that is how a cloud projected ahead of price is drawn. ```text const spanA = draw.line(0); // made once, at module level const spanB = draw.line(1); // onBar(), under bar.isLast(): spanA.set(t, a0, tAhead, a1); spanB.set(t, b0, tAhead, b1); spanA.fillTo(1, rgba(34, 197, 94, 51)); // a green fill, 20% opaque ``` The other line must be live when `fillTo` runs. A fill toward the line's own id, toward an id no handle holds, or toward a box, label or polyline is refused by name, and `fillTo` exists on lines only. When either line is extended (`extend(Extend.Right)`, say), the shading follows the extended segments to the canvas edge instead of stopping at the drawn points. ### Paths built point by point A polyline handle takes its points from a buffer the module owns: `x0, y0, x1, y1, ...` in a `StaticArray`, and `setPoints(points, count)` sends the first `count` pairs (1..100,000; a run's live polylines hold 524,288 points in all). Re-send as the path grows; the host copies the points, so the buffer is yours to shift. `closed(true)` joins the last point back to the first, `fill(c)` fills the polygon under the stroke, `smooth(true)` curves the path through its points, and `arrow(Arrow.End)` puts a head on the last segment; a path sent with one point draws a dot. A zigzag through the last twelve confirmed swings: ```typescript param("strength", 3, { min: 1, max: 20, description: "Bars on each side that confirm a swing" }); input("high", ohlcv.high); output("swing", none, overlay, { description: "The newest confirmed swing price" }); handles.polyline({ color: "#f97316", width: 2 }); const KEEP = 12; const MAX_STRENGTH = 20; const WINDOW = 2 * MAX_STRENGTH + 1; const highs = new StaticArray(WINDOW); const lows = new StaticArray(WINDOW); const times = new StaticArray(WINDOW); // The path's points, x0, y0, x1, y1, ... in one buffer the host reads count pairs from. const points = new StaticArray(2 * KEEP); const path = draw.polyline(0); let k: i32 = 3; let window: i32 = 7; let cursor: i32 = 0; let filled: i32 = 0; let stored: i32 = 0; let lastDir: i32 = 0; let swing: f64 = NaN; let changed: bool = false; function append(x: f64, y: f64): void { if (stored == KEEP) { // Full: drop the oldest point, keep the newest KEEP - 1. for (let i = 0; i < 2 * (KEEP - 1); i++) points[i] = points[i + 2]; stored -= 1; } points[2 * stored] = x; points[2 * stored + 1] = y; stored += 1; changed = true; } function onStart(): void { k = i32(p_strength()); window = 2 * k + 1; } function onBar(): void { highs[cursor] = bar.high(); lows[cursor] = bar.low(); times[cursor] = bar.time(); cursor = (cursor + 1) % window; if (filled < window) filled += 1; changed = false; if (filled < window) return; // The candidate is the bar k bars back, the center of the window. const center = (cursor + k) % window; let isHigh = true; let isLow = true; for (let i = 0; i < window; i++) { if (i == center) continue; if (highs[i] >= highs[center]) isHigh = false; if (lows[i] <= lows[center]) isLow = false; } // Swings alternate: a high after a high is skipped. if (isHigh && lastDir != 1) { lastDir = 1; swing = highs[center]; append(times[center], swing); } else if (isLow && lastDir != -1) { lastDir = -1; swing = lows[center]; append(times[center], swing); } out_swing(swing); // A new swing: re-emit the whole path under the same id. if (changed && stored >= 2) path.setPoints(points, stored); } ``` ### Declare every kind you draw **Run** records the `handles.*` declarations in the sheet it derives as a `handles` map, one entry per kind with the defaults every new handle of that kind starts from, under their snake_case names. A kind you did not declare refuses its draw calls by name when the indicator runs, so an indicator that draws boxes and labels declares exactly those two, and a `handles.label` whose labels carry text needs a `string(...)` declaration. Field ranges are on the [Limits](../reference/limits.md) page. ### What the chart receives After a run the chart holds one record per LIVE handle, in creation order: its kind, the bar it was created on, the bar it was last mutated on, and its final geometry and style (`createdBar`, `mutatedBar`, `props`). A deleted handle is simply absent. On the live chart the newest bar is re-evaluated as updates arrive (coalesced, at most about once a second): the chart puts the module AND its handles back to the state after the last closed bar and re-runs the bar, so an update never stacks a second copy of what the forming bar drew; when the bar closes, the chart re-runs it once more as a closed bar (with `bar.isLast()` false) before the new bar starts, so a chart that has been open all day shows exactly what a fresh load shows ([Execution model](../core-concepts/execution-model.md)). ## Limits | Family | Cap | | --- | --- | | `box` declarations | 16 per indicator, each drawn once per bar | | `segment` declarations | 16 per indicator, each drawn once per bar | | renderers | 64 per indicator | | declared drawings | 64 per indicator; a declared polyline of at most 64 points | | live handles | 500 per kind, 1500 in total; a polyline handle of at most 100,000 points and 524,288 across the live polylines; 4096 draw calls per bar | | drawings on the chart | 2,000 per run; a run that would draw more keeps the newest 2,000, and the indicator's legend row says "Drawings capped" | | string slots | 64, each at most 4096 bytes; 64 KiB of strings per row, 2 MiB per run for strings and frames together | | bar offsets | literals in -500..500, whole or fractional; a box or segment offset clamps to the loaded range; a handle's absolute coordinates never clamp | Handles run under the 500-per-kind ceiling, so a module that keeps drawing deletes stale objects to stay under it; a tracker that evicts its oldest zone with `delete()` stays under it by construction. The caps on declared shapes are on declarations, not objects: a repeating pattern is one `box` gated per bar, or one handle per live occurrence. ## What you cannot do - Move a declared shape later: only the forming bar is re-evaluated, where the chart replaces that one bar's shapes on each live update, never stacks them. Anything that must move, restyle, or vanish on a later bar is a handle. - Draw an unbounded number of things. The caps above are the whole budget; a handle-drawing indicator deletes what it no longer needs. - Reach more than 500 bars away with a literal offset on a box or segment; pass an output handle for a data-driven reach, and it still clamps to the loaded range. A handle takes an absolute time instead and never clamps. - Fill a box with a named color: the fill takes the opacity, so `color` must be hex, `rgb()`, `hsl()` or a theme token. - Reuse a name across families: outputs, boxes, segments, renderers, and declared drawings share one namespace (handles are ids and have no name). - Let an output color, widen, or gate itself. - Put text in an output, or a string in a param. - Draw from `onStart()`: handle calls belong in `onBar()`, beside the outputs. - Pin a label to the pane's right edge by coordinates alone: a line handle extends past its points with `extend(Extend.Right)`, a line or a label keeps beside the price axis with `stickyRight(true)` (or `sticky_right` on the declaration), and otherwise a label sits where its coordinates say unless it takes a pane `anchor` ([Cards, frames and panels](cards-frames-panels.md#pane-pixel-placement)). - Draw a composite tool (a retracement ladder, a long-and-short box, a rotated text card) in one call: build it from handles, extended lines with `fillTo`, boxes with text, `extend` and a gradient, labels with the `Callout`, `Knockout` and `Plain` looks and an `angle`. # Cards, frames and panels Tables, cards, panels and widgets show one snapshot of your indicator rather than one value per bar: a styled table in a corner, a status card, a small chart in its own pane, a ladder, a feed or a meter. Each is a declaration over outputs, string slots or frames the module already writes, and each selects one snapshot per run: levels docked on the price axis, a heatmap, a table or tiles in their own pane below the chart, and the widgets on the price pane. The per-bar vocabulary lives on other pages: marks, tints and fills on [Plotting](plotting.md), coordinate drawings and handles on [Drawing objects](drawing-objects.md), the canvases drawn from cells (heatmaps, footprints, letter profiles, strike matrices) on [Price canvases](price-canvases.md). Every colour word on this page takes `"#rrggbb"`, `"#rrggbbaa"` or a theme token (`theme.up`, `theme.down`, `theme.text`, `theme.muted`, `theme.bg`, `theme.grid`, `theme.accent`); a token follows the chart's theme and re-resolves on a theme switch without a rerun. A number prints through one `format` word (`price`, `%`, `si`, `int`, `0`, `0.0`, `0.00`, `0.000`, `usd`, `auto`) with `decimals` (0..8), `signed` and `unit` (0..8 characters) beside it ([Styling](styling.md)). ## Marks, tints and fills The three per-bar visuals that sit beside a card, in one line each: - A shaped mark on some bars is `render.shape(name, { output, shape, where?, color?, color_by?, colors?, width?, location?, glow?, fill?, fill_opacity?, char?, font_family?, tooltip? })`, one of ten shapes (`circle`, `cross`, `triangle_up`, `triangle_down` with its apex down, `diamond`, `arrow_up`, `arrow_down`, `flag`, `square`, and `char`, one character in `font_family`) drawn at the output's value where the gate is nonzero, so the mark sits where the output points (`low` for below the bar, `high` for above, a level for an absolute price), or where `location` puts it (`"above_bar"`, `"below_bar"`, `"top"`, `"bottom"`) without a second output; `width` (px, any positive number) sizes it, `glow` halos it, `fill: false` keeps the outline only, `fill_opacity` fades the interior and `tooltip` is a template shown on the mark: [Plotting](plotting.md#text-labels-tables-strips-tints). - A tinted candle is `render.barcolor(name, { where, color?, color_by?, colors? })`, body and wick, and a tint behind the bar is `render.bgcolor` with the same options; the two stack (a regime background under trend-colored candles). With `width` (0.5..10 px) and `line_style` a `bgcolor` draws one vertical line per gated bar instead of a band: [Plotting](plotting.md#text-labels-tables-strips-tints). - A fill between two lines is a `range()` over two drawn outputs (`edge_width: 0` keeps the band and drops the edge lines), a `fill("upper", "lower", { color?, opacity?, color_by?, colors?, color_packed_by?, z? })` that tints the interior only on one pane, or a one-bar `box` on every bar, gated by `when` for a conditional fill: [Plotting](plotting.md#lines-areas-columns-dots-marks), [Styling](styling.md) and [Drawing objects](drawing-objects.md#boxes). ## Styled tables `render.table(name, { rows, cols, cells, position?, ...look, styles? })` draws a grid of words in a corner of the chart, and you style it in the same declaration: column widths, fills with opacity and gradients, a frame apart from the lines between cells, bold type, merged cells and rounded corners. Each cell is a string slot your file writes in `onBar()`, and the table shows the newest bar where every cell was written. ```typescript // A styled board in the top right corner: a title across both columns, then a trend row and a close row. output("close_line", line, overlay, { color: "#94a3b8", description: "The close, drawn so the table has a chart" }); output("trend_state", none); string("title", { max_bytes: 24 }); string("under_title", { max_bytes: 1 }); string("trend_label", { max_bytes: 16 }); string("trend_word", { max_bytes: 16 }); string("close_label", { max_bytes: 16 }); string("close_text", { max_bytes: 32 }); // The title spans both columns of row one, over the cell under_title holds. The label column is held at 90px and // the value column is measured. Dotted lines sit between the rows; a solid, rounded frame sits around the table. render.table("board", { rows: 3, cols: 2, cells: ["title", "under_title", "trend_label", "trend_word", "close_label", "close_text"], position: "top_right", width: 220, column_widths: [90, 0], cell_padding: 6, font_size: 11, align: "right", text_color: "#e2e8f0", background_gradient: ["#0f172a", "#1e293b"], background_opacity: 0.9, border_color: "#475569", border_width: 1, corner_radius: 8, grid_color: "#334155", grid_width: 1, grid_style: "dotted", grid_lines: "rows", styles: [ { cell: "title", colspan: 2, align: "center", font_weight: "bold", gradient: ["#1d4ed8", "#0f172a"], gradient_direction: "horizontal" }, { cell: "trend_label", align: "left" }, { cell: "close_label", align: "left" }, { cell: "trend_word", font_weight: "bold", color_by: "trend_state", colors: ["#7f1d1d", "#14532d"], opacity: 0.5 }, ], }); let average = new Sma(20); function onBar(): void { const close = bar.close(); if (isNaN(close)) return; const mean = average.update(close); const up = !isNaN(mean) && close > mean; out_close_line(close); out_trend_state(up ? 1.0 : 0.0); str_title("Trend board"); // A covered cell is never drawn, but the table waits for a bar that wrote every cell, so write it empty. str_under_title(""); str_trend_label("Trend"); str_trend_word(up ? "up" : "down"); str_close_label("Close"); sb_clear(); sb_auto(close); str_close_text_sb(); } ``` - **The title spans both columns.** `colspan: 2` on the `title` cell covers the cell to its right. That cell keeps a slot of its own, `under_title`, written empty every bar: a covered cell is never drawn, but the table still waits for a bar that wrote all six cells. - **Two gradients.** The table blends top to bottom at 90% opacity; the title cell has its own gradient, left to right. - **A frame and a grid.** `border_*` is the line around the table, here solid with rounded corners. `grid_*` is the lines between cells, here dotted and between rows only. - **A cell that follows the data.** The trend cell's fill is picked by the `trend_state` output every bar: 0 is the first colour, 1 the second. ### Table options Every key is optional except `rows`, `cols` and `cells`. | Key | What it sets | Values | | --- | --- | --- | | `rows`, `cols`, `cells` | the grid, and one slot name per cell, row by row | at most 32 rows and 8 columns; `rows * cols` names | | `position`, `offset` | the corner, and a nudge from it | one of the nine anchors, `top_left` to `bottom_right`; `[x, y]` in px, each within 200 | | `width`, `column_widths` | the table's width, and each column's | px, up to 4096; one width per column, `0` fits the words, and spare width goes to the `0` columns | | `header_rows`, `header_row`, `header_column` | which rows and column are headers | `header_rows` up to `rows`; `header_row` is the one-row switch; `header_column` makes the first column a header | | `font_size`, `font_family`, `font_weight` | the type | 6 to 64 px; a font name; `"normal"` or `"bold"` | | `cell_padding`, `align`, `valign` | the space and the alignment in every cell | 0 to 64 px; `"left"`, `"center"`, `"right"`; `"top"`, `"middle"`, `"bottom"` | | `text_color`, `header_text_color` | the text colour | a colour | | `background_color`, `header_background_color`, `background_opacity` | the fills, and how solid they are | a colour; opacity 0 to 1 | | `background_gradient`, `gradient_direction` | a fill that blends from colour to colour | 2 to 8 colours; `"vertical"` (the default) or `"horizontal"` | | `border_color`, `border_width`, `border_style`, `corner_radius` | the frame around the table | width 0 to 10; `"solid"`, `"dashed"` or `"dotted"`; corners 0 to 32 px | | `grid_color`, `grid_width`, `grid_style`, `grid_lines` | the lines between cells | width 0 to 10; the three styles; `"all"`, `"rows"`, `"cols"` or `"none"` | | `position_by` + `positions`, `font_size_by` | a corner or a type size an output picks per bar | a list of anchors; an output holding the size | | `rows_by` | how many rows paint: a table whose rows come and go | a data-only output; its value on the table's bar, rounded down and held to 0..`rows`, is the count of rows painted from the top, the rest left out | | `styles` | one entry per styled cell, named by its slot | `{ cell, ... }` with the cell keys below | The cell keys, inside a `styles` entry: | Key | What it sets | | --- | --- | | `colspan`, `rowspan` | how many columns to the right, and rows below, the cell covers | | `color`, `opacity` | the cell's fill, and how solid it is | | `gradient`, `gradient_direction` | the cell's own gradient (2 to 8 colours), and its own direction | | `text_color`, `font_size`, `font_weight` | the cell's type | | `align`, `valign` | the cell's alignment | | `tooltip` | the cell's hover text: a string slot, its words on the table's bar | A table sized for its longest form can paint fewer rows, and a cell can explain itself on hover: ```typescript output("rows_shown", none); // 3 while the extra rows are on, else 2 string("label", { max_bytes: 16 }); string("value", { max_bytes: 16 }); string("note", { max_bytes: 96 }); // ... one slot per cell of rows 2 and 3 ... render.table("board", { rows: 3, cols: 2, cells: ["label", "value", "a2", "b2", "a3", "b3"], rows_by: "rows_shown", styles: [{ cell: "value", tooltip: "note" }] }); ``` ### Colours that follow the data Every colour key takes a ladder, the way `render.bgcolor` does. Name an output with `_by`, and its value on the table's bar picks an entry of `s`: `color_by: "trend_state", colors: ["#7f1d1d", "#14532d"]`. The value is rounded down, a value outside the list picks the first entry, and `NaN` keeps the static colour. An output that holds a packed colour is named with `_packed_by` instead. A colour reads one ladder, never both. Colours in a table are literals. A `param.color` paints an output, a renderer's static colour or a legend entry, never a table. A second board, with a header row and a tinted trend cell, in one literal: ```typescript output("close_line", line, overlay, { color: "#94a3b8", description: "The close, drawn so the table has a chart" }); output("trend_state", none); string("tf_label", { max_bytes: 16 }); string("trend_word", { max_bytes: 16 }); string("close_label", { max_bytes: 16 }); string("close_text", { max_bytes: 32 }); // A 2 x 2 board: a bold label column held at 80px, numbers right aligned, hairline row separators, // and the trend cell tinted by the trend output every bar (index 0 = down, 1 = up). render.table("board", { rows: 2, cols: 2, cells: ["tf_label", "trend_word", "close_label", "close_text"], position: "top_right", width: 220, column_widths: [80, 0], header_rows: 1, cell_padding: 5, font_size: 11, align: "right", background_color: "#0f172a", background_opacity: 0.92, text_color: "#e2e8f0", header_background_color: "#1e293b", border_color: "#334155", border_width: 1, grid_color: "#1e293b", grid_width: 1, grid_lines: "rows", styles: [{ cell: "tf_label", align: "left", font_weight: "bold" }, { cell: "close_label", align: "left" }, { cell: "trend_word", color_by: "trend_state", colors: ["#7f1d1d", "#14532d"], opacity: 0.35 }] }); let average = new Sma(20); function onBar(): void { const close = bar.close(); if (isNaN(close)) return; const mean = average.update(close); out_close_line(close); const up = !isNaN(mean) && close > mean; out_trend_state(up ? 1.0 : 0.0); str_tf_label("Trend"); str_trend_word(up ? "up" : "down"); str_close_label("Close"); sb_clear(); sb_auto(close); str_close_text_sb(); } ``` ### Limits and refusals | Rule | What the build says | | --- | --- | | one width per column | `render.table 'board' column_widths lists 1 widths for 2 columns (one per column; 0 = measured)` | | a span stays inside the grid | `table 'board' cell 'title' spans 3 columns from column 1 of 2` | | a slot that spans must fit at every cell that lists it, so a covered cell needs its own slot | `table 'board' cell 'title' spans 2 columns from column 2 of 2` | | a style names one of the table's cells | `render.table 'board' styles names cell 'ghost', which is not one of its cells (title, under_title, trend_label, trend_word, close_label, close_text)` | | `rows_by` names a data-only output | `render.table 'board' rows_by names 'close_line', which is a drawn output; rows_by takes a data-only output (plot none) holding how many rows paint` | | a cell's `tooltip` names a string slot | `render.table 'board' styles cell 'title' tooltip names 'ghost', which is not a declared string slot (the cell's hover text is the slot's text)` | | a ladder comes as a pair | `table 'board' cell 'trend_word' declares color_by without colors (both or neither)` | | colours are literals | `render.table 'board' text_color references a param, but a param paints an output, a renderer's static colour or a legend entry, never a table; write a colour literal here` | | a gradient has 2 to 8 colours | `gradient must list at least 2 color stops` | | opacity is 0 to 1 | `opacity must be <= 1` | | corners are 0 to 32 px | `corner_radius must be <= 32` | | words come from the list | `option 'grid_lines' takes "all", "rows", "cols", "none" (a string literal), not 'diagonal'` | The words in a cell are plain text: never a link, never markup. A table declared without the look keys draws the chart's plain table. The chart draws tables with its own table engine, so a styled table shows in screenshots like every other plot. For inline bars and sparklines in a table under the chart, declare a `panel.table` over a frame instead (Frames, panels and compact widgets, below); it takes the same look words, and up to 128 rows by 12 columns. ## Status cards `draw.card(name, { title, anchor?, offset?, z?, state_by?, rows, headline?, ...look })` declares a run-level status card in `drawings[]`, available from `abi_version: "wrun-2"`. Names share the output, renderer, drawing, box and segment namespace. A sheet accepts at most 8 cards within its 64-drawing limit; each card has 1 to 12 rows, or 0 to 12 when it has a headline. A pane holds 32 cards, feeds and meters across every indicator. `title` takes 1 to 40 characters, 80 when it holds a `{{template}}`; each row's `label` takes 1 to 24, 64 with a template. A template reads a declared output as `{{name}}` (its declared format, else six significant digits) or `{{name:format}}`, a string slot as `{{name}}`, and the chart's names as `{{symbol}}`, `{{exchange}}` and `{{timeframe}}`; an unknown name stays as written, and a templated output joins the card's complete-row rule below. `anchor` uses the same nine positions as label renderers: `top_left`, `top_center`, `top_right`, `middle_left`, `middle_center`, `middle_right`, `bottom_left`, `bottom_center`, `bottom_right`. Defaults are `top_right`, `offset: [0, 0]` and `z: 0`. Each offset is an integer from -4096 to 4096; `z` is an integer. Each row has an optional `value`: a literal string of 0 to 64 characters, `{ text: "slot_name" }`, or `{ output: "output_name", format?, decimals?, signed?, unit? }`. Output references always name declared numeric outputs; `text` always names a declared string slot. Slot contents retain their declared byte limit. The format words are the shared ten plus `pct` (the value times 100, two decimals and `%`); without one a value prints at six significant digits. `usd` prints a `$` with two decimals under 1,000 and `$1.2K`, `$1.2M`, `$1.2B`, `$1.2T` above; `auto` prints six significant digits with `,` thousands; `price` prints at the chart's price digits; `decimals` overrides the word's digits, `signed` puts a `+` on positives, `unit` is appended as written. A row that declares `usd`, `auto`, `int` or `pct` with no `decimals`, `signed` or `unit` keeps the text it always printed (`$1234567.00`, six significant digits with no thousands separators, the rounded integer, the value times 100 at two decimals), so an older card reads exactly as before; the shared text above applies to the other words and to any row that declares a companion. A row's `color` is a colour word or an object containing both `color_by` and `colors`, with at least two colour words. The output's floored value picks a palette entry; a finite index outside the palette selects entry 0. `countdown_to: { output: "deadline" }` carries the numeric output as raw epoch milliseconds, without converting it to seconds. `clock: true` asks the chart to show its clock. Both may appear beside a value. A `headline` is one big number above the rows: `{ output | text, format?, decimals?, signed?, unit?, color?, font_size?, font_weight?, align? }`, exactly one of `output` or `text`, the format and colour words as a row's, `font_size` 6..64 (default 24), `font_weight` `"bold"` unless declared, `align` `"left"`, `"center"` or `"right"`. `rule` draws a hairline between the headline and the rows (default on when both exist) in `rule_color` (default the label ink at 0.4 alpha). The look, every key optional, absent keeping today's card: | Keys | What they set | | --- | --- | | `chrome` | `"state"` (the default: the state word, the stripe, the border in the state colour) or `"plain"` (no state word, no stripe, no border, a muted title, controls on hover); any key below declared beside it wins | | `show_state`, `state_colors`, `stripe`, `controls` | the state word; four colours for ok, armed, fired and error (exclusive with `accent_color`); the 3 px stripe; the hide and collapse glyphs `"always"`, `"hover"` or `"none"` (with `"none"` the glyphs stay hidden at rest, and hovering the card still shows them, so a viewer can always hide or collapse it) | | `accent_color`, `title_text_color`, `title_font_size`, `title_font_weight` | the accent (default the state ink), the title's ink (default the accent), size and weight | | `background_color`, `background_opacity`, `background_gradient`, `gradient_direction` | the surface (default the theme card surface), its alpha (0..1), 2..8 stops `"vertical"` or `"horizontal"` | | `border_color`, `border_width`, `border_style`, `corner_radius`, `padding` | the border (default the accent, 0..10 px, `"solid"`, `"dashed"` or `"dotted"`), the corners (0..32 px, default 6), the inset (0..24 px, default 6) | | `width`, `opacity` | a fixed width (80..1200 px; absent measures the text under a 40% cap, a declared width drops the cap and clamps to the pane width minus 16), the whole card's alpha | | `font_size`, `font_family`, `font_weight`, `text_color`, `label_text_color` | the type (6..64 px, default 12; `"ui"`, `"mono"`, `"serif"` or `"rounded"`; `"normal"`, `"medium"` or `"bold"`), the value ink (default the theme text) and the label ink (default the theme muted ink) | | `above_drawings` | paint over every handle and user drawing, still under the legend (default under them) | | `safe_area` | start the offset past the chart's own chrome: the legend stack at `top_left`, the pane action bar at `top_center` and `top_right`, the price-axis tags on the right anchors (default the pane's 8 px edge) | | `panel` | `"overlay"` (the price pane) or `"lower"` (the indicator's own lower pane; an indicator with none keeps the card on price) | The newest ready bar with every referenced numeric output finite and every referenced slot present wins. Empty strings count as present. If no bar is complete, the newest ready bar still supplies the card: missing values become `""`, missing colours and countdowns are omitted, and missing state reads `ok`. With no ready bar the selection is `{ kind: "card", name, card: null }`. `state_by` maps exactly `0` to `ok`, `1` to `armed`, `2` to `fired`, and `3` to `error`; other values read `ok`. `rows` stays a literal object array, including the nested value, colour and countdown objects. References are literal names, never handles; variables, spreads and expressions are refused. **Run** reads these options and removes the declaration before AssemblyScript typechecks the file; no card accessor is needed. This declaration assumes the named outputs and string slot have already been declared: ```typescript draw.card("session_pnl", { title: "{{symbol}} session", anchor: "top_left", safe_area: true, chrome: "plain", headline: { output: "pnl", format: "usd", signed: true, color: { color_by: "pnl_side", colors: ["theme.down", "theme.up"] } }, rows: [ { label: "Trades", value: { output: "trades", format: "int" } }, { label: "Win rate", value: { output: "win_rate_pct", format: "%", decimals: 1 } }, { label: "Status", value: { text: "status_text" } }, { label: "Next close", countdown_to: { output: "close_at_ms" } }, ], background_color: "#0f141bf2", corner_radius: 10, width: 220, }); ``` `draw.feed` and `draw.meter` take the same look, placement and `panel` words, plus a `title`; a feed also takes `time_format`, a meter `bar_height` and `track_color`, and a ladder its own list (Frames, panels and compact widgets, below). Cards share the selection's 8 MiB expanded-result budget. Accounting is 16 bytes per card and per card row, 8 per carried number (the two offsets, `z`, and each countdown), plus the UTF-8 bytes of every carried string: kind, name, title, headline, state, anchor, row labels, resolved values and colours. Exceeding the budget refuses with `wrun_render_result_too_large`. ## Everything rolls back correctly Markers, tints and bands are per-bar records over outputs, so they follow the engine's forming-bar rule: the chart snapshots the module after the last closed bar and replays the revised forming bar from that snapshot as live updates arrive, and that bar's marks, tints, and slices are replaced, never stacked. Cards replace their selected snapshot on each run. Handle drawings roll back the same way: the chart keeps the handles beside the module state, puts both back before each replay, and re-runs the closed bar once more when it closes, so a handle the forming bar created or moved is never duplicated by an update ([Execution model](../core-concepts/execution-model.md)). ## Every primitive in one module A marker on each bullish and bearish cross, a background tint by the sign of the delta, a shaded band, and a two-cell dashboard, over one moving-average pair. ```typescript param("fast", 9, { min: 1, max: 200 }); param("slow", 21, { min: 2, max: 400 }); const fast = output("fast", line, overlay, { color: "#2563eb", width: 2, description: "Fast average" }); const slow = output("slow", line, overlay, { color: "#94a3b8", width: 1, description: "Slow average" }); output("high", none, overlay, { description: "The bar high: where a bearish mark sits" }); output("low", none, overlay, { description: "The bar low: where a bullish mark sits" }); output("bullish", none, overlay, { description: "1 on a bullish-cross bar" }); output("bearish", none, overlay, { description: "1 on a bearish-cross bar" }); output("delta_bucket", none, overlay, { description: "0 fast below slow, 1 fast above: the tint ladder index" }); string("regime_text", { max_bytes: 8 }); string("delta_text", { max_bytes: 16 }); // Markers: an arrow below the bar on a bullish cross, one above it on a bearish cross. render.shape("buy_mark", { output: "low", shape: "arrow_up", where: "bullish" }); render.shape("sell_mark", { output: "high", shape: "arrow_down", where: "bearish" }); // Bar color: a background tint by regime; index 0 is red, index 1 green, on every bar. render.bgcolor("regime_tint", { where: "fast", color_by: "delta_bucket", colors: ["#ef444418", "#22c55e18"] }); // Fill between: a slice per bar between the two averages. box("ribbon", { top: fast, bottom: slow, color: "#2563eb", opacity: 0.1, borderWidth: 0 }); // A 1x2 dashboard: regime word, delta value. render.table("dashboard", { rows: 1, cols: 2, cells: ["regime_text", "delta_text"], position: "top_right" }); let fastSma = new Sma(9); let slowSma = new Sma(21); const cross = new Cross(); function onStart(): void { fastSma = new Sma(i32(p_fast())); slowSma = new Sma(i32(p_slow())); } function onBar(): void { const close = bar.close(); const fastValue = fastSma.update(close); const slowValue = slowSma.update(close); const crossed = cross.update(fastValue, slowValue); if (isNaN(slowValue)) return; const above = fastValue >= slowValue; out_fast(fastValue); out_slow(slowValue); out_high(bar.high()); out_low(bar.low()); out_bullish(crossed == 1 ? 1.0 : 0.0); out_bearish(crossed == -1 ? 1.0 : 0.0); out_delta_bucket(above ? 1.0 : 0.0); str_regime_text(above ? "long" : "short"); sb_clear(); sb_f64(fastValue - slowValue, 2); sb_text(" delta"); str_delta_text_sb(); } ``` `regime_tint` gates on `fast`, which is finite on every ready bar, so the tint appears everywhere and the ladder picks the color; gate on a `0`/`1` output instead to tint only some bars. The 8-digit hex colors carry the tint's alpha. ## Frames, panels and compact widgets Use `wrun-4` frames for one JSON snapshot that survives the whole run. Declare `frame("book")`, then write it from `onBar()`: a frame rebuilt on every bar or live tick is built in the generated frame buffer with `fb_clear()`, `fb_text(s)`, `fb_str(s)`, `fb_int(n)`, `fb_f64(x, decimals)` and `fb_num(x)` and sent with `writeFrameBuffer(FRAME_BOOK)`, allocation-free; a small frame the module already holds as a string goes through `writeFrame(FRAME_BOOK, json)`. Each frame holds its last write, up to 96 KiB; up to eight frames share a 2 MiB transport budget with strings. | Declaration | Snapshot | | --- | --- | | `plot.levels({ name, frame, dock, width_frac?, poc?, labels?, color?, span?, ...style })` | `{ prices, values, colors?, ...fields }`; 1..512 monotonic prices and equally sized values, null for gaps; with `span: "time"`, `{ spans: [{ start, end, prices, values, colors? }] }`, 1..64 spans and at most 4096 rows in all | | `panel.bars`, `panel.line`, `panel.scatter`, `panel.histogram`, `panel.pie`, `panel.heatmap`, `panel.table`, `panel.tiles` | `{ rows, ...chrome }`; kind-specific tuples, at most 2000 rows (a table 128), empty allowed; a chrome field beside `rows` overrides the declared word for that run | | `plot.matrix({ name, frame, dock?, columns?, ...style })` | `{ prices, cells, cols?, title?, highlight?, range? }`; 1..128 prices, one cell row per price of 1..12 columns, at most 1536 cells ([Price canvases](price-canvases.md)) | | `draw.ladder({ name, frame, side, divider?, title?, ...style })` | `{ rows, divider?, title? }`; 1..256 price/value/fraction/color rows | | `draw.feed({ name, frame, anchor?, offset?, z?, title?, time_format?, ...style })` | `{ lines, title? }`; 1..50 millisecond-time/text/color rows | | `draw.minicharts({ name, frame, anchor?, x?, y?, columns?, panel_width?, panel_height?, gap?, ... })` | `{ title?, panels }`; 1..12 labelled panels of 0..100 candles | Levels default to a width fraction of 0.12. Every colour in a declaration or a frame takes `#rrggbb`, `#rrggbbaa` or a theme token (`theme.up`, `theme.down`, `theme.text`, `theme.muted`, `theme.bg`, `theme.grid`, `theme.accent`), resolved by the chart at paint time and again on a theme switch. The levels style words (placement, bars, labels, the point of control, hover and the frame fields they read) are on [Docked profiles](cards-frames-panels.md#docked-profiles). Panels declare name, title, x (`time`, `index`, `number`, `category`), place (`below`, `side`) and frame. Bars, line and table also declare series (1..8, a table 1..12), each `{ name, color? }` plus its own look. Histograms and heatmaps use category x; a `number` x takes decimal row keys (a price grid) on a line, bars or scatter. An unwritten frame leaves its consumer absent; a malformed written frame refuses with `wrun_frame_invalid`. ### Panel words Every word is optional and scoped by kind: a word on a kind it does not list refuses by name ("`` is only valid for "). Absent words keep the chart's look, except `font_family`, whose default `"ui"` is the app font (the four words are `"ui"`, `"mono"`, `"serif"`, `"rounded"`). | Kind | Words | | --- | --- | | every kind | `height_frac` (0.05..0.9, the pane's share of the chart), `maximize` (the legend's fullscreen button), `font_family`, `format` with `decimals` (0..8), `signed` and `unit` (1..8 characters) for every value the panel prints | | line, bars, histogram, scatter, pie | `chrome` (`"box"`, `"grid"` or `"none"`; pie `"box"` or `"none"`), `hover_card` (a readout under the pointer, default on; not on pie or table), `legend_style` (`"none"`, `"title"`, `"pane"`, `"chips"` on a line; bars drop `"chips"`; scatter and pie take `"none"` or `"title"`), `x_title`, `y_title` (1..40), `y_min`, `y_max` (the value axis), `y_zero` (line and scatter), `x_min`, `x_max` (a line with x `index` or `number`, scatter) | | line, bars, histogram, scatter | `badge` (`{ text: 1..24, color?, text_color? }`): a chip on the panel's title row, filled `theme.accent` unless `color` says otherwise, its text in whichever of light or dark reads on the fill unless `text_color` picks one | | line | `stats_row` (with `maximize`), `smooth`, `points`, `labels` (each series' last value at its end), `glow`, `animate`, `legend_latest` (with `legend_style: "chips"`), `fill_mode` (`"flat"` or `"signed"`), `fill_positive_color`, `fill_negative_color`, `fill_fade`, `stroke_fade` (`{ pivot: number | "spot", edge_opacity?, left_color?, right_color? }`), `markers` (0..8 of `{ x: number | string | "spot", label?, color?, badge?, wash?, valign?: "top" | "middle" | "point", y?, series?, show_value?, line_style?, width? }`; `valign: "point"` makes the marker a callout pinned at a point on the curve instead of a line down the pane, `y` fixing the value or `series` naming the declared series whose value at `x` anchors it, the first series by default; `y` and `series` need `"point"`, and `series` must be a declared series), `x_format`, `x_decimals`, `x_unit` (with x `index` or `number`), `positive_color`, `negative_color` | | a line series | `fill`, `fill_color`, `style` (`"line"`, `"bars"`, `"step"`), `smooth`, `points`, `labels`, `width` (0.5..20), `line_style`, `legend` | | bars | `color_mode` (`"series"` or `"sign"`), `positive_color`, `negative_color`, `x_format`, `x_decimals`, `x_unit` (with x `index` or `number`); a series `legend` | | histogram | `color`, `labels` (each bin's count), `bins` (2..200 slots reserved on the axis) | | scatter | `color`, `guides` (0..8 of `{ axis: "x" | "y", value, label?, color?, line_style?, width? }`), `quadrants` (`{ x, y, colors: [4], labels?: [4] }`), `trails`, `trail_width` (0.5..6), `trail_fade`, `label_overlap` (`"hide"`, `"leader"`, `"show"`; with `labels`) | | pie | `hole_total`, `hole_caption` (1..24; both need `hole > 0`), `slice_gap` (0..8 px), `border_color`, `border_width` (0..10) | | heatmap | `scale` (`"palette"` or `"signed"`), `palette` (2..8 colours), `min`, `max`, `highlight` (`{ row?, col? }`), `row_title`, `col_title` (1..24), `positive_color`, `negative_color` | | tiles | `columns` (1..8), `accent` (`"auto"` or `"neutral"`), `positive_color`, `negative_color` | | table | the styled-table words (`position`, `offset`, `width`, `column_widths`, `cell_padding`, `font_size`, `font_weight`, `align`, `valign`, `text_color`, `header_text_color`, `background_color`, `background_opacity`, `background_gradient`, `gradient_direction`, `header_background_color`, `border_color`, `border_width`, `border_style`, `grid_color`, `grid_width`, `grid_style`, `grid_lines`, `header_column`) with the same ranges; a series `align`, `format`, `decimals`, `signed`, `unit` (the column's) | A colour word of a panel declaration may name a setting instead of a colour: `"@"` for a declared `param.color` binds every colour word above: a series' `color` and `fill_color`, `positive_color` and `negative_color`, the `fill_*_color` pair, a heatmap's `palette` entries, a histogram's or a scatter's `color`, the badge, marker, guide and quadrant colours, the stroke fade's inks and the pie and table colours. The build writes the setting's default in its place, so the sheet reads as if you had written that colour, and the chart paints the trader's pick there before every run; the setting's row is the colour's one control ([The Style page](../settings/style-page.md)). A reference to anything but a `param.color` is refused by name (`panel.bars 'flow' positive_color references "@side", which is not a param.color`), and an `@` in a title or a series name stays text. A colour inside a frame's rows (a pie slice, a scatter dot, a tile, a table cell) is run data: the module reads its `param.color` and writes the colour into the row. ```typescript param.color("bid_ink", "#22c55e", { label: "Bids" }); param.color("ask_ink", "#ef4444", { label: "Asks" }); panel.line({ name: "depth_curve", title: "Cumulative depth", x: "number", place: "below", frame: depth, chrome: "grid", series: [{ name: "Bids", color: "@bid_ink", fill: true, fill_color: "@bid_ink" }, { name: "Asks", color: "@ask_ink" }] }); ``` The rich words of a line (`smooth`, `points`, `glow`, `animate`, the fills, the stroke fade, `markers`, `stats_row`, `legend_latest`, `hover_card`, `legend_style: "chips"`, a series `fill_color`, `style: "step"`, `smooth`, `points` or `labels`) need a chrome of `"grid"` or `"none"`: the declared word, else `"grid"` on an unstacked `index` or `number` line, else `"box"`. A time line with `chrome: "grid"` spaces its points by time and labels the axis in the chart's display timezone. A rich line panel (a chrome of `"grid"` or `"none"`, or a `number` or `index` x) keeps its own value axis on the left, so the pane's right-hand value strip stays blank: the strip belongs to series that share the chart's time axis, and a curve over strikes or distances does not. An indicator's panels also stack above its time-series panes: the panes that read the time axis sit at the bottom, next to the time axis, and the panels sit above them, each group in declaration order. ### Panel frames Beside `rows`, a frame may carry per-run chrome: `title` (1..40) and `caption` (1..64) on every kind; `badge` (`{ text, color?, text_color? }`) on line, bars, histogram and scatter; `markers`, `x_min`, `x_max`, `y_min`, `y_max` on a line; `y_min`, `y_max` on bars and histogram; `x_min`, `x_max`, `y_min`, `y_max`, `guides`, `quadrants` on scatter; `min`, `max`, `highlight` and `summary` (0..5 of `{ label, values }`, the values following the columns) on a heatmap; `hole_text` and `hole_caption` (1..24) on a pie. A frame field overrides the declared word of the same name for that run, `markers` replaces the declared list and `badge` replaces the declared chip (a frame without the key leaves the declared chip standing); any other key beside `rows` refuses by name. Frame markers take the same words as declared ones, `valign: "point"` with `y` or `series` included, so a module can point at the peak it just found. Rows take colour words wherever they took a hex colour: a scatter row's colour, a pie slice's, a tiles row's, a table cell's. A bars value may be `{ value, color? }` to colour one bar. A tiles row may end with its own format word (`[label, value, caption?, color?, spark?, format?]`, with null to skip an element). A table cell may be a styled object with `text`, `value` (printed through the column's or the table's format when text is absent), `color`, `bar`, `spark`, `background_color`, `opacity`, `gradient`, `text_color`, `font_size`, `font_weight`, `align`, `valign`, `colspan` and `rowspan`; a table takes 128 rows and 12 columns, the other kinds 2000 rows. ### Side placement and windows `place: "side"` marks a panel as side content. On a chart it mounts below the candles like every other panel, in a pane of its own with the same options. What it adds is a **window**: the chart offers an Indicator with side content a cell of its own, where the candles are hidden and the Indicator's panels, tables and lower pane fill the whole height. The window keeps the market and timeframe of its cell; that market is still the file's first input and its clock, so the Indicator runs exactly as it does on a chart. How a reader opens one: - The window icon beside the Indicator's name in the legend. It shows while the sheet places any panel `side`; once the reader has seen or used it, it moves in with the row's hover buttons. - The legend menu: **Open as window** (a new cell beside the chart, which keeps its Indicator), **Show as window here** (this cell becomes the window) and, on a window, **Show chart**. - **Add as window** in the Indicators picker, on a package page and in search. In a window the header carries the Indicator's name, its settings and its menu; the market chip still switches the market and the Indicator runs again. Drawing tools, price alerts and replay are off there. An Indicator with nothing to show beside the candles (price-pane plots only) gets a "Nothing to show as a window" card with Show chart. Removing the Indicator from a window that was opened into its own cell closes that cell and the layout returns to what it was; a chart turned into a window in place comes back as a chart. The window sizes the panes itself: tables take the height their rows need (together at most half the window), the other panels and the lower pane share the rest. `width_px` (160..480) and `height_px` (80..800) are accepted only with `place: "side"` and are reserved for a strip beside the price pane that panels do not use today; the strip beside the price axis is for `dock: "side"` matrices ([Price canvases](price-canvases.md)). A window also remembers and answers: - The pointer on a window's index or category panes (a curve over strikes, a bars panel): a guide snaps to the nearest key with a dot per series, a readout shows the values there, and the legend values follow the key; leaving the pane shows the last row again. `tooltipEnabled: false` removes the card only. - The split a reader drags between a window's panes is remembered across reloads for that Indicator; it resets when the Indicator's set of panes changes. - Closing a window cell can be undone: Ctrl+Z / Cmd+Z brings the cell back where it was, with the Indicator. - A draft you are editing can open as a window: press Open as window beside Run in the editor; every later Run lands in that window, and stopping the draft or closing its tab closes the cell. A draft window is not saved: after a reload the cell is an empty chart. `draw.meter({ name, label, fraction: { output }, ramp, text?, anchor?, offset?, z? })` reads the last ready numeric fraction. Ramp has 2..5 colour words; text is a short literal or `{ slot }`. A card row's `spark: { output, window }` carries its last 2..64 ready values, oldest first. A ladder, feed or meter takes a `title` (1..40 characters; a ladder or feed frame may carry its own `"title"` per run, and a meter title takes the card templates), the card surface and chrome words (the background, border, `corner_radius`, `padding`, `width`, `opacity`, the type words, `chrome`, `stripe`, `controls`, `accent_color`, `above_drawings`, `safe_area` and `panel`, with the card's ranges) and an `offset` of -4096..4096 px. A ladder also takes `width_frac` (0.02..0.5, default 0.14), `offset: [x, 0]` (x 0..4096 px inward from the axis), `opacity`, `color` (the row default), `labels`, `format`, `decimals`, `signed`, `unit` (the value text), `text_color`, `font_size`, `font_family`, `font_weight` and `divider_color`; its frame carries 1..256 rows with colour words. A feed takes `time_format` (`"HH:mm:ss"`, `"HH:mm"`, `"MM-dd HH:mm"` or `"none"`); a meter takes `bar_height` (2..40 px) and `track_color`, and its ramp takes colour words. `draw.minicharts({ name, frame, ... })` pins a grid of up to 12 small candle charts to the pane, the grid a multi-timeframe matrix draws. Its options: `anchor` (default `top_right`) and `x`, `y` pixel offsets from it (-2000..2000, default -16 and 48), `z`, `columns` 1..4 (2), `panel_width` 72..320 (128), `panel_height` 48..220 (72), `gap` 0..48 (8), the colours `background_color`, `border_color`, `text_color`, `bull_color`, `bear_color` and `wick_color`, `candle_style` (`candles`, `hollow`, `bars`, `line`, `area`), two moving averages (`ma_fast_length`, `ma_slow_length`, each drawn only when given, with `ma_fast_color`, `ma_slow_color`, `ma_width` 0.5..5 and the `show_fast_ma` / `show_slow_ma` switches) and `show_change` / `show_volume` for the change badge and the volume strip, which the chart computes from the candles. Every option takes a literal. The colours, the switches, the average lengths, `ma_width` and `candle_style` also take an `"@"` reference, so a setting changes that option: a `param.color` for a colour, a `param.bool` for a switch, a `param.int` with `min` 1 or more for a length, a `param.number` or `param.int` within 0.5 to 5 for `ma_width`, a `param.choice` over the style words for `candle_style`. The setting's default draws until it is changed, and one setting may drive several options. The frame carries the title and each panel's label and candles, `[time_ms, open, high, low, close, volume]` oldest first, volume `null` for none. Build it with the grid's writers: `mc_begin(title)` clears the frame buffer and opens the grid, `mc_panel(label)` opens a panel, `mc_bar(openSec, open, high, low, close, volume)` appends a candle (its open in epoch seconds), `mc_candles(label, list, bars, formingSec, open, high, low, close, volume)` writes a whole panel from a `CandleList` of closed candles plus the forming candle (`NaN` for none; it is drawn last when it opens after the newest closed one, `bars` candles at most), and `writeMiniCharts(FRAME_GRID)` sends it. `mc_leg(label, list, bars, in_x_view(), in_x_cells(), bar.time())` reads that forming candle off a `candles` input declared with `view: "forming"`, the timeframe's live candle on the live bar. A title of `{{symbol}}` reads as the chart's market. A grid with no reference gets settings of its own: the look's colours, candle style, averages and switches ("Panel background", "Bull candle", "Fast MA length", "Candle style", "Show volume", ...), each defaulting to the value written here; write colours as `"#rrggbb"` or `"#rrggbbaa"`. `out.inset("vol", { dock: "bottom", height_px: 28, shape: "histogram" })` declares an ordinary numeric output in a compact strip. Every ready row appears in its history. Insets work on every ABI. An inset also takes `color`, `colors` (`[up, down]` by sign, or a ladder beside `color_by`) and `opacity`. The build extracts these object declarations before AssemblyScript type checking. `frame()` returns its slot index; generated `FRAME_` constants provide the same index. The frame builder exists only when frames are declared. Its UTF-8 scratch is one `StaticArray` sized to the largest declared `max_bytes`, reserved during module initialization and reused by every `fb_*` append and by `writeFrame`, so repeated frame writes never grow guest memory; the string handed to `writeFrame` is the caller's own allocation, which is why a frame rebuilt per bar builds through `fb_*` instead. `fb_text` appends raw JSON text (punctuation, keys, words), `fb_str` a quoted and escaped JSON string, `fb_int` an integer, `fb_f64` a number with a fixed decimal count, `fb_num` a double's shortest spelling; `fb_f64` and `fb_num` write `null` for a non-finite value, JSON's gap. Both senders pass the bytes the frame requires, so an oversized frame refuses host-side by the slot's `max_bytes`, never truncated. The caps an author meets are on [Limits](../reference/limits.md). ### Pane pixel placement Line, box, label and polyline handle declarations accept `anchor` as the default for newly created handles: `handles.line({ anchor: "top_left" })`. Absent means chart coordinates. The nine pane spots are `top_left`, `top_center`, `top_right`, `middle_left`, `middle_center`, `middle_right`, `bottom_left`, `bottom_center`, `bottom_right`; both coordinates become CSS pixel offsets. `top` and `bottom` change only y; x remains chart time. `left` and `right` change only x; y remains chart price. Offsets start at the pane's content rectangle with no extra inset. Left and top measure inward to the right and down; right and bottom measure inward to the left and up. Centre offsets are signed, and negative offsets are allowed everywhere. The renderer scales pixels once by device pixel ratio and clips every drawing to its own pane. Time culling applies only when x remains chart time. `safe_area: true` on a handle declaration (`handles.box({ anchor: "top_right", safe_area: true })`) starts the offsets past the chart's own chrome instead: the legend stack at the top left, the pane action bar at the top centre and right, the price-axis tags on the right. After a handle's `set`, call `.anchor(ANCHOR_TOP_LEFT)` or `style.anchor(handle, ANCHOR_TOP_LEFT)` with an `ANCHOR_*` constant. `ANCHOR_CHART` restores chart coordinates and clears a declared default until the handle is recreated. These helpers go through `style` with prop 7, integer values 0..13. Invalid values refuse as `wrun_draw_style_out_of_range`. Label handles also take `align`: which edge of the text sits on x, with (x, y) staying the anchor point. `handles.label({ align: "left" })` is the default for new labels; `left` starts the text at x, `right` ends it there, `center` or an omitted word centres it as before. After a label's `text`, call `.align(ALIGN_RIGHT)` or `style.align(label, ALIGN_RIGHT)` with an `ALIGN_*` constant; `ALIGN_DEFAULT` restores centred text and clears a declared default. These use prop 8, integer values 0..3, on labels and box text (a box takes `align`, `valign` and `padding` too); other kinds refuse as `wrun_draw_prop_unsupported`. ## Docked profiles `plot.levels({ name, frame, dock, ... })` draws a profile docked on the price axis from a frame the module writes each run: one bar per price, its length the row's value. The required words and today's optional ones (`width_frac`, `poc`, `labels`, `color`) keep their meaning; everything below is opt-in, erased before the file compiles, and takes `"@"` on any style key so the Style page can change it without a rerun of your code ([The Style page](../settings/style-page.md)). Where the profile sits: | Word | Value | What it does | | --- | --- | --- | | `panel` | `"overlay"` (default), `"lower"` | on the price pane, or on the indicator's own lower pane, where the frame's prices read on that pane's value scale (an RSI or CVD profile beside the oscillator); `"lower"` needs a lower pane | | `behind_candles` | boolean (default false) | the bar fills draw under the candles; labels, the POC, the value-area lines, borders and the outline stay above; refused on `"lower"`, beside `gradient`, or with `shape: "outline"` | | `span` | `"pane"` (default), `"time"` | docked on the pane edge, or one profile per time span the frame carries ([Price canvases](price-canvases.md#profiles-anchored-in-time)) | | `offset` | `[x, 0]`, x 0..4096 px | inward from the pane edge, or from the neighbour named by `beside` | | `beside` | another declared level's name | dock right inside that level (open interest beside gamma); refused when it names itself, an undeclared level, a level on another panel or dock side, or closes a cycle | | `width_px` | 16..600 px | a fixed dock width instead of `width_frac`; refused beside it | | `scale`, `scale_max` | `"own"` (default), `"shared"`, `"fixed"`; a number > 0 | `"shared"` scales every `"shared"` level of the indicator by the largest row among them; `"fixed"` fills the dock at `scale_max` (required there, refused otherwise) and clamps longer rows; the frame's `scale_max` overrides it per run | | `thickness_px` | 2..40 px | the tallest a bar may be, centred on its price | | `step` | number > 0 (price units) | every row spans its price plus and minus half a step (a strike ladder) | How the bars paint: | Word | Value | What it does | | --- | --- | --- | | `color`, `opacity` | a colour word; 0..1 (default 0.65) | the bar colour and the alpha every bar colour's own alpha is multiplied by; `opacity: 1` paints the exact colour | | `baseline` | `"edge"` (default), `"center"` | `"center"` puts zero mid-dock: positive values grow one way, negative the other (a net gamma profile); under `"edge"` a negative row draws nothing | | `series` | 1..8 of `{ name, color }`, unique names | one row split into stacked segments (calls and puts), fed by the frame's `series` arrays | | `gradient`, `gradient_direction` | 2..8 colour words; `"horizontal"` (default) or `"vertical"` | fading bars: horizontal runs from the bar's root to its tip; a row or segment with its own colour keeps it and takes the stops' alpha profile; refused beside `behind_candles` | | `border_color`, `border_width`, `border_style` | a colour word; 0..10 px (1 when a colour is set, else 0); `"solid"`, `"dashed"`, `"dotted"` | an outline on every bar and segment | | `shape` | `"bars"` (default), `"outline"` | `"outline"` draws no fills, one stepped line along the bar tips per side, broken at null rows | | `outside_opacity` | 0..1 (default 0.5) | how far rows outside the frame's value area fade | | `value_area_color`, `value_area_width`, `value_area_line_style`, `value_area_labels` | a colour word (default the level colour); 0..10 px (default 1, 0 draws no lines); `"dashed"` unless declared; boolean | the two lines across the dock at the value area's low and high prices, and the `VAH` and `VAL` tags beside them | Labels, the point of control and hover: | Word | Value | What it does | | --- | --- | --- | | `labels` | boolean | the row labels; a frame with `labels_text` turns them on unless the declaration says `false` | | `format`, `decimals`, `signed`, `unit` | the shared number words | how a row without its own text (and its hover value) prints; without a format word the value prints as written | | `text_color`, `font_size`, `font_weight`, `font_family` | a colour word (default the row colour at full alpha); 6..64 px (default 10); the weight word; `"ui"`, `"mono"`, `"serif"`, `"rounded"` (default the chart axis font) | the label type | | `label_place` | `"outside"` (default), `"inside"`, `"axis"` | beyond the bar tip, inside the bar at its tip, or inside at its root | | `hover` | boolean (default false) | hovering a row opens the chart's hover card: the price, the value, one line per series, the row's tooltip | | `poc`, `poc_color`, `poc_width`, `poc_line_style`, `poc_extend`, `poc_label` | boolean; a colour word (default the POC row colour); 1..10 px; the line style; `"dock"` (default) or `"pane"` (across the whole pane); boolean | the point of control line (the largest row unless the frame names `poc_price`) and its tag (`POC` and the price, or the frame's `poc_label_text`) | The frame carries the data and may carry per-run chrome beside `prices`, `values` and `colors`: | Field | Value | What it does | | --- | --- | --- | | `prices`, `values`, `colors` | 1..512 strictly ordered numbers; as many numbers or null (a gap that keeps its band); colour strings (`#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()`, a theme token; anything else falls back to the level colour) | the rows | | `labels_text` | one string of 0..24 characters or null per row | that text on the row; `""` leaves the row bare; null prints the formatted value | | `series` | one array of numbers or null per declared series, each the length of `prices` | the segments, stacking outward from the baseline in series order; `values` still names the row for its label and hover; never beside `colors` | | `lows`, `highs` | one number or null per row, both arrays together, `low` below `high` | a row's own price band; a null pair falls back to `step`, else to the neighbours' midpoints | | `tooltips` | one string of 0..64 characters or null per row | the hover card's hint | | `value_area` | `[low, high]` prices | the value area: rows outside dim, two lines across the dock | | `poc_price`, `poc_label_text` | a number; 1..24 characters | the price the POC line marks; the tag's text (turns the tag on) | | `scale_max` | a number > 0 | the fixed scale for this run; `scale: "fixed"` only | | `spans` | the time-anchored shape | with `span: "time"`, in place of the fields above | A frame whose shape disagrees with the declaration (a `series` count that differs, `colors` beside `series`, a `scale_max` on a level whose scale is not `"fixed"`, a `spans` frame on `span: "pane"`) refuses the run as `wrun_frame_invalid`. A two-sided profile of calls and puts, net at the baseline, with a value area and a tagged point of control: ```typescript param.choice("side", ["right", "left"], "right"); param.color("call_ink", "#38bdf8"); param.color("put_ink", "#a78bfa"); const gexFrame = frame("gex_levels", { max_bytes: 32768 }); plot.levels({ name: "gex", frame: gexFrame, dock: "@side", baseline: "center", width_frac: 0.18, series: [{ name: "Calls", color: "@call_ink" }, { name: "Puts", color: "@put_ink" }], labels: true, format: "usd", label_place: "inside", poc: true, poc_extend: "pane", poc_label: true, hover: true, outside_opacity: 0.4 }); ``` ```json { "prices": [62000, 62500, 63000, 63500], "values": [41000, -18500, 25000, 9000], "series": [ [52000, 11000, 30000, 12000], [-11000, -29500, -5000, -3000] ], "labels_text": ["C 52.0K / P 11.0K", null, "", null], "tooltips": ["Calls 1,240 OI / Puts 860 OI", null, null, null], "value_area": [62000, 63000], "poc_price": 62000, "poc_label_text": "Max pain" } ``` A session volume profile the module bins itself, docked right: rows six pixels thick, the value area marked by dotted amber lines with their `VAH` and `VAL` tags, rows outside it faded, and the point of control drawn across the dock. ```typescript const sessionFrame = frame("session_rows", { max_bytes: 16384 }); plot.levels({ name: "session_profile", frame: sessionFrame, dock: "right", width_frac: 0.2, color: "#38bdf8", thickness_px: 6, outside_opacity: 0.35, value_area_color: "#f59e0b", value_area_width: 1.5, value_area_line_style: "dotted", value_area_labels: true, poc: true, }); ``` Open interest by price with every bar outlined in a dotted hairline, the fills at half strength, and a strong dashed point of control across the whole pane with its tag on: the look for a profile that sits under other overlays and still has to read. ```typescript const oiRows = frame("oi_rows", { max_bytes: 16384 }); plot.levels({ name: "oi_profile", frame: oiRows, dock: "left", width_frac: 0.15, color: "#a78bfa", opacity: 0.5, border_color: "#c4b5fd", border_width: 1, border_style: "dotted", poc: true, poc_color: "#f472b6", poc_width: 3, poc_line_style: "dashed", poc_extend: "pane", poc_label: true, }); ``` A liquidation map docked right inside that open interest profile, on a fixed scale so a new cluster never rescales the dock, each row spanning the price band its frame gives it instead of the midpoints between neighbours. ```typescript const oiRows = frame("oi_rows", { max_bytes: 16384 }); const liqRows = frame("liq_rows", { max_bytes: 16384 }); plot.levels({ name: "oi_profile", frame: oiRows, dock: "left", width_frac: 0.15, color: "#a78bfa", format: "si" }); plot.levels({ name: "liq_map", frame: liqRows, dock: "left", beside: "oi_profile", width_frac: 0.08, color: "#f97316", scale: "fixed", scale_max: 50000000, format: "usd", hover: true, }); ``` ```json { "prices": [61250, 61750, 62250, 62750], "values": [12500000, 48000000, 9000000, 31000000], "lows": [61000, 61500, 62000, 62500], "highs": [61500, 62000, 62500, 63000], "scale_max": 60000000 } ``` ### Session profiles from cells The chart can also draw a profile itself from `volume_profile` cells, one per session, through `plot.profile` ([Price canvases](price-canvases.md#profiles-anchored-in-time)); it styles the same parts in its own words. Here each row splits buy and sell volume, rows outside the value area take a faded pair of colours, the point of control is thick, the three levels stay drawn until price trades through them, and hovering a cell reads it out. ```typescript input("close", ohlcv.close); input("profile", volume_profile.cells, { max_cells: 8192 }); plot.profile({ name: "session_vp", cells: "profile", span: "session", session: "1d", mode: "split_bar", buy_color: "#34d399", sell_color: "#f87171", row_height: 25, value_area: 0.7, outside_buy_color: "#34d39955", outside_sell_color: "#f8717155", poc: true, poc_width: 3, naked: true, level_labels: true, cell_hover: true, }); ``` The caps a profile meets (four levels, 512 rows, 64 spans and 4096 rows across them, 96 KiB per frame) are on [Limits](../reference/limits.md). ## Panel rows, kind by kind A panel draws a small chart of its own in a pane below the chart (`place: "below"`), or as side content (`"side"`) that mounts below on a chart and fills a window ([Side placement and windows](#side-placement-and-windows)). The file writes the panel's rows into a frame, usually once, on the newest bar, and each kind reads its rows in its own shape: | Panel | One row | Options of its own | | --- | --- | --- | | `panel.bars`, `panel.line` | `[key, v1, ..., vN]`: one value per series, `null` for a gap | `series` (1 to 8, required), `stacked`; bars also `orientation` (`"vertical"` or `"horizontal"`) | | `panel.scatter` | `[key, x, y, size?, color?, label?]`: a dot at (`x`, `y`), the label at most 24 characters | none | | `panel.histogram` | `[label, count]`: a bin's label (1 to 24 characters) and its count | `bins` (2 to 200), `orientation`; `x: "category"` only | | `panel.pie` | `[name, value, color?]`: one slice, at most 24 | `hole` (0 to 0.8) | | `panel.heatmap` | `[x key, y key, value]`: one cell, `null` for an empty one | `x: "category"` only | | `panel.table` | one cell per series and no key: words (at most 64 characters), a number, or a styled cell `{ text?, value?, color?, bar?, spark?, background_color?, opacity?, gradient?, text_color?, font_size?, font_weight?, align?, valign?, colspan?, rowspan? }`; at most 128 rows | `series` (1 to 12, required) | | `panel.tiles` | `[label, value, caption?, color?, spark?]`: one tile, at most 24 | none | Every panel also takes `name`, `title`, `x`, `place` and `frame`, and the look words of its kind (Frames, panels and compact widgets, above). `x` says what a key is: `"time"` an epoch second, `"index"` a whole number, `"number"` a decimal (a price grid), `"category"` a word of 1 to 64 characters. A panel holds at most 2,000 rows unless the table says fewer (128), and an empty `rows` list draws an empty panel. A color is a colour word: a hex literal such as `"#16a34a"`, `"#rrggbbaa"` or a theme token. Volume by weekday as stacked bars, one bar per day of the week: ```typescript // Volume by weekday in a pane below the chart: one bar per day of the week, up-bar and down-bar volume stacked. output("weekday", none, overlay, { description: "The bar's weekday, 0 Sunday to 6 Saturday, UTC" }); const days = frame("days", { max_bytes: 1024 }); panel.bars({ name: "by_weekday", title: "Volume by weekday (UTC)", x: "category", place: "below", frame: days, stacked: true, series: [{ name: "Up bars", color: "#16a34a" }, { name: "Down bars", color: "#dc2626" }] }); const NAMES: StaticArray = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const upVolume = new StaticArray(7); const downVolume = new StaticArray(7); let clock = new Clock(); function onBar(): void { const close = bar.close(); if (isNaN(close)) return; clock.update(bar.time()); const day = clock.weekday(); if (close >= bar.open()) upVolume[day] += bar.volume(); else downVolume[day] += bar.volume(); out_weekday(f64(day)); if (!bar.isLast()) return; // One row per weekday: [category, up volume, down volume], one value per series. fb_clear(); fb_text('{"rows":['); for (let i = 0; i < 7; i++) { if (i > 0) fb_text(","); fb_text("["); fb_str(NAMES[i]); fb_text(","); fb_f64(upVolume[i], 0); fb_text(","); fb_f64(downVolume[i], 0); fb_text("]"); } fb_text("]}"); writeFrameBuffer(FRAME_DAYS); } ``` - **One value per series.** `["Mon", up, down]` carries the two declared series in their order; `stacked: true` puts them in one bar. - **Written once, on the newest bar.** The sums grow on every bar, and the frame is built on `bar.isLast()` with the `fb_*` writers, which never allocate. - **The last write wins.** The panel shows what the newest bar wrote; a frame never written leaves the panel off the chart, and a row in the wrong shape stops the run with `wrun_frame_invalid`. The looks below take the words of the Panel words table, kind by kind; every declaration assumes `onBar()` writes the named frame. ### Line panels A gamma exposure curve: net dealer gamma by strike, filled green where it is positive and red where it is negative, the fill fading away from the line, the spot strike marked with a badge that prints the value there, a chip on the title row naming the regime, a callout pinned to the curve at the peak strike, and a stats row for the maximised view. Reach for the signed fill whenever a curve's sign is the story. The `badge` word is the chip: its text, an optional fill (`theme.accent` when absent) and an optional text colour (light or dark, whichever reads on the fill, when absent). A marker with `valign: "point"` is the callout: instead of a line down the pane it draws a dot on the curve with a short leader to its label, anchored where the named `series` crosses `x` (the first series when `series` is absent), or at an exact value when `y` is given. ```typescript const gexRows = frame("gex_rows", { max_bytes: 16384 }); panel.line({ name: "gex_curve", title: "Gamma exposure by strike", x: "number", place: "below", frame: gexRows, height_frac: 0.3, chrome: "grid", maximize: true, stats_row: true, y_zero: true, fill_mode: "signed", fill_positive_color: "#22c55e", fill_negative_color: "#ef4444", fill_fade: true, badge: { text: "LONG GAMMA", color: "#22c55e", text_color: "#0b0d12" }, markers: [ { x: "spot", label: "Spot", badge: true, show_value: true }, { x: 64000, label: "Peak", valign: "point", series: "Net gamma", color: "#22c55e" }, ], series: [{ name: "Net gamma", color: "#e2e8f0", width: 2, smooth: true }], }); ``` The frame carries the rows and, per run, the chrome that moves: a caption naming the expiry window, a fixed value axis so the curve does not rescale on every tick, the badge (a frame badge replaces the declared chip for that run, so the regime flips to "SHORT GAMMA" in red when the module finds dealers short), and the markers. The frame's list replaces the declared one, so the spot marker is written again beside the flip strike, which the module finds where the curve crosses zero and washes down the pane in its colour, and two callouts pin the peak and the trough: the peak reads its height off the `Net gamma` series, the trough is pinned at an exact `y`. ```json { "title": "Gamma exposure by strike", "caption": "Dealer gamma per 1% move, 0 to 7 days to expiry", "y_min": -4000000, "y_max": 6000000, "badge": { "text": "SHORT GAMMA", "color": "#ef4444", "text_color": "#f4f4f4" }, "markers": [ { "x": "spot", "label": "Spot", "badge": true, "show_value": true }, { "x": 63500, "label": "Flip", "color": "#f59e0b", "line_style": "dashed", "valign": "middle", "wash": true }, { "x": 64000, "label": "Peak", "valign": "point", "series": "Net gamma", "color": "#22c55e", "show_value": true }, { "x": 61000, "label": "Trough", "valign": "point", "y": -2500000, "color": "#ef4444" } ], "rows": [ [60000, -1200000], [61000, -2500000], [62000, -800000], [63000, 1500000], [64000, 3900000], [65000, 2200000] ] } ``` A cumulative depth curve, bids and asks by distance from the mid price: both axes titled, the x axis printed as a percentage with two decimals, the value axis pinned from 0 to 500 so the two sides share one scale across runs, and chips in the legend showing each side's latest value. ```typescript const depth = frame("depth", { max_bytes: 16384 }); panel.line({ name: "depth_curve", title: "Cumulative depth", x: "number", place: "below", frame: depth, x_title: "Distance from mid", y_title: "Size (BTC)", x_format: "auto", x_decimals: 2, x_unit: "%", y_min: 0, y_max: 500, legend_style: "chips", legend_latest: true, format: "0.0", series: [ { name: "Bids", color: "#22c55e" }, { name: "Asks", color: "#ef4444" }, ], }); ``` A liquidation density curve around spot, glowing and animated as it updates, its stroke fading toward the edges, red for the longs below spot and green for the shorts above, with a tint under the line in the same ink. Reach for the fade when the pivot is the point and the tails are context. ```typescript const liqs = frame("liqs", { max_bytes: 16384 }); panel.line({ name: "liq_density", title: "Liquidation density", x: "number", place: "below", frame: liqs, glow: true, animate: true, stroke_fade: { pivot: "spot", edge_opacity: 0.15, left_color: "#ef4444", right_color: "#22c55e" }, series: [{ name: "Liquidations", color: "#f59e0b", width: 1.5, fill: true, fill_color: "#f59e0b33" }], }); ``` ### Bars Funding by venue as bars coloured by sign, the axis pinned from -0.05% to 0.05% so venues compare across runs, and a readout under the pointer. ```typescript const funding = frame("funding", { max_bytes: 4096 }); panel.bars({ name: "funding_by_venue", title: "8h funding by venue", x: "category", place: "below", frame: funding, color_mode: "sign", positive_color: "#22c55e", negative_color: "#ef4444", y_title: "Funding", y_min: -0.05, y_max: 0.05, hover_card: true, format: "0.000", unit: "%", series: [{ name: "Funding", color: "#94a3b8" }], }); ``` ### Scatter Each venue as a dot of funding against open interest change: the plane cut into four named quadrants at zero, a guide where funding turns rich, the funding axis pinned so the quadrants keep their place, venue names on the dots with leader lines where they would collide, and every dot trailing its last positions, the trail fading as it ages. ```typescript const venues = frame("venues", { max_bytes: 8192 }); panel.scatter({ name: "funding_vs_oi", title: "Funding against OI change", x: "number", place: "below", frame: venues, x_title: "8h funding", y_title: "OI change, 24h", x_min: -0.05, x_max: 0.1, labels: true, label_overlap: "leader", quadrants: { x: 0, y: 0, colors: ["#22c55e22", "#3b82f622", "#ef444422", "#f59e0b22"], labels: ["Longs crowding", "Shorts crowding", "Shorts unwinding", "Longs unwinding"], }, guides: [{ axis: "x", value: 0.03, label: "Rich", color: "#f59e0b", line_style: "dashed" }], trails: true, trail_width: 1.5, trail_fade: true, }); ``` ### Pie Open interest by expiry as a ring: the hole prints the total with a caption under it, a small gap between slices and a thin border on each in the pane's own ink. ```typescript const expiries = frame("expiries", { max_bytes: 4096 }); panel.pie({ name: "oi_by_expiry", title: "Open interest by expiry", x: "category", place: "below", frame: expiries, hole: 0.55, hole_total: true, hole_caption: "Total OI", slice_gap: 2, border_color: "#0f172a", border_width: 1, format: "usd", legend_style: "title", }); ``` The frame's rows are `[name, value, color?]` slices; for a run where the hole should say something other than the total, the frame carries its own words, `hole_text` with a `hole_caption` that replaces the declared one. ```json { "hole_text": "$1.92B", "hole_caption": "Expiring this week", "rows": [ ["04 Oct", 820000000, "#38bdf8"], ["11 Oct", 460000000, "#818cf8"], ["25 Oct", 390000000, "#a78bfa"], ["27 Dec", 250000000, "#c4b5fd"] ] } ``` ### Heatmap Funding by venue and hour as a signed heatmap, positive cells warm and negative cells cool around a neutral zero, the axes titled and a readout under the pointer. Reach for the signed scale when the data has a meaningful zero; a palette scale suits ranks and counts. ```typescript const hours = frame("hours", { max_bytes: 16384 }); panel.heatmap({ name: "funding_heat", title: "Funding by venue and hour", x: "category", place: "below", frame: hours, scale: "signed", positive_color: "#f97316", negative_color: "#38bdf8", row_title: "Venue", col_title: "Hour (UTC)", hover_card: true, format: "0.000", unit: "%", }); ``` The frame's rows are `[hour, venue, value]` cells; beside them it can light the live hour's column and add summary rows under the grid, their values following the columns in the order the rows first named them. ```json { "caption": "8h funding, the last 7 days", "highlight": { "col": "16:00" }, "summary": [{ "label": "Mean", "values": [0.0047, 0.0037] }], "rows": [ ["08:00", "Binance", 0.01], ["08:00", "Bybit", 0.0062], ["08:00", "OKX", -0.002], ["16:00", "Binance", 0.0071], ["16:00", "Bybit", 0.0045], ["16:00", "OKX", -0.0004] ] } ``` ### Tiles Tiles for the headline numbers, three across, each tile's accent following the sign of its value: green ink on a positive flow, red on a negative one, the numbers printed compact and signed. ```typescript const summary = frame("summary", { max_bytes: 4096 }); panel.tiles({ name: "flow_tiles", title: "24h flow", x: "category", place: "below", frame: summary, columns: 3, accent: "auto", positive_color: "#22c55e", negative_color: "#ef4444", hover_card: true, format: "si", signed: true, unit: "BTC", }); ``` ### Table A table beside the chart with its first column as the row headers: a dashed frame, muted header ink, hairlines between rows, every cell centred vertically, the two value columns right-aligned and each formatted its own way. ```typescript const board = frame("board", { max_bytes: 8192 }); panel.table({ name: "venue_board", title: "Venue board", x: "category", place: "side", frame: board, width_px: 320, header_column: true, header_text_color: "#94a3b8", header_background_color: "#1e293b", text_color: "#e2e8f0", border_color: "#475569", border_width: 1, border_style: "dashed", grid_color: "#334155", grid_width: 1, grid_lines: "rows", valign: "middle", cell_padding: 6, column_widths: [96, 0, 0, 0], series: [ { name: "Venue" }, { name: "Market" }, { name: "OI", align: "right", format: "usd" }, { name: "Funding", align: "right", format: "0.000", unit: "%" }, ], }); ``` The frame's cells are words, numbers or styled cells: a styled cell may span rows, so a venue's name sits once beside its two markets, and a `value` cell with `bar` draws an inline bar behind the number, its length 0 to 1 of the cell; `caption` prints a line under the table. ```json { "caption": "USDT perpetuals, 15:04 UTC", "rows": [ [ { "text": "Binance", "rowspan": 2, "valign": "middle", "font_weight": "bold" }, "BTCUSDT", { "value": 4210000000, "bar": 1 }, 0.01 ], ["", "ETHUSDT", { "value": 1830000000, "bar": 0.43 }, 0.0082], [ { "text": "Bybit", "font_weight": "bold" }, "BTCUSDT", { "value": 2640000000, "bar": 0.63 }, -0.0041 ] ] } ``` ## Ladder, feed and meter Three small widgets sit on the price pane. A ladder is a column of bars at their prices along one side, a feed is a few lines of text with their times, and a meter is one bar filled from 0 to 1. | Widget | Declare | Write | | --- | --- | --- | | Ladder | `draw.ladder({ name, frame, side, divider?, title?, ...style })`, `side` `"left"` or `"right"` | a frame `{ rows, divider?, title? }`: 1 to 256 rows `[price, value, fraction, color?]`, the fraction (0 to 1) the bar's length; `divider` `{ label, price }` marks one price, and a divider in the frame replaces the declared one | | Feed | `draw.feed({ name, frame, anchor?, offset?, z?, title?, time_format?, ...style })` | a frame `{ lines, title? }`: 1 to 50 lines `[time, text, color?]`, the time in epoch milliseconds and the text at most 80 characters | | Meter | `draw.meter({ name, label, fraction: { output }, ramp, text?, anchor?, offset?, z?, title?, bar_height?, track_color?, ...style })` | no frame: the meter reads the output on the newest ready bar, a fraction from 0 to 1, colored along `ramp` (2 to 5 colors); `text` is a literal or `{ slot }` | The `title`, `time_format`, `bar_height` and `track_color` words, and the card surface and chrome a widget shares, are listed under Frames, panels and compact widgets above; a ladder's frame rows and a meter's ramp take colour words. The last bars' volume by price as a ladder on the right of the pane: ```typescript // The last bars' volume by price as a ladder on the right of the pane: eight rows from the window's low to its high, the busiest in amber. param.int("window", 100, { min: 10, max: 500, label: "Bars in the window" }); output("close_line", line, overlay, { color: "#94a3b8", description: "The close" }); const rows = frame("rows", { max_bytes: 2048 }); draw.ladder({ name: "volume_by_price", frame: rows, side: "right" }); const MAX = 500; const ROWS = 8; const closes = new StaticArray(MAX); const volumes = new StaticArray(MAX); const binned = new StaticArray(ROWS); let window: i32 = 100; let head: i32 = 0; let count: i32 = 0; function onStart(): void { window = i32(p_window()); } function onBar(): void { const close = bar.close(); if (isNaN(close)) return; out_close_line(close); closes[head] = close; volumes[head] = bar.volume(); head = (head + 1) % window; if (count < window) count += 1; if (!bar.isLast()) return; let lo = Infinity; let hi = -Infinity; for (let i = 0; i < count; i++) { lo = Math.min(lo, closes[i]); hi = Math.max(hi, closes[i]); } if (!(hi > lo)) return; const step = (hi - lo) / f64(ROWS); for (let r = 0; r < ROWS; r++) binned[r] = 0.0; for (let i = 0; i < count; i++) { const r = i32(Math.min(f64(ROWS - 1), Math.floor((closes[i] - lo) / step))); binned[r] += volumes[i]; } let busiest = 0.0; for (let r = 0; r < ROWS; r++) busiest = Math.max(busiest, binned[r]); if (busiest <= 0.0) return; // One row per price band: [price, volume, the bar's length 0 to 1, color]. The divider marks the last close. fb_clear(); fb_text('{"rows":['); for (let r = 0; r < ROWS; r++) { if (r > 0) fb_text(","); fb_text("["); fb_num(lo + step * (f64(r) + 0.5)); fb_text(","); fb_f64(binned[r], 0); fb_text(","); fb_f64(binned[r] / busiest, 3); fb_text(","); fb_str(binned[r] == busiest ? "#f59e0b" : "#64748b"); fb_text("]"); } fb_text('],"divider":{"label":"Last","price":'); fb_num(close); fb_text("}}"); writeFrameBuffer(FRAME_ROWS); } ``` - **Rows at prices.** Each row is a price band's middle price, its volume, its bar's length against the busiest band, and its color. - **The divider.** `"divider": { "label": "Last", "price": close }` in the frame marks the last close. - **Sized once.** The window lives in two `StaticArray`s made at module start, so nothing allocates per bar. An RSI meter in the corner, and a feed of the RSI's last five crossings of 70 and 30: ```typescript // An RSI meter in the top-left corner, and a feed of the last five times the RSI crossed 70 or 30. param.int("length", 14, { min: 2, max: 100, label: "RSI length" }); output("rsi", line, lower, { color: "#a855f7", description: "RSI" }); output("rsi_fraction", none, lower, { description: "The RSI as a fraction, 0 to 1: the meter's fill" }); string("rsi_text", { max_bytes: 8 }); draw.meter({ name: "rsi_meter", label: "RSI", fraction: { output: "rsi_fraction" }, ramp: ["#3b82f6", "#64748b", "#ef4444"], text: { slot: "rsi_text" }, anchor: "top_left" }); const events = frame("events", { max_bytes: 2048 }); draw.feed({ name: "rsi_events", frame: events, anchor: "bottom_left" }); const KEEP = 5; const times = new StaticArray(KEEP); const ups = new StaticArray(KEEP); let head: i32 = 0; let count: i32 = 0; let rsi = new Rsi(14); let prev: f64 = NaN; function onStart(): void { rsi = new Rsi(i32(p_length())); } function onBar(): void { const value = rsi.update(bar.close()); if (isNaN(value)) return; out_rsi(value); out_rsi_fraction(value / 100.0); sb_clear(); sb_f64(value, 1); str_rsi_text_sb(); // A crossing joins the feed; the oldest of the five leaves. const above = prev <= 70.0 && value > 70.0; const below = prev >= 30.0 && value < 30.0; if (above || below) { times[head] = bar.time() * 1000.0; ups[head] = above; head = (head + 1) % KEEP; if (count < KEEP) count += 1; } prev = value; if (!bar.isLast() || count == 0) return; // One line per crossing, newest first: [time in milliseconds, text, color]. fb_clear(); fb_text('{"lines":['); for (let k = 0; k < count; k++) { const i = (head - 1 - k + KEEP) % KEEP; if (k > 0) fb_text(","); fb_text("["); fb_f64(times[i], 0); fb_text(","); fb_str(ups[i] ? "RSI crossed above 70" : "RSI crossed below 30"); fb_text(","); fb_str(ups[i] ? "#ef4444" : "#3b82f6"); fb_text("]"); } fb_text("]}"); writeFrameBuffer(FRAME_EVENTS); } ``` - **The meter reads an output.** `fraction: { output: "rsi_fraction" }` takes the RSI over 100 on the newest bar, and `text: { slot: "rsi_text" }` prints the reading on it. - **The feed is a frame.** Each line is `[time, text, color]`, the time `bar.time() * 1000` because a feed counts milliseconds, newest first. - **Nothing to show, nothing written.** The feed is written only once a crossing exists; until then the frame stays unwritten and the feed is off the chart. ## Next - **Plotting:** lines, marks, tints and fills, one value per bar ([Plotting](plotting.md)) - **Drawing objects:** lines, boxes and labels your file places and moves ([Drawing objects](drawing-objects.md)) - **HUD and hover cards:** tiles in a corner and a card under the cursor ([HUD and hover cards](hud-and-hover-cards.md)) - **Limits:** every cap on frames, panels, tables and widgets ([Limits](../reference/limits.md)) # Price canvases Five declarations draw a canvas on the price chart from cells the chart already serves or from a frame the module writes: `plot.heatmap` (a time by price heatmap), `plot.footprint` (one footprint column per bar), `plot.tpo` (letter blocks per period and price), `plot.profile` (a volume profile anchored to a session, a range, the developing session or the visible window) and `plot.matrix` (a strike by expiry table docked on the price axis). A `place: "side"` panel is not a canvas: on a chart it mounts below like every panel, and its right-side home is a window ([Side placement and windows](cards-frames-panels.md#side-placement-and-windows)). Like `plot.levels`, each is an object literal the build reads and removes before the file compiles; the module declares the canvas and the chart draws it from the rows it already holds, so a footprint costs no output and no transport of its own. ## What the canvases share - **Names.** Every canvas has a `name`, unique across outputs, string slots, frames, levels, panels, renderers, drawings, heatmaps, profiles and matrices. A canvas is a child of the indicator: it shows and hides with the indicator's eye, and its legend X removes the indicator. - **Cells.** A heatmap, footprint, TPO or profile reads a declared celled input through `cells`: `book` or `volume_profile` for a heatmap, `volume_profile` for a footprint or a profile, `volume_profile` or `intrabar` for a TPO (absent on a TPO means the chart's own candles). A `cells` word that names an input of another class is refused by name ("heatmap 'h' reads cells from a book or volume_profile input; 'm1' is intrabar"). - **Colours.** Every colour word takes `"#rrggbb"`, `"#rrggbbaa"` or a theme token (`theme.up`, `theme.down`, `theme.text`, `theme.muted`, `theme.bg`, `theme.grid`, `theme.accent`); a token follows the chart's theme and re-resolves on a theme switch without a rerun. - **Type.** `font_family` is `"ui"`, `"mono"`, `"serif"` or `"rounded"` (system fonts only, nothing downloads); `font_weight` is `"normal"`, `"medium"` or `"bold"`; `font_size` is 6..64 px. - **Numbers.** `format` is one of `price`, `%`, `si`, `int`, `0`, `0.0`, `0.00`, `0.000`, `usd`, `auto`; `decimals` (0..8), `signed` and `unit` (0..8 characters) refine it and are refused without it ("decimals needs format"). `price` prints at the chart's price digits. - **Settings.** Any style key except a name or a reference (`cells`, `grid`, `price_low`, `price_step`, `start`, `end`, `frame`) takes `"@"`, so a `param.color` or `param.choice` on the Style page repaints the canvas without a rerun of your code ([The Style page](../settings/style-page.md)). - **Caps.** At most 4 heatmaps and 4 matrices per indicator; footprints, TPOs and profiles share one cap of 4. The numbers are on [Limits](../reference/limits.md). - **Opt-in.** Every word has a default that keeps the chart's own look for that canvas; declare only what you want changed. ## Heatmaps `plot.heatmap(options)` paints one coloured cell per bar and price row. The cells come from a celled input (`cells`) or from a grid of outputs the module writes (`grid`); exactly one of the two is declared. ```typescript input("close", ohlcv.close); input("depth", book.cells, { max_cells: 1000, block_size: 10 }); // The book as a heatmap behind the candles: size per level, the 98th percentile as the top of the scale. plot.heatmap({ name: "liquidity", cells: "depth", value: "size", palette: ["theme.bg", "#2563eb", "#22d3ee", "#facc15"], opacity: 0.85, tooltip: "{{value:si}} at {{price}}" }); ``` | Word | Value | What it does | | --- | --- | --- | | `cells` | a `book` or `volume_profile` input | the rows to paint: book levels, or profile buckets | | `value` | book `"size"` (default), `"bid"`, `"ask"`, `"signed"`; profile `"total"` (default), `"buy"`, `"sell"`, `"delta"` | the number a cell carries; `signed` and `delta` make a diverging scale | | `grid` | an `out.grid` name | the module's own grid instead of cells (below) | | `price_low`, `price_step` | output names (`price_step` may be a number > 0) | with `grid`: the bottom edge of row 0 on each bar, and every row's height in price; both required, both refused beside `cells` | | `row_height` | number > 0 | with book cells: the row height in price (default the book's own grouping); refused on other cells | | `palette` | 2..8 colours, low to high | default `["theme.bg", "#2563eb", "#22d3ee", "#facc15"]`, or `["theme.down", "theme.bg", "theme.up"]` when `center` applies | | `min`, `max` | numbers, `min` below `max` | the ends of the scale (default from the data, below) | | `center` | number | the midpoint of a diverging scale; default 0 under `value: "signed"` or `"delta"`, else absent; refused outside `min..max` when both are declared | | `auto_quantile` | 0.5..1 (default 0.98) | the quantile that sets an automatic end of the scale; refused beside both `min` and `max`; the viewer's Sensitivity row moves it | | `scale` | `"linear"` (default), `"sqrt"`, `"log"` | how a value maps onto the palette; `"log"` is refused on a diverging scale | | `floor` | number >= 0 (default 0) | a cell whose distance from the centre is at or under it draws nothing (so does an empty cell) | | `opacity` | 0..1 (default 0.85) | multiplies every cell colour's own alpha | | `behind_candles` | boolean (default true) | under the candles; `false` paints over them | | `cell_gap` | 0..4 px (default 0) | a gap between neighbouring cells | | `labels` | boolean (default false) | print each cell's value where the cell is tall and wide enough | | `font_size`, `font_weight`, `font_family`, `text_color` | 6..64 (10), the weight word, the family word (`"ui"`), a colour (`theme.text`) | the label type | | `format`, `decimals`, `signed`, `unit` | the number words (default `"auto"`) | how a label and the readout print a value | | `tooltip` | a template of at most 200 characters | a hover readout over the cell; absent means no readout | | `label` | 1..40 characters (default the name in words) | the readout's title | The automatic scale is read over every cell of the full run: a one-sided scale runs from 0 (for `size`, `bid`, `ask`, `total`, `buy` and `sell`) or from the low quantile to the high quantile; a diverging scale runs the same distance either side of `center`. Live ticks keep the run's scale, so colours do not shift between runs. The viewer moves the automatic end with the heatmap's Sensitivity row on the Style page (Subtle to Vivid, starting at your `auto_quantile`), with no rerun ([The Style page](../settings/style-page.md#the-sensitivity-row)). The tooltip template reads `{{value}}` and `{{value:}}` (the cell's number), `{{price}}` (the row's middle), `{{price_low}}`, `{{price_high}}`, `{{time}}` and `{{label}}`; an unknown placeholder stays as written. A heatmap draws beside the chart's own book or options heatmap without touching it. A book heatmap on a slow chart reads better in coarser rows. This one regroups the book into rows of 25 price units, maps size through a square-root scale so a few large resting orders do not wash out the rest, leaves a one-pixel gap between cells and blanks the thinnest levels: ```typescript input("close", ohlcv.close); input("depth", book.cells, { max_cells: 1000, block_size: 10 }); plot.heatmap({ name: "depth_rows", cells: "depth", value: "size", row_height: 25, scale: "sqrt", cell_gap: 1, floor: 2, opacity: 0.9 }); ``` ### A grid the module computes `out.grid(name, { rows })` declares `rows` (2..128) data-only outputs named `_0` to `_` and generates one writer, `out_(row: i32, value: f64)`, for `onBar()`. A row left unwritten is an empty cell (NaN); a row outside `0..rows - 1` aborts the run by name. The heatmap then names the grid and two price outputs: the bottom edge of row 0 on each bar and the row height. ```typescript param("rows", 64, { min: 2, max: 128 }); input("close", ohlcv.close); output("low_edge", none, overlay, { description: "The bottom of row 0: 32 rows under the close" }); output("step", none, overlay, { description: "The row height in price" }); out.grid("heat", { rows: 64 }); plot.heatmap({ name: "liq", grid: "heat", price_low: "low_edge", price_step: "step", center: 0, floor: 0.5 }); function onBar(): void { const step = bar.close() * 0.0025; out_step(step); out_low_edge(bar.close() - 32.0 * step); // Row 32 sits on the close; rows above it read positive, rows below negative, fading with distance. for (let row = 0; row < 64; row++) out_heat(row, f64(row - 32) * bar.volume() / 32.0); } ``` The grid's outputs count toward the 256 outputs a file may declare, and the rows must be contiguous in declaration order (the build lays them out for you). The [Liquidation Heat](../cookbook/liquidation-heat.md) recipe is a complete grid heatmap, and [OI Liquidation Heat](../cookbook/liquidation-heat-oi.md) builds the same grid from open interest; [Book Heat](../cookbook/book-heat.md) paints the order book. ## Footprints `plot.footprint(options)` draws one footprint column per chart bar from the bar's `[low, high, buy, sell]` buckets: the module only declares it. ```typescript input("close", ohlcv.close); input("profile", volume_profile.cells, { max_cells: 8192 }); plot.footprint({ name: "fp", cells: "profile", mode: "split_bar", imbalance: true, imbalance_ratio: 3, stacked_imbalances: 3 }); ``` | Word | Value | What it does | | --- | --- | --- | | `cells` | a `volume_profile` input | required; a book input is refused by name | | `mode` | `"cluster"`, `"split_bar"` (default), `"stacked_bar"`, `"ladder"`, `"imbalance_only"` | the column's drawing | | `buy_color`, `sell_color` | colours | default the chart's profile pair | | `row_height` | number > 0 or `"auto"` (default) | the row height in price; `"auto"` is the input's bucket width times the footprint's row table ([Row height](#row-height)) | | `labels` | boolean (default true) | print the cell numbers | | `hide_zero` | boolean (default false) | leave zero cells blank | | `text_color` | colour | the cell ink (default the chart's footprint ink) | | `imbalance`, `imbalance_ratio`, `imbalance_buy_color`, `imbalance_sell_color`, `stacked_imbalances` | boolean (false), 1.1..20 (3), colours (`theme.up`, `theme.down`), 0..10 (3) | mark buy and sell imbalances at the ratio, and stacks of them | | `poc`, `poc_color`, `poc_width` | boolean (true), colour (`theme.text`), 1..10 px (1) | the point of control per column | | `value_area`, `vah`, `val`, `vah_color`, `val_color` | 0.5..0.95 (0.7), booleans (true), colours (`theme.text`) | the value area and its edges | | `cell_metric` | `"volume"`, `"delta"` (default), `"trades"` | what a cluster cell reads | | `volume_color` | colour (default `"#4a90e2"`) | the volume cell colour | | `grading` | `"candle"` (default), `"session"`, `"visible"` | the range the cell shading is scaled over | | `font_family`, `font_weight` | the family and weight words (default the chart's indicator font) | the type | | `cell_hover` | boolean (default true) | the chart's cell readout on hover | A cluster footprint reads volume per row rather than delta. This one shades every cell against the session's largest cell instead of its own candle's, in one teal, leaves zero cells blank in rows of 10 price units, and turns the chart's hover readout off so the numbers printed in the cells are the only readout: ```typescript input("close", ohlcv.close); input("profile", volume_profile.cells, { max_cells: 8192 }); plot.footprint({ name: "clusters", cells: "profile", mode: "cluster", cell_metric: "volume", volume_color: "#2dd4bf", grading: "session", hide_zero: true, row_height: 10, cell_hover: false }); ``` [Volume Footprint](../cookbook/volume-footprint.md) is the complete recipe. ## TPO letters `plot.tpo(options)` prints a letter for every letter interval of the period in which a price row traded (profile buckets) or lay inside the bar's range (finer bars, or the chart's own candles when `cells` is absent). ```typescript input("close", ohlcv.close); input("m5", intrabar.cells, { interval: "5m", max_cells: 288 }); plot.tpo({ name: "tpo", cells: "m5", period: "day", letter_minutes: 30, initial_balance: true, single_prints: true }); ``` | Word | Value | What it does | | --- | --- | --- | | `cells` | a `volume_profile` or `intrabar` input, or absent | the bars or buckets the letters are built from | | `period` | `"day"` (default), `"week"`, `"month"` | one profile per period | | `letter_minutes` | 1..240 (default 30) | the minutes one letter stands for | | `row_height` | number > 0 or `"auto"` (default) | the row height in price; `"auto"` comes from the market, and a period past 512 rows is coarsened ([Row height](#row-height)) | | `display` | `"letters"`, `"blocks"`, `"both"` (default) | letters, blocks, or both | | `palette` | 2..26 colours | the colours across the letters (default the chart's TPO scheme) | | `color_mode` | `"period"` (default), `"count"`, `"volume"`, `"delta"` | what colours a block; `"volume"` and `"delta"` need `volume_profile` cells | | `value_area`, `poc`, `vah`, `val`, `poc_color`, `vah_color`, `val_color` | 0.5..0.95 (0.7), booleans (true), colours (`theme.text`, `theme.muted`, `theme.muted`) | the value area and its edges | | `outside_va_opacity` | 0..1 (default 0.3) | how far rows outside the value area fade | | `initial_balance`, `ib_color` | boolean (true), colour (`theme.text`) | the initial balance bracket | | `single_prints`, `single_prints_color`, `poor_extremes`, `poor_extremes_color` | booleans (false), colours (`"#cc0033"`, `"#ffd966"`) | single prints and poor highs and lows | | `counts` | boolean (default false) | the TPO count per row | | `volume_profile` | boolean (default false) | a volume profile beside the letters; needs `volume_profile` cells | | `font_family`, `font_weight`, `cell_hover` | as a footprint | the type and the hover readout | A day profile coloured by what each row traded, with the structure marks a profile reader looks for: poor highs and lows in amber, single prints in red, the TPO count printed on every row and a volume profile beside the letters: ```typescript input("close", ohlcv.close); input("profile", volume_profile.cells, { max_cells: 8192 }); plot.tpo({ name: "day_tpo", cells: "profile", period: "day", color_mode: "volume", poor_extremes: true, poor_extremes_color: "#f59e0b", single_prints: true, single_prints_color: "#ef4444", counts: true, volume_profile: true }); ``` A letter must be a whole multiple of the feeding bars' interval: 30-minute letters over 1h candles cannot be placed, and the legend chip and the Console say so ("tpo 't': 30-minute letters need bars of 30 minutes or a whole divisor; the bars here are 1h (declare an intrabar input with interval "5m" and name it in cells)"). [TPO Letters](../cookbook/tpo-letters.md) is the complete recipe. ## Profiles anchored in time `plot.profile(options)` draws a volume profile over a span of time from `volume_profile` cells: one per session, one over a range two outputs mark, the developing session, or the visible window. ```typescript input("close", ohlcv.close); input("profile", volume_profile.cells, { max_cells: 8192 }); plot.profile({ name: "session_vp", cells: "profile", span: "session", session: "1d", value_area_shade: true, background: true }); ``` | Word | Value | What it does | | --- | --- | --- | | `cells` | a `volume_profile` input | required | | `span` | `"session"` (default), `"range"`, `"developing"`, `"visible"` | what one profile covers | | `session` | `"auto"` (default), `"1h"`, `"2h"`, `"4h"`, `"6h"`, `"12h"`, `"1d"`, `"1w"`, `"us"`, `"eu"`, `"asia"` | the session length under `"session"` and `"developing"`; refused on `"range"` and `"visible"`; `"auto"` follows the chart interval | | `start`, `end` | output names, epoch seconds read on the last ready row | the range under `span: "range"` (`start` required there, `end` defaults to the newest bar; both refused elsewhere); a run whose start is not a number, or whose end is not after it, mounts no profile and the legend chip says why | | `mode` | `"stacked_bar"` (default), `"split_bar"`, `"outline"`, `"delta"` | the bars' drawing | | `buy_color`, `sell_color` | colours | default the chart's profile pair | | `dock` | `"left"`, `"right"` | which edge of the span the bars grow from (default left, right on `"visible"`) | | `width_frac` | 0.05..1 | the bars' share of the span (default 0.7), or of the pane under `"developing"` and `"visible"` (default 0.2) | | `row_height` | number > 0 or `"auto"` (default) | the row height in price; `"auto"` is the input's bucket width times the profile's row table ([Row height](#row-height)) | | `value_area`, `value_area_shade`, `outside_buy_color`, `outside_sell_color` | 0.5..0.95 (0.7), boolean (true), colours (the base colours at half strength) | the value area and how rows outside it paint | | `poc`, `poc_color`, `poc_width`, `vah`, `val`, `vah_color`, `val_color` | booleans (true), colours (`theme.text`), 1..10 px (1) | the point of control and the value area edges | | `level_labels` | boolean (default false) | price tags on the three levels | | `naked` | boolean (default false) | keep a level drawn until price trades through it | | `extend` | `"none"`, `"right"` | extend the levels to the right (default right on `"developing"`, none otherwise) | | `behind_candles` | boolean (default false) | under the candles; needs `span: "session"` and a filled mode | | `background`, `background_color`, `background_opacity` | boolean (false), colour (`theme.text`), 0..1 (0.08) | a box behind each session; needs `span: "session"` | | `font_family`, `font_weight`, `cell_hover` | as a footprint | the type and the hover readout | [Session Volume Profile](../cookbook/session-volume-profile.md) is the complete recipe. A profile the module computes itself (open interest by strike, a liquidation map) is a `plot.levels` whose frame carries spans: ```typescript const oiFrame = frame("oi_spans", { max_bytes: 32768 }); plot.levels({ name: "oi", frame: oiFrame, dock: "left", span: "time", width_frac: 0.4 }); ``` With `span: "time"` the frame is `{ "spans": [...] }`, one entry per profile, each drawn inside its own time span (with `span: "pane"`, the default, the frame keeps the `{ prices, values, colors? }` shape docked on the pane edge): ```json { "spans": [ { "start": 1727740800, "end": 1727827200, "prices": [61000, 61500, 62000], "values": [120.5, 310.2, 88.0] }, { "start": 1727827200, "end": 1727913600, "prices": [61500, 62000, 62500], "values": [90.0, null, 140.0], "colors": ["theme.up", "theme.muted", "theme.up"] } ] } ``` A frame holds at most 64 spans, each `start` before its `end` in epoch seconds, 1..512 strictly ordered prices with as many values (null for a gap) and optional colours, and at most 4096 rows across every span. A frame whose shape does not match the declared `span` refuses the run with `wrun_frame_invalid`. Every other `plot.levels` word applies ([Docked profiles](cards-frames-panels.md#docked-profiles)); [Session OI Levels](../cookbook/session-oi-levels.md) is the recipe. ## Row height A footprint, a TPO and a profile draw one row per band of price, `row_height` tall. A number is used as written. `"auto"`, the default, takes its unit from the first of these the chart knows for the market, never from the decimals the closes print: 1. **The bucket width** of the `volume_profile` input named in `cells`. A footprint and a profile always read one, so their auto stops here. 2. **The block size the chart serves** for the market (its TPO catalog, else its volume profile catalog), or, with no catalog, the venue's instrument tick when the chart holds one (CME futures). 3. **The median candle range** divided by 4, snapped up to 1, 2 or 5 x 10^k. 4. **Otherwise 1.** Rungs 1 and 2 are multiplied by the chart's row table, the figures the chart's own footprint, TPO and session profile use; the range unit (rung 3) already scales with the interval, so it is used as is. | Chart interval | 1m | 5m | 15m | 30m | 1h | 4h | 1d | 1w | | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | | Footprint | 2 | 5 | 8 | 10 | 15 | 25 | 50 | 250 | | TPO | 5 | 10 | 15 | 20 | 25 | 30 | 50 | 100 | | Profile | 5 | 10 | 15 | 20 | 25 | 50 | 100 | 200 | A week TPO multiplies its figure by 1.5 and a month TPO by 2, rounded down (22 and 30 at 15m). ### 512 rows per period 512 rows is the most a TPO period (a day, a week or a month) holds before the chart steps in. Past that it doubles the row height and folds again until every period fits, at most 6 times, whether the height came from `"auto"` or from a number. The TPO still draws, and every run says so with a warning row in the editor's Console and the warning on the indicator's legend chip, naming the declaration and both heights: - `tpo 'tpo': row_height auto coarsened from 1.5 to 12 (512 rows per day is the chart's limit)` - `tpo 'tpo': row_height 1 coarsened to 8 (512 rows per day is the chart's limit)` The cap depends on how far the market's price moves, so only the running chart applies it; the build never refuses a row height for it. A number so fine that one bar alone spans more than 4,096 rows is still refused by name, and rows thinner than 3 px on screen merge as you zoom out ([Limits](../reference/limits.md)). ### BTC at 15m [TPO Letters](../cookbook/tpo-letters.md) declares no `row_height` and no `cells`. On Binance's BTCUSDC perpetual at 15m the chart serves a 5-dollar TPO block and the TPO figure at 15m is 15, so a row is 5 x 15 = 75 dollars, the row the chart's own TPO draws there. A day holds about 25 to 90 rows, each tall enough for its letters (a letter needs a row about 8 px tall). The price tick times the same figure would make 0.1 x 15 = 1.5-dollar rows: every row under a pixel at the default zoom, no letter printed, and 48 times the rows to fold and paint (22,946 over 16 days against 476). That is why `"auto"` reads what the venue serves and never the decimals the closes print. ## Strike matrices `plot.matrix(options)` docks a table of numbers on the price axis: one row per price, one column per expiry (or whatever the columns are), each cell read from a frame the module writes on the live bar. It needs `abi_version: "wrun-4"`, which a frame declaration stamps for you. ```typescript const board = frame("board", { max_bytes: 65536 }); plot.matrix({ name: "gex", frame: board, dock: "right", column_width: 56, price_column: true, palette: ["theme.down", "theme.bg", "theme.up"], center: 0, format: "si", tooltip: "{{column}} {{value:si}} at {{price}}" }); ``` | Word | Value | What it does | | --- | --- | --- | | `frame` | a declared frame | the snapshot below; required | | `dock` | `"right"` (default), `"left"`, `"side"` | against the price axis, or in the strip beside the chart | | `columns` | 1..12 names of 1..16 characters | the column labels (default the frame's `cols`, else `"1"`, `"2"`, ...); a frame with another column count refuses the run | | `column_width` | 24..160 px (default 56) | one column's width | | `row_max_px` | 8..64 px (default 24) | the tallest a row may grow | | `price_column`, `header` | booleans (false, true) | a price column on the axis side; the sticky header row | | `palette`, `min`, `max`, `center`, `auto_quantile`, `scale` | as a heatmap (no default palette) | the fill behind a numeric cell; without a palette a cell has no fill unless it carries its own colour | | `opacity` | 0..1 (default 1) | multiplies every cell fill | | `format`, `decimals`, `signed`, `unit` | the number words (default `"auto"`) | how a numeric cell prints when it carries no text | | `font_size`, `font_weight`, `font_family`, `align`, `text_color` | 6..64 (10), the weight word, the family word (`"ui"`), `"left"`, `"center"`, `"right"` (`"right"`), a colour (`theme.text`) | the cell type | | `header_text_color`, `header_background_color` | colours (`theme.muted`, `theme.bg`) | the header row | | `background_color`, `background_opacity` | colour (`theme.bg`), 0..1 (0.85) | the backdrop behind the table | | `grid_color`, `grid_width`, `grid_style`, `grid_lines` | colour (`theme.grid`), 0..10 (1), `"solid"`, `"dashed"`, `"dotted"`, `"all"`, `"rows"`, `"cols"`, `"none"` | the lines between cells | | `border_color`, `border_width`, `border_style` | colour (`theme.grid`), 0..10 (0), the line style | the frame around the table | | `cell_padding` | 0..12 px (default 4) | the inset inside a cell | | `highlight_color` | colour (default `theme.accent`) | the outline of the frame's highlighted row and column | | `tooltip`, `label` | a template of at most 200 characters; 1..40 characters | the hover readout and its title; the template reads `{{value}}`, `{{value:}}`, `{{text}}`, `{{price}}`, `{{column}}` and `{{label}}` | A matrix can take chrome of its own. This board docks on the left, prints its header row in the accent colour on a dark fill, frames the table with a dashed border and draws lines between rows only: ```typescript const board = frame("board", { max_bytes: 65536 }); plot.matrix({ name: "oi_board", frame: board, dock: "left", header_text_color: "theme.accent", header_background_color: "#0f172a", border_color: "theme.grid", border_width: 1, border_style: "dashed", grid_lines: "rows" }); ``` The frame is a strict object: ```json { "prices": [60000, 62000, 64000, 66000], "cells": [ [1.2e6, -4.1e5, null], [2.5e6, 8.0e5, "n/a"], [ { "value": -3.3e6, "text_color": "theme.down", "font_weight": "bold" }, 1.1e6, 2.0e5 ], [4.0e5, 1.5e5, 9.0e4] ], "cols": ["27JUN", "25JUL", "26SEP"], "title": "GEX by expiry", "highlight": { "price": 64000, "col": 0 }, "range": { "min": -5e6, "max": 5e6 } } ``` `prices` holds 1..128 numbers in strictly rising or falling order; `cells` holds exactly one row per price, every row the same width (1..12 columns, at most 1536 cells in all); `cols` names the columns (1..16 characters each); `title` (1..40 characters) prints above the table; `highlight` marks a price row and a column index (0-based); `range` moves the palette bounds per run. A cell is `null` (empty), a number (printed through `format`), a string of up to 16 characters, or an object with any of `value`, `text`, `color` (the fill), `text_color` and `font_weight`. A frame that breaks any of this refuses the run with `wrun_frame_invalid: .`, never a truncated table. [Strike Matrix](../cookbook/strike-matrix.md) builds the board from the live options chain ([Options kit](../functions/options-kit.md)). ## The strip beside the chart A matrix joins a strip to the right of the price axis with `dock: "side"`, its rows still on the price pane's prices. The strip takes at most 40% of the chart's width and folds away on a chart narrower than 480 px, where its items are not drawn. Matrices sit nearest the axis, one column each; a column the strip cannot fit at its smallest size (24 px) is not drawn. The strip has no pointer interaction yet: no hover cards there, and no drag to resize it. Panels do not use the strip: a `place: "side"` panel mounts below the chart like every panel, and the chart offers it a window of its own ([Side placement and windows](cards-frames-panels.md#side-placement-and-windows)). Its `width_px` (160..480) and `height_px` (80..800) stay accepted with `place: "side"` only, reserved for a strip that panels do not use today. ## What refuses Every misuse is refused by name when Run derives the sheet: a fifth heatmap ("heatmaps must declare at most 4 entries"), `cells` beside `grid`, a `value` word of the other cell class, `out.grid` rows outside 2..128 ("out.grid 'heat' rows must be between 2 and 128"), a grid heatmap without `price_step`, a footprint over book cells, a TPO `color_mode` of `"volume"` without profile cells, `letter_minutes` of 0, `span: "range"` without `start`, `behind_candles` on a visible-window profile, a matrix under `wrun-3` ("matrices needs abi_version "wrun-4" (wrun-3 has no frame channel)"), 13 columns, `width_px` on a panel placed below. A frame that fails its shape refuses the run instead, as `wrun_frame_invalid`. The caps and ranges are on [Limits](../reference/limits.md). # Legend The legend names the indicator, then shows each drawn output's label and value; every word on it comes from a declaration. ## What it is The legend shows each output's `label`, or its name read as words when there is none (`bb_upper` reads "Bb upper"), and its value in the output's `format`; `legend: false` keeps an output out of the legend, and `legend({ title: "..." })` adds words after the indicator's name. Outputs mount in declaration order, which is their legend and paint order. A `render.legend` entry adds a line the outputs do not carry, a string slot's words or an output's formatted value, printed after the value lines. The dot before an entry shows the colour the hovered bar is drawn in, so a `color_by` ladder, a packed colour or a column's sign pair reads in the legend as it does on the chart. A candle group is one entry reading its open, high, low and close (`O 1.20 H 1.31 L 1.18 C 1.29`), its dot the up or down colour of the hovered bar. A `range()` band has a row of its own (`legend: false` drops it, `label` names it), a guide (`role: "guide"`) never appears, and a pane declared with a `title` heads its own legend with that title. ## Declare it - `legend({ title })`: the words after the indicator's name, `(14)` style; `{{length}}` reads a setting by name. Without it the legend shows the package name. - Per output: `label` (the legend and Style page name), `format` (`price`, `%`, `si`, `int`, `0`, `0.0`, `0.00`, `0.000`, `usd`, `auto`) with `decimals` (0..8), `signed` and `unit` (printed after the value; unbounded on an output, as it always was), and `legend: false`. `price` prints at the chart's price digits on every pane; `usd` reads `$1.2M`, `auto` six significant digits with `,` thousands ([Styling](styling.md#placement-per-output)). - Per band: `range(a, b, { legend: false, label: "Band" })`. - `render.legend(name, { text?, value?, format?, color?, color_by?, colors? })`: an entry in the legend, a string slot's words (`text`) or an output's formatted value (`value`), in a static `color` or colored per bar by a ladder (`color_by` and `colors`, both or neither). - `color: "@name"` on a legend entry, or an entry of its `colors`, binds that spot to a `param.color`, so the user picks the entry's color ([The Style page](../settings/style-page.md)). ```typescript param.int("length", 20, { min: 2, max: 500 }); const basis = output("basis", line, overlay, { color: "#2962ff", width: 2, label: "Basis", format: "price", tooltip: "{{label}} {{value:price}}" }); output("rsi", line, lower, { color: "#8b5cf6", label: "RSI", format: "0.0" }); output("state", none); string("regime", { max_bytes: 16 }); legend({ title: "({{length}})" }); render.legend("regime_entry", { text: "regime", color_by: "state", colors: ["#ef5350", "#26a69a"] }); ``` ## What the chart draws ![the legend row: the name with its title suffix, then two labelled values](/wrun/images/wrun-legend.svg) The legend reads "Basis 20", then "Basis 64,120.5", "RSI 61.3" and the regime word in the ladder's color. An output's `tooltip` is shown on its legend entry, and resting the cursor on the entry opens the output's hover card ([Hover cards](hud-and-hover-cards.md#hover-cards)). ## Gotchas - `legend: false` keeps an output out of the legend; `visible: false` starts it hidden; a `role: "guide"` output is never listed. - `decimals`, `signed` and a printed `unit` need `format`; a `unit` without one is recorded and never printed. - A legend entry needs `text` (a string slot) or `value` (an output), and takes `color_by` and `colors` together, not one without the other. - `legend` needs a nonempty title. - A presentation name used twice is refused. - The legend is drawn where the indicator runs in the browser. ## Related - [What the chart shows](overview.md), [Hover cards](hud-and-hover-cards.md#hover-cards), [HUD cards](hud-and-hover-cards.md#hud-cards), [Blocks and tiles](hud-and-hover-cards.md#blocks-and-tiles) - [The Style page](../settings/style-page.md): the `"@name"` binding - [Plotting](plotting.md): the outputs the legend lists # Labels, tooltips, badges The words a plot, a text mark or a label answers with: the label's style, the tooltip template, and the badge chips a ladder output turns into. ## What it is `style` on a text or label renderer picks its look: `price_label` draws the price tag on the axis (the one word `render.text` takes), and a `render.label` also takes `plain`, `pill` (a rounded chip at the value), `callout` (a leader line to its bar) and `badge` (a dot in the label's color). `tooltip` on an output is a template over its own value and any output or string slot, shown on the legend entry and when the cursor nears the line; on `render.text` and `render.label` it is one per renderer, shown on its marks. `badges` names ANOTHER output, a ladder whose labels become chips on the hover card. `style`, `tooltip`, `hover` and `badges` are presentation keys, stripped before the compiler like an output's. ![The five label styles side by side on one small pane: plain words above their bar, a pill chip at the value, a callout chip with a leader line down to its bar, a badge dot in the label's color before its words, and a price_label tag on the price axis tied to its bar](/wrun/images/wrun-label-styles.svg) 1. **plain**: the words alone, at the label's `x` and `y`. 2. **price_label**: a tag on the price axis at the mark's price, the one style `render.text` also takes. 3. **pill**: a rounded chip at the value. 4. **callout**: a chip with a leader line down to its bar. 5. **badge**: a dot in the label's color, then the words. ## Declare it ```typescript const basis = output("basis", line, overlay, { color: "#2962ff", width: 2, label: "Basis", format: "price", tooltip: "{{label}} {{value:price}}" }); // A text mark on every stretched bar (the slot is left unwritten on quiet bars). render.text("stretch_mark", { y: "average", text: "readout", color: "#f59e0b", size: 10 }); // One label riding the newest bar, at the average. render.label("average_tag", { x: "bar_time", y: "average", text: "tag", color: "#38bdf8", size: 11 }); ``` A template placeholder is `{{name}}` for an output or a string slot, `{{name:format}}` with a format from the list (`price`, `%`, `si`, `int`, `0`, `0.0`, `0.00`, `0.000`), `{{label}}` and `{{value}}` for the owning output: `"{{label}} {{value:price}} · {{state}}"`. A price tag on every signal bar is `render.text` with `style: "price_label"`: each written bar's text sits at `y` as a price tag. ![One tooltip template resolving: the declaration reads tooltip with label, value as a price and state in double braces; the legend entry shows Basis 83,600.00 and the word stretched; the same words sit under the cursor near the line on the chart; and the hover card carries the ladder output's labels as badge chips](/wrun/images/wrun-tooltip-template.svg) 1. **The template** on the output: `{{label}}` is the output's label, `{{value:price}}` its value at the chart's price precision, `{{state}}` a string slot or another output. 2. **On the legend entry**: the same words, resolved on the newest bar. 3. **Near the line**: the same tooltip when the cursor nears the plot. 4. **Badges**: the ladder output named in `badges` turns its labels into chips on the hover card. ## What the chart draws What each surface answers with: | Surface | Answers with | | --- | --- | | a plot (a line, columns, marks) | its `hover` blocks; without them its `tooltip`, else its label and value | | a legend entry | the output's `tooltip`, and the output's hover card when the cursor rests on it | | a text mark (`render.text`) | the renderer's `tooltip` on its marks, its `hover` list, its `badges`; `style: "price_label"` draws each mark as a price tag | | a label (`render.label`) | the same, with `style` one of `plain`, `price_label`, `pill`, `callout`, `badge` | | a HUD tile | the tile again as a hover card | | a table or a drawing | nothing | ## Gotchas - The pill, callout and badge label styles draw on the next engine pin. - `render.text` takes `price_label` only (no `style` is plain text); a label `style` outside the five words is refused. - `size` is an integer pixel count, 6..64, on `render.text` and `render.label` (`10` reads as small text, `16` as large); there is no alignment option on renderer text. - An output badging itself is refused; `badges` names a declared output. - A label has no corner position: it sits at its `x` and `y`, anchored to a bar and a price. A corner readout is a HUD card ([HUD cards](hud-and-hover-cards.md#hud-cards)). - In a template an unknown name stays as written. ## Related - [Hover cards](hud-and-hover-cards.md#hover-cards), [HUD cards](hud-and-hover-cards.md#hud-cards), [Legend](legend.md), [What the chart shows](overview.md) - [Plotting](plotting.md): the text, label and table renderers, and the string slots they read - [Styling](styling.md): the rest of the style vocabulary # HUD and hover cards Two surfaces read the indicator's newest values and are drawn by the chart from declarations alone: a HUD card of typed tiles pinned at one of the nine chart anchors, never moving with price, and the hover card that opens when the cursor rests on a legend entry, a HUD tile, a line, a text mark or a label. Both are built from one kit of blocks, `tile.*` on the HUD card and `block.*` on the hover card, and both are drawn where the indicator runs in the browser, on the desktop chart. The third surface, the legend entry, is on [Legend](legend.md); the three together, with a complete module, on [What the chart shows](overview.md). The status card, the meter, the feed, the ladder and the stats row share the HUD's words; their looks close the page ([The other widgets](#the-other-widgets)). ## HUD cards `render.hud(name, { position, title?, columns?, tiles, look?, ... })` is a card at one of the nine anchors, one or two columns, its tiles from the block kit under the `tile.*` name. It is the home for text that sits at a viewport anchor and does not move with price: a corner readout is a HUD card with one tile (or a `render.label` with a `position`, or a one-cell `render.table` at the same anchor, [Plotting](plotting.md#fixed-position-text)), a dashboard a card of several. Its surface, type and frame are words on the declaration, and `look` picks a whole set of them at once. ```typescript const basis = output("basis", line, overlay, { color: "#2962ff", width: 2, label: "Basis", format: "price", tooltip: "{{label}} {{value:price}}" }); output("rsi", line, lower, { color: "#8b5cf6", label: "RSI", format: "0.0" }); output("basis_chg", none); output("state", none); string("regime", { max_bytes: 16 }); render.hud("board", { position: "top_right", title: "Basis", columns: 2, tiles: [tile.value("Basis", "basis", { format: "price", delta: "basis_chg" }), tile.spark("RSI", "rsi", { bars: 32 }), tile.gauge("RSI level", "rsi", { min: 0, max: 100, format: "int" }), tile.pill("Regime", "regime", { color_by: "state", colors: ["#ef5350", "#26a69a"] })] }); ``` - `position`: one of the nine anchors, `top_left`, `top_center`, `top_right`, `middle_left`, `middle_center`, `middle_right`, `bottom_left`, `bottom_center`, `bottom_right`; a position outside them is refused. - `title`: the card's heading. `columns`: 1 or 2. - `tiles`: the card's tiles, any of the eight kinds ([Blocks and tiles](#blocks-and-tiles)); a presentation name used twice is refused. Every tile takes `color` or a `color_by` plus `colors` ladder (colour words, theme tokens included, never both); absent, the tile keeps its ink (marks in the output colour, text in the card text), or the look's default when the card declares a `look`. ![a card of four tiles in the top-right corner: a value with its change, a sparkline, a dial and a chip](/wrun/images/wrun-hud.svg) The card in the corner shows the basis with its change, a sparkline, a dial and the regime chip, and follows every live tick. Resting the cursor on a tile shows that tile again as a hover card, and a wheel over the card still zooms the chart. The card keeps clear of the pane chrome: the right anchors sit clear of the price axis, the bottom anchors clear of the time axis, and `safe_area: true` keeps the top anchors clear of the legend and the pane action bar too. It disappears with its indicator when the indicator is hidden or removed. ### Moving a card A viewer can move any card on their chart: drag it from anywhere on the card (on a phone, hold its grip, then drag). Near one of the nine places it snaps in and a label names the place ("Top right, under Market"); anywhere else it stays where it is let go. Option (Alt) drops it without the snap, Esc cancels, and a double-click sends a moved card back. The move is the viewer's, saved with the overlay on that chart; `position` and `offset` stay the card's default, and the Position row on the indicator's Style page ([The Style page](../settings/style-page.md#the-position-row)) says where the card is and puts it back. A card moved into a place that already holds cards joins the far end of its stack. ### Surface, type and frame Every word is optional and absent keeps today's card. Colours are `"#rrggbb"`, `"#rrggbbaa"` or a theme token (`"theme.bg"`, [Styling](styling.md#theme-colours)). ```typescript output("basis", line, overlay, { color: "#2962ff", width: 2, label: "Basis", format: "price" }); render.hud("board", { position: "top_right", title: "Market", columns: 2, width: 260, offset: [12, 8], safe_area: true, background_color: "theme.bg", background_opacity: 0.85, border_color: "theme.grid", corner_radius: 10, font_family: "mono", accent_color: "theme.up", chrome: "brackets", tiles: [tile.value("Basis", "basis", { format: "price", headline: true })] }); ``` - Size and place: `width` (80..1200 px; default 300, 180 at one column), `offset: [x, y]` (each -4096..4096 px; x moves inward from a left or right edge and y inward from the top or bottom, signed on the centre and middle anchors), `z` (an integer; cards in one anchor stack by it, lower nearer the anchor), `safe_area: true` (a top-left card starts under the legend, top-centre and top-right cards under the pane action bar, right-hand cards clear of the price-axis tags; `offset` applies from the cleared edge), `mobile: true` (the card shows on phones too, with pointer-free tiles and at most the larger of 140 px and 60 percent of its own width; absent, phones keep the chart as it is). - Surface: `background_color`, `background_opacity` (0..1, default 0.92), `background_gradient` (2 to 8 colours) with `gradient_direction` (`"vertical"`, the default, or `"horizontal"`), `border_color`, `border_width` (0..10, default 1), `border_style` (`"solid"`, `"dashed"`, `"dotted"`), `corner_radius` (0..32, default 12), `padding` (0..24), `shadow` (default true), `opacity` (0..1, the whole card). - Type: `text_color`, `title_text_color` (default the muted ink), `font_family` (`"ui"`, the app font; `"mono"`; `"serif"`; `"rounded"`; each a system stack, nothing downloads), `title_case` and `label_case` (`"none"`, `"upper"`, `"lower"`, `"small_caps"`; the title is upper and labels are as written by default; values and numbers never change case), `text_glow` (0..8 px, a static halo in the text colour). - Accent: `accent_color` (default `"theme.accent"`) inks every gauge, meter fill, spark and headline that declares no colour of its own; `accent_color_by` plus `accent_colors` (2 to 8) make the whole card follow an output per bar (green rising, red falling), and the pair excludes `accent_color`. - Frame: `chrome` is `"card"` (today's box), `"none"` (no box, text over the chart; pair it with a translucent background), `"brackets"` (four corner marks in the accent), `"rules"` (a thick and a thin rule above the title), `"tag"` (a small accent block at the top left), `"title_bar"` (the title on a full-width bar in the accent), `"window"` (bevelled edges and a two-colour title bar, its buttons decoration only) or `"cover"` (a square tile left of the title holding the chart's base asset over the accent gradient); `texture` is `"none"` or `"scanlines"` (static 1 px lines every 3 px). A flat card that takes its colour from the data: a dashed hairline border, no drop shadow, the title in the full text ink, and every gauge, meter fill, spark and headline inked by the sign of the bar's basis change, red falling and green rising. Reach for it when the card should read as part of the chart, not as a window over it. ```typescript output("basis", line, overlay, { color: "#2962ff", width: 2, label: "Basis", format: "price" }); output("basis_chg", none); output("rsi", line, lower, { color: "#8b5cf6", label: "RSI", format: "0.0" }); // 0 on a bar where the basis fell, 1 where it rose: the whole card's accent follows it. output("basis_dir", none); render.hud("flat", { position: "top_right", title: "Basis", tiles: [tile.value("Basis", "basis", { format: "price", delta: "basis_chg", headline: true }), tile.gauge("RSI", "rsi", { min: 0, max: 100, format: "int" })], accent_color_by: "basis_dir", accent_colors: ["theme.down", "theme.up"], border_style: "dashed", border_width: 1, padding: 14, shadow: false, title_text_color: "theme.text", }); ``` An instrument readout: mono type, the title and labels in capitals, a soft halo on the text, scanlines over the surface and bracket corners instead of a box. Reach for it on a dark chart where the card should look like a gauge cluster rather than a dialog. ```typescript output("rsi", line, lower, { color: "#8b5cf6", label: "RSI", format: "0.0" }); render.hud("instrument", { position: "bottom_left", title: "RSI", tiles: [tile.gauge("Level", "rsi", { min: 0, max: 100, format: "int", draw: "ticks" }), tile.spark("Trend", "rsi", { bars: 32 })], text_color: "#7DF9FF", font_family: "mono", title_case: "upper", label_case: "upper", text_glow: 4, texture: "scanlines", chrome: "brackets", }); ``` A pocket card that shows on phones too: one column, a fixed 160 px width, a small inset, no shadow, and `safe_area` so it starts under the legend. Absent `mobile`, a phone keeps the chart as it is and the card stays a desktop readout. ```typescript output("basis", line, overlay, { color: "#2962ff", width: 2, label: "Basis", format: "price" }); output("basis_chg", none); render.hud("pocket", { position: "top_left", columns: 1, tiles: [tile.value("Basis", "basis", { format: "price", delta: "basis_chg" })], mobile: true, safe_area: true, width: 160, padding: 8, shadow: false, }); ``` Nothing on a card animates, blurs or loads from outside the chart, and no script ever writes CSS: a shared indicator cannot paint over the chart with anything the words above do not describe. ### Looks `look` stands for a whole row of the table below; any word declared beside it wins, so a look is a starting point, not a cage. Absent, the card keeps today's look untouched. The viewer can switch the look on the indicator's Style page ([The Style page](../settings/style-page.md)), where every HUD gets a Look row with "As made" first, unless the file bound the look to a setting (below). | look | surface | type | draws | chrome | palette | | --- | --- | --- | --- | --- | --- | | `default` | the defaults above | the defaults above | arc, line, bar, capsule | card | adaptive | | `glass` | background `#1c1c1ee6` (`#f2f2f7e6` on a light chart), border `#ffffff29` 1 px, corner radius 22, padding 16, shadow | `rounded`, no title, headline 34 | gauge ring, spark area, meter bar, pill dot | card | adaptive | | `stage` | background gradient `#2c6b46`, `#1b3a28`, `#121212`, `#121212` vertical, corner radius 10, padding 14 | `ui`, headline 30 bold, accent `#1ed760` | gauge bar, meter split, spark line, pill text | cover | home | | `signal` | background `#000000`, border `#1d1d1d` 1 px, corner radius 14 | `ui`, headline 38, no title | spark line (height 78, a zero line), meter split | card | home; the whole card's accent follows `accent_color_by` | | `terminal` | background `#000000`, border `#3a3a3a` 1 px, corner radius 0, padding 6 | `mono`, title and labels upper, label ink `#F7A21B`, text `#ffffff` | gauge text, spark text, meter text, pill text | title_bar (bar `#F7A21B`, title ink `#000000`) | home | | `broadsheet` | background `#F6F1E7`, text `#141414`, corner radius 0, padding 14 | `serif`, title small_caps, headline 20 | spark dots, meter bar, pill text, rows with dot leaders | rules | home | | `chart_desk` | background `#ffffff`, text `#121212`, corner radius 0, padding 14 | `ui`, no title | spark bars (height 96, the baseline at the meter mark), pill text | tag (`#E3120B`) | home | | `dial` | background `#EBE8E1`, text `#1d1d1b`, corner radius 16, padding 14, accent `#E05A1E` | `ui`, no title | gauge dial, meter scale, pill led | card | home | | `grid` | background `#F2F1EC`, text `#111111`, corner radius 0, padding 14, accent `#E30613` | `ui`, headline 44 bold | spark line (ink), meter split (ink and accent), pill text | rules | home | | `cockpit` | background `#030e12c2`, corner radius 0, padding 12, text `#7DF9FF`, accent `#FFB000` | `mono`, title and labels upper, text glow 6 | gauge ticks, meter segments, spark line, pill text | brackets | home | | `phosphor` | background `#05160B`, text `#3BFF73`, corner radius 12, padding 12 | `mono`, text glow 5 | gauge text, spark text, meter text, pill text | none | home; texture scanlines | | `classic` | background `#C0C0C0`, text `#000000`, corner radius 0, padding 8 | `ui` | gauge blocks, meter blocks, spark line, pill dot | window (bevels, a title bar from `#000080` to `#1084D0`) | home | A "home" look keeps its paper on a dark and a light chart alike (a printed object does not invert); an "adaptive" look resolves its theme tokens per theme. The "draws" column is each tile kind's `draw` default under that look ([Blocks and tiles](#blocks-and-tiles)); a tile that declares its own `draw` keeps it. To let the trader pick among looks you chose, bind `look` to a setting: `look: "@card_look"` names a `param.choice` whose every choice is one of the twelve looks. The build writes the setting's default look on the card, and the card takes the picked look the moment the setting changes. That setting's row is the look's one control: the Style page draws no Look row for a bound card (its Position row stays), and any word declared beside `look` still wins over the picked look. ```typescript param.choice("card_look", ["phosphor", "glass", "terminal"], "phosphor", { label: "Card look" }); render.hud("desk", { position: "top_right", title: "RSI", look: "@card_look", tiles: [tile.gauge("Level", "rsi", { min: 0, max: 100 })] }); ``` Start from a look and change only what you must. `phosphor` with the scanlines off, a softer glow, a card frame around it and an upper-case title keeps its green-on-black type and its text-drawn tiles; every word beside `look` wins. ```typescript output("rsi", line, lower, { color: "#8b5cf6", label: "RSI", format: "0.0" }); string("regime", { max_bytes: 16 }); render.hud("desk", { position: "top_right", title: "RSI", tiles: [tile.gauge("Level", "rsi", { min: 0, max: 100, format: "int" }), tile.pill("Regime", "regime")], look: "phosphor", texture: "none", text_glow: 2, chrome: "card", title_case: "upper", }); ``` The rules, each refused by name when broken: - `columns` takes 1 or 2; a `position` outside the nine anchors is refused. - `look`, `chrome`, `texture`, `title_case`, `label_case`, `font_family` and a tile's `draw` outside their words are refused by name, with the list; so are `width` outside 80..1200, `offset` outside -4096..4096, `corner_radius` outside 0..32 and `text_glow` outside 0..8. - `look: "@name"` naming no setting, or a setting that is not a `param.choice` over looks only, is refused by name (`render.hud 'desk' look references "@ink", which is not a param.choice over default|glass|...`); `look` is the card's only word a setting can drive, so a card colour stays a literal. - `accent_color` beside `accent_color_by` is refused, and the ladder needs its `accent_colors`. - One headline tile per card: a second `headline: true` is refused. - A corner readout is a HUD card, a `render.label` with a `position`, or a one-cell table; a label placed by `x` and `y` sits at its price. - A presentation name used twice is refused. - The HUD is drawn where the indicator runs in the browser; a phone shows a card only when it declares `mobile: true`. ## Hover cards `hover: [...]` on an output, `render.text` or `render.label`, or `hover(handle, [...])` at the top level, is the card the cursor opens, from the block kit under the `block.*` name: one card per output, either form. Without one the surface shows the output's `tooltip`, else its label and value ([Labels, tooltips, badges](labels-and-tooltips.md)). `badges` names another output, a ladder whose labels become chips on the card. ```typescript const basis = output("basis", line, overlay, { color: "#2962ff", width: 2, label: "Basis", format: "price", tooltip: "{{label}} {{value:price}}" }); output("rsi", line, lower, { color: "#8b5cf6", label: "RSI", format: "0.0" }); output("basis_chg", none); string("regime", { max_bytes: 16 }); hover(basis, [block.value("Basis", "basis", { format: "price", delta: "basis_chg" }), block.rows([["RSI", "rsi", "0.0"], ["Regime", "regime"]]), block.meter("RSI", "rsi", { min: 0, max: 100, marks: [30, 70] }), block.chips("regime")]); ``` ![the card a tile opens under the cursor: a value block with its delta](/wrun/images/wrun-hover-card.svg) Where the card appears: - a line: the output whose value at the cursor's bar sits nearest the cursor. On the price pane the line has to be within 12 pixels of the cursor; in an indicator's own lower pane the card follows the cursor's bar anywhere in the pane (a histogram answers over its whole column); - a legend entry: the value under the cursor names its output; - a HUD tile: the tile is shown again as a hover card; - a text mark or a label: the `hover` list on the `render.text` or `render.label` that drew it ([Plotting](plotting.md#text-labels-tables-strips-tints)). Around the blocks: `hint` on the output, or on a value block, adds its words to the card; `badges` adds the chips of the ladder it names; the card's edge takes the output's color, the current rung of its `color_by` ladder when it has one, else its static `color`. The rules, each refused by name when broken: - `hover` takes an output handle first (bind a handle with a top-level `const h = output(...)` and pass `h`), not a string literal. - One hover per output: inline or top-level, never both. - A hover needs at least one block. - `badges` takes ANOTHER output; an output badging itself is refused. - A table or a drawing shows no card. - In a `tooltip` or a `value` template an unknown name stays as written. ## Blocks and tiles The block kit is one vocabulary with two names: `tile.*` builds the tiles of a `render.hud(...)` card, `block.*` the blocks of a `hover(...)` card, the same constructors under their other name. A constructor lives inside the card's literal only and is erased before the compiler. `value`, `spark`, `gauge` and `meter` read a numeric output, `pill` a string slot, `chips` either, `rows` a list of `[label, output or slot, format?]` lines, and `rings` one to three outputs each with its own range. Every block reads the newest row, so a data-only output written per bar is the way to carry a delta or a regime onto either card. | Constructor | Draws | Options | | --- | --- | --- | | `value(label, output, { format?, delta?, tooltip?, hint?, color?, color_by?, colors?, headline?, font_size? })` | the output's newest value under its label, a signed delta from the `delta` output beside it; `headline: true` spans every column of a HUD card at `font_size` (default 22) with a hairline under it | `format` one of the ten; `delta` an output; `tooltip` and `hint` words; `font_size` 6..64 (default 16) | | `spark(label, output, { bars?, color?, color_by?, colors?, draw?, height? })` | a sparkline of the output's last bars (64 at most) | `bars` a positive integer; `draw` `line` (the default), `area`, `dots`, `bars` or `text`; `height` 16..120 px (default 28) | | `gauge(label, output, { min, max, format?, color?, color_by?, colors?, draw? })` | a dial between `min` and `max` | `min` and `max` required, `min` below `max`; `format` one of the ten; `draw` `arc` (the default), `ring`, `dial`, `ticks`, `bar`, `blocks` or `text` | | `meter(label, output, { min, max, marks?, color?, color_by?, colors?, draw?, height? })` | a bar between `min` and `max` with marks | `min` and `max` required, `min` below `max`; `marks` a list of numbers; `draw` `bar` (the default), `split`, `blocks`, `segments`, `scale` or `text`; `height` 16..120 px (default 4) | | `pill(label, slot, { color?, color_by?, colors?, draw?, headline?, font_size? })` | the slot's words as a chip, colored by a ladder | `draw` `capsule` (the default), `dot`, `led` or `text`; `headline` and `font_size` as on `value` (default 10, 22 as a headline) | | `rows(label?, [[label, output or slot, format?], ...], { color?, color_by?, colors?, leader? })` | rows of label and value | the caption may be left out: the rows array alone; the options object after it is optional too; `leader` `none` (the default) or `dots`, a dotted rule between each key and its value | | `chips(slot or ladder output)` | the slot's words, or the ladder's labels, as chips | none | | `rings(label?, [[label, output, { min, max, color? }], ...], { height? })` | one to three concentric rings, outermost first, each the share of its own `min..max` its output holds, with a legend beside them (dot, label, value) | 1..3 entries, `min` below `max`; `color` a colour word (default the accent at full, 70 and 45 percent strength); `height` 16..120 px (default 86) | The ten formats are `price`, `%`, `si`, `int`, `0`, `0.0`, `0.00`, `0.000`, `usd` (`$1.2M`) and `auto` (six significant digits with `,` thousands) ([Styling](styling.md#placement-per-output)). Colour: every block takes `color` (a colour word: `"#rrggbb"`, `"#rrggbbaa"` or a theme token such as `"theme.up"`) or a `color_by` output plus a `colors` ladder; with both, the ladder wins and `color` is the fallback where the index is not finite. Absent, the block keeps its ink: marks in the output's colour, text in the card's text colour, or the look's default on a HUD card that declares a `look`. A `rows` block's colour inks every value in it. What the `draw` words mean: `ring` is one circular track; `dial` a printed dial with ticks every 5 and a needle over 270 degrees; `ticks` a half arc with a tick every 10 and the value in the middle; `bar` a track with a knob; `blocks` a segmented progress bar; `text` block characters in the card's font (a gauge or meter as `████▌·····` over 10 cells, a spark as `▁▂▃▄▅▆▇█` over at most 16 cells); `area` the line plus a faint fill and an end dot; `dots` a stippled line; `bars` one column per bar from the first meter mark or zero; `split` the fill in the accent and the rest in `theme.down`, with labels at both ends; `segments` 20 cells; `scale` a tuning scale with a pointer; `capsule` today's tinted pill; `dot` the word in its colour after a small dot; `led` a lit lamp before the word; and `text` on a pill the word alone in its colour. A hover card keeps today's drawings unless the block declares `draw`. A reading column on a HUD card: an area spark taller than the default, a split meter with its marks, and rows with dotted leaders between each label and its value. Reach for the taller spark when the card has the room, and for the leaders when a label sits far from its value. ```typescript output("rsi", line, lower, { color: "#8b5cf6", label: "RSI", format: "0.0" }); string("regime", { max_bytes: 16 }); render.hud("column", { position: "middle_right", columns: 1, tiles: [ tile.spark("RSI", "rsi", { bars: 48, draw: "area", height: 48 }), tile.meter("Level", "rsi", { min: 0, max: 100, marks: [30, 70], draw: "split", height: 16 }), tile.rows("Readout", [["RSI", "rsi", "0.0"], ["Regime", "regime"]], { leader: "dots" }), ], }); ``` An options-desk tile: three rings, outermost first, the calls' share of open interest, the puts' share and the IV rank, each over its own range and in its own ink, with the legend beside them. Reach for rings when three shares belong together and a bar each would read as three unrelated meters. ```typescript // Each share is 0 to 1 and the IV rank 0 to 100: a ring fills by its output's place in its own range. output("calls_share", none); output("puts_share", none); output("iv_rank", none); render.hud("options_desk", { position: "top_right", title: "Options desk", columns: 1, tiles: [ tile.rings("Positioning", [ ["Calls", "calls_share", { min: 0, max: 1, color: "theme.up" }], ["Puts", "puts_share", { min: 0, max: 1, color: "theme.down" }], ["IV rank", "iv_rank", { min: 0, max: 100, color: "theme.accent" }], ], { height: 96 }), ], }); ``` Outputs and slots are named by a bound handle or by their name as a string: `tile.value("Basis", basis, ...)` with `const basis = output("basis", ...)` above it, or `tile.value("Basis", "basis", ...)`. The sheet records the name either way. The rules, each refused by name when broken: - A card takes block constructors (`block.(...)` or `tile.(...)`), kinds `value`, `spark`, `gauge`, `pill`, `rows`, `meter`, `chips`, `rings`, and needs at least one. - `gauge` and `meter` are refused without `min` and `max`, and need `min` below `max`; so does each ring. - `bars` takes a positive integer literal; `colors` needs at least one color. - A `pill` reads a string slot, `chips` a slot or a ladder output, the numeric kinds an output; a block whose output or slot does not exist is refused. - `draw` on a `value`, `rows` or `chips` block, or a word outside the kind's own list, is refused by name ("spark draw must be one of ..."); `headline` on a kind other than `value` or `pill`, two headline tiles in one card, and `height` on a kind other than `spark`, `meter` or `rings` are refused too. - A spark's `color` or a pill's `colors` is a literal (a theme token counts): a setting paints an output, a renderer or a legend entry only, never a block. - A block has no size, alignment or color of its own beyond these options: the chart draws it. ## HUD starters Six complete cards to start from, under **HUDs** in the editor's template picker, each in one of the [looks](#looks). Each answers one question first, as its headline, and reads the numbers behind it underneath: - [Market HUD](../cookbook/market-hud.md), glass: the trend, then RSI, the taker buy share and the last closed bar's volume on three rings, ATR and the open-interest change in coins. - [Decision board](../cookbook/decision-board.md), broadsheet: a headline counting the six factors, the net score as a stippled line and the factors in rows with dotted leaders. - [Order flow HUD](../cookbook/order-flow-hud.md), chart desk: who is pushing price, each bar's buy share as bars from the 50 mark, the window's buy share, the net delta and the liquidations. - [Order book HUD](../cookbook/order-book-hud.md), cockpit: the heavier side of the book, the bid share in segments, the dollars resting by distance from the mid and the biggest walls. - [Session HUD](../cookbook/session-hud.md), stage: the session trading now, how much of it has passed, the session up next, the day's range used and the distance from the VWAP and the prior day's high and low. - [Gamma map](../cookbook/gamma-map.md), terminal: dealer gamma at spot, net GEX, spot against the gamma flip and the four levels as numbered rows. ## The other widgets The HUD card is one of six widgets that share a vocabulary. The status card, the feed and the meter (`draw.card`, `draw.feed`, `draw.meter`) take the HUD's surface words plus a chrome of their own, the ladder (`draw.ladder`) its own short list, and the stats row under the price pane (`render.stats_row`) a colour ladder and a priority. Their contracts, with the full tables, are on [Cards, frames and panels](cards-frames-panels.md#status-cards) and [Plotting](plotting.md#text-labels-tables-strips-tints); this section shows them as looks. Every word is optional and absent keeps today's widget. A status card without the state chrome: no state word, no stripe, no hide or collapse glyphs until the viewer hovers it, painted over your drawings, a hairline in the grid colour between the headline and the rows, a small medium-weight title and muted labels. Reach for it when the card is a dashboard, not an alarm. ```typescript output("pnl", none); output("trades", none); output("win_rate", none); draw.card("session", { title: "{{symbol}} session", anchor: "top_left", headline: { output: "pnl", format: "usd", signed: true }, rows: [ { label: "Trades", value: { output: "trades", format: "int" } }, { label: "Win rate", value: { output: "win_rate", format: "%", decimals: 1 } }, ], show_state: false, stripe: false, controls: "none", above_drawings: true, rule_color: "theme.grid", title_font_size: 11, title_font_weight: "medium", label_text_color: "theme.muted", }); ``` An alarm card: the state word and the stripe on, the four state inks declared, the hide and collapse glyphs always visible, a hairline under the headline, a clock row and a countdown to the next close. Reach for it when the card is the indicator's alarm panel. ```typescript output("spread", none); // 0 ok, 1 armed, 2 fired, 3 error: the card's state word and inks follow it. output("alert_state", none); output("close_at_ms", none); draw.card("alarm", { title: "Spread alarm", anchor: "top_right", state_by: "alert_state", headline: { output: "spread", format: "0.00" }, rows: [ { label: "Now", clock: true }, { label: "Next close", countdown_to: { output: "close_at_ms" } }, ], show_state: true, stripe: true, controls: "always", state_colors: ["theme.muted", "theme.accent", "theme.up", "theme.down"], rule: true, }); ``` A meter with a thicker bar on a grey track, and a feed that prints each line's clock time, both showing their hide and collapse glyphs on hover only. Reach for the thicker bar when the meter stands alone in a corner; `time_format: "none"` drops the time column when the times add nothing. ```typescript output("rsi_fraction", none); string("rsi_text", { max_bytes: 8 }); const events = frame("events", { max_bytes: 2048 }); draw.meter({ name: "rsi_meter", label: "RSI", fraction: { output: "rsi_fraction" }, ramp: ["#3b82f6", "#64748b", "#ef4444"], text: { slot: "rsi_text" }, anchor: "top_left", bar_height: 8, track_color: "theme.grid", controls: "hover" }); draw.feed({ name: "rsi_events", frame: events, anchor: "bottom_left", title: "Crossings", time_format: "HH:mm", controls: "hover", stripe: false }); ``` The feed's frame as the script writes it: one to fifty `lines` of a time in milliseconds, a text of at most 80 characters and an optional colour, newest first, and a per-run `title` that replaces the declared one. ```json { "title": "Crossings", "lines": [ [1759536000000, "RSI crossed above 70", "#ef4444"], [1759532400000, "RSI crossed below 30", "#3b82f6"] ] } ``` A slim mono ladder with its row labels on and the divider line in the accent colour. The divider itself comes with the frame (its `divider`, a label and a price); `divider_color` inks it. ```typescript output("close_line", line, overlay, { color: "#94a3b8", label: "Close" }); const rows = frame("rows", { max_bytes: 2048 }); draw.ladder({ name: "volume_by_price", frame: rows, side: "right", width_frac: 0.12, labels: true, font_family: "mono", font_size: 10, color: "#64748b", divider_color: "theme.accent" }); ``` The ladder's frame: one to 256 `rows` of a price, a size, the bar's length from 0 to 1 and an optional colour, and a `divider` that marks one price. A divider in the frame replaces a declared one. ```json { "rows": [ [63950, 1820, 0.62, "#64748b"], [64000, 2940, 1, "#f59e0b"], [64050, 1210, 0.41, "#64748b"] ], "divider": { "label": "Last", "price": 64010 } } ``` Two rows in the statistics strip: a diverging row whose bull and bear inks are the `colors` pair, kept readable longest as bars narrow (`priority: 1`), and a row inked per bar by a packed colour the script writes (`color_packed_by`, [Colors](../functions/colors-kit.md)), the first to lose its text (`priority: 3`). ```typescript output("basis_chg", none); output("rsi", line, lower, { color: "#8b5cf6", label: "RSI", format: "0.0" }); // A packed colour per bar, written with toPacked(...) from the colors kit. output("rsi_ink", none); render.stats_row("basis_row", { output: "basis_chg", title: "Basis chg", format: "price", polarity: "diverging", colors: ["theme.up", "theme.down"], priority: 1 }); render.stats_row("rsi_row", { output: "rsi", title: "RSI", format: "0.0", color_packed_by: "rsi_ink", priority: 3 }); ``` ## Related - [Cards, frames and panels](cards-frames-panels.md#status-cards): the status card, the ladder, the feed and the meter in full - [What the chart shows](overview.md): the three surfaces together, with a complete module - [Legend](legend.md): the legend entry, and `color_by` on it - [Labels, tooltips, badges](labels-and-tooltips.md): the `tooltip` template a card falls back to, and `badges` - [Plotting](plotting.md): the outputs, string slots and renderers a card reads # TA library Every wrun indicator can use `./sdk/ta`, which ships with the editor: 52 stateful classes covering averages, statistics, oscillators, ranges, trend systems, and events. Each class folds one bar per `update()` call, keeps its own window, and has fixed arithmetic, warm-up, and missing-value rules, checked bit-exact against a reference run. This page is the catalog: every class with its constructor, its `update()` arguments, its fields, and the one convention you need to know about it. The function pages ([Moving averages](moving-averages.md), [Oscillators](oscillators.md), [Trend and volatility](trend-indicators.md), [Volume and VWAP](volume-indicators.md), [Series functions](series-functions.md)) go deeper on each group with compiled examples. Two sibling modules pick up where the catalog stops, there with classes of the same shape: `./sdk/ta-plus` adds the textbook indicators the catalog never had (the double and triple EMAs, TRIX, Kaufman's adaptive average, the ultimate oscillator, Vortex, Aroon, choppiness, the volume lines and more), and `./sdk/stats` adds allocation-free list math over a `StaticArray` plus `History`, the `x[n]` window every indicator needs sooner or later. Both are on [Extra indicators](extra-indicators.md); the kits for text, time, colors, order flow, levels and market structure sit beside it in this section. ## How every class works - **Allocate in the constructor, never in `update()`**, so per-bar memory stays flat. - **`update(...)` folds one bar and returns the current value**, `NaN` until the class is warm (a few classes report a partial window from bar 0 instead; the tables say which). - **Multi-output classes fill fields.** `update()` returns the primary line and the other lines sit in public fields you read after the call. - **`reset()` restores the just-constructed state.** Nothing on the chart calls it for you: a revised forming bar replays from a snapshot of the module taken after the last closed bar, so a live update never folds the same bar twice. - **Periods are `i32`, params are `f64`.** Construct in `onStart()` from a param (`new Sma(i32(p_period()))`), keep the object in a module-level `let`, and update it once per bar in `onBar()`. A period below 1 is clamped to 1 (except `Donchian`, which uses the period as given). ## The shipped classes ### Averages and smoothing | Class | Construct | Per bar | Convention | | --- | --- | --- | --- | | `Sma` | `new Sma(period)` | `.update(x)` | plain window mean; `NaN` until `period` bars exist and whenever the window holds a non-finite value | | `Ema` | `new Ema(period)` | `.update(x)` | the mean of the first `period` finite values seeds it, then `x * alpha + prev * (1 - alpha)` with `alpha = 2 / (period + 1)`; a non-finite input after the seed makes it `NaN` for good | | `Rma` | `new Rma(period)` | `.update(x)` | Wilder smoothing, the accumulator inside `Rsi` and `Atr`: same seed as `Ema`, then `(prev * (period - 1) + x) / period` | | `Wma` | `new Wma(period)` | `.update(x)` | linear weights, the newest value weighs `period`, the oldest 1; strict window | | `Hma` | `new Hma(period)` | `.update(x)` | `wma(2 * wma(x, round(period / 2)) - wma(x, period), round(sqrt(period)))`; first value at bar `period - 1 + round(sqrt(period)) - 1` | | `Vwma` | `new Vwma(period)` | `.update(x, volume)` | `sum(x * volume) / sum(volume)`; `NaN` when the volume sum is 0 | | `Alma` | `new Alma(length, offset = 0.85, sigma = 6)` | `.update(x)` | Gaussian weights centered at `offset * (length - 1)`, width `length / sigma`; strict window | | `Swma` | `new Swma()` | `.update(x)` | `(x[3] + 2 x[2] + 2 x[1] + x[0]) / 6` with `x[0]` the newest; first value at bar 3 | | `Linreg` | `new Linreg(period, offset = 0)` | `.update(x)` | least-squares line through the window evaluated at `period - 1 - offset` (offset truncated to an integer); strict window | ### Statistics and series math | Class | Construct | Per bar | Convention | | --- | --- | --- | --- | | `Sum` | `new Sum(period)` | `.update(x)` | no warm-up: bar 0 already returns the partial window; `NaN` inputs are skipped | | `Median` | `new Median(period)` | `.update(x)` | middle of the sorted window, the mean of the two middle values on an even period; strict window | | `Percentile` | `new Percentile(period, pct)` | `.update(x)` | nearest rank: `rank = ceil(pct / 100 * period)`, result `sorted[max(0, rank - 1)]`; `pct` clamped to 0..100; strict window | | `Variance` | `new Variance(period)` | `.update(x)` | population variance (divide by `period`, not `period - 1`); strict window | | `Stdev` | `new Stdev(period)` | `.update(x)` | population standard deviation, the square root of `Variance`; strict window | | `Zscore` | `new Zscore(period)` | `.update(x)` | `(x - mean) / stdev` over the window; `NaN` bars inside the window are skipped for the sums, the variance still divides by `period`; `0` when the deviation is exactly 0 | | `Correlation` | `new Correlation(period)` | `.update(a, b)` | Pearson correlation of two series; `NaN` when either window holds a non-finite value or the denominator is 0 | | `Change` | `new Change(n = 1)` | `.update(x)` | `x - x[n]`; `NaN` for the first `n` bars | | `Mom` | `new Mom(n)` | `.update(x)` | the same math as `Change` under another name | | `Roc` | `new Roc(period)` | `.update(x)` | `(x - x[n]) / x[n] * 100`; `NaN` for the first `n` bars and when `x[n]` is 0 | | `Cum` | `new Cum()` | `.update(x)` | running sum from bar 0; a non-finite bar makes it `NaN` for good | | `Fixnan` | `new Fixnan()` | `.update(x)` | repeats the last finite value over a non-finite bar; `NaN` until the first finite value | ### Oscillators and momentum | Class | Construct | Per bar | Convention | | --- | --- | --- | --- | | `Rsi` | `new Rsi(period)` | `.update(x)` | Wilder RSI; bar 0 feeds nothing, first value at bar `period`; a zero average loss returns 100, so a flat window is 100 | | `Cmo` | `new Cmo(length)` | `.update(x)` | `100 * (up - down) / (up + down)` over the last `length` changes; first value at bar `length`; a zero total returns 0 | | `Tsi` | `new Tsi(short = 13, long = 25)` | `.update(x)` | double EMA (long, then short) of momentum over the double EMA of its absolute value; the engine's parameter order is `(short, long)`; first value at bar `long + short - 1` | | `Cci` | `new Cci(period = 20, constant = 0.015)` | `.update(high, low, close)` | over typical price `(high + low + close) / 3`; first value at bar `period - 1`; 0 when the mean deviation is 0 | | `Mfi` | `new Mfi(period = 14)` | `.update(high, low, close, volume)` | money flow over typical price times volume; first value at bar `period`; a zero negative flow returns 100 | | `Wpr` | `new Wpr(length = 14)` | `.update(high, low, close)` | Williams %R; first value at bar `length - 1`; a flat window returns 0 | | `Stoch` | `new Stoch(periodK, smoothK, periodD)` | `.update(high, low, close)` returns `k` | fields `k`, `d`; raw %K is 0 on a flat window; first `k` at bar `periodK + smoothK - 2`, first `d` at bar `periodK + smoothK + periodD - 3` | | `Stochastic` | `new Stochastic(kPeriod = 14, kSmoothing = 3, dPeriod = 3)` | `.update(high, low, close)` returns `k` | the other spelling, with its own rules: `k = d = 0` before bar `kPeriod - 1` (never `NaN`), 50 on a flat window, a `NaN` `k` or `d` is reported as 50 | | `Macd` | `new Macd(fast = 12, slow = 26, signal = 9)` | `.update(x)` returns `macd` | fields `macd`, `signal`, `hist`; the line appears at bar `slow - 1`, the signal at bar `slow + signal - 2`; `hist = macd - signal` | | `Obv` | `new Obv()` | `.update(close, volume)` | on-balance volume; bar 0 returns 0; an unchanged close adds nothing | ### Ranges and bands | Class | Construct | Per bar | Convention | | --- | --- | --- | --- | | `Tr` | `new Tr()` | `.update(high, low, close)` | `max(high - low, abs(high - prevClose), abs(low - prevClose))`; bar 0 is plain `high - low` | | `Atr` | `new Atr(period = 14)` | `.update(high, low, close)` | `Rma` of `Tr`; first value at bar `period - 1`; a non-finite range after the seed makes it `NaN` for good | | `Bb` | `new Bb(period, mult)` | `.update(x)` returns `basis` | fields `basis`, `upper`, `lower`; `Sma` basis and population `Stdev` width; all three `NaN` while the window holds a non-finite value | | `Keltner` | `new Keltner(period, mult, atrPeriod)` | `.update(x, high, low, close)` returns `basis` | fields `basis`, `upper`, `lower`; `Ema` basis plus `Atr` width; the basis is reported as soon as the EMA is seeded, the bands once the ATR is finite | | `Donchian` | `new Donchian(period = 12)` | `.update(high, low)` returns `basis` | fields `basis` (the midline `update()` returns), `upper`, `lower`; no warm-up, the window is partial at the start; older `NaN` highs and lows are skipped, the current bar's `NaN` propagates | | `Highest` | `new Highest(period = 12)` | `.update(x)` | the window maximum; field `bars` is the offset of that maximum (0 = this bar, negative = bars ago, the newest bar wins a tie); pass the high for a bar-high window | | `Lowest` | `new Lowest(period = 12)` | `.update(x)` | the window minimum; field `bars` is the offset of that minimum; pass the low for a bar-low window | | `HighestBars` | `new HighestBars(period)` | `.update(x)` | the offset as the primary value (0 or negative); field `value` is the matching high | | `LowestBars` | `new LowestBars(period)` | `.update(x)` | the offset as the primary value; field `value` is the matching low | ### Trend systems | Class | Construct | Per bar | Convention | | --- | --- | --- | --- | | `Adx` | `new Adx(period = 14)` | `.update(high, low, close)` returns `adx` | fields `adx`, `plusDi`, `minusDi`; the seed is the plain sum of the first `period` true ranges and directional moves, then `s = s - s / period + x` (the engine's form, not an `Rma`); `+DI` and `-DI` appear at bar `period`, ADX at bar `2 * period - 1`; a non-finite bar after the seed leaves every output `NaN` for good | | `Ichimoku` | `new Ichimoku(conversionPeriod = 9, basePeriod = 26, laggingSpanPeriod = 52, displacement = 26)` | `.update(high, low, close)` returns `tenkan` | fields `tenkan`, `kijun`, `senkouA`, `senkouB`, `chikou`; windows are partial from bar 0 (no `NaN` warm-up), the first `displacement` bars use the current bar's values instead of shifted ones, a `NaN` output is reported as 0; `chikou` is the current close (see the accuracy section) | | `Psar` | `new Psar(start = 0.02, increment = 0.02, maxValue = 0.2)` | `.update(high, low, close)` | the stop-and-reverse level; bar 0 is `NaN`, bar 1 picks the first trend from `close[1] >= close[0]`; a non-finite bar makes every later bar `NaN`, as the engine's full recompute does | | `Supertrend` | `new Supertrend(factor, atrPeriod)` | `.update(high, low, close)` returns `line` | fields `line`, `direction` (`1` up, the line sits below price; `-1` down); both `NaN` while the ATR is `NaN`, and the band state is left untouched across such a gap | | `Vwap` | `new Vwap(anchor = "", price = "hlc3")` | `.update(open, high, low, close, volume, tsMs = NaN)` | `anchor` is `""` (one accumulation from bar 0), `"day"`, `"week"`, `"month"`, `"quarter"`, `"year"` (UTC calendar boundaries) or a numeric string bucket width in milliseconds; `tsMs` is the bar's open time in milliseconds since the epoch and is read only when an anchor is set (the `time` source hands seconds, multiply by 1000); `price` is `hlc3`, `hl2`, `ohlc4`, `hlcc4`, or `close`; a non-finite bar makes the current bucket `NaN` until the next one starts | ### Events and conditions | Class | Construct | Per bar | Convention | | --- | --- | --- | --- | | `Rising` | `new Rising(period)` | `.update(x)` | 1 when `x` is strictly above every one of the previous `period` values, else 0; `NaN` while the bar index is below `period` | | `Falling` | `new Falling(period)` | `.update(x)` | the mirror of `Rising` | | `PivotHigh` | `new PivotHigh(leftbars, rightbars)` | `.update(high)` | the value of the bar `rightbars` back when it beats every value `leftbars` before and `rightbars` after it, reported only on the confirming bar, `NaN` otherwise; ties never count | | `PivotLow` | `new PivotLow(leftbars, rightbars)` | `.update(low)` | the mirror of `PivotHigh` | | `ValueWhen` | `new ValueWhen(occurrence)` | `.update(condition, x)` | `x` on the most recent bar where `condition` was true (`occurrence` 0), or the one before (`1`); a condition is true when it is finite and not 0; the current bar counts | | `BarsSince` | `new BarsSince()` | `.update(condition)` | bars since the condition was last true, 0 on a true bar; `NaN` until the first true bar | | `Cross` | `new Cross()` | `.update(a, b): i32` | `+1` when `a` crosses above `b`, `-1` below, `0` otherwise; the engine's previous-bar rule: crossover is `prevA < prevB && a >= b`, crossunder is `prevA > prevB && a <= b`; any `NaN` among the four values gives 0, and so does the first bar | `Cross` folds three questions into one return value: test `> 0`, `< 0`, or `!= 0`. A composite over the shipped classes: a `Macd` read through its three fields, a `Cross` gate over the line and its signal, and an `Rsi`. The declarations at the top name the four params and the four outputs; the module below them only folds the bars: ```typescript param("fast", 12, { min: 1, max: 200 }); param("slow", 26, { min: 2, max: 400 }); param("signal", 9, { min: 1, max: 200 }); param("rsi_len", 14, { min: 2, max: 200 }); output("macd", line, lower); output("signal", line, lower); output("rsi", line, lower); // Data-only: +1 on a bullish MACD cross, -1 on a bearish one, 0 otherwise. output("crossed", none); let macd = new Macd(12, 26, 9); let rsi = new Rsi(14); let cross = new Cross(); function onStart(): void { macd = new Macd(i32(p_fast()), i32(p_slow()), i32(p_signal())); rsi = new Rsi(i32(p_rsi_len())); cross = new Cross(); } function onBar(): void { const close = bar.close(); macd.update(close); const strength = rsi.update(close); const crossed = f64(cross.update(macd.macd, macd.signal)); if (isNaN(macd.signal) || isNaN(strength)) return; out_macd(macd.macd); out_signal(macd.signal); out_rsi(strength); out_crossed(crossed); } ``` ## The catalog by page Every class grouped by what it does. Every class ships in `./sdk/ta`; the page column is where the group is explained with compiled examples. **Moving averages and smoothing** | Class | Where | | --- | --- | | `Sma` | [Moving averages](moving-averages.md) | | `Ema` | [Moving averages](moving-averages.md) | | `Rma` | [Moving averages](moving-averages.md) | | `Wma` | [Moving averages](moving-averages.md) | | `Hma` | [Moving averages](moving-averages.md) | | `Vwma`, `update(x, volume)` | [Moving averages](moving-averages.md) | | `Alma` | [Moving averages](moving-averages.md) | | `Swma` | [Moving averages](moving-averages.md) | | `Linreg` | [Moving averages](moving-averages.md) | **Oscillators and momentum** | Class | Where | | --- | --- | | `Rsi` | [Oscillators](oscillators.md) | | `Wpr` | [Oscillators](oscillators.md) | | `Cmo` | [Oscillators](oscillators.md) | | `Tsi` | [Oscillators](oscillators.md) | | `Macd`, fields `macd`, `signal`, `hist` | [Oscillators](oscillators.md) | | `Stoch`, fields `k`, `d` | [Oscillators](oscillators.md) | | `Stochastic`, fields `k`, `d` | [Oscillators](oscillators.md) | | `Cci` | [Oscillators](oscillators.md) | | `Mfi` | [Oscillators](oscillators.md) | | `Change` | [Series functions](series-functions.md) | | `Mom` | [Oscillators](oscillators.md) | | `Roc` | [Oscillators](oscillators.md) | **Trend and volatility** | Class | Where | | --- | --- | | `Adx`, fields `adx`, `plusDi`, `minusDi` | [Trend and volatility](trend-indicators.md) | | `Ichimoku`, five fields | [Trend and volatility](trend-indicators.md) | | `Psar` | [Trend and volatility](trend-indicators.md) | | `Supertrend`, fields `line`, `direction` | [Trend and volatility](trend-indicators.md) | | `Tr` | [Trend and volatility](trend-indicators.md) | | `Atr` | [Trend and volatility](trend-indicators.md) | | `Bb`, fields `basis`, `upper`, `lower` | [Moving averages](moving-averages.md) | | `Keltner`, fields `basis`, `upper`, `lower` | [Moving averages](moving-averages.md) | | `Donchian`, fields `basis`, `upper`, `lower` | [Moving averages](moving-averages.md) | | `Stdev` | [Trend and volatility](trend-indicators.md) | | `Variance` | [Trend and volatility](trend-indicators.md) | | price helpers (`hl2`, `hlc3`, `ohlc4`, `hlcc4`): arithmetic on the declared inputs | [Series functions](series-functions.md) | **Volume** | Class | Where | | --- | --- | | `Obv`, `update(close, volume)` | [Volume and VWAP](volume-indicators.md) | | `Vwap`, anchored on the bar's open time | [Volume and VWAP](volume-indicators.md) | | `Cum` | [Volume and VWAP](volume-indicators.md) | **Statistics** | Class | Where | | --- | --- | | `Sum` | [Series functions](series-functions.md) | | `Median` | [Series functions](series-functions.md) | | `Percentile` | [Series functions](series-functions.md) | | `Correlation`, `update(a, b)` | [Series functions](series-functions.md) | | `Zscore` | [Series functions](series-functions.md) | **Bars and events** | Class | Where | | --- | --- | | `Highest`, `Lowest`, field `bars` | [Series functions](series-functions.md) | | `HighestBars`, `LowestBars`, field `value` | [Series functions](series-functions.md) | | `PivotHigh`, `PivotLow` | [Series functions](series-functions.md) | | `Rising`, `Falling` | [Series functions](series-functions.md) | | `ValueWhen` | [Series functions](series-functions.md) | | `BarsSince` | [Series functions](series-functions.md) | | `Cross`, `+1` / `-1` / `0` | [Series functions](series-functions.md) | | `Fixnan` | [Series functions](series-functions.md) | | `isNaN(x)` | [Series functions](series-functions.md) | | `nz(x, replacement)`, a two-line helper | [Series functions](series-functions.md) | The order book and volume profile scans are not classes: they are loops over a celled input on the [Order flow](order-flow-kit.md) page. ## Conventions (read this once) These rules hold across the library and explain nearly every edge case. **Warm-up is `NaN`, with named exceptions.** A windowed class returns `NaN` until it has a full window and never fabricates an early value. The classes that report a partial window from bar 0: `Sum`, `Donchian`, `Ichimoku`, `Cum` (bar 0 is `x`), `Obv` (bar 0 is 0), and `Stochastic` (0 before its window fills). Decide per bar whether to `return` from `onBar()` before writing or to write the `NaN`; either way the chart draws nothing on that bar ([Execution model](../core-concepts/execution-model.md)). **Windows are strict; accumulators poison.** A class that recomputes over its window (`Sma`, `Wma`, `Alma`, `Linreg`, `Median`, `Percentile`, `Variance`, `Stdev`, `Correlation`, `Bb`, `Highest`, `Lowest`, and the rest of the window family) yields `NaN` while any value inside the window is not finite and heals as soon as it leaves. A class that carries a running accumulator (`Ema`, `Rma`, `Atr`, `Rsi`, `Macd`, `Adx`, `Cum`, `Psar`, `Supertrend`'s ATR) restarts its seed when a non-finite value arrives before the seed completes, and turns `NaN` for good when one arrives after, because the engine never reseeds. `Sum` and `Zscore` skip `NaN` bars instead. Run a sparse source through `Fixnan` or an `isNaN` guard before an accumulator. **Columns are yours to choose.** The classes take one value per bar: pass `bar.high()` to `Highest`, `HighestBars`, and `PivotHigh` for a bar-high window, `bar.low()` to their counterparts, or the close for a close-based one. **Classes compose.** `update()` takes any `f64`, including another class's output (`rsi.update(sma.update(close))`) and any series derived from a celled input. Warm-up propagates through the composition because `NaN` propagates through arithmetic. **Values carry; events do not.** A missing scalar carries the latest eligible value forward by default (the `missing` policy on the source decides), so math keeps working. Events are stricter: `Cross.update()` returns `0` on any bar where either side is `NaN`, `BarsSince` and `ValueWhen` read a condition you computed (finite and not 0), so stale data cannot fabricate a signal. **`Rma` is Wilder.** `Rsi`, `Atr`, `Keltner`, and `Supertrend` share the same accumulator, so an indicator built by hand over `Rma` agrees with the shipped classes. ## Multi-output indicators An indicator with several streams is a class with several fields. There are no tuple outputs and nothing indexes into an array: `update()` returns the primary line, and you read the other fields after the call and write each to its own output ([Named streams](../core-concepts/named-streams.md)). | Class | `update()` returns | Fields | | --- | --- | --- | | `Bb`, `Keltner`, `Donchian` | `basis` | `basis`, `upper`, `lower` | | `Macd` | `macd` | `macd`, `signal`, `hist` | | `Stoch`, `Stochastic` | `k` | `k`, `d` | | `Supertrend` | `line` | `line`, `direction` | | `Adx` | `adx` | `adx`, `plusDi`, `minusDi` | | `Ichimoku` | `tenkan` | `tenkan`, `kijun`, `senkouA`, `senkouB`, `chikou` | | `Highest`, `Lowest` | the extreme | `bars` (the offset of that extreme) | | `HighestBars`, `LowestBars` | the offset | `value` (the extreme at that offset) | Fields feed a band declaration or a box the same way outputs do: write `bb.upper` and `bb.lower` to two drawn outputs and declare a `range()` between their names ([Styling](../presentation/styling.md)), or a `box` between their handles ([Drawing objects](../presentation/drawing-objects.md)). ## Over microstructure Every windowed class also runs over order-flow series, which is where the library stops being a price-only kit. A celled `volume_profile` input delivers each bar's `[low, high, buy, sell]` tuples; sum the buy and sell columns into a per-bar delta and the delta is just an `f64` any class can fold: an `Rsi` over it is delta-RSI, a running sum is cumulative volume delta. Declaring the celled input switches the derived sheet to the second runtime contract, and the numeric classes compute exactly as before. ```typescript param("period", 14, { min: 2, max: 200, description: "RSI window over the per-bar delta" }); input("close", ohlcv.close); input("profile", volume_profile.cells, { max_cells: 8192 }); output("delta", line, lower, { color: "#94a3b8", width: 1, description: "Buy minus sell volume across the bar's profile" }); output("delta_rsi", line, lower, { color: "#7c3aed", width: 2, description: "RSI of the per-bar delta" }); output("cvd", line, lower, { color: "#22d3ee", width: 2, description: "Cumulative volume delta" }); let rsi = new Rsi(14); let cvd: f64 = 0.0; function onStart(): void { rsi = new Rsi(i32(p_period())); } function onBar(): void { const n = in_profile_cells(); if (n < 0) return; // this bar carries no block let delta = 0.0; if (n > 0) { const cells = in_profile_view(); for (let i = 0; i + 3 < n; i += 4) delta += cells[i + 2] - cells[i + 3]; } cvd += delta; const deltaRsi = rsi.update(delta); out_delta(delta); out_delta_rsi(deltaRsi); out_cvd(cvd); } ``` The [Order flow](order-flow-kit.md) page has every footprint read as a scan over the same tuples, and does the same over book levels for a book-imbalance average. ## Accuracy as a contract **Every class is checked bit-exact against a reference implementation.** The reference run covers a 690-bar 1h BTCUSDT window; each class is compiled through the real build and executed through the real runtime over the same bars, and every output is compared per bar: a maximum absolute deviation of 0 and identical `NaN` placement (a bar that is `NaN` in the reference is `NaN` here, and only that bar). The conventions on this page are written down so that comparison holds, and a change that drifts a class from the reference numbers fails the build. **Two honest exceptions.** - **`Ichimoku.chikou` is the current close.** The engine computes the lagging span by reading `close[i + displacement]`, a bar in the future of bar `i`, and only falls back to the current close on the last `displacement` bars of the series. A class that sees one bar at a time cannot read ahead, so the field carries the current close on every bar; it matches the engine on those last bars only. The other four fields match bar for bar. - **`Vwap` anchors beyond `""`, `"day"`, and a numeric millisecond width are unproven on that window.** The `"week"`, `"month"`, `"quarter"`, and `"year"` boundaries follow the engine's UTC calendar arithmetic, but the 690-bar window does not exercise a deep-history quarter or year, and the engine's session-calendar bucketing and RTH session filter (venues with a trading-session calendar) are not mirrored, since a per-bar class never sees a session calendar. Everything else in the catalog, warm-up bars included, matches the reference bar for bar. # Moving averages These classes smooth price into a trend line or wrap it in a band. Every one of them ships with the editor in the `./sdk/ta` module (`Sma`, `Ema`, `Wma`, `Hma`, `Alma`, `Swma`, `Vwma`, `Rma`, `Linreg`, `Bb`, `Keltner`, `Donchian`), each one built the same way: allocate in the constructor, fold one bar per `update()`, return `NaN` until the window is full, restore with `reset()`. Name the class you need (nothing to import), construct it in `onStart()`, and call `update()` once per bar in `onBar()`. Every class has fixed arithmetic and edge rules, named in its section ([TA library](ta-library.md) lists the full catalog and the proof). Every windowed average is `NaN` until its window fills. An `Sma(20)` draws nothing for its first 19 bars, then begins once the 20th has loaded; the Hull average warms up a little longer because of its internal weighted sub-windows. Donchian is the exception: it reads the rolling extreme of whatever has loaded from the very first bar. `NaN` is the warm-up signal, so a `NaN` written to one output and an early `return` from `onBar()` that leaves every output unwritten are the two honest ways to say "not yet" ([Execution model](../core-concepts/execution-model.md)). The shape every class shares: | Member | Meaning | | --- | --- | | `new X(period)` | allocates the window once; a `period` below 1 is clamped to 1 | | `.update(x): f64` | folds one bar, returns the current value, `NaN` until warm | | `.reset(): void` | back to the freshly constructed state; nothing on the chart calls it for you | A non-finite input (a `NaN` or an infinity) inside a window makes the windowed averages return `NaN` for as long as that bar sits in the window; the two running averages (`Ema`, `Rma`) never recover from one. ## Single-line averages ### Sma `new Sma(period)`, `.update(x)`: the unweighted mean of the last `period` values, summed oldest to newest and divided by the period. Warm-up is `period` bars (first value at bar `period - 1`). ```typescript let sma = new Sma(20); function onStart(): void { sma = new Sma(i32(p_period())); } ``` Read the period through its accessor and cast it: params are `f64`, class periods are `i32`, and AssemblyScript refuses the implicit conversion. ### Ema `new Ema(period)`, `.update(x)`: the exponential average. Weights recent bars more heavily, so it turns faster than `Sma`. The first `period` finite values seed it with their simple mean, then `x * alpha + prev * (1 - alpha)` with `alpha = 2 / (period + 1)` takes over, so it is `NaN` for `period - 1` bars. A non-finite input before the seed completes restarts the seed count; one after the seed sets the value to `NaN` for good (the engine never reseeds). ```typescript let ema = new Ema(20); ``` ### Wma `new Wma(period)`, `.update(x)`: the linearly weighted average. The newest bar carries weight `period`, the oldest weight `1`, so it sits between `Sma` and `Ema` in responsiveness. The weighted sum walks oldest to newest and is divided by `period * (period + 1) / 2`; `NaN` until `period` bars have been seen and whenever any value in the window is not finite. ```typescript let wma = new Wma(20); function onStart(): void { wma = new Wma(i32(p_period())); } ``` ### Hma `new Hma(period)`, `.update(x)`: the Hull average, three `Wma` windows composed as `WMA(2 * WMA(round(n / 2)) - WMA(n), round(sqrt(n)))`. Very low lag and smooth. The half and square-root lengths are rounded (never below 1), the inner difference is `NaN` until both inner windows are warm, and the outer window then needs `round(sqrt(n))` finite values, so the first value lands at bar `n - 1 + round(sqrt(n)) - 1`. ```typescript let hma = new Hma(21); function onStart(): void { hma = new Hma(i32(p_hull_period())); } ``` ### Alma `new Alma(length, offset = 0.85, sigma = 6)`, `.update(x)`: the Arnaud Legoux average, a Gaussian-weighted window. `offset` places the peak of the bell at `offset * (length - 1)` from the oldest bar (near the newest bar by default), `sigma` sets its width as `length / sigma`. The weights are computed once in the constructor with the engine's arithmetic, so `update()` is a single weighted pass, walked oldest to newest. `NaN` until `length` bars have been seen and whenever any value in the window is not finite. ```typescript let alma = new Alma(20); // offset 0.85, sigma 6 let sharp = new Alma(20, 0.9, 4.0); // peak closer to the newest bar, narrower bell ``` Omitting `offset` and `sigma` is the same as passing `0.85` and `6`. ### Swma `new Swma()`, `.update(x)`: the symmetrically weighted average, the fixed four-tap smoother `(x[3] + 2 * x[2] + 2 * x[1] + x[0]) / 6` with `x[0]` the newest bar. `NaN` on the first three bars (first value at bar 3) and whenever any of the four values is not finite; no period to choose. ```typescript let swma = new Swma(); ``` ### Vwma `new Vwma(period)`, `.update(price, volume)`: the volume-weighted average takes two values per bar, `sum(price * volume) / sum(volume)` over the window. Bars with more volume pull the average harder, so it tracks where trading actually happened. It needs the bar's volume beside the price (`bar.volume()`). `NaN` until `period` bars have been seen, whenever any price or volume in the window is not finite, and when the volume sum is exactly `0`. ```typescript let vwma = new Vwma(20); function onBar(): void { out_vwma(vwma.update(bar.close(), bar.volume())); } ``` ### Rma `new Rma(period)`, `.update(x)`: the running (Wilder) average, the smoothing inside RSI and ATR. The first `period` finite values seed it with their simple mean, then `rma = (rma * (period - 1) + x) / period`. Smoother and slower than `Ema` for the same period. This is the same Wilder accumulator `Rsi` and `Atr` use internally, so an RSI built by hand over `Rma` and the shipped `Rsi` agree. A non-finite input before the seed completes restarts the seed count; one after the seed sets the value to `NaN` for good. ```typescript let rma = new Rma(14); ``` ### Linreg `new Linreg(period, offset = 0)`, `.update(x)`: linear regression fits a least-squares line over the last `period` bars (x positions `0` oldest to `period - 1` newest) and returns its value at the newest bar (`offset` `0`) or `offset` bars back along the fitted line: the trend's fitted price rather than an average. `offset` is an `f64` truncated to an integer. `NaN` until `period` bars have been seen and whenever any value in the window is not finite; a period of `1` returns the value itself. ```typescript let linreg = new Linreg(20); // value at the newest bar let lagged = new Linreg(20, 2.0); // the fitted line two bars back ``` ## Band helpers A band is three named streams. A wrun indicator has no tuple outputs: the class exposes `upper`, `basis`, and `lower` as fields after each `update()`, and you write each one to its own output ([Named streams](../core-concepts/named-streams.md)). Shading between the edges is a `range()` band over the two drawn edges ([Styling](../presentation/styling.md)) or a `box` on every bar ([Cards, frames and panels](../presentation/cards-frames-panels.md)). ### Bb `new Bb(period, mult)`, `.update(x)`: Bollinger bands. `basis` is the window mean (the same arithmetic as `Sma`), `upper` and `lower` sit `mult` population standard deviations away (the same arithmetic as `Stdev`, the textbook Bollinger form). `update()` returns the basis and fills the three fields. All three are `NaN` until `period` bars exist and whenever any bar of the window is not finite; the window heals as soon as the bad bar leaves it. Both arguments are required; the usual call is `new Bb(20, 2.0)`. ```typescript let bb = new Bb(20, 2.0); function onBar(): void { bb.update(bar.close()); out_bb_upper(bb.upper); out_bb_basis(bb.basis); out_bb_lower(bb.lower); } ``` ### Keltner `new Keltner(period, mult, atrPeriod)`, `.update(x, high, low, close)`: like Bollinger, but the width comes from the average true range instead of the standard deviation, so it reacts to range rather than dispersion. `basis` is an `Ema(period)` over `x` (usually the close), the width is an `Atr(atrPeriod)` over the three prices (the Wilder-smoothed true range; its class is on the [Trend and volatility](trend-indicators.md#volatility-primitives) page) multiplied by `mult`. `update()` returns the basis and fills the three fields. The basis is reported as soon as the `Ema` is seeded, even while the ATR is still warming up; `upper` and `lower` are `NaN` unless both the basis and the ATR are finite. ```typescript let kc = new Keltner(20, 1.5, 10); function onBar(): void { const close = bar.close(); kc.update(close, bar.high(), bar.low(), close); out_kc_upper(kc.upper); out_kc_basis(kc.basis); out_kc_lower(kc.lower); } ``` ### Donchian `new Donchian(period = 12)`, `.update(high, low)`: the highest high and lowest low over `period` bars as `upper` and `lower`, with their midpoint as `basis` (the value `update()` returns). Because it reads the rolling extreme rather than averaging, it is finite from the first loaded bar: the window is partial at the start, and bar 0 returns `(high + low) / 2` of that bar alone. The current bar's high and low always take part, even when `NaN` (so a `NaN` bar propagates), while non-finite highs or lows of older bars are skipped. The period is used as given: the clamp to 1 the other classes apply does not run here. ```typescript let dc = new Donchian(20); function onBar(): void { dc.update(bar.high(), bar.low()); out_dc_upper(dc.upper); out_dc_basis(dc.basis); out_dc_lower(dc.lower); } ``` ## Putting them together Every average and band on one chart: the nine single-line averages on the price pane, the three band helpers as nine more outputs. Every class is used by name with nothing to import. Each class is constructed in `onStart()` from a param, then updated once and its value written in `onBar()`. ```typescript param("period", 20, { min: 2, max: 400, description: "Window for every average and band" }); param("hull_period", 21, { min: 4, max: 400, description: "Hull average window" }); param("mult", 2, { min: 0.5, max: 5, description: "Bollinger standard-deviation multiplier" }); param("kc_mult", 1.5, { min: 0.5, max: 5, description: "Keltner ATR multiplier" }); param("atr_period", 10, { min: 1, max: 200, description: "Keltner ATR window" }); output("sma", line, overlay, { color: "#2563eb", width: 2, description: "Simple moving average" }); output("ema", line, overlay, { color: "#dc2626", width: 2, description: "Exponential moving average" }); output("hma", line, overlay, { color: "#7c3aed", width: 2, description: "Hull moving average" }); output("wma", line, overlay, { color: "#ea580c", width: 2, description: "Weighted moving average" }); output("alma", line, overlay, { color: "#0891b2", width: 2, description: "Arnaud Legoux moving average" }); output("swma", line, overlay, { color: "#0f766e", width: 1, description: "Symmetrically weighted moving average" }); output("vwma", line, overlay, { color: "#b45309", width: 2, description: "Volume-weighted moving average" }); output("rma", line, overlay, { color: "#059669", width: 2, description: "Wilder running moving average" }); output("linreg", line, overlay, { color: "#4b5563", width: 2, description: "Least-squares regression value" }); output("bb_upper", line, overlay, { color: "#0f766e", width: 1, description: "Bollinger upper band" }); output("bb_basis", line, overlay, { color: "#64748b", width: 1, description: "Bollinger basis" }); output("bb_lower", line, overlay, { color: "#be123c", width: 1, description: "Bollinger lower band" }); output("kc_upper", line, overlay, { color: "#16a34a", width: 1, description: "Keltner upper channel" }); output("kc_basis", line, overlay, { color: "#94a3b8", width: 1, description: "Keltner basis" }); output("kc_lower", line, overlay, { color: "#dc2626", width: 1, description: "Keltner lower channel" }); output("dc_upper", line, overlay, { color: "#0ea5e9", width: 1, description: "Donchian upper band" }); output("dc_basis", line, overlay, { color: "#94a3b8", width: 1, description: "Donchian midpoint" }); output("dc_lower", line, overlay, { color: "#f97316", width: 1, description: "Donchian lower band" }); let sma = new Sma(20); let ema = new Ema(20); let hma = new Hma(21); let wma = new Wma(20); let alma = new Alma(20); let swma = new Swma(); let vwma = new Vwma(20); let rma = new Rma(20); let linreg = new Linreg(20); let bb = new Bb(20, 2.0); let kc = new Keltner(20, 1.5, 10); let dc = new Donchian(20); function onStart(): void { const period = i32(p_period()); sma = new Sma(period); ema = new Ema(period); hma = new Hma(i32(p_hull_period())); wma = new Wma(period); alma = new Alma(period); swma = new Swma(); vwma = new Vwma(period); rma = new Rma(period); linreg = new Linreg(period, 0.0); bb = new Bb(period, p_mult()); kc = new Keltner(period, p_kc_mult(), i32(p_atr_period())); dc = new Donchian(period); } function onBar(): void { const close = bar.close(); const high = bar.high(); const low = bar.low(); const smaValue = sma.update(close); const emaValue = ema.update(close); const hmaValue = hma.update(close); const wmaValue = wma.update(close); const almaValue = alma.update(close); const swmaValue = swma.update(close); const vwmaValue = vwma.update(close, bar.volume()); const rmaValue = rma.update(close); const linregValue = linreg.update(close); bb.update(close); kc.update(close, high, low, close); dc.update(high, low); // Donchian is finite from the first bar, so its band draws at once; // the slower lines simply write NaN until their own windows fill. out_sma(smaValue); out_ema(emaValue); out_hma(hmaValue); out_wma(wmaValue); out_alma(almaValue); out_swma(swmaValue); out_vwma(vwmaValue); out_rma(rmaValue); out_linreg(linregValue); out_bb_upper(bb.upper); out_bb_basis(bb.basis); out_bb_lower(bb.lower); out_kc_upper(kc.upper); out_kc_basis(kc.basis); out_kc_lower(kc.lower); out_dc_upper(dc.upper); out_dc_basis(dc.basis); out_dc_lower(dc.lower); } ``` The whole module is one chart overlay with eighteen legend entries, and once the indicator is published every one of them is an output an alert can follow ([Alerts](alerts.md)). ## Warm-up and named streams Two ideas worth seeing on their own: an average is blank until its window fills, and a band's three levels are three independent outputs you can draw or hide one at a time. The Bollinger edges below are drawn while the basis stays data-only (`none`): it still computes and you can read it at the Console prompt, but the chart shows only the two bands. All three begin together once the 20-bar window completes, because one `Bb` feeds them. ```typescript param("period", 20, { min: 2, max: 400 }); output("sma20", line, overlay, { color: "#2563eb", width: 2, description: "NaN until the window is complete" }); output("bb_upper", line, overlay, { color: "#16a34a", width: 2, description: "Bollinger upper band" }); output("bb_basis", none, overlay, { description: "Computed, never drawn: a value without a line" }); output("bb_lower", line, overlay, { color: "#dc2626", width: 2, description: "Bollinger lower band" }); let sma = new Sma(20); let bb = new Bb(20, 2.0); function onStart(): void { sma = new Sma(i32(p_period())); bb = new Bb(i32(p_period()), 2.0); } function onBar(): void { const close = bar.close(); const value = sma.update(close); bb.update(close); // Return before writing while the window is filling: no legend value, nothing drawn. if (isNaN(value)) return; out_sma20(value); out_bb_upper(bb.upper); out_bb_basis(bb.basis); out_bb_lower(bb.lower); } ``` ## Alma and Swma boundaries `Alma`'s omitted `offset` and `sigma` default to `0.85` and `6`, so a default construction and an explicit one agree to the last bit; `Swma` uses the fixed four-tap weighting, so it agrees with the arithmetic spelled out by hand over the last four closes. The module below computes both forms and writes each line only where the class agrees with the manual form, which is every bar once the windows are warm: a `NaN` written on a disagreeing bar would leave a visible hole. ```typescript param("length", 10, { min: 2, max: 200 }); output("alma_check", line, lower, { color: "#2563eb", width: 2, description: "ALMA with the default offset 0.85 and sigma 6" }); output("swma_check", line, lower, { color: "#16a34a", width: 2, description: "SWMA against the four-tap arithmetic" }); let almaDefault = new Alma(10); let almaExplicit = new Alma(10, 0.85, 6.0); let swma = new Swma(); // The manual SWMA keeps the last four closes by hand: x0 is the newest. let x0: f64 = NaN; let x1: f64 = NaN; let x2: f64 = NaN; let x3: f64 = NaN; function onStart(): void { almaDefault = new Alma(i32(p_length())); almaExplicit = new Alma(i32(p_length()), 0.85, 6.0); swma = new Swma(); } function onBar(): void { const close = bar.close(); const a = almaDefault.update(close); const b = almaExplicit.update(close); const s = swma.update(close); x3 = x2; x2 = x1; x1 = x0; x0 = close; const manual = (x3 + 2.0 * x2 + 2.0 * x1 + x0) / 6.0; const almaCheck = Math.abs(a - b) < 0.000001 ? b : NaN; const swmaCheck = Math.abs(s - manual) < 0.000001 ? s : NaN; out_alma_check(almaCheck); out_swma_check(swmaCheck); } ``` ## Points to remember - An average is an object you own: construct it in `onStart()`, keep it in a module-level `let`, feed it in `onBar()`. There is no series to pass in; the class sees one value per bar, which is the whole [execution model](../core-concepts/execution-model.md). - A band's `upper` / `basis` / `lower` streams are three fields read after `update()` and three outputs. Nothing indexes into a tuple. - `Vwma` reads volume through `bar.volume()` and a two-argument `update(price, volume)`: a wrun indicator names every field it reads. - Warm-up is `NaN`, and `NaN` propagates through arithmetic. `isNaN(x)` is the test; a two-line `nz` helper is on the [Series functions](series-functions.md) page. - Every class here is checked bit-exact against the reference run described on the [TA library](ta-library.md) page, edge rules included. # Oscillators Oscillators measure momentum and overbought or oversold pressure. Every oscillator ships with the editor as a stateful class in `./sdk/ta`: `Rsi`, `Wpr`, `Cmo`, `Tsi`, `Macd`, `Stoch`, `Stochastic`, `Cci`, `Mfi`, `Mom`, and `Roc` (the full catalog is on the [TA library](ta-library.md) page). Name the ones you use; there is nothing to import. A multi-output oscillator exposes its streams as fields after `update()` (`macd.signal`, `stoch.k`) and each one goes to its own output. Every oscillator needs a warm-up window. Until enough bars have loaded to fill its longest period the value is `NaN` and nothing draws. `Rsi(14)` is `NaN` for its seed window and `Macd` warms up over the slow average plus the signal period. Leading bars are blank, then the line begins. The compiled modules below also fold two classes that live elsewhere: `Adx`, which takes longest because it smooths directional movement twice, on [Trend and volatility](trend-indicators.md#adx), and `Obv` on [Volume and VWAP](volume-indicators.md#obv). ## Reference Every class allocates in its constructor, never in `update()`, and `reset()` restores the just-constructed state. A `period` below 1 is clamped to 1. Construct in `onStart()` from a param (params are `f64`, periods are `i32`, so `new Rsi(i32(p_rsi_period()))`). ### Rsi `new Rsi(period)`, `.update(x)`: the relative strength index, bounded 0..100. Wilder smoothing (the gain and loss averages of the first `period` one-bar changes seed it, then `avg = (avg * (period - 1) + gain) / period`), first value at bar `period`. The ratio step is `100 - 100 / (1 + gain / loss)`, and a zero average loss returns `100`: a flat window reads 100, never 50; all-loss reads 0. A non-finite input before the seed restarts the seed count; after the seed it makes the value `NaN` for good. ```typescript let rsi = new Rsi(14); function onStart(): void { rsi = new Rsi(i32(p_rsi_period())); } ``` ### Wpr `new Wpr(length = 14)`, `.update(high, low, close)`: Williams %R over a high/low window, `-100 * (highest high - close) / (highest high - lowest low)`, bounded -100..0. `NaN` for the first `length - 1` bars and whenever any high or low in the window (or the close) is non-finite; a flat window (highest equals lowest) returns `0`. ```typescript let wpr = new Wpr(14); function onStart(): void { wpr = new Wpr(i32(p_length())); } ``` ### Cmo `new Cmo(length = 9)`, `.update(x)`: the Chande momentum oscillator compares summed gains and losses over the last `length` one-bar changes, `100 * (up - down) / (up + down)`. `NaN` until bar `length` (the first bar has no previous value), `0` when `up + down == 0` (a flat window), and `NaN` when any value in the window, or the bar before it, is non-finite. ```typescript let cmo = new Cmo(14); function onStart(): void { cmo = new Cmo(i32(p_length())); } ``` ### Tsi `new Tsi(short = 13, long = 25)`, `.update(x)`: the true strength index double-smooths one-bar momentum and its absolute value with an EMA of `long` then an EMA of `short`, and returns their ratio times 100. The engine's parameter order is `short` first, then `long`. Each EMA stage seeds on the mean of its first `period` finite inputs, so the first value lands at bar `long + short - 1`; the ratio is `NaN` when either stage is not finite or the denominator is `0`. ```typescript let tsi = new Tsi(13, 25); function onStart(): void { tsi = new Tsi(i32(p_short()), i32(p_long())); } ``` ### Macd `new Macd(fastPeriod = 12, slowPeriod = 26, signalPeriod = 9)`, `.update(x)`: returns the MACD line and fills the fields `macd`, `signal`, and `hist`. The MACD line is the fast EMA minus the slow EMA, each leg seeded on the mean of its first `period` finite inputs (first value at bar `slowPeriod - 1`), the signal is an EMA of that line seeded on its first `signalPeriod` finite values (first value at bar `slowPeriod + signalPeriod - 2`), and `hist` is `macd - signal` when both are finite. A non-finite input after a seed poisons that leg to `NaN`. Draw the histogram as a `histogram` output around zero, or test a signal-line cross with `Cross.update(macd.macd, macd.signal)` ([Series functions](series-functions.md)). ```typescript let macd = new Macd(12, 26, 9); function onStart(): void { macd = new Macd(i32(p_fast()), i32(p_slow()), i32(p_signal())); } // after macd.update(close): macd.macd, macd.signal, macd.hist ``` ### Stoch and Stochastic `new Stoch(periodK, smoothK, periodD)`, `.update(high, low, close)`: the stochastic oscillator. Raw %K is `100 * (close - lowest low) / (highest high - lowest low)` over `periodK` bars (`0` on a flat window, `NaN` while the window is short or holds a non-finite value), `k` is the strict simple average of the last `smoothK` raw values, and `d` is the same average of the last `periodD` values of `k`. `update()` returns `k`; read `k` and `d` as fields. First `k` at bar `periodK + smoothK - 2`, first `d` at bar `periodK + smoothK + periodD - 3`. Two spellings with different edge rules ship. `new Stochastic(kPeriod = 14, kSmoothing = 3, dPeriod = 3)` is the other one: bars before `kPeriod - 1` report `k = 0` and `d = 0` (not `NaN`), a flat window reads `50`, `k` is the raw value itself until the smoothing window fills, `d` equals `k` until its own window fills, and a `NaN` `k` or `d` is reported as `50`. Reach for `Stoch` unless you want those edge rules. ```typescript let stoch = new Stoch(14, 3, 3); function onStart(): void { stoch = new Stoch(i32(p_period_k()), 3, 3); } // after stoch.update(high, low, close): stoch.k, stoch.d ``` Both read the close as the source. Pine's `ta.stoch(source, high, low, length)` takes any source: `SourceStoch` in `./sdk/ta-plus` ([Extra indicators](extra-indicators.md)) moves the source into `update(source, high, low)` and keeps `Stoch`'s smoothing and fields; fed the close it is `Stoch` on every window with a range (on a flat window `Stoch` reads 0 where `SourceStoch` follows TradingView: the previous raw %K when the source sits on the window's value, `NaN` otherwise). ```typescript let stochHl2 = new SourceStoch(14, 3, 3); function onStart(): void { stochHl2 = new SourceStoch(i32(p_period_k()), 3, 3); } // in onBar(): stochHl2.update((bar.high() + bar.low()) / 2.0, bar.high(), bar.low()); stochHl2.k, stochHl2.d ``` ### Cci `new Cci(period = 20, constant = 0.015)`, `.update(high, low, close)`: the commodity channel index over the typical price `(high + low + close) / 3`, `(tp - sma) / (constant * meanDev)` where `sma` is the window mean of the typical price and `meanDev` the mean absolute deviation around it. Readings beyond +100 and -100 mark momentum extremes. `NaN` for the first `period - 1` bars; `0` when the mean deviation is `0`. ```typescript let cci = new Cci(20, 0.015); function onStart(): void { cci = new Cci(i32(p_period()), 0.015); } ``` ### Mfi `new Mfi(period = 14)`, `.update(high, low, close, volume)`: the money flow index, a volume-weighted RSI bounded 0..100. Each bar's raw flow is `typical price * volume`, added to the positive sum when the typical price rose against the previous bar, to the negative sum when it fell, and to neither when equal; the result is `100 - 100 / (1 + positive / negative)`, and a zero negative sum returns `100`. `NaN` for the first `period` bars; a non-finite bar fails both comparisons and adds nothing, as in the engine. It needs volume, so pass `bar.volume()` beside the prices. Above 80 reads overbought and below 20 oversold; price at a new high while the index above 80 is not is a classic exhaustion read. ```typescript let mfi = new Mfi(14); function onStart(): void { mfi = new Mfi(i32(p_period())); } ``` ### Mom and Roc `new Roc(n)`, `.update(x)`: rate of change in percent, `((x - x[n]) / x[n]) * 100`, `NaN` for the first `n` bars and when the lagged value is `0`. `new Mom(n)`, `.update(x)`: momentum, the raw difference `x - x[n]`, `NaN` for the first `n` bars and when either value is non-finite. `Change` is the same math under another name with a one-bar default lag; it lives with the series helpers on [Series functions](series-functions.md#trend-extremes-and-counting). ```typescript let mom = new Mom(10); let roc = new Roc(10); function onStart(): void { mom = new Mom(i32(p_lag())); roc = new Roc(i32(p_lag())); } ``` ## Putting them together One module wiring every oscillator above into a lower pane, the multi-output ones (`Macd`, `Stoch`, `Adx`) written stream by stream. Every class is used by name with nothing to import. `onBar()` returns before writing until the slowest line (`Adx`) is warm so every output starts together; write each value as it comes instead if you want the fast lines to appear first, since a `NaN` draws nothing ([Execution model](../core-concepts/execution-model.md)). ```typescript param("rsi_period", 14, { min: 2, max: 200 }); param("fast", 12, { min: 1, max: 200, description: "MACD fast EMA" }); param("slow", 26, { min: 2, max: 400, description: "MACD slow EMA" }); param("signal", 9, { min: 1, max: 200, description: "MACD signal EMA" }); param("adx_period", 14, { min: 1, max: 200 }); output("rsi", line, lower, { color: "#7c3aed", width: 2, description: "Relative strength index" }); output("wpr", line, lower, { color: "#2563eb", width: 1, description: "Williams %R" }); output("cmo", line, lower, { color: "#16a34a", width: 1, description: "Chande momentum oscillator" }); output("tsi", line, lower, { color: "#9333ea", width: 1, description: "True strength index" }); output("macd", line, lower, { color: "#1d4ed8", width: 2, description: "MACD line" }); output("signal", line, lower, { color: "#ea580c", width: 2, description: "MACD signal" }); output("hist", histogram, lower, { color: "#15803d", description: "MACD histogram" }); output("stoch_k", line, lower, { color: "#0e7490", width: 2, description: "Stochastic %K" }); output("stoch_d", line, lower, { color: "#be123c", width: 2, description: "Stochastic %D" }); output("cci", line, lower, { color: "#4b5563", width: 1, description: "Commodity channel index" }); output("mfi", line, lower, { color: "#16a34a", width: 2, description: "Money flow index" }); output("mom", line, lower, { color: "#9333ea", width: 1, description: "Momentum over 10 bars" }); output("roc", line, lower, { color: "#0891b2", width: 1, unit: "%", description: "Rate of change over 10 bars" }); output("adx", line, lower, { color: "#111827", width: 2, description: "Average directional index" }); output("di_plus", line, lower, { color: "#2563eb", width: 1, description: "+DI" }); output("di_minus", line, lower, { color: "#dc2626", width: 1, description: "-DI" }); output("obv", line, lower, { color: "#0f766e", width: 1, description: "On-balance volume" }); let rsi = new Rsi(14); let wpr = new Wpr(14); let cmo = new Cmo(14); let tsi = new Tsi(13, 25); let macd = new Macd(12, 26, 9); let stoch = new Stoch(14, 3, 3); let cci = new Cci(20, 0.015); let mfi = new Mfi(14); let mom = new Mom(10); let roc = new Roc(10); let adx = new Adx(14); let obv = new Obv(); function onStart(): void { rsi = new Rsi(i32(p_rsi_period())); wpr = new Wpr(14); cmo = new Cmo(14); tsi = new Tsi(13, 25); macd = new Macd(i32(p_fast()), i32(p_slow()), i32(p_signal())); stoch = new Stoch(14, 3, 3); cci = new Cci(20, 0.015); mfi = new Mfi(14); mom = new Mom(10); roc = new Roc(10); adx = new Adx(i32(p_adx_period())); obv = new Obv(); } function onBar(): void { const close = bar.close(); const high = bar.high(); const low = bar.low(); const volume = bar.volume(); const rsiValue = rsi.update(close); const wprValue = wpr.update(high, low, close); const cmoValue = cmo.update(close); const tsiValue = tsi.update(close); macd.update(close); stoch.update(high, low, close); const cciValue = cci.update(high, low, close); const mfiValue = mfi.update(high, low, close, volume); const momValue = mom.update(close); const rocValue = roc.update(close); adx.update(high, low, close); const obvValue = obv.update(close, volume); // ADX is the slowest line here; return before writing until it is warm so every output begins together. if (isNaN(adx.adx)) return; out_rsi(rsiValue); out_wpr(wprValue); out_cmo(cmoValue); out_tsi(tsiValue); out_macd(macd.macd); out_signal(macd.signal); out_hist(macd.hist); out_stoch_k(stoch.k); out_stoch_d(stoch.d); out_cci(cciValue); out_mfi(mfiValue); out_mom(momValue); out_roc(rocValue); out_adx(adx.adx); out_di_plus(adx.plusDi); out_di_minus(adx.minusDi); out_obv(obvValue); } ``` ## Edge behavior: Wpr, Cmo, Tsi Two edges are worth seeing: %R is `NaN` until its window fills, and CMO returns `0` on a flat series. One module shows both with the shipped classes. `flat` is a series held at 100 on every bar, so its `Cmo` changes are all zero and the class returns `0` once its window is full; `wpr_warm` is `1` on the bars where the class has an answer and `0` before, so the boundary is a step you can read off the pane. ```typescript param("length", 14, { min: 2, max: 200 }); output("wpr", line, lower, { color: "#2563eb", width: 2, description: "Williams %R" }); output("cmo", line, lower, { color: "#16a34a", width: 2, description: "Chande momentum oscillator" }); output("tsi", line, lower, { color: "#7c3aed", width: 2, description: "True strength index, short 13 long 25" }); output("wpr_warm", none, lower, { description: "1 once %R has a full window, 0 before" }); output("cmo_flat", line, lower, { color: "#ea580c", width: 1, description: "CMO of a flat series: 0 once its window fills" }); let wpr = new Wpr(14); let cmo = new Cmo(14); let cmoFlat = new Cmo(5); let tsi = new Tsi(13, 25); function onStart(): void { wpr = new Wpr(i32(p_length())); cmo = new Cmo(i32(p_length())); cmoFlat = new Cmo(5); tsi = new Tsi(13, 25); } function onBar(): void { const close = bar.close(); const wprValue = wpr.update(bar.high(), bar.low(), close); const cmoValue = cmo.update(close); const cmoFlatValue = cmoFlat.update(100.0); const tsiValue = tsi.update(close); out_wpr(wprValue); out_cmo(cmoValue); out_tsi(tsiValue); out_wpr_warm(isNaN(wprValue) ? 0.0 : 1.0); out_cmo_flat(cmoFlatValue); } ``` ## Warm-up in practice To see warm-up directly, draw a few oscillators and look at where each line begins. The leading gap is the seed window: each line stays blank until its longest period has enough bars, then turns finite. `Adx` starts last (bar `2 * period - 1`), `Rsi` first (bar `period`), `Stoch` at bar `period + 1` with its default smoothing of 3, and `Macd` once its slow EMA is seeded. This module writes every output on every bar (a `NaN` draws nothing) instead of returning early, so the pane shows each line switching on at its own bar. ```typescript param("period", 14, { min: 2, max: 200 }); output("rsi", line, lower, { color: "#7c3aed", width: 2, description: "Finite from bar period on" }); output("macd", line, lower, { color: "#2563eb", width: 2, description: "Finite once the slow EMA is seeded" }); output("stoch_k", line, lower, { color: "#0891b2", width: 2, description: "Finite after the %K window plus smoothing" }); output("adx", line, lower, { color: "#111827", width: 2, description: "Finite after two smoothing windows" }); let rsi = new Rsi(14); let macd = new Macd(12, 26, 9); let stoch = new Stoch(14, 3, 3); let adx = new Adx(14); function onStart(): void { const period = i32(p_period()); rsi = new Rsi(period); macd = new Macd(12, 26, 9); stoch = new Stoch(period, 3, 3); adx = new Adx(period); } function onBar(): void { const close = bar.close(); const rsiValue = rsi.update(close); macd.update(close); stoch.update(bar.high(), bar.low(), close); adx.update(bar.high(), bar.low(), close); // Every bar has a row; each output carries NaN until its own class is warm. out_rsi(rsiValue); out_macd(macd.macd); out_stoch_k(stoch.k); out_adx(adx.adx); } ``` ## Points to remember - Every oscillator takes the fields it needs as arguments, each one read from the bar (`bar.high()`, `bar.low()`, `bar.close()`, `bar.volume()`); reading a field is what puts it on the sheet. - `macd.hist`, `stoch.d`, and the ADX trio are fields on the class and one output each. - A MACD divergence (price at a new high while `macd.macd` is not) is a weakening trend; the `hist` output shrinking toward zero says the same thing earlier. - Every class here is checked bit-exact against the reference run on the [TA library](ta-library.md) page, edge rules included (`Rsi` at 100 on a flat window, `Stochastic` at 50, `Wpr` and `Cmo` at 0). # Trend and volatility Trend tools read direction and strength, volatility tools read range: the directional movement system, the Ichimoku cloud, the parabolic stop-and-reverse, the ATR trailing stop, the band pair, and the volatility primitives under them. Every one on this page ships as a class in `./sdk/ta` (`Adx`, `Ichimoku`, `Psar`, `Supertrend`, `Tr`, `Atr`, `Stdev`, `Variance`), and the bands are outputs plus a `range()` declaration. MACD is an oscillator and lives on the [Oscillators](oscillators.md#macd) page. The full catalog is on the [TA library](ta-library.md) page. | System | What it reads | | --- | --- | | Directional movement (`Adx`) | trend strength as `adx`, with `plusDi` and `minusDi`; above 25 is a strong trend, below 20 often a range | | Ichimoku cloud (`Ichimoku`) | five components; the two leading spans come out of the class already shifted, the lagging span is drawn back by declaration | | Parabolic SAR (`Psar`) | a dot that trails price and jumps to the other side when the trend flips | | Supertrend (`Supertrend`) | an ATR-banded trailing stop with a direction the chart colors by | ## Adx `new Adx(period = 14)`, `.update(high, low, close)` returns the ADX line; after each update the fields `adx`, `plusDi`, and `minusDi` hold the three streams. The class follows the engine's own arithmetic rather than a textbook Wilder average: the true range and the two directional movements are first summed over the first `period` bars (a plain sum, not a mean), then each running sum is smoothed as `s = s - s / period + x`; `plusDi` and `minusDi` are the smoothed movements over the smoothed true range times 100 (`0` when that range is exactly `0`); DX is the normalized DI difference, `0` when both DI lines are `0`; and `adx` seeds on the plain average of the first `period` DX values before smoothing as `(adx * (period - 1) + dx) / period`. So the DI lines appear at bar `period` and `adx` at bar `2 * period - 1`. Bar 0 has no previous bar and produces nothing; a non-finite bar before the seed clears the partial sums and restarts the run, and a non-finite bar after the seed leaves all three outputs `NaN` for the rest of the series. ```typescript let adx = new Adx(14); function onStart(): void { adx = new Adx(i32(p_adx_period())); } ``` Read `adx.plusDi > adx.minusDi` for direction and `adx.adx` rising for conviction; a `Cross` object over the two DI lines ([Series functions](series-functions.md)) turns the crossover into a signal. Pine's `ta.dmi(diLength, adxSmoothing)` takes two lengths: `+DI` and `-DI` over `diLength`, ADX over `adxSmoothing`. `Adx` smooths all three over one period, so when the two differ reach for `Dmi` in `./sdk/ta-plus` ([Extra indicators](extra-indicators.md)), which keeps Pine's form (Wilder's `Rma` on both) and the same three fields; with equal lengths the two classes agree on a series without a hole (`Dmi` skips a missing bar the way TradingView's `ta.dmi` does, where `Adx` reads `NaN` for good). ```typescript let dmi = new Dmi(14, 14); function onStart(): void { dmi = new Dmi(i32(p_di_length()), i32(p_adx_smoothing())); } // after dmi.update(high, low, close): dmi.adx, dmi.plusDi, dmi.minusDi ``` ## Ichimoku `new Ichimoku(conversionPeriod = 9, basePeriod = 26, laggingSpanPeriod = 52, displacement = 26)`, `.update(high, low, close)` returns `tenkan`; fields `tenkan`, `kijun`, `senkouA`, `senkouB`, `chikou`. Each line is the midpoint of the highest high and lowest low over its period. The engine conventions the class keeps: - **Partial windows, no warm-up.** Bar 0 already has a value from its own bar; there is no `NaN` lead-in. A `NaN` high or low inside the window is skipped, but the current bar's own `NaN` poisons the value, and every output that comes out `NaN` is reported as `0`. - **The displacement is inside the math.** `senkouA` on bar `i` is `(tenkan + kijun) / 2` as it stood `displacement` bars earlier, and `senkouB` is the 52-bar midpoint from `displacement` bars earlier; on the first `displacement` bars both fall back to the current bar's values. The two fields are therefore already the cloud that belongs on the current bar, so write them to plain outputs with no `displacement_bars`. The projection past the newest loaded bar is not emitted, exactly as in the engine. - **`chikou` is the current close.** The engine reads the close of bar `i + displacement`, a future bar, and only falls back to the current close on the last `displacement` bars of the series; a class that sees one bar at a time cannot read ahead, so the field is the current close. Declaring `displacement_bars: -26` on its output makes the chart draw that close 26 bars back, which is the lagging span as the engine's chart shows it. That shift is drawing only: the value stays on the bar that computed it. ```typescript const cloud = new Ichimoku(9, 26, 52, 26); // In onBar(): one update per bar, then read the five lines by name. cloud.update(bar.high(), bar.low(), bar.close()); const bullish = cloud.senkouA >= cloud.senkouB; // the cloud on this bar, already shifted ``` The cloud itself is two boxes: `box("cloud_up", { top: senkouA, bottom: senkouB, when: aAbove })` draws, for each bar, a one-bar slice between the two spans the class reports for that bar, and a second box gated the other way draws the bearish slices in the other color. The declarations are compiled in [Cloud, stop, and dots](#cloud-stop-and-dots) below. ## Psar `new Psar(start = 0.02, increment = 0.02, maxValue = 0.2)`, `.update(high, low, close)` returns the SAR price for the bar, which jumps to the other side of price when the trend flips; there is no direction field, so read the side as `close > sar` (or the SAR against the low), or track the flips with a `Cross` object over price and the SAR. The acceleration factor starts at `start`, grows by `increment` on every new extreme, and caps at `maxValue`; the SAR never crosses the two prior bars' lows in an up trend or highs in a down trend, and when price trades through it the trend flips and the SAR jumps to the last extreme. Engine conventions: bar 0 is `NaN`; bar 1 decides the opening trend from `close[1] >= close[0]` and returns `low[0]` for an up trend or `high[0]` for a down one; the close is only read on those two bars; a non-finite high or low (or close on bars 0 and 1) makes the SAR `NaN` for that bar and every bar after it, because the engine recomputes the whole series and stops at the first bad bar. Lower acceleration (`0.01` to `0.02`) gives smoother stops with fewer reversals; `0.05` and up reacts faster and flips more in ranges. ```typescript let psar = new Psar(0.02, 0.02, 0.2); function onBar(): void { const close = bar.close(); const sar = psar.update(bar.high(), bar.low(), close); if (isNaN(sar)) return; const side = close > sar ? 1.0 : -1.0; out_psar(sar); out_side(side); } ``` Draw the SAR as a `scatter` output so it reads as dots, and color the dots by `side` through a `color_by` ladder ([Styling](../presentation/styling.md)). ## Supertrend `new Supertrend(factor, atrPeriod)` (no defaults; `3` and `10` are the usual values), `.update(high, low, close)` returns the stop line and fills the fields `line` and `direction` (`1` for an up trend, the line sits below price; `-1` for a down trend, the line sits above). The bands sit `factor` ATRs either side of the bar midpoint `(high + low) / 2`; the upper band ratchets down (a lower basic band, or the previous close above the old band, replaces it) and the lower band ratchets up symmetrically; the direction flips to `-1` when the close drops below the lower band in an up trend and to `1` when it rises above the upper band in a down trend; the line is the lower band in an up trend and the upper band in a down one. The ATR inside is the same Wilder smoothing as `Atr`, so the first finite bar is `atrPeriod - 1`, where the direction starts as `1` when the close is at or above the midpoint. While the ATR is `NaN` both fields are `NaN` and the band state is left untouched, so a gap neither resets nor advances the bands; a non-finite high, low, or close on a bar with a finite ATR gives `NaN` for that bar alone. Because a shape cannot read a bool, the direction is a number, ready to be written to a data-only output and used as a `color_by` index. ```typescript let st = new Supertrend(3.0, 10); function onStart(): void { st = new Supertrend(p_factor(), i32(p_atr_period())); } function onBar(): void { st.update(bar.high(), bar.low(), bar.close()); if (isNaN(st.line)) return; out_st_line(st.line); out_st_dir(st.direction > 0.0 ? 1.0 : 0.0); } ``` ## Bands as outputs `Bb` and `Keltner` (both described on the [Moving averages](moving-averages.md) page) expose `basis`, `upper`, and `lower` fields; each becomes an output. A wrun indicator declares the band between them. `range(upper, lower, options)` names the two edge outputs (both must be drawn) and the chart draws the band: both edge lines at `edge_width` and `edge_line_style` with a tinted interior in `color`, a `colors` + `color_by` ladder to tint it per bar, or a vertical `gradient` ([Styling](../presentation/styling.md)). A `box` on every bar between the two edge outputs, with `from` and `to` left at `0`, is the other way to shade ([Cards, frames and panels](../presentation/cards-frames-panels.md)); the slices tile into a channel, and either edge may stay data-only. ## Volatility primitives | Class | Construct | Per bar | Returns | | --- | --- | --- | --- | | `Tr` | `new Tr()` | `.update(high, low, close)` | the true range of the current bar: the largest of `high - low`, `abs(high - prevClose)`, `abs(low - prevClose)`, taken pairwise left to right; on bar 0 just `high - low` | | `Atr` | `new Atr(period = 14)` | `.update(high, low, close)` | the Wilder-smoothed true range: the seed is the plain average of the first `period` true ranges (first value at bar `period - 1`), then `(prev * (period - 1) + tr) / period` | | `Stdev` | `new Stdev(period)` | `.update(x)` | the rolling population standard deviation (divide by `period`, not `period - 1`) | | `Variance` | `new Variance(period)` | `.update(x)` | the rolling population variance, the same window arithmetic as `Stdev` without the square root | All four ship in `./sdk/ta`; they are the building blocks the band and stop classes use internally, exposed so custom volatility logic composes the same way. Their `NaN` rules differ, and the differences are the engine's: `Tr` returns `NaN` on a non-finite high or low and on the bar after a non-finite close; `Atr` restarts its seed when a non-finite true range arrives before the seed completes, and stays `NaN` for good after one arrives later (the engine never reseeds); `Stdev` and `Variance` are strict windows, `NaN` until `period` bars exist and whenever any value in the window is not finite, healing as soon as the bad bar leaves. ```typescript const tr = new Tr(); let atr = new Atr(14); let stdev = new Stdev(20); let variance = new Variance(20); function onStart(): void { atr = new Atr(i32(p_atr_period())); stdev = new Stdev(i32(p_period())); variance = new Variance(i32(p_period())); } function onBar(): void { const close = bar.close(); const trValue = tr.update(bar.high(), bar.low(), close); const atrValue = atr.update(bar.high(), bar.low(), close); const stdevValue = stdev.update(close); const varianceValue = variance.update(close); if (isNaN(atrValue)) return; out_tr(trValue); out_atr(atrValue); out_stdev(stdevValue); out_variance(varianceValue); } ``` `Stdev` is strict: a `NaN` sample anywhere in its window makes the result `NaN` until the sample leaves the window; guard the input with `isNaN` when a sparse source feeds it. ## Putting them together The directional system, the SAR, the Supertrend stop, and the volatility primitives in one module. The stop line is colored by its regime through a `color_by` ladder over a data-only `st_dir` output, the SAR draws as dots, and the ADX trio and the four volatility lines share a lower pane. ```typescript param("adx_period", 14, { min: 1, max: 200 }); param("factor", 3, { min: 0.5, max: 10, description: "Supertrend ATR multiplier" }); param("atr_period", 10, { min: 1, max: 200, description: "ATR window for Supertrend and the atr line" }); output("st_line", line, overlay, { width: 2, color_by: "st_dir", colors: ["#dc2626", "#16a34a"], description: "Supertrend stop, red in a short regime, green in a long one" }); output("st_dir", none, overlay, { description: "0 short, 1 long: the palette index for st_line" }); output("psar", scatter, overlay, { color: "#9333ea", description: "Parabolic SAR dots" }); output("adx", line, lower, { color: "#111827", width: 2, description: "Average directional index" }); output("di_plus", line, lower, { color: "#2563eb", width: 1, description: "+DI" }); output("di_minus", line, lower, { color: "#dc2626", width: 1, description: "-DI" }); output("tr", line, lower, { color: "#94a3b8", width: 1, description: "True range" }); output("atr", line, lower, { color: "#f97316", width: 2, description: "Average true range" }); output("stdev", line, lower, { color: "#0891b2", width: 1, description: "Rolling standard deviation of the close" }); output("variance", line, lower, { color: "#7c3aed", width: 1, description: "Rolling variance of the close" }); let adx = new Adx(14); let psar = new Psar(0.02, 0.02, 0.2); let st = new Supertrend(3.0, 10); let tr = new Tr(); let atr = new Atr(10); let stdev = new Stdev(10); let variance = new Variance(10); function onStart(): void { adx = new Adx(i32(p_adx_period())); psar = new Psar(0.02, 0.02, 0.2); st = new Supertrend(p_factor(), i32(p_atr_period())); tr = new Tr(); atr = new Atr(i32(p_atr_period())); stdev = new Stdev(i32(p_atr_period())); variance = new Variance(i32(p_atr_period())); } function onBar(): void { const close = bar.close(); const high = bar.high(); const low = bar.low(); adx.update(high, low, close); const psarValue = psar.update(high, low, close); st.update(high, low, close); const trValue = tr.update(high, low, close); const atrValue = atr.update(high, low, close); const stdevValue = stdev.update(close); const varianceValue = variance.update(close); out_st_line(st.line); out_st_dir(st.direction > 0.0 ? 1.0 : 0.0); out_psar(psarValue); out_adx(adx.adx); out_di_plus(adx.plusDi); out_di_minus(adx.minusDi); out_tr(trValue); out_atr(atrValue); out_stdev(stdevValue); out_variance(varianceValue); } ``` ## Cloud, stop, and dots Ichimoku with its two-color cloud and the lagging span drawn back, the Supertrend stop colored by regime, and the SAR as dots, in one overlay. ```typescript param("factor", 3, { min: 0.5, max: 10, description: "Supertrend ATR multiplier" }); param("atr_period", 10, { min: 1, max: 200, description: "Supertrend ATR window" }); output("tenkan", line, overlay, { color: "#0891b2", width: 1, description: "Conversion line, 9-bar midpoint" }); output("kijun", line, overlay, { color: "#be123c", width: 1, description: "Base line, 26-bar midpoint" }); // The spans are already shifted inside the class: the value on a bar is the cloud for that bar. const senkouA = output("senkou_a", line, overlay, { color: "#0f766e", width: 1, description: "Leading span A, the class shifts it 26 bars ahead" }); const senkouB = output("senkou_b", line, overlay, { color: "#b45309", width: 1, description: "Leading span B, the class shifts it 26 bars ahead" }); output("chikou", line, overlay, { color: "#64748b", width: 1, displacement_bars: -26, description: "Lagging span, the close drawn 26 bars back" }); const aAbove = output("a_above", none, overlay, { description: "1 where span A is above span B: the bullish cloud gate" }); const bAbove = output("b_above", none, overlay, { description: "1 where span B is above span A: the bearish cloud gate" }); // The cloud: one slice per bar between the two spans, tinted by which span is on top. box("cloud_up", { top: senkouA, bottom: senkouB, when: aAbove, color: "#16a34a", opacity: 0.12, borderWidth: 0 }); box("cloud_down", { top: senkouB, bottom: senkouA, when: bAbove, color: "#dc2626", opacity: 0.12, borderWidth: 0 }); output("st_line", line, overlay, { width: 2, color_by: "st_dir", colors: ["#dc2626", "#16a34a"], description: "Supertrend stop, colored by regime" }); output("st_dir", none, overlay, { description: "0 short, 1 long" }); output("psar", scatter, overlay, { color: "#9333ea", description: "Parabolic SAR" }); let cloud = new Ichimoku(9, 26, 52, 26); let st = new Supertrend(3.0, 10); let psar = new Psar(0.02, 0.02, 0.2); function onStart(): void { cloud = new Ichimoku(9, 26, 52, 26); st = new Supertrend(p_factor(), i32(p_atr_period())); psar = new Psar(0.02, 0.02, 0.2); } function onBar(): void { const close = bar.close(); const high = bar.high(); const low = bar.low(); cloud.update(high, low, close); st.update(high, low, close); const psarValue = psar.update(high, low, close); out_tenkan(cloud.tenkan); out_kijun(cloud.kijun); out_senkou_a(cloud.senkouA); out_senkou_b(cloud.senkouB); out_chikou(cloud.chikou); out_a_above(cloud.senkouA >= cloud.senkouB ? 1.0 : 0.0); out_b_above(cloud.senkouB > cloud.senkouA ? 1.0 : 0.0); out_st_line(st.line); out_st_dir(st.direction > 0.0 ? 1.0 : 0.0); out_psar(psarValue); } ``` ## Bands: outputs, a range, and a shaded box The Bollinger pair as three outputs, a `range()` declaration recording the band in the sheet, and a `box` on every bar shading the same pair on the chart, gated so the shade only shows while the basis is rising. ```typescript param("period", 20, { min: 2, max: 400 }); param("mult", 2, { min: 0.5, max: 5, description: "Standard-deviation multiples" }); const upper = output("upper_band", line, overlay, { color: "#64748b", width: 1, description: "Basis plus mult deviations" }); output("basis", line, overlay, { color: "#2563eb", width: 1, description: "20-bar simple average" }); const lowerBand = output("lower_band", line, overlay, { color: "#64748b", width: 1, description: "Basis minus mult deviations" }); const rising = output("rising", none, overlay, { description: "1 while the basis rises: the shade gate" }); // The sheet-level band: honored by hosts that draw ranges, ignored by the chart lane today. range("upper_band", "lower_band", { color: "#2563eb", edge_width: 1, edge_line_style: "dotted" }); // The chart-drawn band: one slice per bar between the same two outputs, tiling into a channel. box("band_shade", { top: upper, bottom: lowerBand, when: rising, color: "#2563eb", opacity: 0.12, borderWidth: 0 }); let sma = new Sma(20); let stdev = new Stdev(20); let mult: f64 = 2.0; let mid: f64 = NaN; let prevMid: f64 = NaN; function onStart(): void { sma = new Sma(i32(p_period())); stdev = new Stdev(i32(p_period())); mult = p_mult(); } function onBar(): void { const close = bar.close(); prevMid = mid; mid = sma.update(close); const sd = stdev.update(close); if (isNaN(mid) || isNaN(sd)) return; out_upper_band(mid + mult * sd); out_basis(mid); out_lower_band(mid - mult * sd); out_rising(!isNaN(prevMid) && mid > prevMid ? 1.0 : 0.0); } ``` ## Reading them - **Ichimoku.** Price above the cloud (both spans) is an uptrend, below it a downtrend; a `tenkan` over `kijun` cross that agrees with the cloud is the classic entry. - **Supertrend.** The flip of `direction` is the signal; the line itself is the trailing stop for the position the regime implies. # Volume and VWAP Volume tools fold volume into the calculation to read buying and selling pressure, participation, and the price volume actually paid. Three ship as classes in the editor's `./sdk/ta` module (`Obv`, `Vwap`, `Cum`); the money flow index is an oscillator and lives on the [Oscillators](oscillators.md#mfi) page. Volume itself is the bar's own field (`bar.volume()`), and the buy and sell split is two `trades.volume` inputs with a `side`. | Type | What it reads | | --- | --- | | On-balance volume (`Obv`) | a cumulative line that adds volume on up bars and subtracts it on down bars | | Volume-weighted average price (`Vwap`) | the fair price weighted by where volume traded, cumulative or reset on a calendar anchor | | Cumulative sum (`Cum`) | the running total of any series, the primitive behind OBV-style accumulation | For volume-weighted moving averages see `Vwma` on the [Moving averages](moving-averages.md) page; for per-price buy and sell volume inside one bar see [Order flow](order-flow-kit.md). ## Obv `new Obv()`, `.update(close, volume)`. A running cumulative line with no period: bar 0 returns `0`, then the bar's volume is added when the close rose against the previous close, subtracted when it fell, and ignored when equal (or when either close is `NaN`). The whole state is the previous close and the running total, which `reset()` clears. Its level depends on how much history the chart loaded; its slope does not, and the slope is what confirms or contradicts price. ## Vwap `new Vwap(anchor = "", price = "hlc3")`, `.update(open, high, low, close, volume, tsMs)` returns the running VWAP: price times volume over volume, accumulated from an anchor and reset at each boundary. `price` picks the bar price: `hlc3`, `hl2`, `ohlc4` (the only mode that reads the open), `hlcc4`, or `close`. `tsMs` is the bar's open time in milliseconds since the epoch and is only read when an anchor is set: `bar.time()` delivers seconds, so pass `bar.time() * 1000.0`. | Anchor | Boundary | First finite value | | --- | --- | --- | | `""` (none) | never; one accumulation from the first loaded bar | the first bar | | `"day"` | every 00:00 UTC | the first bar | | `"week"` | every Monday 00:00 UTC | the first bar | | `"month"`, `"quarter"`, `"year"` | the first of the period, 00:00 UTC | the first bar | | `"14400000"` (any number of milliseconds, as a string) | every bucket of that width, floored from the epoch | the first bar | **No leading gap, and anchored lines are stable.** The engine starts a bucket on the first bar of the series whatever the calendar says, so an anchored line is finite from bar 0 and its first period is partial: inside it the level depends on where the loaded history starts, and from the first boundary on every period computes only from its own bars, so loading older history cannot change later buckets. The no-anchor form is partial for the whole series (two charts with different history depths disagree, and the level shifts when history loads), which is why the anchored forms are the ones to share. **Engine conventions.** A bar whose high, low, close or volume is not finite (or open, for `"ohlc4"`) marks the current bucket invalid: the value is `NaN` from that bar until the next bucket starts, forever in the no-anchor form. A zero total volume is `NaN`. A `NaN` time with an anchor set clears the sums and returns `NaN`. The no-anchor, `"day"` and numeric-millisecond anchors match the engine bit for bit on the reference window; `"week"`, `"month"`, `"quarter"` and `"year"` are computed from the same calendar arithmetic but unproven on that window. Not mirrored, because a per-bar class never sees the data: the engine's session-calendar bucketing on venues with a trading calendar, its regular-trading-hours filter, and the extra history it loads for quarter and year anchors. ```typescript const weekly = new Vwap("week"); // hlc3 price, resets every Monday 00:00 UTC const session = new Vwap("14400000", "close"); // four-hour buckets over the close // In onBar(): bar.time() is epoch seconds, the class wants milliseconds. const tMs = bar.time() * 1000.0; const weekValue = weekly.update(bar.open(), bar.high(), bar.low(), bar.close(), bar.volume(), tMs); ``` **Any source.** The price is one of the five named ones. Pine's `ta.vwap(source)` takes any series: `SourceVwap(anchor)` in `./sdk/ta-plus` ([Extra indicators](extra-indicators.md)) keeps the same sums and anchors over a source you compute, `update(source, volume, tsMs)`; fed `(high + low + close) / 3` it is `Vwap(anchor, "hlc3")`. ```typescript const daily = new SourceVwap("day"); // In onBar(): the session VWAP of the bar's ohlc4. const o = bar.open(), h = bar.high(), l = bar.low(), c = bar.close(); const value = daily.update((o + h + l + c) / 4.0, bar.volume(), bar.time() * 1000.0); ``` **Custom anchors.** The anchor is a bucket id computed from the bar's open time: a UTC day index for `"day"`, a week index whose origin is three days before the epoch (so weeks start on Monday) for `"week"`, and civil calendar math for the month, quarter and year forms. When the id changes the sums reset, and the same reset-on-id rule is a few lines of arithmetic for any anchor the class does not name (a session open, a news bar, a manual level): ```typescript // The same rule the class applies, written out for a custom anchor. const DAY_MS: f64 = 86400000.0; const dayId = Math.floor(tMs / DAY_MS); // "day" const weekId = Math.floor((tMs + 3.0 * DAY_MS) / (7.0 * DAY_MS)); // "week", Monday origin const bucketId = Math.floor(tMs / 14400000.0); // "14400000" ``` Assign the class to a module-level `let` like any other; there is nothing to wait for, the line is finite on the first bar with volume. ## Cum `new Cum()`, `.update(x)`. It is barely a class: a running total of whatever you feed it from the start of the data, bar 0 returning `x` itself. It is the primitive behind OBV-style accumulation and custom anchored math (feed it signed volume for a delta proxy). ```typescript const cumDelta = new Cum(); function onBar(): void { const volume = bar.volume(); // Signed volume: the bar's volume counted toward the side of its close. const deltaValue = cumDelta.update(bar.close() >= bar.open() ? volume : -volume); out_cum_delta(deltaValue); } ``` A non-finite sample sets the running total to `NaN` for the rest of the history, which is the engine's `cum` rule; a sparse input that must not poison it goes through `Fixnan` first, or through a `missing: "zero"` policy on the source ([Series functions](series-functions.md)). ## Putting them together The four on one lower pane plus the two VWAP lines on the price pane: a money flow index, on-balance volume, a cumulative signed-volume delta proxy, and the cumulative and daily VWAPs. ```typescript param("mfi_period", 14, { min: 2, max: 200 }); output("vwap_cum", line, overlay, { color: "#2563eb", width: 1, description: "Cumulative VWAP from the first loaded bar" }); output("vwap_day", line, overlay, { color: "#eab308", width: 2, description: "VWAP reset at each UTC day boundary" }); output("mfi", line, lower, { color: "#16a34a", width: 2, description: "Money flow index, 0..100" }); output("obv", line, lower, { color: "#0f766e", width: 1, description: "On-balance volume" }); output("cum_delta", line, lower, { color: "#22d3ee", width: 1, description: "Cumulative signed volume: a delta proxy" }); let mfi = new Mfi(14); const obv = new Obv(); const cumDelta = new Cum(); const vwapCum = new Vwap(); const vwapDay = new Vwap("day"); function onStart(): void { mfi = new Mfi(i32(p_mfi_period())); } function onBar(): void { const close = bar.close(); const high = bar.high(); const low = bar.low(); const volume = bar.volume(); const mfiValue = mfi.update(high, low, close, volume); const obvValue = obv.update(close, volume); const open = bar.open(); // Signed volume: the bar's volume counted toward the side of its close. const deltaValue = cumDelta.update(close >= open ? volume : -volume); const tsMs = bar.time() * 1000.0; const vwapCumValue = vwapCum.update(open, high, low, close, volume, tsMs); const vwapDayValue = vwapDay.update(open, high, low, close, volume, tsMs); out_vwap_cum(vwapCumValue); out_vwap_day(vwapDayValue); out_mfi(mfiValue); out_obv(obvValue); out_cum_delta(deltaValue); } ``` ## VWAP anchors side by side The four VWAP variants side by side. Every line is finite from the first bar; the anchored ones reset at their boundary and are partial before the first one. `day_start` is a data-only flag that is `1` on the bar that opens a new UTC day, the bar where the daily sums reset, so the boundary is a value you can read after a run or hand to a declared alert. ```typescript input("open", ohlcv.open); output("vwap_cum", line, overlay, { color: "#2563eb", width: 2, description: "VWAP with no anchor, from the first loaded bar" }); output("vwap_day", line, overlay, { color: "#16a34a", width: 2, description: "VWAP anchored to the UTC day" }); output("vwap_week", line, overlay, { color: "#f97316", width: 2, description: "VWAP anchored to the UTC week" }); output("vwap_month", line, overlay, { color: "#7c3aed", width: 2, description: "VWAP anchored to the UTC month" }); output("day_start", none, overlay, { description: "1 on the bar that opens a new UTC day" }); const DAY_MS: f64 = 86400000.0; const cumulative = new Vwap(""); const daily = new Vwap("day"); const weekly = new Vwap("week"); const monthly = new Vwap("month"); let prevDay: f64 = NaN; function onBar(): void { const open = bar.open(); const high = bar.high(); const low = bar.low(); const close = bar.close(); const volume = bar.volume(); const tMs = bar.time() * 1000.0; // bar.time() is seconds; the class wants milliseconds const day = Math.floor(tMs / DAY_MS); const dayStart = !isNaN(prevDay) && day != prevDay ? 1.0 : 0.0; prevDay = day; const cumValue = cumulative.update(open, high, low, close, volume, tMs); const dayValue = daily.update(open, high, low, close, volume, tMs); const weekValue = weekly.update(open, high, low, close, volume, tMs); const monthValue = monthly.update(open, high, low, close, volume, tMs); out_vwap_cum(cumValue); out_vwap_day(dayValue); out_vwap_week(weekValue); out_vwap_month(monthValue); out_day_start(dayStart); } ``` ## Seeing the VWAP anchor boundary To see exactly where each anchored line resets, write a flag that is `1` on the bar whose bucket id differs from the previous bar's, the way `day_start` does above for the daily line. The flag is an output, so after a **Run** the editor's Console prompt reads it without drawing it: `last 200 day_start` lists the newest 200 bars, and `at day_start` reads one bar. To see the boundaries on the chart instead, declare the flag as a `histogram` on `lower` while you check, then set it back to `none`. ## Buy and sell volume The close-versus-open sign above is a proxy. The real side split is a source: `trades` serves per-period volume aggregated by aggressor side, one series per side, so buy and sell volume are two inputs on the same feed with a `side`. A venue that skips bars on one side delivers nothing on that bar; `missing: "zero"` turns the gap into `0` so the delta stays finite. The chart serves `trades` as per-bar volume by side (`volume` with `side: "BUY"` or `"SELL"`, in base-asset units) over the loaded history and live; any other `trades` field is refused by name. ```typescript input("close", ohlcv.close); input("buy", trades.volume, { side: "BUY", missing: "zero", description: "Volume traded by aggressive buyers" }); input("sell", trades.volume, { side: "SELL", missing: "zero", description: "Volume traded by aggressive sellers" }); output("delta", histogram, lower, { color_by: "delta_sign", colors: ["#ef5350", "#26a69a"], description: "Buy minus sell volume per bar" }); output("delta_sign", none, lower, { description: "0 on a sell-dominant bar, 1 on a buy-dominant bar" }); output("cvd", line, lower, { color: "#22d3ee", width: 2, description: "Cumulative volume delta" }); let cvd: f64 = 0.0; function onBar(): void { const delta = in_buy() - in_sell(); cvd += delta; out_delta(delta); out_delta_sign(delta >= 0.0 ? 1.0 : 0.0); out_cvd(cvd); } ``` The primary input stays the close so the wrun indicator follows the chart's market and interval; the two side inputs align to its grid row for row. Side volume always comes from the chart's own market: the chart refuses a `symbol` + `exchange` pin on a `trades` input ("the browser lane serves market pins on secondary ohlcv inputs only (typed feeds, cells and time follow the chart's own market)"). The [aggregated CVD recipe](../cookbook/aggregated-cvd.md) builds on the same two inputs. ## Reading them - **Volume quality.** High volume on a breakout confirms the move; a low-volume breakout often reverses. `delta` and `cvd` say which side did the volume, not just how much. - **Divergence.** Price at a new high while `obv` or `cvd` is not is a warning. - **Anchors.** The daily VWAP is the intraday fair price; the cumulative line is context that drifts with the loaded window. # Series functions Series functions answer the questions that come up constantly when writing an indicator: did two lines just cross, is a series rising, what is the highest high in the last 10 bars, what is the window total or z-score, how many bars since a condition was true, and is this value real or missing. A wrun indicator sees one bar at a time ([Execution model](../core-concepts/execution-model.md)), so each is a small class in `./sdk/ta` that keeps the history it needs and folds one bar per `update()` call, or a two-line function over `NaN`. Every class on this page ships in the kit; the full catalog is on the [TA library](ta-library.md) page. The one series question this page does not answer is `source[n]`, the value a few bars back. That is `History` from `./sdk/stats`: construct it in `onStart()`, `push()` once per bar, read `ago(n)`, `NaN` until the window holds the bar. The same module does the list math (`mean`, `stdev`, `slope`, `correlation`, `median`, `percentile`) over a `StaticArray` without allocating, and `./sdk/ta-plus` adds the running extremes, `PercentRank`, `Range` and `Mode` over a window; all on [Extra indicators](extra-indicators.md). The pivots below are also the raw material of the [Market structure kit](market-structure-kit.md), whose `Swings` turns them into confirmed swing highs and lows. ## Price-source helpers `hl2`, `hlc3`, `ohlc4`, and `hlcc4` are arithmetic on the bar's fields. Read the fields you need and write the formula: | Helper | Formula | | --- | --- | | `hl2` | `(high + low) / 2.0` | | `hlc3` | `(high + low + close) / 3.0` | | `ohlc4` | `(open + high + low + close) / 4.0` | | `hlcc4` | `(high + low + close + close) / 4.0` | There is no implicit source: a wrun indicator names every field it reads, `bar.high()` for the high, `bar.close()` for the close. ```typescript output("hl2", line, overlay, { color: "#2563eb", width: 1, description: "Bar midpoint" }); output("hlc3", line, overlay, { color: "#16a34a", width: 1, description: "Typical price" }); output("ohlc4", line, overlay, { color: "#f97316", width: 1, description: "Four-price average" }); output("hlcc4", line, overlay, { color: "#7c3aed", width: 1, description: "Close-weighted average" }); function onBar(): void { const open = bar.open(); const high = bar.high(); const low = bar.low(); const close = bar.close(); out_hl2((high + low) / 2.0); out_hlc3((high + low + close) / 3.0); out_ohlc4((open + high + low + close) / 4.0); out_hlcc4((high + low + close + close) / 4.0); } ``` ## Crossovers and signals `Cross` ships in `./sdk/ta`: `new Cross()`, `.update(a, b): i32` returns `+1` on the bar `a` crosses above `b`, `-1` on the bar it crosses below, and `0` otherwise, including the first bar and any bar where either side is `NaN`. The edge rule is the engine's: a crossover is `a` below `b` on the previous bar and `a` at or above `b` on this bar (touching counts on the current bar, not on the previous one), and a crossunder is the mirror image. One object answers all three questions: | You want | wrun form | | --- | --- | | a cross above | `cross.update(a, b) == 1` | | a cross below | `cross.update(a, b) == -1` | | a cross in either direction | `cross.update(a, b) != 0` | Call `update()` exactly once per bar per pair: it remembers the previous pair, so a second call on the same bar would compare the bar to itself. Keep the result in a local and test it as many times as you like. A crossover against a constant (`rsi` over `70`) is `cross.update(value, 70.0)`. The classic signal-line pattern is a `Cross` over the MACD line and its signal; the result is a `0`/`1` step you can draw, or a gate for a `shape` output that marks the price bar ([Plotting](../presentation/plotting.md)): ```typescript const crossed = cross.update(macd.macd, macd.signal); bullish = crossed == 1 ? 1.0 : 0.0; ``` ## Trend, extremes, and counting | You want | wrun form | Returns | | --- | --- | --- | | is the series rising | `new Rising(period)`, `.update(x)` | `1` when `x` is strictly above every one of the previous `period` values, else `0`; `NaN` for the first `period` bars | | is the series falling | `new Falling(period)`, `.update(x)` | `1` when `x` is strictly below every one of the previous `period` values, else `0`; `NaN` for the first `period` bars | | the change over `n` bars | `new Change(n)`, `.update(x)` | `x` now minus `x` `n` bars ago (`n` defaults to 1); `NaN` for the first `n` bars | | the window high | `new Highest(period)`, `.update(x)`, field `bars` | the highest value in the window; `bars` is the same offset `HighestBars` reports for the window | | the window low | `new Lowest(period)`, `.update(x)`, field `bars` | the lowest value in the window; `bars` is the same offset `LowestBars` reports | | how long ago the window high was | `new HighestBars(period)`, `.update(x)`, field `value` | how many bars ago the window's high sits, as `0` or a negative number (`0` = this bar, `-3` = three bars ago); `value` is the high itself | | how long ago the window low was | `new LowestBars(period)`, `.update(x)`, field `value` | the same offset for the window's low | | bars since a condition | `new BarsSince()`, `.update(cond)` | bars elapsed since `cond` was last true (`0` on that bar), `NaN` until it has been true once | | the value when a condition last held | `new ValueWhen(occurrence)`, `.update(cond, x)` | `x` on the `occurrence`-th most recent bar where `cond` was true (`0` = the latest, the current bar counts) | | a percentile over a window | `new Percentile(period, pct)`, `.update(x)` | the nearest-rank `pct`-th percentile over the window | | a median over a window | `new Median(period)`, `.update(x)` | the middle value (the mean of the two middle values on an even window) | | a correlation over a window | `new Correlation(period)`, `.update(a, b)` | the rolling Pearson correlation, -1..1 | | a window total | `new Sum(period)`, `.update(x)` | the sum of the last `period` values; no warm-up, bar 0 already returns the partial window | | a z-score over a window | `new Zscore(period)`, `.update(x)` | `(x - mean) / stdev` over the window; `0` when the deviation is exactly `0` | A condition is an `f64`: true means finite and not `0`, so a `0`/`1` series or a comparison cast with `? 1.0 : 0.0` both work. You feed the class the series it should scan, so `new Highest(20)` over `bar.high()` is the 20-bar high and a `Lowest(20)` over `bar.low()` is the 20-bar low. Among equal values the newest bar wins the offset. The `bars` fields are handy for "is the high in the window recent?" logic, and `BarsSince` counts up from the last time a condition fired, the natural cooldown and recency check. Construct the windowed ones in `onStart()` from a param and update each once per bar: ```typescript let highs = new Highest(20); let lows = new Lowest(20); let p80 = new Percentile(20, 80.0); let median = new Median(20); const rising = new Rising(3); // In onBar(): the return value is the extreme, the field is its offset. // const hi = highs.update(high); // highs.bars: 0 = this bar, -3 = three bars ago // const lo = lows.update(low); // const up = rising.update(close); // 1, 0, or NaN for the first 3 bars ``` Every windowed class here follows the strict window rule: `NaN` until `period` bars exist, and `NaN` whenever any value inside the window is not finite. `Percentile` sorts the window and takes rank `ceil(pct / 100 * period)` (1-based, `pct` clamped to 0..100); `Median` sorts and takes the middle, averaging the two middle values on an even period; `Correlation` returns `NaN` when either side has no variance. None of them allocates after construction. `Sum` and `Zscore` are the two that skip a `NaN` bar instead of refusing the window (`Zscore` still divides its variance by `period`), and `Sum` has no warm-up at all: a 20-bar `Sum` draws from the first bar where a 20-bar `Stdev` draws from bar 19. ## Pivot confirmation `PivotHigh` and `PivotLow` are causal confirmation signals. A pivot only emits after `rightbars` later bars have closed, so the emitted value appears on the confirmation bar and lags the true pivot bar by `rightbars`. Nothing reads a future bar and no earlier bar repaints; an indicator could not do otherwise, because `onBar()` sees one bar. `new PivotHigh(leftbars, rightbars)` and `new PivotLow(leftbars, rightbars)` keep `left + right + 1` bars and, once the window is full, test the candidate `rightbars` back: strictly higher than every other value in the window is a pivot high, strictly lower a pivot low (a tie never counts), and `update(x)` returns the candidate's value on the confirmation bar only and `NaN` everywhere else, including any window with a non-finite value. Feed `PivotHigh` the high and `PivotLow` the low. ```typescript const pivotHigh = new PivotHigh(2, 2); const pivotLow = new PivotLow(2, 2); const lastHigh = new Fixnan(); // In onBar(): a value on the confirmation bar, NaN between. // const confirmed = pivotHigh.update(bar.high()); // const level = lastHigh.update(confirmed); // the last confirmed pivot, carried forward ``` A confirmed pivot is a sparse series: one value on the confirmation bar, `NaN` between. To carry it forward into a continuous line, run it through `Fixnan` below. ## Handling missing values Warm-up is `NaN`, and any arithmetic touching `NaN` stays `NaN`. `isNaN` and `isFinite` are AssemblyScript builtins on `f64`; there is no separate missing-value type, so a missing value is `NaN` everywhere and these two are the whole vocabulary. The helpers are two lines each: | You want | wrun form | | --- | --- | | is the value missing | `isNaN(x)` | | is the value a real number | `isFinite(x)` (finite and not `NaN`) | | a replacement for a missing value | `nz(x, replacement)` below | | the last real value carried forward | `new Fixnan()`, `.update(x)`: carries the last finite value forward, `NaN` until the first one | ```typescript function nz(x: f64, replacement: f64): f64 { return isNaN(x) ? replacement : x; } const fixnan = new Fixnan(); // In onBar(): fixnan.update(sparse) repeats the last finite value across every NaN bar. ``` ## Every series function in one module A `sparse` series (forced `NaN` for the first 10 bars) drives the missing value helpers so you can see them flip; the rest run over real bars. The classes are the shipped ones from `./sdk/ta`. ```typescript param("fast", 5, { min: 1, max: 200 }); param("slow", 13, { min: 2, max: 400 }); param("window", 10, { min: 2, max: 200, description: "Window for the extremes, percentile, median, and correlation" }); output("crossover", line, lower, { color: "#2563eb", description: "1 on the bar the fast average crosses above the slow" }); output("crossunder", line, lower, { color: "#dc2626", description: "1 on the bar it crosses below" }); output("cross", line, lower, { color: "#7c3aed", description: "1 on either cross" }); output("rising", line, lower, { color: "#16a34a", description: "1 while the close is above its previous 3 values" }); output("falling", line, lower, { color: "#ea580c", description: "1 while the close is below its previous 3 values" }); output("barssince", line, lower, { color: "#0891b2", description: "Bars since the close was above the fast average" }); output("change", line, lower, { color: "#4b5563", description: "3-bar change" }); output("highest", line, lower, { color: "#0f766e", description: "Window highest high" }); output("lowest", line, lower, { color: "#be123c", description: "Window lowest low" }); output("highestbars", line, lower, { color: "#9333ea", description: "Offset of the window high (0 = this bar, negative = bars ago)" }); output("lowestbars", line, lower, { color: "#1d4ed8", description: "Offset of the window low" }); output("valuewhen", line, lower, { color: "#0e7490", description: "The close on the most recent bullish cross" }); output("percentile", line, lower, { color: "#b45309", description: "80th percentile of the close over the window" }); output("median", line, lower, { color: "#a16207", description: "Median close over the window" }); output("correlation", line, lower, { color: "#15803d", description: "Correlation between open and close over the window" }); output("nz", line, lower, { color: "#6d28d9", description: "nz over the sparse series, the open as the replacement" }); output("isna", line, lower, { color: "#0e7490", description: "1 while the sparse series is NaN" }); output("isnum", line, lower, { color: "#374151", description: "1 while the sparse series is a finite number" }); output("fixnan", line, lower, { color: "#3b82f6", description: "The sparse series with gaps carried forward" }); output("pivot_high", line, lower, { color: "#2563eb", description: "Confirmed 2/2 pivot highs, carried forward" }); output("pivot_low", line, lower, { color: "#dc2626", description: "Confirmed 2/2 pivot lows, carried forward" }); function nz(x: f64, replacement: f64): f64 { return isNaN(x) ? replacement : x; } let fast = new Sma(5); let slow = new Sma(13); const cross = new Cross(); const rising = new Rising(3); const falling = new Falling(3); const barsSince = new BarsSince(); const change = new Change(3); let highs = new Highest(10); let lows = new Lowest(10); const valueWhen = new ValueWhen(0); let percentile = new Percentile(10, 80.0); let median = new Median(10); let correlation = new Correlation(10); const fixnan = new Fixnan(); const pivotHigh = new PivotHigh(2, 2); const pivotLow = new PivotLow(2, 2); const stableHigh = new Fixnan(); const stableLow = new Fixnan(); let barIndex: i32 = 0; function onStart(): void { fast = new Sma(i32(p_fast())); slow = new Sma(i32(p_slow())); const window = i32(p_window()); highs = new Highest(window); lows = new Lowest(window); percentile = new Percentile(window, 80.0); median = new Median(window); correlation = new Correlation(window); } function onBar(): void { const close = bar.close(); const openValue = bar.open(); const f = fast.update(close); const s = slow.update(close); const crossed = cross.update(f, s); const risingValue = rising.update(close); const fallingValue = falling.update(close); const since = barsSince.update(!isNaN(f) && close > f ? 1.0 : 0.0); const changeValue = change.update(close); const highValue = highs.update(bar.high()); const lowValue = lows.update(bar.low()); const whenValue = valueWhen.update(crossed == 1 ? 1.0 : 0.0, close); const pctValue = percentile.update(close); const medianValue = median.update(close); const corrValue = correlation.update(openValue, close); const sparse = barIndex < 10 ? NaN : close; const fixed = fixnan.update(sparse); const pivotHighValue = stableHigh.update(pivotHigh.update(bar.high())); const pivotLowValue = stableLow.update(pivotLow.update(bar.low())); barIndex += 1; out_crossover(crossed == 1 ? 1.0 : 0.0); out_crossunder(crossed == -1 ? 1.0 : 0.0); out_cross(crossed != 0 ? 1.0 : 0.0); out_rising(risingValue); out_falling(fallingValue); out_barssince(since); out_change(changeValue); out_highest(highValue); out_lowest(lowValue); out_highestbars(highs.bars); out_lowestbars(lows.bars); out_valuewhen(whenValue); out_percentile(pctValue); out_median(medianValue); out_correlation(corrValue); out_nz(nz(sparse, openValue)); out_isna(isNaN(sparse) ? 1.0 : 0.0); out_isnum(isFinite(sparse) ? 1.0 : 0.0); out_fixnan(fixed); out_pivot_high(pivotHighValue); out_pivot_low(pivotLowValue); } ``` `barIndex` is state the module counts itself: there is no `barIndex` global, so it lives at module level with the rest of the state that spans bars. ## A Donchian breakout A 20-bar high and low channel as a `Highest` over the high and a `Lowest` over the low, with a gated mark on the bar that closes above the channel. The gate is a data-only output, and the `shape` output draws only where it is nonzero. ```typescript param("period", 20, { min: 2, max: 400, description: "Channel lookback" }); output("hi", line, overlay, { color: "#16a34a", width: 1, description: "Upper Donchian band" }); output("lo", line, overlay, { color: "#dc2626", width: 1, description: "Lower Donchian band" }); output("breakout_mark", shape, overlay, { color: "#16a34a", shape_where: "breakout", description: "The close on a breakout bar" }); output("breakout", none, overlay, { description: "1 when the close is above the previous bar's upper band" }); let highs = new Highest(20); let lows = new Lowest(20); let prevHi: f64 = NaN; function onStart(): void { highs = new Highest(i32(p_period())); lows = new Lowest(i32(p_period())); } function onBar(): void { const close = bar.close(); // Test against the channel as it stood BEFORE this bar, so the bar cannot break its own high. const breakout = !isNaN(prevHi) && close > prevHi ? 1.0 : 0.0; const hi = highs.update(bar.high()); const lo = lows.update(bar.low()); prevHi = hi; if (isNaN(hi)) return; out_hi(hi); out_lo(lo); out_breakout_mark(close); out_breakout(breakout); } ``` ## Window statistics, a channel, and a cross The window statistics (`Highest`, `Lowest`, `Sum`, `Stdev`), a `Donchian` channel, a `Cross` over two averages, and an `isFinite` gate in one module. `Stdev` and `Donchian` are explained with their families, on [Trend and volatility](trend-indicators.md#volatility-primitives) and [Moving averages](moving-averages.md#donchian). ```typescript param("period", 20, { min: 2, max: 400 }); output("low20", line, overlay, { color: "#dc2626", width: 1, description: "Lowest low over the period" }); output("high20", line, overlay, { color: "#16a34a", width: 1, description: "Highest high over the period" }); output("donchian_mid", line, overlay, { color: "#94a3b8", width: 1, description: "Donchian midpoint" }); output("volume_sum", line, lower, { color: "#0891b2", description: "Volume summed over the period" }); output("volatility", line, lower, { color: "#7c3aed", description: "Standard deviation of the close" }); output("bullish", none, lower, { description: "1 on the bar the fast average crosses above the slow" }); output("bearish", none, lower, { description: "1 on the bar it crosses below" }); output("any_cross", none, lower, { description: "1 on either" }); output("valid", none, lower, { description: "1 when every input on the bar is a finite number" }); let highs = new Highest(20); let lows = new Lowest(20); let volumeSum = new Sum(20); let stdev = new Stdev(20); let donchian = new Donchian(20); let fast = new Sma(5); let slow = new Sma(20); const cross = new Cross(); function onStart(): void { const period = i32(p_period()); highs = new Highest(period); lows = new Lowest(period); volumeSum = new Sum(period); stdev = new Stdev(period); donchian = new Donchian(period); fast = new Sma(5); slow = new Sma(period); } function onBar(): void { const close = bar.close(); const high = bar.high(); const low = bar.low(); const volume = bar.volume(); const valid = isFinite(close) && isFinite(high) && isFinite(low) && isFinite(volume) ? 1.0 : 0.0; const highValue = highs.update(high); const lowValue = lows.update(low); const sumValue = volumeSum.update(volume); const volatility = stdev.update(close); donchian.update(high, low); const crossed = cross.update(fast.update(close), slow.update(close)); out_low20(lowValue); out_high20(highValue); out_donchian_mid(donchian.basis); out_volume_sum(sumValue); out_volatility(volatility); out_bullish(crossed == 1 ? 1.0 : 0.0); out_bearish(crossed == -1 ? 1.0 : 0.0); out_any_cross(crossed != 0 ? 1.0 : 0.0); out_valid(valid); } ``` ## Warm-up and the missing-value window Windowed functions cannot produce a value until they have seen enough bars: a 20-bar `Percentile` or `Correlation` returns `NaN` until 20 bars exist, just as a 20-bar `Sma` does. When the underlying series is itself `NaN` for a stretch, the warm-up shifts forward by the same amount, because the windowed classes refuse a window with a `NaN` inside it. `Fixnan` shows the recovery: it produces its first real value the moment the source has one, then holds it across every gap. Feed the `sparse` series from the tour into a 5-bar `Percentile` and the `percentile` output stays `NaN` through bar 14, not bar 4. ## Practices that carry over - **Lookback periods.** Choose them for the interval you trade: short windows for scalping and lower timeframes, longer ones for swing context. Declare the range on the param (`min`, `max`) and size buffers from `max` so the module never allocates per bar. - **One object per statistic.** Compute a statistic once per bar and reuse the return value or field rather than constructing a second object over the same series. - **Cross detection.** Combine a cross with a trend read (`Adx`, a slope, a higher-timeframe fold) before treating it as a signal; in a chop the cross flips every few bars. - **Donchian channels.** Breakout logic in trends, support and resistance in ranges: the same two outputs, different rules on top. - **Data validation.** Check `isFinite` before a division, and write `NaN` rather than a made-up number when a value has no answer; a `NaN` draws nothing and never trips an alert. # Extra indicators The [TA library](ta-library.md) is the core catalog of 52 classes. The `./sdk/ta-plus` module adds 30 more with the same shape: construct the class in `onStart()`, call `update()` once per bar. Twenty-six are textbook indicators the catalog never had; four are Pine's own spellings of classes the library has in another shape. The 26 are the mean and percent-rank deviations, Bollinger and Keltner widths, the volume lines (accumulation and distribution, the Williams pair, the volume indices, price-volume trend, intraday intensity), the running extremes, mode, range and centre of gravity, the double and triple EMAs, the zero-lag EMA, TRIX, Kaufman's efficiency ratio and adaptive average, the ultimate oscillator, Vortex, Aroon and choppiness. The four are `Dmi` with its two lengths, `SourceStoch` and `SourceVwap` over any source series, and `HeikinAshi`. Every class composes the library's own primitives and follows its three rules: `NaN` until the class is warm, `NaN` while a non-finite input sits in its window (the two volume indices carry instead), and no allocation after the constructor. The window arithmetic under them (`History`, `List`, the `stats.*` functions, rounding and a seeded random) is on [Stats, history and lists](stats-history-lists.md). ## Every class in `./sdk/ta-plus` `update()` returns the primary stream; the extra streams are public fields you read after the call. "First value" is the bar index of the first non-`NaN` result on a clean series (bar 0 is the first bar). Periods below 1 are clamped to 1. | Class | Construct | `update(...)` | Fields | Definition | First value | | --- | --- | --- | --- | --- | --- | | `Dev` | `(period)` | `(x)` | | mean absolute deviation of the last `period` values from their `Sma`: `sum(abs(x - sma)) / period` | bar `period - 1` | | `PercentRank` | `(period)` | `(x)` | | `100 * count(previous period values <= x) / period`, the current bar excluded from the count | bar `period` | | `Bbw` | `(period, mult)` | `(x)` | `basis`, `upper`, `lower` | `(upper - lower) / basis` over `Bb(period, mult)`; `NaN` when the basis is 0 | bar `period - 1` | | `Kcw` | `(period, mult, atrPeriod)` | `(x, high, low, close)` | `basis`, `upper`, `lower` | `(upper - lower) / basis` over `Keltner(period, mult, atrPeriod)`; `NaN` when the basis is 0 | bar `max(period, atrPeriod) - 1` | | `AccDist` | `()` | `(high, low, close, volume)` | | running sum of `((2 * close - high - low) / (high - low)) * volume`, the term 0 when `high == low` | bar 0 | | `Wad` | `()` | `(high, low, close)` | | running sum of: `close - min(low, prevClose)` when the close rose, `close - max(high, prevClose)` when it fell, else 0; bar 0 returns 0 | bar 0 | | `Wvad` | `()` | `(open, high, low, close, volume)` | | `((close - open) / (high - low)) * volume` for this bar, never a running sum; `NaN` when `high == low` | bar 0 | | `Nvi` | `()` | `(close, volume)` | | starts at 1; when `volume < prevVolume`: `nvi = prev + ((close - prevClose) / prevClose) * prev`, else `prev`; a close of 0 or `NaN`, on this bar or the previous one, keeps `prev` | bar 0 | | `Pvi` | `()` | `(close, volume)` | | the same with `volume > prevVolume` | bar 0 | | `Pvt` | `()` | `(close, volume)` | | running sum of `((close - prevClose) / prevClose) * volume`; bar 0 returns 0 | bar 0 | | `RunningMax` | `()` | `(x)` | | the largest finite `x` seen so far (a non-finite `x` is skipped) | the first finite bar | | `RunningMin` | `()` | `(x)` | | the smallest finite `x` seen so far (a non-finite `x` is skipped) | the first finite bar | | `Mode` | `(period)` | `(x)` | | the most frequent value of the window (exact equality); ties go to the smallest | bar `period - 1` | | `Range` | `(period)` | `(x)` | | `Highest - Lowest` of the last `period` values | bar `period - 1` | | `Cog` | `(period)` | `(x)` | | `-(sum of x[i] * (i + 1)) / sum(x[i])` for `i = 0` (the newest) to `period - 1`; `NaN` when the sum is 0 | bar `period - 1` | | `Iii` | `()` | `(high, low, close, volume)` | | `(2 * close - high - low) / ((high - low) * volume)` for this bar; `NaN` when the denominator is 0 | bar 0 | | `Dema` | `(period)` | `(x)` | | `2 * e1 - e2`, `e1 = Ema(period)` of `x`, `e2 = Ema(period)` of `e1` | bar `2 * period - 2` | | `Tema` | `(period)` | `(x)` | | `3 * e1 - 3 * e2 + e3` | bar `3 * period - 3` | | `Zlema` | `(period)` | `(x)` | | `Ema(period)` of `x + (x - x[lag])`, `lag = floor((period - 1) / 2)` | bar `lag + period - 1` | | `Trix` | `(period)` | `(x)` | | `10000 *` the one-bar change of `Ema(Ema(Ema(ln x)))`; `NaN` when `x <= 0` | bar `3 * period - 2` | | `Ultimate` | `(p1 = 7, p2 = 14, p3 = 28)` | `(high, low, close)` | | `BP = close - min(low, prevClose)`, `TR = max(high, prevClose) - min(low, prevClose)`, `avg_n = Sum(BP, n) / Sum(TR, n)`, result `100 * (4 * avg1 + 2 * avg2 + avg3) / 7` | bar `p3` | | `Vortex` | `(period)` | `(high, low, close)` | `plus`, `minus` | `VM+ = abs(high - prevLow)`, `VM- = abs(low - prevHigh)`; `plus = Sum(VM+) / Sum(TR)`, `minus = Sum(VM-) / Sum(TR)` over `period`; returns `plus` | bar `period` | | `Aroon` | `(period)` | `(high, low)` | `up`, `down`, `osc` | `up = 100 * (Highest(period + 1).bars + period) / period`, `down` likewise with `Lowest`, `osc = up - down`; returns `osc` | bar `period` | | `Choppiness` | `(period)` | `(high, low, close)` | | `100 * log10(Sum(TR, period) / (Highest(high, period) - Lowest(low, period))) / log10(period)`; `NaN` when the range is 0 | bar `period - 1` | | `Efficiency` | `(period)` | `(x)` | | `abs(x - x[period]) / Sum(abs(x - x[1]), period)`; `NaN` when the sum is 0 | bar `period` | | `Kama` | `(period, fast = 2, slow = 30)` | `(x)` | | `sc = (er * (2 / (fast + 1) - 2 / (slow + 1)) + 2 / (slow + 1))^2` with `er = Efficiency(period)`; `kama = prev + sc * (x - prev)`, seeded with `x` on the first bar `er` exists | bar `period` | | `Dmi` | `(diLength, adxSmoothing)` | `(high, low, close)` | `adx`, `plusDi`, `minusDi` | Pine's `ta.dmi`: `+DI = 100 * Rma(+DM, diLength) / Rma(TR, diLength)`, `-DI` likewise, `DX = abs(+DI - -DI) / (+DI + -DI)` (over 1 when the sum is 0), `adx = 100 * Rma(DX, adxSmoothing)`; a zero smoothed range holds `+DI` and `-DI` (Pine's `fixnan`); a missing bar is skipped the way TradingView skips it (see below); equal lengths give the library's `Adx` on a series without a hole; returns `adx` | `plusDi`, `minusDi` at bar `diLength`; `adx` at bar `diLength + adxSmoothing - 1` | | `SourceStoch` | `(periodK, smoothK = 1, periodD = 1)` | `(source, high, low)` | `k`, `d` | Pine's `ta.stoch(source, high, low, length)`: raw %K is `100 * (source - Lowest(low)) / (Highest(high) - Lowest(low))` over `periodK`, `k = Sma(smoothK)` of it, `d = Sma(periodD)` of `k`; a flat window follows TradingView (the previous raw %K when the source sits on the window's value, `NaN` otherwise) where the library's `Stoch` reads 0, so fed the close it is `Stoch` on every window with a range; returns `k` | `k` at bar `periodK + smoothK - 2`, `d` at bar `periodK + smoothK + periodD - 3` | | `SourceVwap` | `(anchor = "")` | `(source, volume, tsMs = NaN)` | | Pine's `ta.vwap(source)`: the library's `Vwap` sums, `sum(source * volume) / sum(volume)` from the anchor (`""`, `"day"`, `"week"`, `"month"`, `"quarter"`, `"year"` or a bucket width in milliseconds; `tsMs` is `bar.time() * 1000.0`, read only with an anchor), over any source; fed `(high + low + close) / 3` it is `Vwap(anchor, "hlc3")` | bar 0 | | `HeikinAshi` | `()` | `(open, high, low, close)` | `open`, `high`, `low`, `close` | Pine's `ticker.heikinashi`: `close = (open + high + low + close) / 4`, `open = (previous ha open + previous ha close) / 2` (the bar's own `(open + close) / 2` on the first bar), `high = max(high, ha open, ha close)`, `low = min(low, ha open, ha close)`; returns the smoothed close | bar 0 | A non-finite input (`NaN` or infinite) is handled by shape. A windowed class (`Dev`, `PercentRank`, `Bbw`, `Mode`, `Range`, `Cog`, `Ultimate`, `Vortex`, `Aroon`, `Choppiness`, `Efficiency`, `SourceStoch`) returns `NaN` while the bad bar sits inside its window and heals when it leaves; the running sums (`AccDist`, `Wad`, `Pvt`) stay `NaN` until `reset()`; the per-bar readings (`Wvad`, `Iii`) are `NaN` on that bar alone; the classes built on `Ema` (`Dema`, `Tema`, `Zlema`, `Trix`, and `Kcw` through `Keltner`) follow the `Ema` rule: a bad bar before the seed restarts the seed, a bad bar after it makes the value `NaN` for good. `Dmi` keeps TradingView's own rule for `ta.dmi` instead: `ta.rma` skips a na bar with its state untouched, `ta.tr` and the two changes are na on that bar and on the next one, so each smoother is fed only when its own input is a value, `fixnan` holds `plusDi` and `minusDi` meanwhile, `adx` keeps smoothing the DX of the held values, and two bars after the hole every stream continues from the state it had (the library's `Adx` goes `NaN` for good there). `Kama` re-seeds with the next `x` once its efficiency window is clean again, the running extremes skip the bar, and `Nvi` and `Pvi` never turn `NaN`: they carry their value across a bar whose close, or previous close, is 0 or not a number. `SourceVwap` keeps the `Vwap` rule (the current bucket is `NaN` until the next one starts), and `HeikinAshi` reads `NaN` on the bad bar and restarts its chain on the next finite one, seeding the open as the first bar does. ## Deviation, rank and width Seven classes read one series. `Dev` and `PercentRank` say how far and how high the newest value sits in its window; `Bbw` and `Kcw` are the band widths as one number, with the bands in fields; `Range`, `Mode` and `Cog` are the window's shape. ```typescript param("period", 20, { min: 2, max: 400, description: "Window for every class" }); param("mult", 2, { min: 0.5, max: 5, description: "Band multiplier" }); output("dev", line, lower, { color: "#2563eb", description: "Mean absolute deviation of the close" }); output("prank", line, lower, { color: "#7c3aed", description: "Percent rank of the close" }); output("bbw", line, lower, { color: "#16a34a", description: "Bollinger band width" }); output("bb_upper", line, lower, { color: "#15803d", description: "Upper Bollinger band" }); output("kcw", line, lower, { color: "#ea580c", description: "Keltner channel width" }); output("range", line, lower, { color: "#0891b2", description: "Highest minus lowest close" }); output("mode", line, lower, { color: "#4b5563", description: "Most frequent close" }); output("cog", line, lower, { color: "#be123c", description: "Centre of gravity" }); let dev = new Dev(20); let prank = new PercentRank(20); let bbw = new Bbw(20, 2.0); let kcw = new Kcw(20, 2.0, 10); let closeRange = new Range(20); let mode = new Mode(20); let cog = new Cog(20); function onStart(): void { const period = i32(p_period()); dev = new Dev(period); prank = new PercentRank(period); bbw = new Bbw(period, p_mult()); kcw = new Kcw(period, p_mult(), period / 2); closeRange = new Range(period); mode = new Mode(period); cog = new Cog(period); } function onBar(): void { const close = bar.close(); const devValue = dev.update(close); const prankValue = prank.update(close); const bbwValue = bbw.update(close); const kcwValue = kcw.update(close, bar.high(), bar.low(), close); const rangeValue = closeRange.update(close); const modeValue = mode.update(close); const cogValue = cog.update(close); if (isNaN(devValue)) return; out_dev(devValue); out_prank(prankValue); out_bbw(bbwValue); out_bb_upper(bbw.upper); out_kcw(kcwValue); out_range(rangeValue); out_mode(modeValue); out_cog(cogValue); } ``` `PercentRank` counts the previous `period` values, never the current bar, so its first value lands one bar after `Dev`'s: `onBar()` gates on `Dev` and the first ready row draws a gap for the rank. `Kcw` reports its `basis` once the EMA is seeded and the width once the ATR is warm too. ## Volume and money flow `AccDist`, `Wad` and `Pvt` are running sums: the line so far, from the first bar. `Nvi` and `Pvi` are indices that start at 1 and move only on a bar whose volume fell (`Nvi`) or rose (`Pvi`). `Wvad` and `Iii` are per-bar readings with no memory; for a running line feed one to the library's `Cum`, as the sample does for `Wvad`. ```typescript input("open", ohlcv.open); output("accdist", line, lower, { color: "#2563eb", description: "Accumulation/distribution line" }); output("wad", line, lower, { color: "#7c3aed", description: "Williams accumulation/distribution" }); output("wvad", line, lower, { color: "#16a34a", description: "Williams variable A/D of this bar" }); output("wvad_line", line, lower, { color: "#65a30d", description: "Running total of wvad" }); output("nvi", line, lower, { color: "#dc2626", description: "Negative volume index, from 1" }); output("pvi", line, lower, { color: "#ea580c", description: "Positive volume index, from 1" }); output("pvt", line, lower, { color: "#0891b2", description: "Price-volume trend" }); output("iii", line, lower, { color: "#4b5563", description: "Intraday intensity of this bar" }); output("running_max", line, lower, { color: "#15803d", description: "Highest close so far" }); output("running_min", line, lower, { color: "#be123c", description: "Lowest close so far" }); let accdist = new AccDist(); let wad = new Wad(); let wvad = new Wvad(); let wvadLine = new Cum(); let nvi = new Nvi(); let pvi = new Pvi(); let pvt = new Pvt(); let iii = new Iii(); let runningMax = new RunningMax(); let runningMin = new RunningMin(); function onStart(): void { accdist = new AccDist(); wad = new Wad(); wvad = new Wvad(); wvadLine = new Cum(); nvi = new Nvi(); pvi = new Pvi(); pvt = new Pvt(); iii = new Iii(); runningMax = new RunningMax(); runningMin = new RunningMin(); } function onBar(): void { const open = bar.open(); const high = bar.high(); const low = bar.low(); const close = bar.close(); const volume = bar.volume(); const accdistValue = accdist.update(high, low, close, volume); const wadValue = wad.update(high, low, close); const wvadValue = wvad.update(open, high, low, close, volume); // A flat bar has no reading: add 0, Cum keeps a NaN. const wvadTotal = wvadLine.update(isNaN(wvadValue) ? 0.0 : wvadValue); const nviValue = nvi.update(close, volume); const pviValue = pvi.update(close, volume); const pvtValue = pvt.update(close, volume); const iiiValue = iii.update(high, low, close, volume); const maxValue = runningMax.update(close); const minValue = runningMin.update(close); if (isNaN(accdistValue)) return; out_accdist(accdistValue); out_wad(wadValue); out_wvad(wvadValue); out_wvad_line(wvadTotal); out_nvi(nviValue); out_pvi(pviValue); out_pvt(pvtValue); out_iii(iiiValue); out_running_max(maxValue); out_running_min(minValue); } ``` A running line depends on where the history starts: the chart feeds the module the bars it fetched, so the newest bar's value moves when that window does. Read `AccDist`, `Wad`, `Pvt` and the two indices as shapes, not as levels to alert on. ## Smoothers on the EMA `Dema`, `Tema` and `Zlema` stack the library's `Ema`; `Trix` stacks three of them over the logarithm of the price and reads the one-bar change; `Efficiency` and `Kama` are Kaufman's pair, the ratio of net move to total move and the average whose smoothing follows it. ```typescript param("period", 20, { min: 2, max: 400 }); param("fast", 2, { min: 1, max: 100, description: "Kama fast length" }); param("slow", 30, { min: 1, max: 400, description: "Kama slow length" }); output("dema", line, overlay, { color: "#2563eb", description: "Double EMA" }); output("tema", line, overlay, { color: "#7c3aed", description: "Triple EMA" }); output("zlema", line, overlay, { color: "#16a34a", description: "Zero-lag EMA" }); output("kama", line, overlay, { color: "#ea580c", width: 2, description: "Kaufman adaptive average" }); output("trix", line, lower, { color: "#dc2626", description: "TRIX" }); output("efficiency", line, lower, { color: "#0891b2", description: "Efficiency ratio, 0..1" }); let dema = new Dema(20); let tema = new Tema(20); let zlema = new Zlema(20); let trix = new Trix(20); let efficiency = new Efficiency(20); let kama = new Kama(20, 2, 30); function onStart(): void { const period = i32(p_period()); dema = new Dema(period); tema = new Tema(period); zlema = new Zlema(period); trix = new Trix(period); efficiency = new Efficiency(period); kama = new Kama(period, i32(p_fast()), i32(p_slow())); } function onBar(): void { const close = bar.close(); const demaValue = dema.update(close); const temaValue = tema.update(close); const zlemaValue = zlema.update(close); const trixValue = trix.update(close); const erValue = efficiency.update(close); const kamaValue = kama.update(close); if (isNaN(kamaValue)) return; out_dema(demaValue); out_tema(temaValue); out_zlema(zlemaValue); out_kama(kamaValue); out_trix(trixValue); out_efficiency(erValue); } ``` The warm-ups differ: `Kama` has its first value at bar `period` while `Tema(20)` waits until bar 57 and `Trix(20)` until bar 58. Gate readiness on the line you plot as primary, not on the slowest class. ## Two oscillators `Ultimate` blends buying pressure over three windows; `Vortex` reads the two movement sums against the true range and exposes both lines. ```typescript param("period", 14, { min: 2, max: 400, description: "Vortex window" }); input("high", ohlcv.high); output("ultimate", line, lower, { color: "#2563eb", description: "Ultimate oscillator (7, 14, 28)" }); output("vortex_plus", line, lower, { color: "#16a34a", description: "VI+" }); output("vortex_minus", line, lower, { color: "#dc2626", description: "VI-" }); let ultimate = new Ultimate(7, 14, 28); let vortex = new Vortex(14); function onStart(): void { ultimate = new Ultimate(7, 14, 28); vortex = new Vortex(i32(p_period())); } function onBar(): void { const high = bar.high(); const low = bar.low(); const close = bar.close(); const ultimateValue = ultimate.update(high, low, close); const plus = vortex.update(high, low, close); const minus = vortex.minus; if (isNaN(plus)) return; out_ultimate(ultimateValue); out_vortex_plus(plus); out_vortex_minus(minus); } ``` ## A regime from Aroon and choppiness `Aroon` says which way the recent extreme sits, `Choppiness` says whether the window went anywhere at all. Together they make a three-way regime: trending up, trending down, or ranging. The regime is a data-only output that tints a shape through `color_by` ([Colors](colors-kit.md)). ```typescript param("period", 25, { min: 2, max: 400, description: "Aroon and choppiness window" }); param("chop_ceiling", 61.8, { min: 0, max: 100, description: "Choppiness above this reads as ranging" }); input("high", ohlcv.high); output("aroon_up", line, lower, { color: "#16a34a", description: "Aroon up" }); output("aroon_down", line, lower, { color: "#dc2626", description: "Aroon down" }); output("chop", line, lower, { color: "#4b5563", description: "Choppiness index, 0..100" }); output("regime", none, overlay, { description: "0 ranging, 1 trending up, 2 trending down" }); output("regime_mark", shape, overlay, { color: "#94a3b8", color_by: "regime", colors: ["#94a3b8", "#16a34a", "#dc2626"], description: "The close, tinted by regime", }); let aroon = new Aroon(25); let chop = new Choppiness(25); let chopCeiling: f64 = 61.8; function onStart(): void { const period = i32(p_period()); aroon = new Aroon(period); chop = new Choppiness(period); chopCeiling = p_chop_ceiling(); } function onBar(): void { const high = bar.high(); const low = bar.low(); const close = bar.close(); const osc = aroon.update(high, low); const chopValue = chop.update(high, low, close); if (isNaN(osc) || isNaN(chopValue)) return; let regime: f64 = 0.0; if (chopValue > chopCeiling) regime = 0.0; else regime = osc > 0.0 ? 1.0 : 2.0; out_aroon_up(aroon.up); out_aroon_down(aroon.down); out_chop(chopValue); out_regime(regime); out_regime_mark(close); } ``` `Aroon` runs a `Highest` and a `Lowest` over `period + 1` bars and reads their `bars` offsets, so `up` is 100 on the bar of a fresh high and falls by `100 / period` on every bar the high ages; `Choppiness` is the log ratio of the true-range sum to the window's range, near 100 when the bars overlap and near 0 when they march in one direction. `onBar()` returns before writing until both are warm: `Aroon` needs `period + 1` bars, `Choppiness` needs `period`. ## Pine's spellings of four library classes The library's `Adx`, `Stoch` and `Vwap` are the engine's forms: one length, the close as the source, a named bar price. Four classes here carry the Pine spellings a ported script writes. `Dmi(diLength, adxSmoothing)` is `ta.dmi` with its two lengths (`+DI` and `-DI` smoothed over `diLength`, ADX over `adxSmoothing`, both with the library's `Rma`); with equal lengths it is `Adx` to the last bit of the arithmetic, and across a missing bar it keeps TradingView's state rules (nothing restarts, nothing is poisoned). `SourceStoch` is `ta.stoch(source, high, low, length)` over any source, smoothed like `Stoch`; fed the close it is `Stoch` on every window with a range (a flat window follows TradingView: the previous raw %K when the source sits on the window's value, `NaN` otherwise, where `Stoch` reads 0). `SourceVwap` is `ta.vwap(source)` over any source with `Vwap`'s anchors; fed `(high + low + close) / 3` it is `Vwap(anchor, "hlc3")`. `HeikinAshi` is `ticker.heikinashi` as a class over the bars you feed it (this chart's, or another market's candles); the recursion forgets its start by half every bar, so after about 50 bars the values no longer depend on where the loaded history begins. ```typescript param("di_length", 14, { min: 1, max: 200 }); param("adx_smoothing", 7, { min: 1, max: 200 }); param("stoch_length", 14, { min: 1, max: 200 }); output("adx", line, lower, { color: "#111827", description: "ADX over its own smoothing" }); output("plus_di", line, lower, { color: "#16a34a", description: "+DI" }); output("minus_di", line, lower, { color: "#dc2626", description: "-DI" }); output("stoch_hl2", line, lower, { color: "#2563eb", description: "Stochastic of the bar midpoint" }); output("vwap_ohlc4", line, overlay, { color: "#f59e0b", description: "Session VWAP over ohlc4" }); output("ha_close", line, overlay, { color: "#7c3aed", description: "Heikin Ashi close" }); let dmi = new Dmi(14, 7); let stoch = new SourceStoch(14); const vwap = new SourceVwap("day"); const ha = new HeikinAshi(); function onStart(): void { dmi = new Dmi(i32(p_di_length()), i32(p_adx_smoothing())); stoch = new SourceStoch(i32(p_stoch_length())); } function onBar(): void { const o = bar.open(); const h = bar.high(); const l = bar.low(); const c = bar.close(); const adx = dmi.update(h, l, c); const k = stoch.update((h + l) / 2.0, h, l); const vwapValue = vwap.update((o + h + l + c) / 4.0, bar.volume(), bar.time() * 1000.0); const haClose = ha.update(o, h, l, c); if (isNaN(adx)) return; out_adx(adx); out_plus_di(dmi.plusDi); out_minus_di(dmi.minusDi); out_stoch_hl2(k); out_vwap_ohlc4(vwapValue); out_ha_close(haClose); } ``` `Dmi` warms up last (`diLength + adxSmoothing - 1` bars), so the sample gates on it. `SourceStoch(length)` is the raw %K; pass `smoothK` and `periodD` for the `ta.sma` smoothing a Pine script applies afterwards. `SourceVwap` takes the bar's open time in milliseconds like `Vwap` ([Volume and VWAP](volume-indicators.md#vwap)); `HeikinAshi` exposes the four smoothed values as fields, read after `update()`. ## History and lists `History` (Pine's `close[1]` as `history.ago(1)`), `List`, the `stats.*` window functions, `HandleRing`, `roundTo`, `roundToTick` and `Random` have their own page: [Stats, history and lists](stats-history-lists.md). ## From Pine One to one: the class returns Pine's value on the same bars. The second table holds the three that share Pine's formula with one difference. The `array.*`, `x[n]`, `math.round` and `math.random` rows are on [Stats, history and lists](stats-history-lists.md#from-pine). | Pine | Extra indicators | | --- | --- | | `ta.dev(source, length)` | `new Dev(length)`, `.update(x)` | | `ta.percentrank(source, length)` | `new PercentRank(length)`, `.update(x)` | | `ta.bbw(source, length, mult)` | `new Bbw(length, mult)`, `.update(x)` | | `ta.accdist` | `new AccDist()`, `.update(high, low, close, volume)` | | `ta.wad` | `new Wad()`, `.update(high, low, close)` | | `ta.wvad` | `new Wvad()`, `.update(open, high, low, close, volume)` | | `ta.nvi` | `new Nvi()`, `.update(close, volume)` | | `ta.pvi` | `new Pvi()`, `.update(close, volume)` | | `ta.max(source)` | `new RunningMax()`, `.update(x)` | | `ta.min(source)` | `new RunningMin()`, `.update(x)` | | `ta.mode(source, length)` | `new Mode(length)`, `.update(x)` | | `ta.range(source, length)` | `new Range(length)`, `.update(x)` | | `ta.cog(source, length)` | `new Cog(length)`, `.update(x)` | | `ta.iii` | `new Iii()`, `.update(high, low, close, volume)` | | Pine | Extra indicators | The difference | | --- | --- | --- | | `ta.stoch(source, high, low, length)` with a `na` source, high or low | `new SourceStoch(length)`, `.update(source, high, low)` | TradingView carries the previous %K over a bar whose input is `na`; here the class reads `NaN` while that bar sits in its windows (the library's windowed rule) and heals when it leaves | | `ta.pvt` | `new Pvt()`, `.update(close, volume)` | the same line from the second bar on; the first bar, with no previous close, reads 0 here | | `ta.kcw(source, length, mult, true)` | `new Kcw(length, mult, length)`, `.update(x, high, low, close)` | the range average is the library's Wilder `Atr`; Pine's is an EMA of the true range, so the widths track each other without being equal | # Stats, history and lists The `./sdk/stats` module is the window arithmetic under the [TA library](ta-library.md) and the [extra indicators](extra-indicators.md). It holds a `stats` namespace of allocation-free functions over a `StaticArray` you fill yourself, a `History` that keeps the last `n` values of a series so Pine's `close[1]` becomes `history.ago(1)`, a fixed-capacity `List` for Pine's `array.*` idiom, a `HandleRing` that keeps the newest N drawing ids so the oldest line can be deleted, `roundTo` for `math.round(x, n)`, `roundToTick` for `math.round_to_mintick(x)` and a seeded `Random` for `math.random(min, max, seed)`. Everything here composes the library's own primitives, so a window mean is the library's `Sma` to the last bit, and the library's rules hold: `NaN` until a class is warm, `NaN` while a non-finite entry sits in its window, and no allocation after the constructor. ## Which one | Need | Reach for | | --- | --- | | The previous bar's value of a series you compute, Pine's `x[n]` | `History`: `push()` once per bar, read `ago(n)` | | Entries that come and go on their own schedule, by index, Pine's `array.*` | `List`, with its capacity declared in `onStart()` | | Several readings over one window you fill yourself | the `stats.*` functions over a `StaticArray` and a count | | The ids of the newest N drawings, so the oldest can be deleted | `HandleRing` | | `math.round(x, n)`, `math.round_to_mintick(x)`, `math.random(min, max, seed)` | `roundTo`, `roundToTick`, `Random` | ## Window arithmetic: `stats` `stats` is a namespace of functions over the first `n` entries of a `StaticArray` you own. Nothing here allocates: you size the array in `onStart()`, fill it as bars arrive, and hand the count you filled. A count above the array's length reads the whole array; a count below 1 reads nothing. A non-finite entry among the first `n` makes the result `NaN`, except in `min`, `max`, `argmin` and `argmax`, which skip it. | Function | Returns | | --- | --- | | `stats.sum(a, n)` | the entries added index 0 first; 0 when `n < 1` | | `stats.mean(a, n)` | `sum / n`; `NaN` when `n < 1` | | `stats.variance(a, n)` | the population variance (divide by `n`, the library's `Variance`) | | `stats.stdev(a, n)` | `sqrt(variance)` | | `stats.min(a, n)` | the smallest finite entry; `NaN` when none is finite | | `stats.max(a, n)` | the largest finite entry; `NaN` when none is finite | | `stats.argmin(a, n)` | the index of the smallest finite entry (the lowest index on a tie); `-1` when none | | `stats.argmax(a, n)` | the index of the largest finite entry (the lowest index on a tie); `-1` when none | | `stats.slope(a, n)` | the least-squares slope against the index `0..n-1`, in value per entry; `NaN` when `n < 2` | | `stats.covariance(a, b, n)` | the population covariance of the two arrays' first `n` entries | | `stats.correlation(a, b, n)` | the Pearson correlation, `-1..1`; `NaN` when either side has no variance | | `stats.zscore(x, a, n)` | `(x - mean) / stdev`; 0 when the standard deviation is 0 (the library's `Zscore`) | | `stats.sortAscending(a, n)` | sorts the first `n` entries in place, ascending, `NaN` entries last | | `stats.median(a, n, scratch)` | the middle of the sorted entries (the mean of the two middle ones on an even `n`); copies into `scratch` and sorts there, so `a` keeps its order | | `stats.percentile(a, n, pct, scratch)` | the nearest-rank percentile, `pct` in `0..100`: rank `ceil(pct / 100 * n)`, entry `rank - 1` of the sorted copy (the library's `Percentile`) | | `stats.percentileLinearInterpolation(a, n, pct, scratch)` | the percentile by linear interpolation between the two nearest ranks, `pct` in `0..100`: TradingView's position `k = pct * n / 100 - 0.5` over the sorted copy, `sorted[floor(k)]` plus the fraction of the way to `sorted[floor(k) + 1]`, the smallest entry at or below `k = 0` and the largest at or above `k = n - 1` (so `array.from(3, 1, 8, 5)` reads `1, 1, 2, 3.2, 4, 5, 6.5, 8, 8` at `0, 10, 25, 40, 50, 62.5, 75, 90, 100`, the values captured on TradingView); the result need not be an entry (Pine's `array.percentile_linear_interpolation` and `ta.percentile_linear_interpolation`) | `scratch` is a second array of at least `n` you allocate once beside the first; `median` and the two percentiles copy into it and sort it, and return `NaN` when it is too small. The fence keeps a sliding window of the last `period` closes and volumes, oldest first, and reads it every bar. ```typescript param("period", 50, { min: 2, max: 400, description: "Bars in the window" }); output("mean", line, lower, { color: "#2563eb", description: "Mean close" }); output("stdev", line, lower, { color: "#7c3aed", description: "Standard deviation" }); output("slope", line, lower, { color: "#16a34a", description: "Least-squares slope, price per bar" }); output("correlation", line, lower, { color: "#ea580c", description: "Close-volume correlation" }); output("zscore", line, lower, { color: "#dc2626", description: "Z-score of this close" }); output("median", line, lower, { color: "#0891b2", description: "Median" }); output("p90", line, lower, { color: "#4b5563", description: "90th percentile of the close" }); let period: i32 = 50; let closes = new StaticArray(50); let volumes = new StaticArray(50); let scratch = new StaticArray(50); let filled: i32 = 0; function onStart(): void { period = i32(p_period()); closes = new StaticArray(period); volumes = new StaticArray(period); scratch = new StaticArray(period); } function onBar(): void { const close = bar.close(); const volume = bar.volume(); // Slide the window one bar: drop the oldest, append the newest. if (filled < period) filled += 1; else { for (let k = 1; k < period; k++) { unchecked((closes[k - 1] = closes[k])); unchecked((volumes[k - 1] = volumes[k])); } } unchecked((closes[filled - 1] = close)); unchecked((volumes[filled - 1] = volume)); if (filled < period) return; out_mean(stats.mean(closes, filled)); out_stdev(stats.stdev(closes, filled)); out_slope(stats.slope(closes, filled)); out_correlation(stats.correlation(closes, volumes, filled)); out_zscore(stats.zscore(close, closes, filled)); out_median(stats.median(closes, filled, scratch)); out_p90(stats.percentile(closes, filled, 90.0, scratch)); } ``` The library's `Sma`, `Stdev`, `Correlation`, `Median` and `Percentile` give the same numbers with their own rings; reach for `stats` when one window feeds several readings or is not a fixed number of bars (a session, a swing, the bars since a signal). ## History: `x[n]` from Pine `History` keeps the last `size` values of one series. Construct it in `onStart()`, `push()` once per bar, and read `ago(n)`: `ago(0)` is the value just pushed, `ago(1)` the previous bar's. | Member | Meaning | | --- | --- | | `new History(size)` | keep the last `size` values (a size below 1 keeps one) | | `push(v)` | record this bar's value; the oldest one falls out once full | | `ago(n)` | the value pushed `n` bars ago; `NaN` before `n + 1` pushes or outside `0..size - 1` | | `latest()` | `ago(0)` | | `count()` | how many values it holds, at most `size` | | `size()` | the size it was constructed with | | `max()`, `min()`, `mean()`, `sum()` | over the values held; `NaN` while empty or when one of them is not finite | | `reset()` | empty it | Two Pine idioms port directly: `close[1]` is `history.ago(1)`, and `ta.highest(close, 20)` is `max()` over a `History(20)` of the close (the library's `Highest(20)` gives the same number and the offset of the high as well). ```typescript output("prev_close", line, overlay, { color: "#4b5563", description: "close[1]: the previous bar's close" }); output("highest", line, overlay, { color: "#16a34a", description: "Highest close of the last 20 bars" }); output("change", line, lower, { color: "#2563eb", description: "close - close[1]" }); let history = new History(20); let highest = new Highest(20); function onStart(): void { history = new History(20); highest = new Highest(20); } function onBar(): void { const close = bar.close(); history.push(close); const prevClose = history.ago(1); const highestClose = history.max(); // The library class over the same 20 bars reads the same high. const check = highest.update(close); if (!isNaN(check) && check != highestClose) return; if (isNaN(prevClose)) return; out_prev_close(prevClose); out_highest(highestClose); out_change(close - prevClose); } ``` `history.max()` reads the values held so far: on bar 5 it is the high of six bars while `Highest(20)` is still `NaN`, and from bar 19 on the two agree (the fence checks it). Reach for `History` to look back on a series you compute yourself, an RSI or a spread. ## A percent rank of volume Where does this bar's volume sit against the last 100 bars? `PercentRank` ([Extra indicators](extra-indicators.md#deviation-rank-and-width)) answers it directly, and a `History` of the volumes hands the same window to `stats.percentile` for the threshold the rank crossed. The mark draws on bars whose volume ranks in the top decile. ```typescript param("period", 100, { min: 2, max: 400, description: "Bars in the window" }); input("volume", ohlcv.volume); output("rank", line, lower, { color: "#2563eb", description: "Percent rank of the volume" }); output("p90", line, lower, { color: "#4b5563", description: "90th percentile of volume" }); output("heavy", none, overlay, { description: "1 when the rank is 90 or above" }); output("heavy_mark", shape, overlay, { color: "#ea580c", shape_where: "heavy", description: "The close on a heavy-volume bar" }); let rank = new PercentRank(100); let history = new History(100); let window = new StaticArray(100); let scratch = new StaticArray(100); function onStart(): void { const period = i32(p_period()); rank = new PercentRank(period); history = new History(period); window = new StaticArray(period); scratch = new StaticArray(period); } function onBar(): void { const close = bar.close(); const volume = bar.volume(); const rankValue = rank.update(volume); history.push(volume); // Copy the held volumes into the window, oldest first, and read it. const held = history.count(); for (let k = 0; k < held; k++) unchecked((window[k] = history.ago(held - 1 - k))); const p90Value = stats.percentile(window, held, 90.0, scratch); const heavy = !isNaN(rankValue) && rankValue >= 90.0 ? 1.0 : 0.0; if (isNaN(rankValue)) return; out_rank(rankValue); out_p90(p90Value); out_heavy(heavy); out_heavy_mark(close); } ``` ## Lists: `List` `List` is a fixed-capacity list of `f64` values, Pine's `array.*` idiom with the one difference that the module declares its size up front. Construct it in `onStart()` with the most entries it will hold (a capacity below 1 keeps one; the buffer is allocated once), then push, insert, remove, read and sort it on any bar: no method allocates after the constructor, so a list never grows the module's memory per bar. Index 0 is the oldest entry and a negative index counts from the end, so `get(-1)` is the last entry. Three conventions cover every miss: a read outside the list is `NaN`, a change that does not fit (the list is full, or the index is out of range) returns `false` and changes nothing, and `pushEvict()` is the one way a full list takes a new value: it drops the oldest entry and hands it back. | Member | Meaning | | --- | --- | | `new List(capacity)` | hold at most `capacity` entries (below 1 keeps one); construct in `onStart()` | | `capacity()`, `size()` | the limit, and how many entries are held now | | `isEmpty()`, `isFull()` | `size() == 0`, `size() == capacity()` | | `clear()` | empty the list; the capacity is unchanged | | `push(v)` | append `v` as the last entry; `false` and no change when full | | `pushEvict(v)` | append `v`; when full, first remove and return the oldest entry (index 0), else return `NaN` | | `unshift(v)` | insert `v` at index 0; `false` when full | | `pop()`, `shift()` | remove and return the last, or the first, entry; `NaN` when empty | | `get(i)` | the entry at `i`; a negative `i` counts from the end (`-1` is the last); `NaN` outside the list | | `set(i, v)` | overwrite the entry at `i`, the same indexing; `false` outside the list | | `insert(i, v)` | insert `v` before index `i`, `i` in `0..size()` (`size()` appends); `false` when full or out of range | | `remove(i)` | remove and return the entry at `i`, the same indexing as `get`; `NaN` outside the list | | `first()`, `last()` | the entries at `0` and `size() - 1`; `NaN` when empty | | `indexOf(v)`, `includes(v)` | the lowest index whose entry equals `v`, or `-1`; `NaN` never matches | | `sort(ascending = true)` | sort in place; `NaN` entries sit last in either direction | | `sum()`, `mean()`, `min()`, `max()`, `stdev()` | the `stats.*` definitions over the entries held (population `stdev`); `NaN` while empty or when an entry is not finite, the `History` rule | | `values()` | the backing `StaticArray`: entries `0..size() - 1` are the list, so `stats.median(list.values(), list.size(), scratch)` reads it | The members below give the rest of Pine's `array.*` calls their own name. A Pine call that makes a second array (`array.copy`, `slice`, `abs`, `standardize`, `sort_indices`) writes here into a destination you constructed in `onStart()`: a second `List` with enough capacity (it may be the list itself where that makes sense), or a `StaticArray` of at least `size()` for the indexes. `median` and the two percentiles find their entry by counting, so they need no scratch array and leave the list's order alone, and they read a `NaN` (or infinite) entry as Pine's `na`: `median` skips it, the percentiles count it in `size()` and sort it last, as TradingView does. Nothing below allocates; `List.from` is the one exception and belongs in `onStart()`. | Member | Meaning | | --- | --- | | `List.from([v0, v1, ...], capacity = 0)` | a list holding the values in order, with room for `capacity` entries or for exactly the values given when `capacity` is below their count (`List.from([1.0, 2.0])` is full: size the capacity when the list will grow); construct in `onStart()` | | `copy(into)` | replace the entries of `into` with this list's; `false` and no change when `into` cannot hold `size()` entries | | `slice(into, from, to = size())` | write the entries from index `from` up to but not including `to` into `into`; a negative bound counts from the end, both are clamped to the list, a range that ends at or before it starts leaves `into` empty; `false` when `into` cannot hold the range; a copy, not Pine's live view | | `fill(v, from = 0, to = size())` | set every entry in the same range to `v`, in place; the size is unchanged | | `reverse()` | reverse the entries in place | | `abs(into)` | write the absolute value of every entry into `into` (`into` may be the list itself); `false` when it cannot hold them | | `standardize(into)` | write every entry's `(x - mean()) / stdev()` into `into` (`into` may be the list itself); every result `NaN` when the list is empty, holds a non-finite entry or has no spread; `false` when `into` cannot hold them | | `every()`, `some()` | read the list as a Pine `array` held as `1.0` and `0.0`: an entry is true when it is neither `0` nor `NaN`; both are `false` for an empty list, as on TradingView (`array.every(array.new_bool(0))` is `false` there) | | `sortIndices(into, ascending = true)` | write into the `StaticArray` the indexes `0..size() - 1` ordered by their entries, so `get(into[0])` is the smallest (or, with `false`, the largest) entry; ascending is stable (equal entries keep their index order) with the indexes of `NaN` entries last, and descending is that order reversed as on TradingView, so ties come in reverse index order and the `NaN` indexes first (`[2, 1, 2, 1, 2]` reads `1, 3, 0, 2, 4` and `4, 2, 0, 3, 1`; `[3, NaN, 1, 2]` reads `2, 3, 0, 1` and `1, 0, 3, 2`); the list is untouched; `false` when `into` is shorter than `size()` | | `median()` | the middle entry of the finite entries sorted, the mean of the two middle ones when their count is even; a `NaN` entry is skipped as TradingView skips `na` (`[3, NaN, 1, 8, 5]` reads `4`); `NaN` while no entry is finite | | `percentileNearestRank(pct)` | the nearest-rank percentile, `pct` in `0..100`: rank `ceil(pct / 100 * size())`, the entry at `max(0, rank - 1)` of the sorted list with the `NaN` entries last, so a rank that lands on one reads `NaN`; `NaN` while empty or when `pct` is not finite | | `percentileLinearInterpolation(pct)` | the percentile by linear interpolation between the two nearest ranks: TradingView's position `k = pct * size() / 100 - 0.5` over the sorted list (`NaN` entries last), the entry at `floor(k)` plus the fraction of the way to the next one, the smallest entry at or below `k = 0` and the largest at or above `k = size() - 1`, so `50` is the median of an even count and the result need not be an entry; with a `NaN` entry held a whole position reads the entry there and a fractional one reads `NaN`, as on TradingView (`[3, NaN, 1, 8, 5]` reads `1` at `10`, `NaN` at `25`, `5` at `50`); `NaN` while empty or when `pct` is not finite | The list below keeps impulse closes as levels: a close more than `step_pct` above the previous one goes in through `pushEvict()`, so the newest `keep` of them are held, and a level the close has risen above is removed. Walking `remove()` from the end keeps the lower indexes valid. The nearest level above price is the list's `min()`, and the mean level is rounded with `roundTo` for the legend. ```typescript param("keep", 8, { min: 1, max: 50, description: "Impulse closes kept" }); param("step_pct", 1, { min: 0.1, max: 20, description: "Rise from the previous close that marks an impulse, in percent" }); output("nearest_above", line, overlay, { color: "#16a34a", description: "The lowest kept level above the close" }); output("mean_level", line, overlay, { color: "#94a3b8", description: "Mean of the kept levels, two decimals" }); output("count", none, lower, { description: "Levels kept on this bar" }); let levels = new List(8); let step: f64 = 0.01; let prevClose: f64 = NaN; function onStart(): void { // Construct in onStart(): the list's buffer is allocated once, sized by the param. levels = new List(i32(p_keep())); step = p_step_pct() / 100.0; } function onBar(): void { const close = bar.close(); // An impulse close joins the list; once the list is full the oldest level leaves. if (!isNaN(prevClose) && close > prevClose * (1.0 + step)) levels.pushEvict(close); prevClose = close; // A level the close has risen above is spent: remove it, walking from the end. for (let i = levels.size() - 1; i >= 0; i--) { if (close > levels.get(i)) levels.remove(i); } // No level yet: the outputs stay unwritten and draw nothing on this bar. if (levels.isEmpty()) return; out_nearest_above(levels.min()); out_mean_level(roundTo(levels.mean(), 2)); out_count(f64(levels.size())); } function onReset(): void { levels.clear(); prevClose = NaN; } ``` Only a `List` hands its entries to the `stats` functions, through `values()` and `size()`; a `History` is read with `ago(n)` and its own aggregates, or copied into a window as the volume-rank sample above does. ## Percentiles of a list The close against its own recent spread: the 10th and 90th percentiles of the last closes, their median, and how long ago the highest of them closed, each level rounded to the market's tick. ```typescript // The close against its own recent spread: the 10th and 90th percentiles of the last closes and their median, on the market's tick. param.int("keep", 50, { min: 5, max: 500, label: "Closes kept" }); market.tick_size(); market.price_precision(); output("p90", line, overlay, { color: "#16a34a", description: "90th percentile of the kept closes" }); output("median", line, overlay, { color: "#94a3b8", line_style: "dashed", description: "Median of the kept closes" }); output("p10", line, overlay, { color: "#dc2626", description: "10th percentile of the kept closes" }); output("top_age", none, lower, { description: "Bars since the highest kept close" }); let closes = new List(50); let order = new StaticArray(50); let tick: f64 = 0; let decimals: i32 = 2; function onStart(): void { const keep = i32(p_keep()); closes = new List(keep); order = new StaticArray(keep); tick = p_market_tick_size(); decimals = i32(p_market_price_precision()); } // A level on the market's tick where it publishes one, else at its price decimals. function snap(x: f64): f64 { return tick > 0 ? roundToTick(x, tick) : roundTo(x, decimals); } function onBar(): void { const close = bar.close(); if (isNaN(close)) return; // Keep the newest closes: once the list is full, the oldest leaves. closes.pushEvict(close); if (!closes.isFull()) return; out_p90(snap(closes.percentileLinearInterpolation(90))); out_median(snap(closes.median())); out_p10(snap(closes.percentileLinearInterpolation(10))); // The indexes ordered from the highest close down: order[0] is where the highest sits, 0 the oldest. closes.sortIndices(order, false); out_top_age(f64(closes.size() - 1 - order[0])); } ``` - **The newest closes.** `pushEvict()` appends the close and, once the list is full, drops the oldest, so the list always holds the last `keep` closes. - **Percentiles and the median.** `percentileLinearInterpolation(90)` is Pine's `array.percentile_linear_interpolation(a, 90)`, and `median()` is `array.median(a)`. Both count their way to the answer, so the list keeps its order. - **An order without sorting.** `sortIndices(order, false)` writes the indexes from the highest close down into `order`, a `StaticArray` made in `onStart()`. Ties come newest first, as on TradingView. - **On the tick.** `roundToTick(x, tick)` puts each level on the market's tick. Where the tick reads 0 (only CME markets publish one today), `roundTo(x, decimals)` rounds to the chart's price decimals instead. ## Keep the last N drawings: `HandleRing` Lines, boxes, labels and polylines share one id space, and a drawing stays on the chart until its id is deleted ([Drawing objects](../presentation/drawing-objects.md)). "Keep only the newest five lines" therefore means remembering the ids you created and deleting the oldest when the sixth arrives. `HandleRing` is that memory: `push(id)` records an id and, when the ring is already full, hands back the oldest id for you to delete: `const old = ring.push(id); if (old >= 0) lines[old].delete();` | Member | Meaning | | --- | --- | | `new HandleRing(capacity)` | keep the newest `capacity` ids (below 1 keeps one); construct in `onStart()` | | `push(id)` | record `id` as the newest; when full, first drop and return the oldest id (delete that drawing), else return `-1`; a negative id is not recorded and returns `-1` | | `size()`, `capacity()` | how many ids are held, and the limit | | `get(i)` | the id at `i`, `0` the oldest and `size() - 1` the newest; `-1` outside the ring | | `oldest()`, `newest()` | `get(0)` and `get(size() - 1)`; `-1` when empty | | `remove(id)` | forget an id you deleted early, so it is not handed back later; `false` when the ring does not hold it | | `clear()` | forget every id; the drawings themselves stay | Two rules from the drawing page shape the idiom. A handle object allocates when it is made, so make them once in `onStart()` rather than calling `draw.line(old)` on every eviction: let the ids cycle through one more value than the ring keeps (`KEEP + 1`), make that many handle objects, and the id you set next is never one still on the chart. And the chart caps live handles at 500 per kind and 1500 in all, so a ring's capacity sits well under those. The sample draws a dashed ray at every new `period`-bar high and keeps the newest five: ```typescript param("period", 20, { min: 2, max: 400, description: "Bars a high must top" }); output("highest", line, overlay, { color: "#f5a623", description: "Highest high of the window" }); output("lines", none, lower, { description: "Rays on the chart" }); handles.line({ color: "#f5a623", width: 1, lineStyle: "dashed", extend: "right" }); // Five rays stay on the chart. Ids cycle through six, one more than the ring keeps, // so the id set next is never one still drawn; the six handle objects are made once. const KEEP = 5; const IDS = KEEP + 1; const lines = new Array(); let ring = new HandleRing(KEEP); let highest = new Highest(20); let nextId: i32 = 0; let prevT: f64 = NaN; function onStart(): void { highest = new Highest(i32(p_period())); ring = new HandleRing(KEEP); for (let id = 0; id < IDS; id++) lines.push(draw.line(id)); } function onBar(): void { const t = bar.time(); const high = bar.high(); const top = highest.update(high); const width = isNaN(prevT) ? 60.0 : t - prevT; prevT = t; if (isNaN(top)) return; // bars == 0: this bar set the window's high. if (highest.bars == 0.0) { const id = nextId; nextId = (nextId + 1) % IDS; // Record the id; when the ring is full the oldest comes back and its ray goes. const old = ring.push(id); if (old >= 0) lines[old].delete(); lines[id].set(t, high, t + width, high); } out_highest(top); out_lines(f64(ring.size())); } function onReset(): void { highest.reset(); ring.clear(); nextId = 0; prevT = NaN; } ``` `lines` reads 1, 2, 3, 4 on the first four highs and 5 from then on: the sixth high evicts the first ray's id, `delete()` removes it, and that id is the one set on the very next high. A ray you delete for another reason (a level broken, say) is also handed to `ring.remove(id)`, so the ring never returns an id that is already free. ## Rounding: `roundTo` `roundTo(x, decimals)` rounds `x` to `decimals` places with halves away from zero, Pine's `math.round(x, n)`. `decimals` is clamped to `0..15`; `NaN` and the infinities pass through unchanged. `Math.round` follows JavaScript, where a negative half goes up (`Math.round(-2.5)` is `-2`) and there is no decimals argument; `roundTo(-2.5, 0)` is `-3`. | Call | Result | | --- | --- | | `roundTo(2.5, 0)` | `3` | | `roundTo(-2.5, 0)` | `-3` | | `roundTo(7.125, 2)` | `7.13` | | `roundTo(1.23456, 3)` | `1.235` | | `roundTo(x, -1)` | the same as `roundTo(x, 0)` | | `roundTo(x, 40)` | the same as `roundTo(x, 15)` | | `roundTo(NaN, 2)` | `NaN` | The decision reads the fractional part of `abs(x) * 10^decimals` in `f64` (a fraction of `0.5` or more rounds up, nothing is added first), the result is that integer divided back with the sign restored, and `x` comes back unchanged when it already sits at that precision or when `abs(x)` is `2^52` or more, where every `f64` is an integer. A value whose binary form sits just under a half, `1.005` at two places, rounds down the way its `f64` does. Use `roundTo` for values you compute with, plot or compare; to print a value at a fixed number of decimals use the text builder's `f64(x, decimals)` ([Strings and text](text-formatting.md)), which formats without rounding the number you keep. ## Rounding to a tick: `roundToTick` `roundToTick(x, tick)` rounds `x` to the nearest multiple of `tick`, ties away from zero as `roundTo` does: Pine's `math.round_to_mintick(x)` with the tick given. Pine words the ties of `math.round` and `math.round_to_mintick` the same way ("ties rounding up"), and on TradingView `math.round(-2.5)` reads `-3` (captured), so the tick form follows `roundTo` on a negative tie; a negative tie has not been captured for `math.round_to_mintick` itself, since prices are positive, so that one case rests on the shared wording. On a chart whose market publishes a tick size the call is `roundToTick(x, p_market_tick_size())`; where the tick size reads `0` (the market has only its price decimals) use `roundTo(x, i32(p_market_price_precision()))` instead. `x` comes back unchanged when `tick` is not finite or not above `0`, when `x` is `NaN` or infinite, and when `x` already sits on a tick. | Call | Result | | --- | --- | | `roundToTick(1.125, 0.25)` | `1.25` (a tie, away from zero) | | `roundToTick(-1.125, 0.25)` | `-1.25` | | `roundToTick(1.1, 0.25)` | `1` | | `roundToTick(6.25, 2.5)` | `7.5` | | `roundToTick(100.08, 0.05)` | `100.1` | | `roundToTick(x, 0.01)` | the same as `roundTo(x, 2)`, to the bit | | `roundToTick(x, 0)` | `x` | | `roundToTick(NaN, 0.5)` | `NaN` | A tick that is a unit fraction (`0.01`, `0.25`, `0.0001`, `0.5`, `1`: one over an integer `k`) is handled the way `roundTo` handles decimals, the decision on the fractional part of `abs(x) * k` in `f64` and the result that integer divided by `k`, so the answer is the nearest `f64` to the decimal a trader would write (`100.1`, not `100.10000000000001`). Any other tick (`2.5`, `5`, `0.3`) rounds `abs(x) / tick` and multiplies back. The levels in [Percentiles of a list](#percentiles-of-a-list) are rounded this way, with `roundTo` where the tick reads 0. ## Repeatable random numbers: `Random` `Random` is a seeded generator for Pine's `math.random(min, max, seed)`: the same seed gives the same sequence on every host and every run, and `reseed(seed)` in `onReset()` replays it. Construct it in `onStart()`; `next()` and `between()` never allocate. Pine's unseeded `math.random()` differs on every run and there is no clock here to draw a seed from, so pick one. The generator is TradingView's: a seeded `math.random` there draws `java.util.Random`'s `nextDouble()` sequence for the seed, and so does `Random`, draw for draw (seed `42` starts `0.7275636800328681`, `0.6832234717598454`, `0.30871945533265976`; seed `1` starts `0.7308781907032909`, `0.41008081149220166`, the values captured on TradingView). TradingView keeps one sequence per `math.random` call site, so construct one `Random` per call a Pine script makes. | Member | Meaning | | --- | --- | | `new Random(seed)` | a generator at `seed` (an integer); construct in `onStart()` | | `next()` | the next value in `[0, 1)`, a multiple of `2^-53`, so `1.0` never comes out; Pine's `math.random()` with the seed | | `between(min, max)` | `min + (max - min) * next()`, a value in `[min, max)`; Pine's `math.random(min, max)` | | `reseed(seed)` | restart the sequence from `seed`: the values that follow are those a new `Random(seed)` gives | A random walk from the first close, the same path on every run: a baseline to test a signal against. ```typescript // A random walk from the first close, up to 1% a bar either way: the same path on every run for one seed, a baseline to test a signal against. param.int("seed", 42, { min: 1, max: 1000000, label: "Seed" }); output("walk", line, overlay, { color: "#a855f7", description: "A seeded random walk from the first close" }); let random = new Random(42); let seed: i64 = 42; let walk: f64 = NaN; function onStart(): void { seed = i64(p_seed()); random = new Random(seed); } function onBar(): void { const close = bar.close(); if (isNaN(close)) return; walk = isNaN(walk) ? close : walk * (1.0 + random.between(-0.01, 0.01)); out_walk(walk); } // A reset replays the same path from the seed. function onReset(): void { random.reseed(seed); walk = NaN; } ``` - **One seed, one path.** `between(-0.01, 0.01)` draws one step per bar, and seed `42` draws the same steps on every run and every host: the walk's first step is `0.7275636800328681` of the way through the range, the value TradingView's `math.random` gives for that seed. - **A reset replays it.** `reseed(seed)` in `onReset()` restarts the sequence, so a reset chart draws the same walk again. - **A baseline.** A signal that scores as well on the walk as on the price has found nothing. ## From Pine One to one: the call returns Pine's value on the same bars. The second table holds the three `List` calls that differ from Pine's `array.*`. | Pine | Here | | --- | --- | | `array.sum`, `array.avg`, `array.variance`, `array.stdev`, `array.min`, `array.max`, `array.indexof(array.min(a))`, `array.covariance`, `array.sort`, `array.median`, `array.percentile_nearest_rank`, `array.percentile_linear_interpolation` | `stats.sum`, `stats.mean`, `stats.variance`, `stats.stdev`, `stats.min`, `stats.max`, `stats.argmin` (and `argmax`), `stats.covariance`, `stats.sortAscending`, `stats.median`, `stats.percentile`, `stats.percentileLinearInterpolation` over a `StaticArray` and a count | | `x[n]` | `new History(size)`, `.push(x)` once per bar, `.ago(n)` | | `array.new()`, `array.push`, `array.pop`, `array.shift`, `array.unshift`, `array.get`, `array.set`, `array.insert`, `array.remove`, `array.first`, `array.last`, `array.indexof`, `array.includes`, `array.size`, `array.clear` | `new List(capacity)` in `onStart()`, then `.push`, `.pop`, `.shift`, `.unshift`, `.get`, `.set`, `.insert`, `.remove`, `.first`, `.last`, `.indexOf`, `.includes`, `.size`, `.clear` | | `array.sort(a)`, `array.sort(a, order.descending)` | `list.sort()`, `list.sort(false)` | | `array.sum`, `array.avg`, `array.min`, `array.max`, `array.stdev` on an array you keep | `list.sum()`, `.mean()`, `.min()`, `.max()`, `.stdev()` | | `array.from(v0, v1, ...)` | `List.from([v0, v1, ...], capacity)` in `onStart()` | | `array.copy(a)`, `array.slice(a, from, to)`, `array.abs(a)`, `array.standardize(a)` | `list.copy(into)`, `.slice(into, from, to)`, `.abs(into)`, `.standardize(into)` with `into` a second `List` made in `onStart()` | | `array.fill(a, v, from, to)`, `array.reverse(a)` | `list.fill(v, from, to)`, `.reverse()` | | `array.every(a)`, `array.some(a)` | `list.every()`, `.some()` over entries held as `1.0` and `0.0` | | `array.sort_indices(a, order)` | `list.sortIndices(into)` / `.sortIndices(into, false)` with `into` a `StaticArray` made in `onStart()` (TradingView's orders: stable ascending with `na` last, that order reversed descending) | | `array.median(a)`, `array.percentile_nearest_rank(a, pct)`, `array.percentile_linear_interpolation(a, pct)` | `list.median()`, `.percentileNearestRank(pct)`, `.percentileLinearInterpolation(pct)` | | `if array.size(lines) > N` then `line.delete(array.shift(lines))` | `new HandleRing(N)` in `onStart()`; `const old = ring.push(id); if (old >= 0) lines[old].delete();` | | `math.round(x, n)` | `roundTo(x, n)` | | `math.round_to_mintick(x)` | `roundToTick(x, p_market_tick_size())` | | `math.random(min, max, seed)` | `new Random(seed)` in `onStart()`, then `.between(min, max)` (`.next()` for `math.random()`) | | Pine | Here | The difference | | --- | --- | --- | | `array.push(a, v)` on an array that grows without bound | `list.push(v)` | a `List` has the capacity it was given in `onStart()`: `push` returns `false` when full, and `pushEvict` drops the oldest entry instead, the keep-the-last-N shape most scripts want | | `array.get(a, i)` | `list.get(i)` | a negative `i` counts from the end here (`get(-1)` is the last entry) and an index outside the list reads `NaN` instead of stopping the script | | `array.slice(a, from, to)` | `list.slice(into, from, to)` | a copy into `into`, where Pine's slice is a live view of the original; a bound outside the list is clamped instead of stopping the script | # Math functions Math in a wrun indicator is AssemblyScript's `Math`: absolute value, rounding, powers and roots, logarithms, and the full trig and hyperbolic families, on `f64`. Call them on any number in `onBar()` and they evaluate per bar, exactly like their JavaScript counterparts, with one difference worth knowing: the compiler is typed, so an integer and a float never mix silently. Every method lives under the `Math.` prefix (capital M). There is no global `abs()` or `round()` for floats; it is always `Math.abs(...)`, `Math.round(...)`. Use them to normalize a signal, scale a value into a range, or build a custom indicator from primitives. ```typescript const spread = close - average; // How far is price from its average, regardless of direction? distance = Math.abs(spread); ``` ## What is available | Group | Methods | | --- | --- | | Basic | `abs`, `sign` | | Rounding | `round`, `floor`, `ceil`, `trunc` | | Powers and roots | `pow`, `sqrt`, `cbrt`, `hypot` | | Exp and log | `exp`, `expm1`, `log`, `log1p`, `log2`, `log10` | | Comparison | `max`, `min` | | Trig (radians) | `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2` | | Hyperbolic | `sinh`, `cosh`, `tanh`, `asinh`, `acosh`, `atanh` | Constants: `Math.PI`, `Math.E`, `Math.SQRT2`, `Math.SQRT1_2`, `Math.LN2`, `Math.LN10`, `Math.LOG2E`, `Math.LOG10E`. Each behaves like its JavaScript counterpart. `Math.max` and `Math.min` take two `f64` arguments and return the larger or smaller; nest them for three. `Math.round` rounds to the nearest integer and returns an `f64`. Trig functions work in radians, so divide a bar count or an angle accordingly (`Math.sin(f64(barIndex) / 10.0)`). **Types.** `Math.*` takes and returns `f64`. An `i32` (a bar count, a loop index, a ring cursor) must be cast in (`f64(count)`) and a result used as an index must be cast out (`i32(Math.floor(x))`); the compiler refuses the implicit conversion with `AS200`. For integer work AssemblyScript has typed builtins that need no cast: `abs(n)`, `max(a, b)`, `min(a, b)`, and integer division truncates on its own (`7 / 2` is `3` for two `i32` values, `3.5` for two `f64`). **There is no `Math.avg`.** To average two values, write the arithmetic (`(a + b) / 2.0`); for a rolling average over a window, use `Sma` ([Moving averages](moving-averages.md)). Calling a method that does not exist is a compile error, not a runtime one: `Math.unknownFn(x)` stops the build with `TS2339: Property 'unknownFn' does not exist on type '~lib/math/NativeMath'`, with the line and column. ## Every method in one module This module runs the full namespace at once. It builds a few helper values first (a `centered` series of price minus its average, a `bounded` value safe for `asin` and `acos`, and a `positive` value safe for `log` and `sqrt`), then writes each `Math.*` result to its own output. ```typescript param("period", 5, { min: 2, max: 200 }); output("abs", line, lower, { description: "Math.abs of the centered close" }); output("round", line, lower, { description: "Math.round" }); output("max", line, lower, { description: "Math.max of high and close" }); output("min", line, lower, { description: "Math.min of low and close" }); output("pow", line, lower, { description: "Math.pow of the small value, squared" }); output("sqrt", line, lower, { description: "Math.sqrt of the positive value" }); output("log", line, lower, { description: "Math.log of the positive value" }); output("floor", line, lower, { description: "Math.floor" }); output("ceil", line, lower, { description: "Math.ceil" }); output("sign", line, lower, { description: "Math.sign: -1, 0, or 1" }); output("sin", line, lower, { description: "Math.sin of the bar index over 8" }); output("cos", line, lower, { description: "Math.cos of the bar index over 8" }); output("tan", line, lower, { description: "Math.tan of the bar index over 40" }); output("asin", line, lower, { description: "Math.asin of the bounded value" }); output("acos", line, lower, { description: "Math.acos of the bounded value" }); output("atan", line, lower, { description: "Math.atan of the centered close" }); output("atan2", line, lower, { description: "Math.atan2 of centered over positive" }); output("sinh", line, lower, { description: "Math.sinh of the small value" }); output("cosh", line, lower, { description: "Math.cosh of the small value" }); output("tanh", line, lower, { description: "Math.tanh of the small value" }); output("asinh", line, lower, { description: "Math.asinh of the small value" }); output("acosh", line, lower, { description: "Math.acosh of the positive value" }); output("atanh", line, lower, { description: "Math.atanh of half the bounded value" }); output("exp", line, lower, { description: "Math.exp of the small value" }); output("expm1", line, lower, { description: "Math.expm1 of the small value" }); output("log1p", line, lower, { description: "Math.log1p of the small value's magnitude" }); output("log2", line, lower, { description: "Math.log2 of the positive value" }); output("log10", line, lower, { description: "Math.log10 of the positive value" }); output("cbrt", line, lower, { description: "Math.cbrt of the centered close" }); output("hypot", line, lower, { description: "Math.hypot of centered and small" }); output("trunc", line, lower, { description: "Math.trunc of the centered close" }); let sma = new Sma(5); let barIndex: i32 = 0; function onStart(): void { sma = new Sma(i32(p_period())); } function onBar(): void { const close = bar.close(); const high = bar.high(); const low = bar.low(); const average = sma.update(close); const centered = close - average; // The bar index is an i32 counter; the trig helpers want an f64 in radians. const bounded = Math.sin(f64(barIndex) / 10.0); const positive = Math.abs(centered) + 1.0; const small = centered / 10.0; barIndex += 1; if (isNaN(average)) return; const angle = f64(barIndex) / 8.0; out_abs(Math.abs(centered)); out_round(Math.round(centered)); out_max(Math.max(high, close)); out_min(Math.min(low, close)); out_pow(Math.pow(small, 2.0)); out_sqrt(Math.sqrt(positive)); out_log(Math.log(positive)); out_floor(Math.floor(centered)); out_ceil(Math.ceil(centered)); out_sign(Math.sign(centered)); out_sin(Math.sin(angle)); out_cos(Math.cos(angle)); out_tan(Math.tan(f64(barIndex) / 40.0)); out_asin(Math.asin(bounded)); out_acos(Math.acos(bounded)); out_atan(Math.atan(centered)); out_atan2(Math.atan2(centered, positive)); out_sinh(Math.sinh(small)); out_cosh(Math.cosh(small)); out_tanh(Math.tanh(small)); out_asinh(Math.asinh(small)); out_acosh(Math.acosh(positive)); out_atanh(Math.atanh(bounded / 2.0)); out_exp(Math.exp(small)); out_expm1(Math.expm1(small)); out_log1p(Math.log1p(Math.abs(small))); out_log2(Math.log2(positive)); out_log10(Math.log10(positive)); out_cbrt(Math.cbrt(centered)); out_hypot(Math.hypot(centered, small)); out_trunc(Math.trunc(centered)); } ``` ## Domain notes A few methods are only defined for part of the number line, exactly as in standard math: - `Math.sqrt` and `Math.log` expect non-negative input. Guard with `Math.abs(x) + 1.0` or a `Math.max` floor if your series can go negative or hit zero. - `Math.asin` and `Math.acos` only accept values in `[-1, 1]`. Feed them something already bounded (the module uses `Math.sin(...)`). - `Math.acosh` expects input `>= 1`. Out-of-domain input produces `NaN` rather than trapping, so an output may simply show gaps where the input left the valid range, and `NaN` written to an output draws nothing and never trips an alert. A division by zero on `f64` yields an infinity, not a trap; `isFinite(x)` catches both before the value reaches an output. Integer division by zero DOES trap and aborts the evaluation, so guard an `i32` divisor. # Control flow Control flow decides what runs on each bar. A wrun indicator is AssemblyScript, so the constructs are the typed C-style ones: `if`/`else`, the `? :` ternary, `switch`, `for`, `while`, `do`/`while`, `break` and `continue`. Everything here runs inside `onBar()`, which the host calls once per bar as it walks the loaded history. The bar loop belongs to the host: your code sees one bar per call, and a loop inside it walks memory you own, never history you do not have. Module-level variables are where state lives between calls. | Construct | Use it for | | --- | --- | | `if` / `else` | decision logic; the condition must be a `bool` | | `? :` | one inline choice; both branches share a type | | `switch` | mapping a discrete code (a mode param) to a value or a branch | | `for` | a known iteration count: a ring buffer, a cell block, a bounded scan | | `while`, `do`/`while` | a condition-driven search with a bound you can prove | | `break`, `continue` | leaving a loop early, skipping an iteration | ## if / else Branch on any boolean condition; the `else` is optional and `else if` chains as usual. The condition must be a real `bool`: `if (value)` on a number is a compile error (`AS200`), so compare (`if (value > 0.0)`, `if (!isNaN(value))`). ```typescript if (close > open) { direction = 1.0; // up bar } else if (close < open) { direction = -1.0; // down bar } else { direction = 0.0; // doji } ``` A value from the last bar, such as the previous close, is a module-level variable the module kept from the last call, not `close[1]` ([Execution model](../core-concepts/execution-model.md)). ## Ternary expressions For a single inline choice, `condition ? a : b` is cleaner than a full `if`. Both branches must have the same type: write `1.0 : 0.0` for an `f64`, not `1 : 0`, or the compiler infers an integer and refuses the assignment. It is the idiomatic way to pick a value or to suppress a draw, since `NaN` written to an output draws nothing: ```typescript // Pick a price based on the bar's direction. const anchor = close > open ? high : low; // Write the low only on signal bars; NaN draws nothing (a shape_where gate is the declared form). const signal = crossed == 1 ? low : NaN; ``` ## switch `switch` matches an integer against `case` labels with a `default` fallback, and falls through without `break` exactly as JavaScript does. Use it to map a discrete code (a mode param) to a value or a branch. ```typescript switch (mode) { case 0: picked = close; break; case 1: picked = high; break; default: picked = low; } ``` ## for loops Use a C-style `for` when you need a fixed number of iterations: summing a window, scanning buckets, accumulating a value. The form is `for (let i = 0; i < end; i++) { ... }`: the counter is an `i32` (`i++` and `i += 1` both work), so cast it when it meets an `f64`. Loop bodies work with locals and with the module's buffers. A `StaticArray` sized from a param's `max` in `onStart()` or at module start is the usual target, and `unchecked(ring[i])` skips the bounds check once the index is provably inside. ```typescript // Sum the last closes by hand, from a ring buffer the module keeps; count is how many slots are filled. let total = 0.0; for (let i = 0; i < count; i++) { total += unchecked(ring[i]); } ``` A celled input's block is the other common loop target: `for (let i = 0; i + 3 < n; i += 4)` walks `[low, high, buy, sell]` tuples ([Order flow](order-flow-kit.md)). There is no history array to loop over (`close[n]` does not exist): a wrun indicator has no timeseries. A window is a `History` you `push()` once per bar and read with `ago(n)` ([Stats, history and lists](stats-history-lists.md)), or by hand a `StaticArray` ring the module fills one bar at a time, sized once from the param's `max` ([Collections](../core-concepts/collections.md)). The ring is the window, and it is yours to size and index. ## while and do/while `while (condition) { ... }` and `do { ... } while (condition)`. Use them when the iteration count depends on what you find, and make the bound explicit: a search back through a ring stops at the ring's size whatever the data says. Here `above` is a ring of booleans (was the close above its average on that bar) and `slot(back)` maps "bars back" to a ring index. ```typescript let back = 0; while (back < count && !unchecked(above[slot(back)])) { back += 1; } ``` A counted `for` is clearer and safer when you know the bound. Reach for `while` only for genuine search-until-found logic, and make sure the condition can end. ## break and continue `break` leaves the loop; `continue` skips to the next iteration. Both work in `for`, `while`, and `do`/`while`, and both are the idiomatic way to stop a scan the moment it has its answer. ## Branches in one module A `for` loop, an `if`/`else` branch, a ternary, and a `switch`, folded into one value, with a bar counter the module keeps itself (there is no `barIndex` global). The `if`/`else`, ternary, `switch` and `for` snippets above appear verbatim inside `onBar()`. ```typescript param("mode", 0, { min: 0, max: 2, description: "0 rotate by bar, 1 high, 2 low: which price the switch picks" }); param("window", 5, { min: 2, max: 50, description: "Closes summed by the for loop" }); output("direction", line, lower, { description: "+1 up bar, -1 down bar, 0 doji" }); output("anchor", line, lower, { description: "The high on up bars, the low otherwise" }); output("signal", none, lower, { description: "The low on bullish-cross bars, NaN elsewhere" }); output("total", line, lower, { description: "The last closes summed by hand" }); output("picked", line, lower, { description: "The price the switch picked" }); output("folded", line, lower, { description: "All four results in one number" }); const MAX_WINDOW = 50; const ring = new StaticArray(MAX_WINDOW); let n: i32 = 5; let cursor: i32 = 0; let count: i32 = 0; let modeParam: i32 = 0; let barIndex: i32 = 0; let sma = new Sma(5); const cross = new Cross(); function onStart(): void { modeParam = i32(p_mode()); n = i32(p_window()); sma = new Sma(n); } function onBar(): void { const close = bar.close(); const open = bar.open(); const high = bar.high(); const low = bar.low(); unchecked((ring[cursor] = close)); cursor = (cursor + 1) % n; if (count < n) count += 1; const crossed = cross.update(close, sma.update(close)); let direction = 0.0; if (close > open) { direction = 1.0; // up bar } else if (close < open) { direction = -1.0; // down bar } else { direction = 0.0; // doji } // Pick a price based on the bar's direction. const anchor = close > open ? high : low; // Write the low only on signal bars; NaN draws nothing (a shape_where gate is the declared form). const signal = crossed == 1 ? low : NaN; // Sum the last closes by hand, from a ring buffer the module keeps; count is how many slots are filled. let total = 0.0; for (let i = 0; i < count; i++) { total += unchecked(ring[i]); } // A per-bar mode: the param, or the bar index modulo 3 when the param is 0. const mode = modeParam == 0 ? barIndex % 3 : modeParam; let picked = NaN; switch (mode) { case 0: picked = close; break; case 1: picked = high; break; default: picked = low; } barIndex += 1; out_direction(direction); out_anchor(anchor); out_signal(signal); out_total(total); out_picked(picked); out_folded(anchor + picked / 100.0 + total); } ``` ## Loops in one module A search-until-found: how many bars back the close last traded above its average, found with a `while` over a ring buffer, plus a `for` that sums the ring, a `for` with `continue` that counts up bars while skipping doji bars, and a `do`/`while` with `break` that finds the first bar back whose range exceeds the current one. The `for` and `while` snippets above appear verbatim inside `onBar()`. ```typescript param("period", 20, { min: 2, max: 200, description: "Average window and search depth" }); output("direction", line, lower, { color: "#94a3b8", description: "+1 when the close rose, -1 when it fell, 0 flat" }); output("close_sum", line, lower, { color: "#7c3aed", description: "The window's closes summed by the for loop" }); output("bars_since_above", line, lower, { color: "#2563eb", description: "Bars back to the last close above the average (0 = this bar), NaN if none in the window" }); output("bars_above", line, lower, { color: "#16a34a", description: "Up bars in the window, doji bars skipped" }); output("wider_back", line, lower, { color: "#f97316", description: "Bars back to the first bar with a wider range than this one, NaN if none" }); const MAX_PERIOD = 200; const ring = new StaticArray(MAX_PERIOD); const opens = new StaticArray(MAX_PERIOD); const ranges = new StaticArray(MAX_PERIOD); const above = new StaticArray(MAX_PERIOD); let size: i32 = 20; let sma = new Sma(20); let cursor: i32 = 0; let count: i32 = 0; let prevClose: f64 = NaN; // The ring slot `back` bars behind the newest write. function slot(back: i32): i32 { return (cursor - 1 - back + size) % size; } function onStart(): void { size = i32(p_period()); sma = new Sma(size); } function onBar(): void { const close = bar.close(); const open = bar.open(); const average = sma.update(close); // if / else: the bar's direction against the previous close the module kept. let direction = 0.0; if (close > prevClose) { direction = 1.0; } else if (close < prevClose) { direction = -1.0; } prevClose = close; unchecked((ring[cursor] = close)); unchecked((opens[cursor] = open)); unchecked((ranges[cursor] = bar.high() - bar.low())); unchecked((above[cursor] = !isNaN(average) && close > average)); cursor = (cursor + 1) % size; if (count < size) count += 1; if (isNaN(average)) return; // for: sum the filled slots of the ring. let total = 0.0; for (let i = 0; i < count; i++) { total += unchecked(ring[i]); } // while: search back until a bar above the average is found, or the window runs out. let back = 0; while (back < count && !unchecked(above[slot(back)])) { back += 1; } const barsSinceAbove = back < count ? f64(back) : NaN; // for with continue: count up bars, skipping doji bars. let ups = 0; for (let i = 0; i < count; i++) { const c = unchecked(ring[i]); const o = unchecked(opens[i]); if (c == o) continue; if (c > o) ups += 1; } // do/while with break: the first bar back whose range exceeds this one. const current = unchecked(ranges[slot(0)]); let probe = 1; let widerBack = NaN; if (count > 1) { do { if (unchecked(ranges[slot(probe)]) > current) { widerBack = f64(probe); break; } probe += 1; } while (probe < count); } out_direction(direction); out_close_sum(total); out_bars_since_above(barsSinceAbove); out_bars_above(f64(ups)); out_wider_back(widerBack); } ``` Every loop here is bounded by `count`, which is bounded by `size`, which is bounded by the param's declared `max`: the module allocates its rings once for `MAX_PERIOD` and never grows. ## Loop limits A wrun indicator has no iteration counter: the chart bounds the whole evaluation with an execution timeout (20 s per run). A module that overruns it is stopped and its worker replaced, so a runaway loop, or a `while` whose condition never becomes false, fails the run loudly rather than hanging the chart. In practice a loop bounded by a param's declared `max` never approaches the timeout; keep loop bounds tied to declared ranges and the module stays cheap on a long history ([Execution model](../core-concepts/execution-model.md)). Loops run once per bar over the loaded history and again each time a live update re-evaluates the forming bar (at most about once a second). A scan of a 200-slot ring is cheap; a scan inside a scan is not, so cache a statistic once per bar and reuse it. ## What to keep in mind - **Per-bar execution.** `onBar()` re-runs on every bar. To carry a value across bars, keep it in a module-level `let`. A `let` inside `onBar()`, a loop or a branch is local to that block and gone when the call returns. - **Typed branches.** Every branch of a ternary and every assignment agrees on a type; `f64` values get float literals (`0.0`, `1.0`), counts and indexes are `i32`. # 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_()` 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__sb()`. The sender hands the chart the line and returns the slot's index (a label handle's `text(...)` takes that index). ```typescript 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__*` 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__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_(s: string): i32` | nothing: clears, appends `s`, sends the same way | | `str__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 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_(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__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 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 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__*` 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_(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 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. # Colors A color in a wrun indicator lives in one of three places: as a literal on a declaration, as a per-bar palette index on a `color_by` output, or as a packed number that `./sdk/color` computes for a drawing handle while the module runs; in every place, seven theme tokens name the chart's own colours instead of a fixed one. This page covers the forms a declaration accepts and where a name is refused, the theme tokens, the per-bar ladder, and the run-time kit: `ink`, `fromHex`, `alpha`, `mix`, `lighten`, `darken`, the channel readers, `toPacked` and `fromPacked` for the packed colour an output carries, `ColorScale` and `Thresholds`. ## Where a color lives | Place | What it is | Who reads it | | --- | --- | --- | | a declaration (`color`, `colors`, `borderColor`, a renderer's `color`) | a string literal, or a `param.color` named as `"@name"` | **Run** reads it as text; the module never executes it | | a `color_by` output | a number per bar, floored into the `colors` palette | the chart, per bar | | a drawing handle (`draw.line`, `draw.box`, `draw.label`, `draw.polyline`) | a packed `i32` from `./sdk/color` or `rgba(...)` | the module, through `color(...)` and `fill(...)` while it runs | The split is the one behind every declaration: the module computes numbers and the chart draws them from the declaration, so the decision behind a color (a regime, a bucket) stays a number your file can test, read at the Console prompt, or hand to a declared alert. What you would ask a color function for, and where each answer lives: | You want | On a declaration | On a handle | | --- | --- | --- | | an RGB color | a literal: `"#2563eb"`, `"rgb(37, 99, 235)"`, `"hsl(221, 83%, 53%)"` | `ink.SKY`, `fromHex("#0ea5e9")`, or `rgba(14, 165, 233, 255)` from `./gen/draw` | | transparency | the `opacity` option (0..1), or alpha inside the color (`"#2563eb66"`) | `alpha(c, 0.4)` | | a lighter, darker or blended color | write the result as a literal | `lighten`, `darken`, `mix` | | a gradient driven by a value | a `colors` palette plus `color_by` over a bucket index: stepped, as fine as the palette is long | `mix(a, b, t)` per bar: continuous | | a named palette | a `colors` literal array; there are no named palettes | `ink.*`, twenty named packed colors | | a color the user picks | `param.color`, bound by `"@name"` ([The Style page](../settings/style-page.md)) | `p_()` reads it as one packed number ([Setting kinds](../settings/kinds.md)) | ## Color forms on declarations The chart parses a declared color string as a CSS color. Every form, and where it is accepted: | Form | Example | Accepted on | | --- | --- | --- | | six-digit hex | `"#2563eb"` | everything | | eight-digit hex, alpha in the last two digits | `"#2563eb66"` | everything; the built-in way to carry alpha | | `rgb()` / `rgba()` | `"rgb(37, 99, 235)"` | everything | | `hsl()` / `hsla()` | `"hsl(221, 83%, 53%)"` | everything | | a named color | `"orange"` | an output's `color` and its `colors` palette, a segment, a box `borderColor`, `render.*`, `draw.*`, a `range()`; refused on a box fill | | `"@name"` | `"@basis_color"` | `color` and a `colors` entry, naming a `param.color` | | a theme token | `"theme.up"` | everything: the chart's own colour, resolved when it paints ([Theme tokens](#theme-tokens)) | A box's `color` is its fill, and the fill takes the box's `opacity`, so the host must be able to rewrite the color with an alpha channel. That is why a box `color` must be hex (3, 4, 6 or 8 digits), `rgb()` / `rgba()`, `hsl()` / `hsla()` or a theme token: a name there is refused at validation with `color must be a hex, rgb() or hsl() color (the fill takes the opacity)`. `borderColor` is a stroke and takes any form. Alpha has two spellings. An eight-digit hex, `rgba()` or `hsla()` bakes it into the color; the `opacity` option (0..1) on an output, a box, a range, a fill, a handle or a card applies it on top. The two compose: a box with `color: "#2563eb"` and `opacity: 0.15` fills at fifteen percent, and an output's `opacity` fades every colour it draws, a ladder's rungs and a candle's border and wick included. The sheet validator checks a box fill's form before the module runs; the chart parses every other color string when it draws. A channel outside its range (`rgb(300, 0, 0)`) is not a build error, but it is not a color the chart can render either, so keep channels in `0..255` and hue, saturation and lightness in their own ranges. Six- or eight-digit hex everywhere is the safe habit: it validates on a fill, it carries alpha, and it is what every worked example in this tree uses. ### Theme tokens Seven words name the chart's own colours instead of a fixed one, and every colour key accepts them: `theme.up` and `theme.down` (the candle colours), `theme.text` (the axis text), `theme.muted` (that text at 55 percent), `theme.bg` (the background), `theme.grid` (the grid lines) and `theme.accent` (the app's accent). The chart resolves a token when it paints and again when the theme switches, with no rerun, so an indicator whose chrome is inked `"theme.text"` over `"theme.bg"` reads on a dark and a light chart alike. A token carries no alpha of its own: fade it through the `opacity` word of the surface (`opacity`, `fill_opacity`, `background_opacity`) or, at run time, `alpha(theme.UP, 0.2)` from `./sdk/color`, which also has the tokens as packed values for the drawing handles ([Theme colours](#theme-colours)). A `theme.` word outside the seven is refused by name, and a `param.color` default or preset stays a concrete colour: put the token on the colour key, not on the setting. The colour words that arrived with the styling round (an output's `fill_color`, `fill_gradient`, `gradient`, `up_color`, `down_color`, `border_colors`, `wick_colors`; a `fill(...)`; a card, feed, meter, ladder or HUD surface; a tile; a `plot.levels` profile and a `panel.*` series; a frame's colour strings) take `#rrggbb`, `#rrggbbaa` or a theme token only, one colour grammar on every new surface: the refusal reads `a colour must be #rrggbb, #rrggbbaa or one of theme.up, theme.down, theme.text, theme.muted, theme.bg, theme.grid, theme.accent`. The older surfaces (an output's `color` and `colors`, a segment, a box border, the `render.*` and `draw.*` colours, a `range()`) keep taking a CSS name, `rgb()` and `hsl()` beside the token, and the new keys of those surfaces (`background_color`, `border_color`, `glow_color`, `emblem_color`, `text_color`, `fill_color`, a `gradient` stop) take the same forms as their siblings. ### Named colors The common names and the hex each one stands for. Write the hex instead of the name and the result is identical on every surface, including the one that refuses names. | Name | Hex | Name | Hex | Name | Hex | | --- | --- | --- | --- | --- | --- | | `red` | `#FF0000` | `green` | `#008000` | `blue` | `#0000FF` | | `orange` | `#FFA500` | `lime` | `#00FF00` | `navy` | `#000080` | | `yellow` | `#FFFF00` | `olive` | `#808000` | `teal` | `#008080` | | `maroon` | `#800000` | `purple` | `#800080` | `aqua` | `#00FFFF` | | `fuchsia` | `#FF00FF` | `gray` | `#808080` | `silver` | `#C0C0C0` | | `black` | `#000000` | `white` | `#FFFFFF` | | | A name and its hex are interchangeable on an output, and each output owns its color: there is no shared array with an index argument. ```text output("sma20", line, overlay, { color: "orange", width: 2 }); output("sma50", line, overlay, { color: "#FFA500", width: 2 }); // the same orange ``` ### Choosing colors - Never rely on color alone: a `shape` mark or a data-only output carries the same decision for anyone who cannot see the tint, and for an alert, which cannot see it at all. - Semantic colors read fastest: green for bullish signals and positive values, red for bearish signals and losses, blue for neutral lines and references, orange or yellow for warnings. - Keep one scheme across related indicators. A package's colors are declarations, so they stay aligned across versions. - Three sets that read well together: `green`, `red`, `blue` for signals; `orange`, `purple`, `teal` for overlays; `lime`, `yellow`, `fuchsia` for volume. - The worked examples in this tree use one muted set that reads on dark and light themes alike: `#2563eb` blue, `#16a34a` green, `#dc2626` red, `#f59e0b` amber, `#7c3aed` violet, `#94a3b8` slate for reference lines, and `#22d3a5` / `#ff5b7f` for bull and bear tints. - **Theme tokens for chrome, fixed colours for meaning.** Ink a label's background `theme.bg`, its text `theme.text` and a grid line `theme.grid`, so the indicator follows the viewer's theme; keep the colours that carry a signal (green, red, amber) as hex, so they mean the same on every chart. - **The light-theme guard.** On a light chart the chart darkens a colour that would vanish against the background; an indicator whose colours are chosen for both themes (theme tokens, or a palette tested on both) opts out with `chart.contrast_guard(false)` at the top of the file and draws exactly as written. ## Per-bar color A declared color cannot change per bar, and the module cannot compute one for a declaration. Per-bar color is a ladder: a data-only (`none`) output holds a palette index, and the drawn output names it with `color_by` over a literal `colors` array. Normalize the value into `0 .. n - 1`, floor it, write it to the `none` output, and put an `n`-entry palette on the drawn output. What each bar draws: | Index on this bar | The output draws | | --- | --- | | finite, inside the palette | that entry (floored) | | finite, outside the palette | entry 0 | | `NaN` or infinite | its static `color`; entry 0 when none is declared | `color_by` and `colors` go together: either without the other is refused by name. An output cannot color itself, so the decision is its own output. The ramp is as fine as the palette is long, and the palette is yours. Five stops of a viridis ramp over RSI, a declared opacity, a two-literal blend driven by a 0/1 index, and a box fill in hex: ```typescript param("period", 14, { min: 2, max: 200 }); // A five-step color ladder over RSI: cool when oversold, hot when overbought. output("rsi", line, lower, { width: 2, color_by: "heat", colors: ["#440154", "#3b528b", "#21918c", "#5ec962", "#fde725"], description: "RSI colored by its own level" }); output("heat", none, lower, { description: "0..4: which fifth of 0..100 the RSI sits in" }); // opacity(color, 40) as a declared opacity. output("sma20", line, overlay, { color: "#2563eb", opacity: 0.4, width: 2, description: "A faded average" }); // blend(green, red, weight) per regime as two literals and a 0/1 index. output("trend", line, overlay, { width: 2, color_by: "trend_up", colors: ["#dc2626", "#16a34a"], description: "The average, red falling, green rising" }); output("trend_up", none, overlay, { description: "1 while the average rises" }); // A box fill: a hex color with the transparency in the declared opacity. const bandHi = output("band_hi", none, overlay); const bandLo = output("band_lo", none, overlay); box("band", { top: bandHi, bottom: bandLo, color: "#2563eb", opacity: 0.12, borderWidth: 0 }); let rsi = new Rsi(14); let sma = new Sma(20); let average: f64 = NaN; let prevAverage: f64 = NaN; function onStart(): void { rsi = new Rsi(i32(p_period())); sma = new Sma(20); } function onBar(): void { const close = bar.close(); const strength = rsi.update(close); prevAverage = average; average = sma.update(close); if (isNaN(strength) || isNaN(average)) return; // Bucket 0..100 into five steps; 100 itself lands in the last bucket. let bucket = Math.floor(strength / 20.0); if (bucket > 4.0) bucket = 4.0; out_rsi(strength); out_heat(bucket); out_sma20(average); out_trend(average); out_trend_up(!isNaN(prevAverage) && average >= prevAverage ? 1.0 : 0.0); out_band_hi(average * 1.01); out_band_lo(average * 0.99); } ``` The `heat` output draws nothing, but it is a value like any other: after a **Run**, type `last 20 heat` at the editor's Console prompt to read the bucket on the newest twenty bars. The index need not be a floor. A condition that answers `0`, `1` or `2` is the same ladder over a three-entry palette, and a name is accepted as an entry: ```typescript param("period", 14, { min: 2, max: 200, description: "RSI length" }); // colors is the palette; level picks the entry per bar: 0 red (overbought), 1 green (oversold), 2 blue (neutral). output("rsi", line, lower, { width: 2, color_by: "level", colors: ["#FF0000", "green", "#0000FF"], description: "RSI with conditional colors" }); output("level", none, lower, { description: "0 above 70, 1 below 30, 2 in between" }); let rsi = new Rsi(14); function onStart(): void { rsi = new Rsi(i32(p_period())); } function onBar(): void { const value = rsi.update(bar.close()); if (isNaN(value)) return; out_rsi(value); out_level(value > 70.0 ? 0.0 : value < 30.0 ? 1.0 : 2.0); } ``` The same pair works on a `width_by` output with a `widths` array for line width, on a `render.legend` entry, and on a `shape` output ([Styling](../presentation/styling.md), [Legend](../presentation/legend.md)). ### Tinting bars The ladder also drives a background tint through `render.bgcolor` and a candle tint through `render.barcolor` ([Plotting](../presentation/plotting.md)): a `where` gate says which bars get a tint, and `color_by` with `colors` picks it per bar. A non-finite index means no tint on that bar (the static `color` never substitutes), so a `NaN` bucket is a clean way to leave a bar alone. ```typescript param("period", 14, { min: 2, max: 100, description: "RSI Period" }); param("overbought", 70, { min: 50, max: 100, description: "Overbought Level" }); param("oversold", 30, { min: 0, max: 50, description: "Oversold Level" }); output("rsi", line, lower, { color: "#2962ff", width: 2, description: "Relative strength index" }); output("zone", none, lower, { description: "0 oversold, 1 overbought: the tint ladder index; NaN in between" }); output("extreme", none, lower, { description: "1 on bars in either zone: the tint gate" }); // A background tint per condition: one declaration, a gate, and a two-entry ladder. render.bgcolor("zones", { where: "extreme", color_by: "zone", colors: ["rgba(76, 175, 80, 0.3)", "rgba(244, 67, 54, 0.3)"] }); let rsi = new Rsi(14); let overbought: f64 = 70.0; let oversold: f64 = 30.0; function onStart(): void { rsi = new Rsi(i32(p_period())); overbought = p_overbought(); oversold = p_oversold(); } function onBar(): void { const value = rsi.update(bar.close()); if (isNaN(value)) return; const hot = value > overbought; const cold = value < oversold; out_rsi(value); out_zone(cold ? 0.0 : hot ? 1.0 : NaN); out_extreme(hot || cold ? 1.0 : 0.0); } ``` Five zones (extreme oversold, oversold, neutral, overbought, extreme overbought) are a five-entry palette and a bucket that lands in `0 .. 4`; the module above keeps two for clarity. ### What has no form A declared color is never computed at run time. Compute the color you want ahead of time and write it as a literal; a blend that depends on a per-bar weight is a ladder over a few blended literals, with the weight bucketed into the index. The module has no color variable type: a declared color is a string literal read once when the sheet is derived, and the only setting that holds a color is a `param.color`. The one place a computed color lands is a drawing handle, through the kit below. ## Colors computed at run time The line, box, label and polyline handles from `./gen/draw` are created and restyled by the module while it runs, and their `color(...)` and `fill(...)` setters take a packed `i32` ([Drawing objects](../presentation/drawing-objects.md)). That is where `./sdk/color` goes: a color computed from a value on this bar, applied to a handle on this bar. Its two scales, `ColorScale` and `Thresholds`, serve the other half: they compute the index a `color_by` output carries, so the color itself stays a literal and the decision stays a number. ### Every export | Export | Signature | Definition | | --- | --- | --- | | `ink` | `ink.RED`, `ink.ORANGE`, `ink.AMBER`, `ink.YELLOW`, `ink.LIME`, `ink.GREEN`, `ink.EMERALD`, `ink.TEAL`, `ink.CYAN`, `ink.SKY`, `ink.BLUE`, `ink.INDIGO`, `ink.VIOLET`, `ink.PURPLE`, `ink.PINK`, `ink.ROSE`, `ink.SLATE`, `ink.GRAY`, `ink.WHITE`, `ink.BLACK` (`i32`) | opaque packed colors, the palette below, packed exactly as `rgba(...)` from `./gen/draw` packs | | `fromHex` | `fromHex(hex: string): i32` | `"#rrggbb"` (opaque) or `"#rrggbbaa"`, either case; any other text aborts with `color: bad hex ` | | `alpha` | `alpha(color: i32, a: f64): i32` | the color with its alpha byte set from an opacity `0..1`, RGB untouched | | `mix` | `mix(a: i32, b: i32, t: f64): i32` | every channel, alpha included, `t` of the way from `a` to `b`, linear in sRGB | | `lighten` | `lighten(color: i32, amount: f64): i32` | each RGB channel moved toward 255 by the fraction, alpha kept | | `darken` | `darken(color: i32, amount: f64): i32` | each RGB channel moved toward 0 by the fraction, alpha kept | | `red`, `green`, `blue` | `red(color: i32): i32` | one channel of a packed color, `0..255` (Pine's `color.r`, `color.g`, `color.b`) | | `opacity` | `opacity(color: i32): f64` | the alpha byte over `255`, `0..1`: what `alpha` takes, so `alpha(c, opacity(c))` is `c` again | | `transparency` | `transparency(color: i32): f64` | Pine's `color.t`: `(255 - alpha) * 100 / 255`, `0` opaque to `100` invisible | | `ColorScale` | `new ColorScale(min: f64, max: f64, steps: i32)`, `.index(v: f64): f64` | `floor((v - min) / (max - min) * steps)` clamped to `0..steps - 1`; `NaN` in, `NaN` out | | `Thresholds` | `new Thresholds(levels: StaticArray)`, `.index(v: f64): f64` | the number of levels at or below `v`, `0..levels.length`; `NaN` in, `NaN` out | | `theme` | `theme.UP`, `theme.DOWN`, `theme.TEXT`, `theme.MUTED`, `theme.BG`, `theme.GRID`, `theme.ACCENT` (`i32`) | the seven theme tokens as packed values the chart resolves when it paints: the candle up and down colours, the axis text, that text at 55 percent, the background, the grid lines and the app's accent | | `isThemeToken` | `isThemeToken(c: i32): bool` | whether a packed value is one of the seven tokens (at any alpha) | | `toPacked`, `fromPacked` | `toPacked(c: i32): f64`, `fromPacked(v: f64): i32` | a packed color as the `f64` a `color_packed_by` output carries, and back; both keep a theme token a token | Construct the scales and parse hex literals in `onStart()`: the constructors allocate, and nothing else in the module allocates per bar. ### Theme colours A theme token is a colour the module names but never computes: the chart fills it in when it paints, from its own theme, and again when the theme switches, so a label inked `theme.TEXT` reads on a dark and a light chart alike. `alpha(theme.UP, 0.2)` is still a token, carrying the alpha with it, and `opacity(c)` and `transparency(c)` read that alpha back; `mix`, `lighten`, `darken`, `red`, `green` and `blue` have no concrete colour to work on and abort with `color: takes a concrete colour; theme tokens resolve at paint time`. A concrete colour never spells a token by accident: an alpha-0 result whose red byte is `0x7e` and green byte `1..7` (an invisible colour before tokens existed) reads as `0`, transparent black, from `rgba(...)`, `alpha`, `mix`, `lighten`, `darken`, `fromHex` and `fromPacked` alike, so it stays invisible and never aborts the readers. A token goes anywhere a packed colour goes: a handle's `color(...)` or `fill(...)`, and an output a drawn output names with `color_packed_by` (write `toPacked(theme.UP)` to it). The same seven words ride every declared colour as strings (`"theme.up"`, [Styling](../presentation/styling.md#theme-colours)). For a concrete colour the module can compute with, the chart hands over its own: `chart.up_color()`, `chart.down_color()` and `chart.grid_color()` declared at the top of the file (like `chart.bg_color()`) are read in `onStart()` through `i32(p_chart_up_color())` and the two others as packed colours, `0` when no chart is there to answer ([Chart context](../settings/sessions-and-units.md#chart-context)); `mix(i32(p_chart_up_color()), ink.WHITE, 0.3)` is a lighter version of the chart's own up colour. The chart's font needs no reader: `font_family: "ui"` on any text resolves to the chart's own typography. ### The palette `ink` names the 500 row of the Tailwind palette plus white and black, each opaque. The swatch column is the hex you would write as a declared literal for the same color. | Name | Swatch | Name | Swatch | | --- | --- | --- | --- | | `ink.RED` | `#ef4444` | `ink.INDIGO` | `#6366f1` | | `ink.ORANGE` | `#f97316` | `ink.VIOLET` | `#8b5cf6` | | `ink.AMBER` | `#f59e0b` | `ink.PURPLE` | `#a855f7` | | `ink.YELLOW` | `#eab308` | `ink.PINK` | `#ec4899` | | `ink.LIME` | `#84cc16` | `ink.ROSE` | `#f43f5e` | | `ink.GREEN` | `#22c55e` | `ink.SLATE` | `#64748b` | | `ink.EMERALD` | `#10b981` | `ink.GRAY` | `#9ca3af` | | `ink.TEAL` | `#14b8a6` | `ink.WHITE` | `#ffffff` | | `ink.CYAN` | `#06b6d4` | `ink.BLACK` | `#000000` | | `ink.SKY` | `#0ea5e9` | `ink.BLUE` | `#3b82f6` | A packed color is one `i32`: `((r & 0xff) << 24) | ((g & 0xff) << 16) | ((b & 0xff) << 8) | (a & 0xff)`, copied from `rgba(...)` in `./gen/draw` bit for bit, so `ink.RED == rgba(239, 68, 68, 255)` and the two mix freely. Because red sits in the top byte, most inks are negative numbers when you print them: that is the packing, not a fault. ### An index from a value `ColorScale` slices `min..max` into `steps` equal bands and answers the band a value falls in, so a `colors` palette with `steps` entries on the drawn output gets one entry per band. `Thresholds` answers how many of its levels sit at or below the value, so a palette with one entry more than the level count gets one entry per zone. Both return an `f64` that goes straight into a data-only output, and the drawn output names that output with `color_by`. The RSI ladder with the kit computing the index, and a zone number you can read after a **Run** with `last 20 zone` at the Console prompt: ```typescript param("period", 14, { min: 2, max: 200, description: "RSI period" }); // Five bands of 0..100, coolest at the bottom: heat picks the entry per bar. output("rsi", line, lower, { width: 2, color_by: "heat", colors: ["#3b82f6", "#0ea5e9", "#64748b", "#f97316", "#ef4444"], description: "RSI colored by its level" }); output("heat", none, lower, { description: "0..4: the fifth of 0..100 the RSI sits in" }); output("zone", none, lower, { description: "0 below 30, 1 between, 2 at or above 70" }); let rsi = new Rsi(14); let scale = new ColorScale(0.0, 100.0, 5); let zones = new Thresholds(new StaticArray(0)); function onStart(): void { rsi = new Rsi(i32(p_period())); // Built once here: index() never allocates, and neither holds bar state. scale = new ColorScale(0.0, 100.0, 5); const levels = new StaticArray(2); levels[0] = 30.0; levels[1] = 70.0; zones = new Thresholds(levels); } function onBar(): void { const strength = rsi.update(bar.close()); out_rsi(strength); out_heat(scale.index(strength)); out_zone(zones.index(strength)); } ``` An RSI of exactly `100` lands in the last band (the scale clamps, it never answers `steps`), and a value on a threshold counts that threshold (`70` is zone `2`). A `NaN` RSI gives a `NaN` index, and the drawn output falls back to its static color on that bar. ### Colors on a handle Handle setters take the packed color as it is computed, so here color can follow a value continuously instead of through a ladder. The window mean drawn as a line handle in `ink.SKY`, restyled to `alpha(ink.SKY, 0.4)` on the newest bar (the forming one), and the window's range as a box whose tint is mixed at run time between two hex literals parsed in `onStart()`: ```typescript param("period", 20, { min: 2, max: 200, description: "Bars in the window" }); output("mean", line, overlay, { color: "#64748b", description: "The window mean" }); output("up_share", none, overlay, { description: "0..1: the share of up closes in the window, the box's mix weight" }); handles.line({ color: "#0ea5e9", width: 2 }); handles.box({ color: "#0ea5e9", opacity: 0.15, borderWidth: 1 }); const level = draw.line(0); const span = draw.box(1); let period: i32 = 20; let sma = new Sma(20); let ups = new Sma(20); let highs = new Highest(20); let lows = new Lowest(20); let bull: i32 = 0; let bear: i32 = 0; let t: f64 = NaN; let prevT: f64 = NaN; let close: f64 = NaN; let prevClose: f64 = NaN; function onStart(): void { period = i32(p_period()); sma = new Sma(period); ups = new Sma(period); highs = new Highest(period); lows = new Lowest(period); // Parse literals here: a bad string aborts with "color: bad hex ", and the message is readable from onStart(). bull = fromHex("#16a34a"); bear = fromHex("#dc2626"); } function onBar(): void { prevT = t; t = bar.time(); prevClose = close; close = bar.close(); const mean = sma.update(close); const share = ups.update(!isNaN(prevClose) && close > prevClose ? 1.0 : 0.0); const hi = highs.update(bar.high()); const lo = lows.update(bar.low()); if (isNaN(mean) || isNaN(hi) || isNaN(prevT)) return; out_mean(mean); out_up_share(share); const start = t - (t - prevT) * f64(period - 1); // The mean across the window: sky, and on the newest bar the same sky at 40% opacity. level.set(start, mean, t, mean).color(bar.isLast() ? alpha(ink.SKY, 0.4) : ink.SKY); // The window's range, tinted by the share of up closes: a gradient computed per bar, not a ladder. const tone = mix(bear, bull, share); span.set(start, hi, t, lo).fill(alpha(lighten(tone, 0.2), 0.15)).color(darken(tone, 0.3)); } ``` `bar.isLast()` answers `1` on the newest bar the chart holds, so the line is drawn faint while that bar is still forming and turns solid once the next bar opens. The box's fill and border are two derivations of one `tone`, so the pair always agree. ### Read a color back `red`, `green` and `blue` read one channel of a packed color, and `opacity` and `transparency` read its alpha. The chart's background arrives packed (`chart.bg_color()`), so a file can pick its ink from it: dark words on a light chart, light words on a dark one. ```typescript // A price tag on the newest bar whose words follow the chart's background: dark on a light chart, light on a dark one. chart.bg_color(); output("close_line", line, overlay, { color: "#94a3b8", description: "The close" }); output("brightness", none, overlay, { description: "The background's brightness, 0 (black) to 255 (white); NaN when the chart did not say" }); output("bg_opacity", none, overlay, { description: "How solid the background is, 0 to 1" }); string("tag", { max_bytes: 32 }); handles.label({ text: "tag", size: 12 }); const tag = draw.label(0); let words: i32 = 0; let brightness: f64 = NaN; let bgOpacity: f64 = NaN; function onStart(): void { const bg = i32(p_chart_bg_color()); // 0 means the chart did not fill the color; then the words stay light. if (bg != 0) { brightness = 0.299 * f64(red(bg)) + 0.587 * f64(green(bg)) + 0.114 * f64(blue(bg)); bgOpacity = opacity(bg); } words = !isNaN(brightness) && brightness > 140.0 ? ink.BLACK : ink.WHITE; } function onBar(): void { const close = bar.close(); if (isNaN(close)) return; out_close_line(close); out_brightness(brightness); out_bg_opacity(bgOpacity); if (!bar.isLast()) return; sb_clear(); sb_text("last "); sb_auto(close); tag.set(bar.time(), close).text(str_tag_sb).color(words); } ``` - **Brightness from the channels.** The three channels weighted `0.299`, `0.587` and `0.114` give 0 for black and 255 for white; above about 140 the background reads as light. On a cream background (`#f4f1ea`) the tag is black. - **0 means unknown.** Where the chart did not fill the background, `p_chart_bg_color()` reads 0 and the words stay white. - **The alpha.** `opacity(bg)` is 1 for a solid background; `transparency(bg)` is the same reading on Pine's 0 to 100 scale, where 0 is solid. - **Only a handle takes it.** The computed color lands on the label through `.color(...)`; a declared color stays a literal (What has no form, above). ### The rules, written out - **Fractions.** An opacity, a mix weight and a lighten or darken amount are clamped to `0..1`; `NaN` reads as `0`, so `alpha(c, NaN)` is invisible, `mix(a, b, NaN)` is `a`, and `lighten(c, NaN)` is `c`. - **Rounding.** A derived channel is `floor(x + 0.5)` held to `0..255`: `alpha(c, 0.5)` sets the alpha byte to `128`, `mix(ink.RED, ink.BLUE, 0.5)` is `#95639d`, `lighten(ink.BLUE, 0.5)` is `#9dc1fb`, `darken(ink.BLUE, 0.5)` is `#1e417b`. - **Readers.** `red`, `green` and `blue` are the packed bytes, exact; `alpha(c, opacity(c))` rebuilds `c` bit for bit; `transparency(c)` is `(255 - alpha) * 100 / 255`, so a color at alpha `128` reads `49.80...`, and `alpha(c, 1.0 - transparency(c) / 100.0)` is `c` again. - **`fromHex`.** Seven characters (`#rrggbb`, alpha `255`) or nine (`#rrggbbaa`), hex digits in either case. Anything else aborts the run with `color: bad hex ` and the string you passed. Call it in `onStart()`: an abort raised there shows its message in the Console, while an abort at module start (a top-level `const c = fromHex(...)`) reaches you as a bare failure with the text lost. - **`ColorScale`.** `v` at `max` lands in the last band. `steps` below `1` reads as `1`; `max` below `min` runs the scale backwards; `max` equal to `min` puts every value above it in the last band and the rest in band `0`. An infinite value clamps to the end it points at. - **`Thresholds`.** A value exactly on a level counts it. The constructor copies the array, order does not matter, and a `NaN` level never counts. - **State and allocation.** Neither scale holds bar state, so a reset has nothing to restore and a reset bar answers the same as any other. `index()`, `alpha`, `mix`, `lighten`, `darken`, the five readers and a successful `fromHex` never allocate, so the per-bar path stays allocation-free under the module's runtime, which never frees. ### From Pine | Pine | wrun | | --- | --- | | `color.new(c, transp)` | on a handle, `alpha(c, (100 - transp) / 100)`: Pine counts transparency `0..100`, `alpha` takes opacity `0..1`; on a declaration, the `opacity` option | | `color.from_gradient(value, bottom, top, c1, c2)` | on a declaration, `new ColorScale(bottom, top, n).index(value)` into a `color_by` output over an `n`-entry `colors` palette from `c1` to `c2`; on a handle, `mix(c1, c2, t)` | | `color.rgb(r, g, b)` | `rgba(r, g, b, 255)` from `./gen/draw` (or `rgb(r, g, b)` there) | | `color.r(c)`, `color.g(c)`, `color.b(c)` | `red(c)`, `green(c)`, `blue(c)` on a packed handle color (a declared color is a literal the module never reads) | | `color.t(c)` | `transparency(c)`, `0..100` as Pine counts it; `opacity(c)` is the same channel on the `0..1` scale `alpha` takes | # Clock and sessions kit The `./sdk/clock` module turns the one number `bar.time()` gives a wrun indicator, the bar's open time in UTC epoch seconds, into the questions a trader asks of a bar: what hour is it in New York, is this the first bar of the day, is the cash session open, which bar is this, how long is a bar, when does it close. A `Clock` answers the calendar questions in a named time zone with its daylight rule applied; a `Session` answers whether the bar sits inside an `0930-1600` window on the days you list, when a session instance starts and when it has just closed. Both are fed once per bar with `update(t)` and read as often as you like, and neither allocates per bar. A `MarketSession` asks the market instead of a zone: it reads the bar's trade date and session phase from the `time` source (Market sessions, below). A higher timeframe built from the chart's own bars or from a `candles` stream is the [Higher-timeframe kit](higher-timeframe-kit.md). The hand-rolled integer math they replace is still on [Time and sessions](../core-concepts/time-and-sessions.md), the manual way, if you want to see what the modules do for you. Construct both classes in `onStart()`, never at module start: a bad zone or a bad session spec aborts in the constructor, and an abort raised in `onStart()` shows its message in the Console, while an abort at module start loses it. ## The clock | Export | Signature | Answers | | --- | --- | --- | | `Clock` | `new Clock(zone: string = "UTC")` | a bar clock in one zone; an unknown zone aborts with `Clock: unknown zone ` | | `update` | `clock.update(t: f64): void` | folds the bar's open time (`bar.time()`, UTC epoch seconds); call once per bar | | `offsetSec` | `clock.offsetSec(): i32` | the zone's offset from UTC for this bar in seconds, daylight time applied (New York reads `-18000` in winter, `-14400` in summer) | | `hour`, `minute`, `second` | `clock.hour(): i32` | the local time of the bar's open: `0`..`23`, `0`..`59`, `0`..`59` (`second` is `0` on every candle grid) | | `weekday` | `clock.weekday(): i32` | `0` Sunday .. `6` Saturday | | `dayOfMonth`, `month`, `year`, `dayOfYear` | `clock.month(): i32` | `1`..`31`, `1`..`12`, the four-digit year, `1`..`366` | | `dayKey` | `clock.dayKey(): i32` | local days since 1970-01-01: the same number on every bar of one local day | | `weekKey` | `clock.weekKey(): i32` | local weeks since Monday 1970-01-05, a monotonic count (weeks start on Monday) | | `monthKey` | `clock.monthKey(): i32` | `year * 12 + (month - 1)` | | `quarterKey` | `clock.quarterKey(): i32` | `year * 4 + (month - 1) / 3` in integer division: the same number on every bar of one local quarter (the year's own key is `year()`) | | `isNewDay`, `isNewWeek`, `isNewMonth`, `isNewQuarter`, `isNewYear` | `clock.isNewDay(): bool` | `true` on the first bar of the period and on the very first update; a quarter opens on January, April, July or October 1st | | `index` | `clock.index(): i32` | updates seen so far, 0-based: `0` on the first bar, `-1` before any | | `intervalSec` | `clock.intervalSec(): f64` | the smallest positive gap between consecutive bar opens seen so far (a weekend gap never widens it); `NaN` until two bars | | `barCloseSec` | `clock.barCloseSec(): f64` | `t + intervalSec()`: where this bar closes, `NaN` while the interval is unknown | | `dayOfWeek` | `clock.dayOfWeek(): i32` | Pine's `dayofweek`: `1` Sunday .. `7` Saturday (`weekday() + 1`), the scale of the `dayofweek.*` constants | | `weekOfYear` | `clock.weekOfYear(): i32` | Pine's `weekofyear`, the ISO 8601 week: weeks run Monday to Sunday and belong to the year their Thursday is in, so week 1 holds January 4th, January 1st to 3rd can still be week 52 or 53 of the year before, and a late-December Monday can already be week 1; `0` before any update | | `epochSec` | `clock.epochSec(year, month, day, hour = 0, minute = 0, second = 0): f64` | Pine's `timestamp(...)` in the clock's zone, in seconds: the UTC instant of a wall-clock time with the daylight rule applied; the parts normalise as Pine's do (month `13`, day `0`, `dayOfMonth + 1` past a month end, hour `24`, negative parts) | | `reset` | `clock.reset(): void` | back to the freshly constructed state; the zone stays | Two more names come with the clock, for a wall-clock time that needs no zone and for Pine's weekday constants: | Export | Signature | Answers | | --- | --- | --- | | `epochSec` | `epochSec(year, month, day, hour = 0, minute = 0, second = 0): f64` | the same instant read in UTC, with no clock: Pine's `timestamp("UTC", ...)`, in seconds, the parts normalised the same way | | `dayofweek` | `dayofweek.SUNDAY` .. `dayofweek.SATURDAY` (`i32`, `1`..`7`) | Pine's `dayofweek.*` constants, the scale `dayOfWeek()` answers on | `epochSec` answers seconds because every time in a wrun indicator is seconds (`bar.time()`, a `param.time` default, a drawing's x); Pine's `timestamp()` answers milliseconds, so a value a script holds in Pine's unit is this times `1000.0`. A wall time inside a daylight gap (02:30 on the morning the clocks go forward) moves forward by the gap, and one in the repeated autumn hour reads as its first, daylight, occurrence: the answers the Java calendar gives TradingView. Neither call allocates, so `clock.epochSec(clock.year(), clock.month(), clock.dayOfMonth(), 9, 30)` on every bar is fine. Dates from 1970-01-01 to 2099-12-31 are valid, and the calendar is integer math (days from civil and civil from days, no floating-point days), so every field is exact. A non-finite `t` does not move the clock: the fields keep their last values, the new-period flags read `false`, the interval is not fed; only `index()` advances. ### Zones and their daylight rules | Zone | Offset (standard / daylight) | Daylight rule | | --- | --- | --- | | `"UTC"`, `"GMT"`, `"Etc/UTC"`, `"Etc/GMT"` | `+00:00` | none | | `"America/New_York"` | `-05:00` / `-04:00` | US: from the second Sunday of March 02:00 local standard time to the first Sunday of November 02:00 local daylight time | | `"America/Toronto"` | `-05:00` / `-04:00` | US | | `"America/Chicago"` | `-06:00` / `-05:00` | US | | `"America/Denver"` | `-07:00` / `-06:00` | US | | `"America/Phoenix"` | `-07:00` | none | | `"America/Los_Angeles"` | `-08:00` / `-07:00` | US | | `"America/Mexico_City"` | `-06:00` | none since 2023 | | `"America/Bogota"` | `-05:00` | none | | `"America/Lima"` | `-05:00` | none | | `"America/Sao_Paulo"` | `-03:00` | none since 2020 | | `"America/Argentina/Buenos_Aires"`, `"America/Buenos_Aires"` | `-03:00` | none since 2010 | | `"Europe/London"` | `+00:00` / `+01:00` | EU: from the last Sunday of March 01:00 UTC to the last Sunday of October 01:00 UTC | | `"Europe/Dublin"` | `+00:00` / `+01:00` | EU | | `"Europe/Lisbon"` | `+00:00` / `+01:00` | EU | | `"Atlantic/Reykjavik"` | `+00:00` | none | | `"Europe/Paris"` | `+01:00` / `+02:00` | EU | | `"Europe/Brussels"` | `+01:00` / `+02:00` | EU | | `"Europe/Amsterdam"` | `+01:00` / `+02:00` | EU | | `"Europe/Berlin"` | `+01:00` / `+02:00` | EU | | `"Europe/Zurich"` | `+01:00` / `+02:00` | EU | | `"Europe/Madrid"` | `+01:00` / `+02:00` | EU | | `"Europe/Rome"` | `+01:00` / `+02:00` | EU | | `"Europe/Vienna"` | `+01:00` / `+02:00` | EU | | `"Europe/Stockholm"` | `+01:00` / `+02:00` | EU | | `"Europe/Oslo"` | `+01:00` / `+02:00` | EU | | `"Europe/Copenhagen"` | `+01:00` / `+02:00` | EU | | `"Europe/Prague"` | `+01:00` / `+02:00` | EU | | `"Europe/Warsaw"` | `+01:00` / `+02:00` | EU | | `"Europe/Budapest"` | `+01:00` / `+02:00` | EU | | `"Europe/Luxembourg"` | `+01:00` / `+02:00` | EU | | `"Europe/Malta"` | `+01:00` / `+02:00` | EU | | `"Europe/Monaco"` | `+01:00` / `+02:00` | EU | | `"Europe/Zagreb"` | `+01:00` / `+02:00` | EU | | `"Europe/Ljubljana"` | `+01:00` / `+02:00` | EU | | `"Europe/Bratislava"` | `+01:00` / `+02:00` | EU | | `"Europe/Belgrade"` | `+01:00` / `+02:00` | EU | | `"Europe/Sarajevo"` | `+01:00` / `+02:00` | EU | | `"Europe/Podgorica"` | `+01:00` / `+02:00` | EU | | `"Europe/Skopje"` | `+01:00` / `+02:00` | EU | | `"Europe/Tirane"` | `+01:00` / `+02:00` | EU | | `"Europe/Vaduz"` | `+01:00` / `+02:00` | EU | | `"Europe/Andorra"` | `+01:00` / `+02:00` | EU | | `"Europe/Gibraltar"` | `+01:00` / `+02:00` | EU | | `"Europe/San_Marino"` | `+01:00` / `+02:00` | EU | | `"Europe/Vatican"` | `+01:00` / `+02:00` | EU | | `"Europe/Helsinki"` | `+02:00` / `+03:00` | EU | | `"Europe/Athens"` | `+02:00` / `+03:00` | EU | | `"Europe/Bucharest"` | `+02:00` / `+03:00` | EU | | `"Europe/Sofia"` | `+02:00` / `+03:00` | EU | | `"Europe/Riga"` | `+02:00` / `+03:00` | EU | | `"Europe/Tallinn"` | `+02:00` / `+03:00` | EU | | `"Europe/Vilnius"` | `+02:00` / `+03:00` | EU | | `"Europe/Kyiv"`, `"Europe/Kiev"` | `+02:00` / `+03:00` | EU | | `"Europe/Chisinau"` | `+02:00` / `+03:00` | EU (since 2022; Moldova switched at 00:00 UTC before) | | `"Asia/Nicosia"`, `"Europe/Nicosia"` | `+02:00` / `+03:00` | EU | | `"Europe/Istanbul"` | `+03:00` | none since 2017 | | `"Europe/Moscow"` | `+03:00` | none since 2015 | | `"Europe/Minsk"` | `+03:00` | none since 2012 | | `"Africa/Johannesburg"` | `+02:00` | none | | `"Africa/Cairo"` | `+02:00` / `+03:00` | Egypt: from the last Friday of April 00:00 local standard time to the last Thursday of October 24:00 local daylight time (since 2023) | | `"Africa/Lagos"` | `+01:00` | none | | `"Africa/Nairobi"` | `+03:00` | none | | `"Asia/Jerusalem"`, `"Asia/Tel_Aviv"` | `+02:00` / `+03:00` | Israel: from the Friday before the last Sunday of March 02:00 local standard time to the last Sunday of October 02:00 local daylight time (since 2013) | | `"Asia/Riyadh"` | `+03:00` | none | | `"Asia/Dubai"` | `+04:00` | none | | `"Asia/Tehran"` | `+03:30` | none since 2023 | | `"Asia/Karachi"` | `+05:00` | none since 2010 | | `"Asia/Kolkata"`, `"Asia/Calcutta"` | `+05:30` | none | | `"Asia/Kathmandu"`, `"Asia/Katmandu"` | `+05:45` | none | | `"Asia/Dhaka"` | `+06:00` | none since 2010 | | `"Asia/Yangon"`, `"Asia/Rangoon"` | `+06:30` | none | | `"Asia/Bangkok"` | `+07:00` | none | | `"Asia/Jakarta"` | `+07:00` | none | | `"Asia/Ho_Chi_Minh"`, `"Asia/Saigon"` | `+07:00` | none | | `"Asia/Singapore"` | `+08:00` | none | | `"Asia/Kuala_Lumpur"` | `+08:00` | none | | `"Asia/Manila"` | `+08:00` | none | | `"Asia/Hong_Kong"` | `+08:00` | none | | `"Asia/Shanghai"` | `+08:00` | none | | `"Asia/Taipei"` | `+08:00` | none | | `"Asia/Seoul"` | `+09:00` | none | | `"Asia/Tokyo"` | `+09:00` | none | | `"Australia/Perth"` | `+08:00` | none since 2010 | | `"Australia/Darwin"` | `+09:30` | none | | `"Australia/Adelaide"` | `+09:30` / `+10:30` | AU: from the first Sunday of October 02:00 local standard time to the first Sunday of April 03:00 local daylight time (it spans the new year; since 2008) | | `"Australia/Brisbane"` | `+10:00` | none | | `"Australia/Sydney"` | `+10:00` / `+11:00` | AU (since 2008) | | `"Australia/Melbourne"` | `+10:00` / `+11:00` | AU (since 2008) | | `"Australia/Hobart"` | `+10:00` / `+11:00` | AU (since 2008) | | `"Pacific/Auckland"` | `+12:00` / `+13:00` | NZ: from the last Sunday of September 02:00 local standard time to the first Sunday of April 03:00 local daylight time (it spans the new year; since 2008) | | `"+HH:MM"`, `"-HH:MM"`, `"+H:MM"` | as written | none: a fixed offset written with a colon, such as `"+05:30"` or `"+5:30"`, hours `00`..`23` (the bound the first `Clock` had, so `"+15:00"` still builds) | | `"+H"`, `"+HHMM"` | as written | none: the same without a colon, such as `"-3"` or `"+0530"`, at most `14:00` either way so a typo cannot pass as an offset | | `"UTC+5:30"`, `"GMT-4"`, `"UTC+0530"` | as written | none: the same offsets after a `UTC` or `GMT` prefix, the way Pine scripts spell them, with the same bounds (`"UTC+15:30"` builds, `"UTC+15"` aborts) | | `"Etc/GMT+N"`, `"Etc/GMT-N"` | the sign inverted | none: the tz database's `Etc` zones, whole hours up to `14` with the sign inverted (`"Etc/GMT+5"` is `-05:00`) | Every year in the valid range follows today's rule for its zone; where a rule changed after 2007 the row says since when (Mexico City kept daylight time until 2022, so a 2019 summer bar reads one hour off here), and a zone's history before that year is not modelled. The table holds every European capital with a zone of its own in the tz database, Reykjavik to Nicosia and Chisinau (Pristina has none: Kosovo keeps Belgrade's time, so `"Europe/Belgrade"` serves it). Anything else passed as a zone aborts at construction with `Clock: unknown zone `. ## Sessions | Export | Signature | Answers | | --- | --- | --- | | `Session` | `new Session(spec: string, zone: string = "UTC", days: string = "0123456")` | an `"HHMM-HHMM"` window in the zone's local time on the weekdays listed; a spec that is not `HHMM-HHMM` aborts with `Session: bad spec `, a day character outside `0`..`6` with `Session: bad days ` | | `update` | `session.update(t: f64): void` | folds the bar's open time; call once per bar | | `isIn` | `session.isIn(): bool` | the bar's open sits inside the window on a listed day | | `isFirst` | `session.isFirst(): bool` | this is the first in-session bar of an instance | | `closed` | `session.closed(): bool` | an instance ended before this bar: `true` on the first update after it, whether this bar is outside or already inside the next instance | | `key` | `session.key(): i32` | the `dayKey` of the instance's opening day while in, `-1` outside | | `reset` | `session.reset(): void` | forgets the running instance; the spec, zone and days stay | The rules, written out: - **Half-open.** A bar is in when `open <= local time of day < close`, tested on the bar's open time. `"0930-1600"` includes the 09:30 bar and excludes the 16:00 bar. `open == close` is never in. `"2400"` is accepted as a close, so `"0000-2400"` is the whole day. - **Crossing midnight.** A close before its open wraps: `"2200-0400"` runs from 22:00 to 04:00 the next morning. Every bar of that window belongs to the day it opened on, so a Friday-night session keyed to Friday runs into Saturday morning. - **The day digits.** `days` lists the weekdays the session may OPEN on, as the same digits `weekday()` uses: `0` Sunday .. `6` Saturday. `"12345"` is Monday to Friday; a `"2200-0400"` session on `"12345"` never opens on Saturday or Sunday night. Pine's session day string counts Sunday as `1`, so its `"23456"` is `"12345"` here. - **One instance per opening day.** `key()` is the local `dayKey` of the opening day; it stays the same across a midnight crossing and changes when the next instance opens. `isFirst()` marks the first bar of an instance. `closed()` marks the first update after an instance ended: on a daily chart, where every bar is a new instance, `closed()` and `isFirst()` are both `true` on every bar after the first. ## Market sessions `Session` is a window you define; `MarketSession` is the market's own calendar, read from the two market facts the chart fills on every bar: `time.trade_date` (the exchange trade date, epoch seconds at 00:00 UTC) and `time.session` (`1` regular, `2` pre-market, `3` after-hours, `0` closed). A CME evening bar already belongs to the next trade date, a US stock bar is pre-market, regular or after-hours by the New York clock, and a market without sessions is regular on every bar with the UTC date as its trade date ([Data sources](../core-concepts/data-sources.md)). | Export | Signature | Answers | | --- | --- | --- | | `MarketSession` | `new MarketSession()` | the market's trade date and session phase, bar by bar | | `update` | `ms.update(tradeDateSec: f64, session: f64): void` | folds `time.trade_date` and `time.session`; call once per bar | | `isRegular`, `isPremarket`, `isAfterHours` | `ms.isRegular(): bool` | the bar's phase: regular, pre-market, after-hours | | `isExtended` | `ms.isExtended(): bool` | pre-market or after-hours | | `isClosed` | `ms.isClosed(): bool` | the market is closed on this bar (any session value other than `1`, `2`, `3`) | | `isNewTradingDay` | `ms.isNewTradingDay(): bool` | the first bar of a trade date, and the very first update | | `isFirstRegularBar` | `ms.isFirstRegularBar(): bool` | the first regular bar of a trade date, after its pre-market bars | | `tradingDayKey` | `ms.tradingDayKey(): i32` | the trade date as days since 1970-01-01 | | `tradingWeekKey` | `ms.tradingWeekKey(): i32` | weeks since Monday 1970-01-05 of the trade date: one number from a Monday trade date through its Sunday | | `tradingMonthKey` | `ms.tradingMonthKey(): i32` | `year * 12 + (month - 1)` of the trade date | | `reset` | `ms.reset(): void` | back to the freshly constructed state | The warm-up rule is the `Clock`'s: the very first update is a new trading day, and the first regular bar seen is a first regular bar even when the loaded history starts inside a session. A non-finite trade date or session moves nothing (the phase and keys keep their values, both flags read `false`); before the first update every check reads `false` and the keys read `0`. The checks map one to one onto Pine's `session.*` variables ([Pine equivalents](../core-concepts/time-and-sessions.md#pine-equivalents)). ## A New York session filter The close, drawn only while the cash session is open, with a data-only flag for the session and one for its first bar. The chart's bars are in UTC; the session is in New York time, daylight rule included, so the window moves with the clocks in March and November without any change to the file. ```typescript output("cash_close", line, overlay, { color: "#2563eb", width: 2, description: "The close while the New York cash session is open, NaN outside" }); output("session_open", none, overlay, { description: "1 from 09:30 to 16:00 New York time on a weekday" }); output("session_start", none, overlay, { description: "1 on the first bar of each cash session" }); let cash = new Session("0930-1600"); function onStart(): void { // Constructed here so a bad spec or zone reports in the Console. cash = new Session("0930-1600", "America/New_York", "12345"); } function onBar(): void { const close = bar.close(); cash.update(bar.time()); const inSession = cash.isIn(); const first = cash.isFirst(); out_cash_close(inSession ? close : NaN); out_session_open(inSession ? 1.0 : 0.0); out_session_start(first ? 1.0 : 0.0); } ``` `session_open` is the gate a mark or an alert reads; `session_start` is where an anchored VWAP or a session high-low box begins. ## A new-day reset of a running sum Volume accumulated since the New York day started, and since the week started, with the bar's position inside the day. `isNewDay()` is `true` on the first bar after local midnight and on the very first bar, so the sums start clean at both; `isNewWeek()` flips on the first bar of Monday. ```typescript output("day_volume", histogram, lower, { color: "#2563eb", description: "Volume since the New York day started" }); output("week_volume", line, lower, { color: "#f59e0b", description: "Volume since the week started (Monday, New York time)" }); output("bars_today", none, lower, { description: "Bars since the day started, 0 on its first bar" }); let clock = new Clock(); let dayVolume: f64 = 0.0; let weekVolume: f64 = 0.0; let barsToday: i32 = 0; function onStart(): void { clock = new Clock("America/New_York"); } function onBar(): void { clock.update(bar.time()); if (clock.isNewDay()) { dayVolume = 0.0; barsToday = 0; } else { barsToday += 1; } if (clock.isNewWeek()) weekVolume = 0.0; const volume = bar.volume(); dayVolume += volume; weekVolume += volume; if (isNaN(bar.close())) return; out_day_volume(dayVolume); out_week_volume(weekVolume); out_bars_today(f64(barsToday)); } ``` The same shape resets on `isNewMonth()`, `isNewQuarter()` or `isNewYear()`, or compares `dayKey()` with a stored value when the period is yours to define. ## An interval-aware bar-close estimate The module never sees a wall clock, but it sees the gaps between bar opens: `intervalSec()` is the smallest positive gap so far, so after two bars it is the chart interval, and a weekend gap never stretches it. `barCloseSec()` adds that interval to the open. A second `Clock` fed the close time answers what hour the bar closes in, which is how a file knows that the 15:30 bar on a 1-hour chart runs past the cash close. ```typescript output("bar_close", none, overlay, { description: "The bar's close time in epoch seconds, NaN until the interval is known" }); output("bar_minutes", none, lower, { description: "The chart interval in minutes, from the smallest gap seen" }); output("close_hour", none, lower, { description: "The New York hour the bar closes in, NaN on the first bar" }); let opens = new Clock(); let closes = new Clock(); function onStart(): void { opens = new Clock("America/New_York"); closes = new Clock("America/New_York"); } function onBar(): void { opens.update(bar.time()); // NaN on the first bar: a non-finite time does not move a clock. closes.update(opens.barCloseSec()); if (isNaN(bar.close())) return; const interval = opens.intervalSec(); out_bar_close(opens.barCloseSec()); out_bar_minutes(interval / 60.0); out_close_hour(isNaN(interval) ? NaN : f64(closes.hour())); } ``` `index()` on the same clock is the bar index, 0 on the first bar the chart loaded; it counts every update, so it restarts after `reset()`. ## Calendar questions Pine's calendar calls answer on the same clock: a wall-clock time as a timestamp, the quarter, the ISO week and the weekday. The price at the New York cash open held through the day, the open of the running quarter, and two data-only readings: ```typescript // The price at the New York cash open, held through the day; the open of the running quarter; the ISO week and a Monday flag. output("cash_open", line, overlay, { color: "#2563eb", width: 2, description: "The open of the bar that holds 09:30 New York time, held for the rest of the day" }); output("quarter_open", line, overlay, { color: "#94a3b8", line_style: "dashed", description: "The open of the running quarter, New York time" }); output("iso_week", none, lower, { description: "The ISO 8601 week of the bar, 1 to 53" }); output("monday", none, lower, { description: "1 on a Monday in New York, else 0" }); let clock = new Clock(); let cashOpen: f64 = NaN; let quarterOpen: f64 = NaN; function onStart(): void { clock = new Clock("America/New_York"); } function onBar(): void { const t = bar.time(); clock.update(t); if (clock.isNewDay()) cashOpen = NaN; if (clock.isNewQuarter()) quarterOpen = bar.open(); // Today's 09:30 in New York as UTC epoch seconds, daylight time applied. const cashOpenSec = clock.epochSec(clock.year(), clock.month(), clock.dayOfMonth(), 9, 30); // The bar that holds 09:30 sets the level. The interval is NaN on the first bar, so nothing is set there. if (t <= cashOpenSec && cashOpenSec < t + clock.intervalSec()) cashOpen = bar.open(); out_cash_open(cashOpen); out_quarter_open(quarterOpen); out_iso_week(f64(clock.weekOfYear())); out_monday(clock.dayOfWeek() == dayofweek.MONDAY ? 1.0 : 0.0); } ``` - **A wall-clock time.** `clock.epochSec(year, month, day, 9, 30)` is that day's 09:30 in New York as UTC epoch seconds: 14:30 UTC in winter, 13:30 UTC in summer. The bar that holds it sets the level, so on a 1-hour chart the line starts at the 09:00 New York bar. - **The quarter.** `isNewQuarter()` is true on the first bar of January, April, July and October, so the open is taken there. The first quarter on the chart starts at the first loaded bar. `quarterKey()` holds one number for the whole quarter, for a reset you compare yourself. - **The week.** `weekOfYear()` is Pine's `weekofyear`, the ISO week: Monday to Sunday, and week 1 holds January 4th. - **The weekday.** `dayOfWeek()` counts 1 Sunday to 7 Saturday, as Pine does, so it compares with `dayofweek.MONDAY`. `weekday()` counts from 0. ## From Pine One-to-one where the meaning is the same; the zone is the difference, since Pine reads the chart's exchange time and a `Clock` reads the zone you name. | Pine | Here | | --- | --- | | `hour`, `minute`, `second` | `clock.hour()`, `clock.minute()`, `clock.second()` | | `dayofweek` (`1` Sunday .. `7` Saturday), `dayofweek.monday` | `clock.dayOfWeek()`, `dayofweek.MONDAY` (`clock.weekday()` counts the same day from `0`) | | `weekofyear` | `clock.weekOfYear()`: the ISO 8601 week, Monday to Sunday (TradingView's own week-of-month example shows a Sunday-evening open still in the week before, and PineTS implements the ISO number) | | `timestamp(year, month, dayofmonth, 9, 30)` | `clock.epochSec(clock.year(), clock.month(), clock.dayOfMonth(), 9, 30)`, in seconds (`* 1000.0` where a script holds Pine's milliseconds) | | `timestamp("America/New_York", 2024, 1, 15, 9, 30)` | `ny.epochSec(2024, 1, 15, 9, 30)` on a `Clock` built in that zone | | `timestamp("UTC", 2024, 1, 15, 9, 30)`, or `timestamp(2024, 1, 15, 9, 30)` on a 24/7 venue (its exchange zone is UTC) | `epochSec(2024, 1, 15, 9, 30)`; a literal date the user may change is a `param.time` default | | `dayofmonth`, `month`, `year` | `clock.dayOfMonth()`, `clock.month()`, `clock.year()` | | `timeframe.change("D")`, `("W")`, `("M")`, `("3M")`, `("12M")` | `clock.isNewDay()`, `isNewWeek()`, `isNewMonth()`, `isNewQuarter()`, `isNewYear()` in the zone you name; a `PeriodLevels` built from the same word answers the same with `isNew()` ([Levels kit](levels-kit.md)) | | `bar_index` | `clock.index()` | | `not na(time(timeframe.period, "0930-1600:23456"))` | `session.isIn()` with `new Session("0930-1600", zone, "12345")` | | `time_close` | `clock.barCloseSec()` (in seconds, from the observed interval) | | `session.ismarket`, `session.isfirstbar_regular`, `time_tradingday` | `MarketSession` over `time.session` and `time.trade_date` (Market sessions, above) | # Higher-timeframe kit Two modules build a higher timeframe without a second data feed. `./sdk/resample` folds the chart's own bars: a `Resampler` for the eight timeframes the built-in indicators' Timeframe setting offers, chart to 1W, and a `Bucket` for any span Pine spells, `"120"`, `"2D"`, `"2W"` or `"3M"`. `./sdk/candles` reads a `candles` stream instead: `Periods` folds its candles into calendar periods, a `CandleList` keeps the newest of them. Feed each class every chart bar. Each section writes its rules out: how buckets are keyed, when one closes, what a read shows. The clock, session and market-session classes beside them are the [Clock and sessions kit](time-and-sessions-kit.md); the fold itself, and the `interval` pin for a real coarser feed, are on [Multi-timeframe](../core-concepts/multi-timeframe.md). ## Which one | Class | Module | Reads | Spans | Reaches back | | --- | --- | --- | --- | --- | | `Resampler` | `./sdk/resample` | the chart's own bars | the built-ins' eight timeframes: chart, 5m, 15m, 30m, 1h, 4h, 1D, 1W | the loaded history | | `Bucket` | `./sdk/resample` | the chart's own bars, on 24/7 markets | any span Pine spells: `"120"`, `"2h"`, `"2D"`, `"2W"`, `"3M"` | the loaded history | | `Periods` | `./sdk/candles` | a `candles` stream | calendar day, week, month, quarter, year | the stream's `bars` | | `CandleList` | `./sdk/candles` | a `candles` stream | the newest N closed candles of the stream's timeframe | the stream's `bars` | ## From the chart bars: `Resampler` The `./sdk/resample` module builds a higher timeframe from the chart's own bars, the way the built-in indicators' Timeframe setting does: a 20-bar average at 1h on a 5m chart averages twenty closed 1h candles, never twenty 5m bars. Feed every chart bar to a `Resampler`; when a selected bar closes, push its value into a `ClosedWindow` or commit it to a `Smoothed`; then read either the closed bars alone (confirmed) or the closed bars plus the forming one as a trial step that is never stored (developing). | Export | Signature | Answers | | --- | --- | --- | | `Resampler` | `new Resampler(option: i32, waitForClose: bool = true)` | the selected timeframe, numbered like the built-ins' Timeframe setting: `tf.CHART`, `tf.FIVE_MINUTES`, `tf.FIFTEEN_MINUTES`, `tf.THIRTY_MINUTES`, `tf.HOUR`, `tf.FOUR_HOURS`, `tf.DAY`, `tf.WEEK` (`0`..`7`); any other number aborts with `Resampler: unknown timeframe ` | | `update` | `r.update(t, tradeDate, open, high, low, close, volume): void` | folds one chart bar: `t` is `bar.time()`, `tradeDate` is `time.trade_date`; call once per bar | | `closed` | `r.closed(): bool` | a selected bar closed on this chart bar, and `r.last` is its candle | | `newBucket` | `r.newBucket(): bool` | this chart bar starts a selected bar | | `last`, `forming` | `r.last.close`, `r.forming.high` | the last closed candle and the forming one: `open`, `high`, `low`, `close`, `volume` and `startSec`, `NaN` until each exists | | `source` | `r.source(which: i32, forming: bool): f64` | one price of either candle: `field.CLOSE`, `field.OPEN`, `field.HIGH` or `field.LOW`, numbered like the built-ins' Source setting | | `confirmed` | `r.confirmed(): bool` | read the committed state alone: `waitForClose` is on, or the timeframe is the chart's | | `refused` | `r.refused(): bool` | your indicator must write `NaN` to every output while this is true (nothing is blanked for you): the selected bar is narrower than the chart's bars, or a 1D or 1W bar came without a trade date | | `widthSec`, `prevStartSec` | `r.widthSec(): f64` | the selected bar's width in seconds; the start of the selected bar before the forming one | | `ClosedWindow` | `new ClosedWindow(cap: i32, keepNa: bool = false)` | the last `cap` closed values: `push(x)`, `count()`, `back(n, x, withX)`, then `mean`, `wma`, `stdev`, `highest`, `lowest`, `highestOffset`, `lowestOffset` and `sum`, each `(p, x, withX)`, and `vwma(volumes, p, x, v, withX)` | | `Smoothed` | `new Smoothed(kind: string, period: i32)` | an `"ema"` or `"rma"` over closed values: `commit(x)`, `value()`, `peek(x)` (one more step, not stored) and `copyFrom(other)` for a scratch copy | The rules, written out: - **Buckets.** 5m to 4h are rolling, `floor(t / width)` from the epoch, on every market. 1D is the exchange trade date and 1W a Monday week of trade dates, both read from `time.trade_date`: a CME bar from the 17:00 Chicago open already belongs to the next day's candle, and a Sunday-evening bar to Monday's week. - **Closing.** A selected bar closes on the first chart bar of the next one, and a gap simply skips the empty buckets. Its candle holds the first bar's open and the last bar's close as those bars gave them, the highest high, the lowest low and the summed volume; a `NaN` high, low or volume on any bar carries into that field. A candle whose last bar closed at `NaN` never closes, while a `NaN` close on an earlier bar does not matter. - **Confirmed and developing.** Confirmed: every chart bar inside selected bar k shows the state after bar k - 1, so a value never changes once it is drawn. Developing: that state plus one step on the forming candle, which is `withX = true` with the forming value on a window and `peek` on a smoother. - **Warm-up** counts closed selected bars: a 20-bar average at 1h needs 20 closed hours inside the loaded history (19 and the forming one when developing). The first bucket may be partial; it still closes and counts. - **Refusal.** `refused()` blanks nothing by itself: while it is true, your indicator must write `NaN` to every output, as the sample below does. The `Resampler` keeps folding underneath, so its candles, windows and smoothers still answer and would draw numbers that mean nothing. It turns true when the selected bar is narrower than the chart's bars (known from the second bar on, once the bar spacing is measured), and stays true from a 1D or 1W bar without a trade date on. A moving average and an EMA of the selected timeframe's closes, with the built-ins' Timeframe and Wait for close settings. The default reads 1h on a 1h chart or a finer one, and refuses on a coarser one: ```typescript param("timeframe", 4, { min: 0, max: 7, description: "0 chart, 1 5m, 2 15m, 3 30m, 4 1h, 5 4h, 6 1D, 7 1W" }); param("period", 20, { min: 1, max: 200, description: "Length in bars of the selected timeframe" }); param("wait_for_close", 1, { min: 0, max: 1, description: "1 confirmed values, 0 developing values" }); input("close", ohlcv.close); input("trade_date", time.trade_date); output("htf_sma", line, overlay, { color: "#2563eb", width: 2, description: "Average of the selected timeframe's closes" }); output("htf_ema", line, overlay, { color: "#f59e0b", width: 2, description: "EMA of the selected timeframe's closes" }); let r = new Resampler(tf.HOUR, true); let closes = new ClosedWindow(20); let ema = new Smoothed("ema", 20); let period: i32 = 20; function onStart(): void { // Constructed here so a bad setting reports in the Console. period = i32(p_period()); r = new Resampler(i32(p_timeframe()), p_wait_for_close() >= 0.5); closes = new ClosedWindow(period); ema = new Smoothed("ema", period); } function onBar(): void { r.update(bar.time(), in_trade_date(), bar.open(), bar.high(), bar.low(), bar.close(), bar.volume()); if (r.closed()) { // A selected bar just closed: its candle is final, so it joins the state. closes.push(r.last.close); ema.commit(r.last.close); } // Developing reads add the forming bar as one trial step, never stored. const live = !r.confirmed(); let sma = closes.mean(period, r.forming.close, live); let smoothed = live ? ema.peek(r.forming.close) : ema.value(); if (r.refused()) { sma = NaN; smoothed = NaN; } out_htf_sma(sma); out_htf_ema(smoothed); } ``` Confirmed, both lines step once per closed hour and hold between; with Wait for close off they follow the forming hour on every chart bar. A chain reads the same way: a signal line over a closed MACD commits each closed MACD value, and its developing read peeks with the trial MACD. ### Pine equivalents | Pine | Here | | --- | --- | | `request.security(syminfo.tickerid, "60", ta.sma(close, 20)[1], lookahead = barmerge.lookahead_on)`: the last closed hour, never repainting | `new Resampler(tf.HOUR, true)` and `closes.mean(20)` over a `ClosedWindow` pushed on `closed()` | | `request.security(syminfo.tickerid, "60", ta.ema(close, 20))` on the realtime bar: the forming hour | `new Resampler(tf.HOUR, false)` and `ema.peek(r.forming.close)` | | `barstate.isconfirmed` before using a higher-timeframe value | confirmed reads (`waitForClose` on), which only ever show closed selected bars | | `timeframe.change("60")` | `r.newBucket()` | | `request.security(syminfo.tickerid, "D", close[1], lookahead = barmerge.lookahead_on)` | `r.last.close` on `tf.DAY`: the exchange trade date's candle | ## Any span: `Bucket` A `Bucket` folds the chart's own bars into a span the chart does not serve, keyed the way TradingView aligns a multi-period timeframe, so `request.security(syminfo.tickerid, "120", close)` and its `"2D"`, `"2W"` and `"3M"` cousins port without a pinned input. The constructor takes the span and the chart's bar interval in seconds: declare `chart.interval_sec();` at top level and pass `p_chart_interval_sec()` from `onStart()`, as the sample below does, so the module knows where each bar closes instead of guessing from the gaps between bars. The span is Pine's timeframe string plus the kit's own letters: digits alone or with `m` are minutes (`"120"`, `"45"`, `"5m"`), `s` seconds, `h` or `H` hours (`"2h"`), `d` or `D` days (`"2D"`; a bare `"D"` is one), `w` or `W` weeks (`"2W"`) and `M` months (`"3M"`): the capital M is months as in Pine, the small m minutes as in the kit's interval pins. Anything else, a count of `0` or an intraday span past a day aborts with `Bucket: bad span `, so construct in `onStart()`. | Export | Signature | Answers | | --- | --- | --- | | `Bucket` | `new Bucket(spec: string, chartIntervalSec: f64)` | buckets of one span over the chart's bars, whose interval is `p_chart_interval_sec()` | | `update` | `b.update(t, open, high, low, close, volume): void` | folds one chart bar: `t` is `bar.time()`; call once per bar | | `closed`, `newBucket` | `b.closed(): bool` | a bucket closed on this chart bar (`b.last` is its candle); this chart bar starts a bucket | | `isLastBar` | `b.isLastBar(): bool` | this chart bar's close (its open plus the chart interval) reaches the bucket's end: the bucket's last bar, where `b.forming` already holds its final candle | | `complete` | `b.complete(): Candle` | the newest bucket complete at this bar's close: `forming` on the bucket's last bar, `last` on every other bar; what Pine's `request.security` shows on historical bars with lookahead off | | `last`, `forming` | `b.last.close`, `b.forming.high` | the last closed bucket and the forming one: `open`, `high`, `low`, `close`, `volume` and `startSec`, `NaN` until each exists | | `startSec`, `endSec`, `widthSec`, `lastEndSec` | `b.endSec(): f64` | the forming bucket's bounds in epoch seconds and its width (shorter than the span on the last bucket of a day or year); the last closed bucket's end | | `lastWhole`, `formingWhole` | `b.lastWhole(): bool` | the candle's bucket started at or after the first bar fed, so it holds every bar of the bucket (the first bucket may have started before the history) | | `refused` | `b.refused(): bool` | the chart's bars cannot build the span (the rule below); your indicator must write `NaN` to every output while it is true | | `refusedReason` | `b.refusedReason(): string` | why, as readable text (`Bucket: span 45 is not a whole multiple of the chart's 1800s bars`); the empty string while the span is served | | `reset` | `b.reset(): void` | back to the freshly constructed state; the span, the interval and a refusal decided at construction stay, a straddle refusal clears | The rules, written out (TradingView's bar alignment on a 24/7 market): - **The chart interval** decides what the bars can build. The span is refused from the first bar, with its reason, when the interval is unknown (`chart.interval_sec()` reads `0`; every place an indicator runs fills it today), when the span is narrower than the bars, or when it is not a whole multiple of them: `"45"` on a 30-minute chart would put the 00:30 bar astride 00:45, a candle Pine serves from finer data the chart does not have (a month counts as 28 days for both tests). Where the bars' own grid cannot meet a boundary, the first bar that straddles one (hourly bars opening on the half hour under `"2h"`, a month boundary that is not a Monday on a weekly chart) refuses the span from that bar on, until `reset()`. A monthly chart's bars have no fixed width, so a `Bucket` refuses them. - **Intraday spans** count from 00:00 UTC of each day, so `"2h"`, `"3h"`, `"4h"`, `"6h"`, `"8h"` and `"12h"` are the epoch floor the `Resampler` uses, while a span that does not divide the day (`"7h"`, `"50"`) ends its last bucket of the day short, at midnight. - **Day spans** count calendar days from January 1st and restart every year: on `"3D"` the bucket after 2026-12-30 is two days long, and 2027-01-01 starts a new one. - **Week spans** count Monday weeks from the year's first Monday, and the days before that Monday belong to the last bucket of the year before: 2027 starts on a Friday, so January 1st to 3rd 2027 sit in the `"2W"` bucket that began on Monday 2026-12-21. - **Month spans** count from January and restart every year: `"3M"` is the quarter, and a `"5M"` year ends with a two-month bucket. - **Closing** is the `Resampler`'s rule: a bucket closes on the first chart bar of a later bucket, when its last bar's close is finite, and `b.last` is never revised; gaps skip empty buckets; the first bucket may be partial, and `lastWhole()` says so. - **Sessions** are not modelled: Pine anchors its intraday buckets at a venue's session open and counts trading days only, which differs from the UTC day on a session venue, and a session-anchored grid (bars opening on the half hour) refuses as a straddle. Use a `Bucket` on 24/7 markets. The close, high and low of the newest two-hour bucket complete at each bar, the way `request.security(syminfo.tickerid, "120", close)` shows them on historical bars, with the span as a setting and the chart's bar interval passed in: ```typescript param("hours", 2, { min: 1, max: 24, description: "Hours per bucket" }); chart.interval_sec(); output("htf_close", line, overlay, { color: "#2563eb", width: 2, description: "Close of the newest bucket complete at this bar" }); output("htf_high", line, overlay, { color: "#16a34a", description: "Its high" }); output("htf_low", line, overlay, { color: "#dc2626", description: "Its low" }); output("bucket_start", none, overlay, { description: "1 on the bar that starts a bucket" }); let b = new Bucket("2h", 0.0); function onStart(): void { // Built here from the setting and the chart's interval: a bad span aborts // with a readable message, a span the bars cannot build refuses with one. b = new Bucket(i32(p_hours()).toString() + "h", p_chart_interval_sec()); } function onBar(): void { b.update(bar.time(), bar.open(), bar.high(), bar.low(), bar.close(), bar.volume()); // complete() is the forming bucket on its last bar and the last closed one otherwise. const c = b.complete(); const blank = b.refused() || isNaN(c.close); out_htf_close(blank ? NaN : c.close); out_htf_high(blank ? NaN : c.high); out_htf_low(blank ? NaN : c.low); out_bucket_start(b.newBucket() ? 1.0 : 0.0); } ``` `b.last` alone is Pine's `request.security(..., close[1], lookahead = barmerge.lookahead_on)`, the previous bucket on every bar of the current one, and `b.forming` its repainting reading of the realtime bar. A `ClosedWindow` or a `Smoothed` pushed on `b.closed()` gives an indicator on the span, exactly as with a `Resampler`. On a chart whose bars cannot build the span (a 3-hour setting on a 2-hour chart) every output reads `NaN` and `b.refusedReason()` says why; show it in a label or a HUD when the port should explain itself. | Pine | Here | | --- | --- | | `request.security(syminfo.tickerid, "120", close)` on historical bars | `b.complete().close` with `new Bucket("120", p_chart_interval_sec())` | | `request.security(syminfo.tickerid, "2D", high[1], lookahead = barmerge.lookahead_on)` | `b.last.high` with `new Bucket("2D", p_chart_interval_sec())` | | `request.security(syminfo.tickerid, "2W", close)` on the realtime bar | `b.forming.close` with `new Bucket("2W", p_chart_interval_sec())` | | `timeframe.change("120")` | `b.newBucket()` | ## Calendar periods and candle lists The `./sdk/candles` module reads a `candles` stream, the closed candles of one timeframe reaching back `bars` deep ([Multi-timeframe](../core-concepts/multi-timeframe.md#history-the-candles-stream)). A `Periods` folds the candles into calendar periods; a `CandleList` keeps the newest of them. Feed each one every bar, an empty block included, with `load(in__view(), in__cells(), bar.time())`: the bar moves the clock even when no candle arrives. Construct them at module start when the period is a constant, or in `onStart()` when a setting picks it. | Export | Signature | Answers | | --- | --- | --- | | `period` | `period.DAY`, `period.WEEK`, `period.MONTH`, `period.QUARTER`, `period.YEAR` | the period a `Periods` folds, numbered `0`..`4` so a choice setting's index passes straight through | | `Periods` | `new Periods(kind: i32, keep: i32 = 1, leg: i32 = period.DAY)` | calendar periods of one kind, keeping the newest `keep` closed ones readable; `leg` is the stream's interval, `period.WEEK` for a `1w` stream and `period.DAY` for `1d` or finer; an unknown kind aborts with `Periods: unknown period `, any other leg with `Periods: a stream's leg is period.DAY (1d or finer) or period.WEEK (1w), not ` | | `load` | `p.load(cells: StaticArray, count: i32, t: f64): i32` | moves the clock to the bar's open `t`, then folds this bar's block; returns the candles taken | | `confirmed` | `p.confirmed(n: i32 = 0): PeriodCandle` | the n-th newest closed period, `0` the last one; all `NaN` for an `n` past `keep - 1` or before n + 1 periods have closed | | `developing` | `p.developing(): PeriodCandle` | the bar's own period, folded from the candles closed so far | | `isNew`, `startSec`, `endSec`, `complete` | `p.isNew(): bool` | the bar starts a new period; the period's bounds in epoch seconds; whether the stream reaches back to its start | | `add`, `at`, `reset` | `p.add(t, open, high, low, close, volume): bool` | folds one candle by hand; moves the clock to a bar opening at `t` (call it before that bar's `add` calls, as `load` does); back to the constructed state | | `PeriodCandle` | `open`, `high`, `low`, `close`, `volume`, `startSec`, `endSec`, `pv`, `pvVolume`, `count`; `vwap()` | one period: the first open, the highest high, the lowest low, the last close, the summed volume, and its VWAP sums | | `CandleList` | `new CandleList(cap: i32)` | the newest `cap` closed candles of the stream | | `load`, `count` | `list.load(cells, count, t): i32`, `list.count(): i32` | keeps this bar's candles; how many it holds, at most `cap` | | `openSec`, `open`, `high`, `low`, `close`, `volume` | `list.high(i: i32): f64` | candle `i`, `0` the oldest kept and `count() - 1` the newest; `NaN` outside the list | The rules, written out: - **Buckets.** Every boundary is 00:00 UTC: a week starts on Monday, a month on the 1st, a quarter on January, April, July or October 1st, a year on January 1st. A candle belongs to the period holding its open, and a daily candle of a market with sessions is stamped with its trade date. - **Confirmed.** A period is confirmed on every bar that opens at or after its end. All of its candles have arrived by then, so a confirmed period never changes. `developing()` never holds the forming candle, so it reads `NaN` until the period's first candle closes. - **Never partial.** A period that began before the stream's first candle reads `NaN` from `confirmed()` and `developing()`, and `complete()` says whether the bar's own period is whole. Ask for enough `bars` to reach the start of every period you read: 366 daily candles always hold the current year. - **Any bar width.** On a monthly or yearly chart one bar carries a month or a year of candles. `Periods` keeps every period a read can still reach, the `keep` newest closed ones and the bar's own, whatever a bar spans, in the memory it allocated at construction. - **Weekly streams.** Construct a `Periods` fed a `1w` stream with `period.WEEK` as its third argument. Its clock then runs on the start of the bar's week, so a week that straddles a month, quarter or year boundary counts in the period it opens in, as calendar requests over weekly candles count it. The stream's interval is declared, never read from the candles, so a `1d` stream with days missing keeps its daily clock. Feed a `1d` stream for exact periods. - **The list.** On the newest bar, a stream declared `bars: N` read through `CandleList(N)` holds the newest N closed candles: the rows `requestBars(sym, tf, { bars: N + 1 })` returns, without its last, the forming candle. That one is the interval's `forming` view, folded from the chart's own bars. On an earlier bar the list holds the candles delivered through that bar, so it serves per-bar math as well as drawing. Last month's high and low, and the average close of the last 20 closed days, on any chart interval: ```typescript input("close", ohlcv.close); input("d", candles.cells, { interval: "1d", bars: 70, description: "The newest 70 closed days" }); output("pm_high", line, overlay, { color: "#16a34a", description: "Last month's high" }); output("pm_low", line, overlay, { color: "#dc2626", description: "Last month's low" }); output("avg20", line, overlay, { color: "#2563eb", description: "Average close of the last 20 closed days" }); const month = new Periods(period.MONTH); const days = new CandleList(20); function onBar(): void { const block = in_d_view(); const n = in_d_cells(); const t = bar.time(); month.load(block, n, t); days.load(block, n, t); const last = month.confirmed(0); // NaN until a whole month has closed inside the stream out_pm_high(last.high); out_pm_low(last.low); let avg: f64 = NaN; if (days.count() == 20) { let sum = 0.0; for (let i = 0; i < 20; i++) sum += days.close(i); avg = sum / 20.0; } out_avg20(avg); } ``` `bars: 70` reaches back past the start of last month on every day of the year, so even a short chart shows it whole. On a chart that loaded more than 70 days, the stream starts just before the first loaded bar instead, and the first month or two read `NaN`: a month that began before the stream is never shown partial. # Order flow The chart serves order flow a candle alone cannot show: the bar's aggressive buy and sell volume as two sided inputs, the bar's volume profile as `volume_profile` cells, a book snapshot as `book` cells and the bar's forced liquidations per side. This page is the arithmetic on top. The `./sdk/orderflow` module ships `delta` and `deltaPct` for a bar, a `Cvd` you restart where you choose, a `VolumeProfile` with its point of control and value area, a `BookImbalance`, and the two detectors the cookbook recipes use, `Absorption` and `LiquidationBurst`. The same scans written by hand over the profile and book cells follow each class, with the readers those cells come through; the classes are those scans with the edge rules pinned. ## What the chart serves | Source | What one bar carries | Notes | | --- | --- | --- | | `trades.volume` with a `side` | the bar's aggressive buy volume, or its sell volume, as one number | declared twice, `side: "BUY"` and `side: "SELL"`; `missing: "zero"` reads 0 on a bar without prints; `currency: "USD"` reads dollars instead of coins | | `volume_profile.cells` | one `[low, high, buy, sell]` tuple per price level | real band edges, in ascending price order, a level with a non-finite member skipped; `buy` and `sell` are the aggressor volumes at that level in coins (base-asset units); the count varies with the bar's range | | `book.cells` | one `[price, size, side]` tuple per level | `side` is `+1` for a bid and `-1` for an ask, `size` the absolute resting quantity; the bids as the snapshot lists them, then the asks from the highest price down; up to 500 levels a side, so a full book is up to 1,000 tuples | | `liquidations.liquidations` with a `side` | the bar's forced liquidations on one side, in USD | the detector sample reads long liquidations from the `SELL` side and short ones from the `BUY` side, both `missing: "zero"` | The two celled sources share these rules: - `max_cells` is required and counts tuples. A bar whose block exceeds it refuses the whole run rather than truncating ("max_cells is 512, so the evaluation is refused (a block is never truncated)"), except on a profile declared `missing: "empty"` (below), where that bar reads an empty block and the run goes on. A profile has one tuple per bucket the bar crossed: BTCUSDT's bucket is $5, so 512 refuses any bar wider than about $2,560, while 8192 covers the majors' profiles at native bucket width; 1000 holds a full book. A low cap refuses wide bars, a high cap reserves memory you never use (8192 profile tuples are 256 KB). - A celled input cannot be the primary input (a celled class has no clock of its own), so a scalar input comes first and sets the grid the blocks join row for row. Declaring one derives the sheet on the second runtime contract (`abi_version: "wrun-2"`); Run does that for you. - Both follow the chart's own market and interval: a `symbol` + `exchange` pin or an `interval` pin is refused ("the browser lane serves market pins on secondary ohlcv inputs only (typed feeds, cells and time follow the chart's own market)"). `description` is the only other option the profile takes. - The book declaration requires `block_size` and accepts `max_depth`, but the chart reads neither: it serves the same snapshots its own order book lane fetches for the chart's market. Size `max_cells` for the deepest book the market shows, not from `max_depth`. - The profile arrives at the market's own bucket width, one tick per bucket, in coins, unless the input says otherwise. `ticks_per_bar` merges that many of the market's own buckets into one, a whole number from 1 to 500 (left out, or 1, the market's own width), and `currency: "USD"` quotes bucket volume in dollars instead of coins (`"Coin"`, the default, keeps coins): `input("profile", volume_profile.cells, { max_cells: 8192, ticks_per_bar: 5, currency: "USD" })`. Any other value stops the build with the option's name. - The bucket size can be a setting: `ticks_per_bar: "@node_ticks"` names a `param.int` whose `min..max` lies inside 1..500, the chart asks for the profile at the setting's value and fetches again when the user changes it: `param.int("node_ticks", 10, { min: 1, max: 50 }); input("profile", volume_profile.cells, { max_cells: 8192, ticks_per_bar: "@node_ticks" })`. - The profile is required: a market without one stops the run with a message naming it. `missing: "empty"` makes it optional, so on a market that serves no profile, or a stretch without one, every bar reads an empty block (`in_profile_cells()` is `0`) and the rest of the indicator draws as usual: `input("profile", volume_profile.cells, { max_cells: 8192, missing: "empty" })`. A bar whose profile has more buckets than `max_cells` reads the same empty block on an optional profile, never a truncated one, so a wide bar blanks that bar's profile parts instead of stopping the run. Scans over an empty block find no level, so code that reads the profile blanks those outputs by itself. Only `"empty"`, and only on `volume_profile.cells`. - Neither block carries the bar's timestamp: the `time` source does when a scan needs one. ## Reading the cells Declare a celled input behind a scalar one, with its cap: ```typescript input("close", ohlcv.close); input("profile", volume_profile.cells, { max_cells: 8192 }); input("book", book.cells, { max_cells: 1000, block_size: 10 }); ``` A celled input has no scalar reader (`in_profile()` does not exist). It reaches `onBar()` through a generated pair, four cells per profile tuple and three per book tuple: | Accessor | Returns | | --- | --- | | `in_profile_cells(): i32`, `in_book_cells(): i32` | the number of `f64` cells this bar (tuples times the tuple width); `0` for a present, empty block; `-1` when the bar carries no block | | `in_profile_view(): StaticArray`, `in_book_view(): StaticArray` | the bar's cells in place: one buffer the build owns, filled before `onBar()` runs and never copied; only the first `in__cells()` values belong to this bar | Read the count, then the block, where you use them. The view is not cleared between bars: an absent or empty bar leaves the previous block in it, so bound every scan by the count. Profile tuple `i` is `cells[4 * i]` through `cells[4 * i + 3]` and `n / 4` is the count; a loop guarded by `i + 3 < n` (`i + 2 < n` over the book) walks whole tuples only, so a block that is not a multiple of the tuple width (it never is, but the guard costs nothing) cannot read past the last cell. The copying reader and the capacity constants are on [Data sources](../core-concepts/data-sources.md#reading-a-block). ## Every export | Export | Signature | What it gives | | --- | --- | --- | | `delta` | `delta(buy: f64, sell: f64): f64` | `buy - sell`; NaN when a side is not finite | | `deltaPct` | `deltaPct(buy: f64, sell: f64): f64` | `(buy - sell) / (buy + sell) * 100`, -100..100; NaN when the sum is 0 | | `Cvd` | `update(buy, sell): f64`, `value(): f64`, `delta(): f64`, `reset()` | the running sum of `buy - sell` since construction or the last `reset()`; a non-finite side counts as 0 on its bar | | `VolumeProfile` | `constructor(rows: i32)`, `begin(low, high)`, `add(low, high, buy, sell)`, `end(valueAreaPct: f64 = 70.0): bool`, `poc()`, `vah()`, `val()`, `total()`, `rowCount()`, `rowLow(i)`, `rowVolume(i)`, `rowBuy(i)`, `rowSell(i)`, `pocRow()`, `vahRow()`, `valRow()`, `reset()` | bands spread over `rows` equal-width rows across `[low, high)`, buy and sell kept per row; the POC row, its centre, and the value area's edges | | `BookImbalance` | `constructor(levels: i32)`, `begin()`, `add(price, size, side: i32)`, `end()`, `bidSize()`, `askSize()`, `ratio()`, `imbalance()`, `bestBid()`, `bestAsk()`, `spread()`, `reset()` | the `levels` highest bids and lowest asks kept whatever order the cells arrive in; their sizes summed, `bid / (bid + ask)`, `(bid - ask) / (bid + ask)`, the touch and the spread | | `Absorption` | `constructor(deltaWindow: i32, atrLength: i32, deltaSpike: f64, maxRange: f64)`, `update(high, low, close, buy, sell): i32`, `delta()`, `norm()`, `range()`, `reset()` | +1 buying absorbed, -1 selling absorbed, 0 otherwise: the absorption recipe's test | | `LiquidationBurst` | `constructor(window: i32, burstZ: f64)`, `update(longLiq, shortLiq): i32`, `longZ()`, `shortZ()`, `reset()` | -1 a long burst, +1 a short burst, 0 neither: the liquidation-bursts recipe's test | Every class allocates in its constructor and never per bar, `NaN` means "nothing" (not warm yet, no such row, no level on that side), and `reset()` restores the freshly constructed state. Counts below 1 (rows, levels, windows) are clamped to 1. Construct the objects in `onStart()` (a settings change rebuilds them there); an abort at module start loses its message. ## Delta and CVD `delta(buy, sell)` is the bar's `buy - sell` and `deltaPct` the same as a percent of the bar's sided volume. `Cvd` keeps the running sum: every `update(buy, sell)` adds the bar's delta and returns the sum, `value()` reads it again, `delta()` reads the last bar's own delta. The sum has no anchor of its own: you decide where it restarts by calling `reset()`. The sample below restarts it on the first bar of each UTC day, gated by a data-only `new_day` flag computed from `bar.time()` (the bar's open in UTC seconds), so the flag is on the chart too and an alert can read it. ```typescript input("close", ohlcv.close); input("buy", trades.volume, { side: "BUY", missing: "zero" }); input("sell", trades.volume, { side: "SELL", missing: "zero" }); output("cvd", line, lower, { color: "#2563eb", width: 2, description: "Cumulative volume delta, restarted each UTC day" }); output("bar_delta", line, lower, { color: "#94a3b8", description: "This bar's buy minus sell volume" }); output("new_day", none, lower, { description: "1 on the first bar of a UTC day (the reset gate)" }); let cvd = new Cvd(); let prevDay: f64 = NaN; function onStart(): void { cvd = new Cvd(); } function onBar(): void { const day = Math.floor(bar.time() / 86400.0); const newDay = !isNaN(prevDay) && day != prevDay ? 1.0 : 0.0; prevDay = day; if (newDay == 1.0) cvd.reset(); const buy = in_buy(); const sell = in_sell(); const barDelta = delta(buy, sell); const sum = cvd.update(buy, sell); out_cvd(sum); out_bar_delta(barDelta); out_new_day(newDay); } ``` The first bar of the history is not a "new day" (there is no previous day to compare), so the sum starts there and restarts at every day boundary after it. Read `new_day` in the Console to check the gate before you alert on the line. ## Volume profile A `VolumeProfile` is built from bands. `begin(low, high)` opens a profile over `[low, high)` split into the constructor's `rows` equal-width rows and clears them; `add(low, high, buy, sell)` folds one band; `end(valueAreaPct)` closes the profile and answers `false` when nothing landed. Call `add()` per cell across as many bars as the profile should cover: a bar profile is `begin`, the bar's cells, `end`; a rolling profile is one `begin`, cells from every bar, `end` when you read it. The frozen rules, the ones the [Volume profile and value area](../cookbook/volume-profile-value-area.md) recipe uses: - A band is spread over the rows it overlaps in proportion to the overlap. A band whose two edges fall in the same row, a band thinner than a row and a point band with `low == high` land whole in that row. - A band entirely outside the span lands whole in the nearest edge row; a band crossing the span's edge keeps the part inside. - The POC is the heaviest row; the lower row wins a tie. `poc()` is that row's centre. - The value area starts at the POC and grows toward the heavier neighbour (up wins a tie) until it holds `valueAreaPct` percent of the total. `vah()` is the area's top row's upper edge and `val()` its bottom row's lower edge, both row EDGES. - Buy and sell are kept per row: `rowBuy(i)`, `rowSell(i)` and their sum `rowVolume(i)`; `rowLow(i)` is the row's lower edge and `total()` what every row holds. The sample reads the bar's `volume_profile` cells with the generated `in_profile_cells()` / `in_profile_view()` pair, loops the four-cell tuples into `add()`, and draws the three levels as lines on price. ```typescript param("rows", 24, { min: 2, max: 128, description: "Price rows across the bar's range" }); param("value_area", 70, { min: 50, max: 95, description: "Value area as a percent of the bar's volume" }); input("close", ohlcv.close); input("profile", volume_profile.cells, { max_cells: 4096, description: "This bar's volume by price level" }); output("poc", line, overlay, { color: "#f59e0b", width: 2, description: "Point of control: the heaviest row's centre" }); output("vah", line, overlay, { color: "#38bdf8", description: "Value area high: the area's upper edge" }); output("val", line, overlay, { color: "#38bdf8", description: "Value area low: the area's lower edge" }); let profile = new VolumeProfile(24); let valueArea: f64 = 70.0; function onStart(): void { profile = new VolumeProfile(i32(p_rows())); valueArea = p_value_area(); } function onBar(): void { profile.begin(bar.low(), bar.high()); // the bar's own range; a flat bar keeps the profile closed const n = in_profile_cells(); if (n >= 4) { const cells = in_profile_view(); for (let i = 0; i + 3 < n; i += 4) { profile.add(cells[i], cells[i + 1], cells[i + 2], cells[i + 3]); } } const ok = profile.end(valueArea); out_poc(ok ? profile.poc() : NaN); out_vah(ok ? profile.vah() : NaN); out_val(ok ? profile.val() : NaN); } ``` `end()` answers `false` on a bar with no bands or a flat range and the three outputs are written `NaN` there, so the lines simply skip it. A clamp such bands into the edge rows on purpose, or skip them before `add()` when you want the recipe's stray-print rule. ### Profile scans by hand The class re-bins the bar's bands into rows you choose. When the native buckets are what you want (the heaviest level as the chart serves it, the bar's sided totals, the range the profile spans), read the tuples directly. Each read is a function over the block, `cells` being the view and `n` the cell count for the bar. All nine take the same two arguments, so a module scans once and reads as many as it likes. | Function | Returns | | --- | --- | | `vpBuy(cells, n)` | total buy volume across the bar's buckets | | `vpSell(cells, n)` | total sell volume | | `vpDelta(cells, n)` | `vpBuy - vpSell`; positive is buy dominant | | `vpTotal(cells, n)` | combined buy + sell volume | | `vpPoc(cells, n)` | the point of control: the midprice of the highest-volume bucket, `NaN` if no buckets | | `vpPocVolume(cells, n)` | combined volume at the point-of-control bucket | | `vpBucketCount(n)` | the number of buckets this bar | | `vpPriceHigh(cells, n)` | the highest `high` across buckets, `NaN` if empty | | `vpPriceLow(cells, n)` | the lowest `low` across buckets, `NaN` if empty | How the two readings differ: | | `VolumeProfile` | Hand scan | | --- | --- | --- | | Rows | `rows` equal-width rows across the span you pass to `begin()` | the chart's native buckets, one tick each | | Point of control | the heaviest row's centre, the lower row on a tie | the heaviest bucket's midprice, the lower bucket on a tie | | Value area | `vah()` and `val()` at `valueAreaPct` | none | | Totals | `total()`, buy and sell per row | `vpBuy`, `vpSell`, `vpDelta`, `vpTotal` | | Range | the span you passed | `vpPriceLow` and `vpPriceHigh` from the buckets | The module below writes nine outputs from one scan per bar. The delta draws as a histogram tinted by sign, the point of control as a line on the price pane, and the rest in a lower pane. A bar with no block (`n < 0`) abstains; a bar with an empty block (`n == 0`) is a real observation of zero volume and writes zeros and `NaN` prices. ```typescript input("close", ohlcv.close); input("profile", volume_profile.cells, { max_cells: 8192 }); output("poc", line, overlay, { color: "#ff9800", width: 2, description: "Point of control" }); output("price_high", line, overlay, { color: "#94a3b8", width: 1, description: "Top of the profile" }); output("price_low", line, overlay, { color: "#94a3b8", width: 1, description: "Bottom of the profile" }); output("total_buy", line, lower, { color: "#26a69a", width: 2, description: "Buy volume summed over the profile" }); output("total_sell", line, lower, { color: "#ef5350", width: 2, description: "Sell volume summed over the profile" }); output("delta", histogram, lower, { color_by: "delta_sign", colors: ["#ef5350", "#26a69a"], description: "Net buy minus sell volume" }); output("delta_sign", none, lower, { description: "0 sell dominant, 1 buy dominant: the delta palette index" }); output("total_volume", line, lower, { color: "#9e9e9e", width: 1, description: "Combined volume" }); output("poc_volume", line, lower, { color: "#ff9800", width: 1, description: "Volume at the point of control" }); output("bucket_count", line, lower, { color: "#64748b", width: 1, description: "Price levels this bar" }); function vpBuy(cells: StaticArray, n: i32): f64 { let total = 0.0; for (let i = 0; i + 3 < n; i += 4) total += cells[i + 2]; return total; } function vpSell(cells: StaticArray, n: i32): f64 { let total = 0.0; for (let i = 0; i + 3 < n; i += 4) total += cells[i + 3]; return total; } function vpDelta(cells: StaticArray, n: i32): f64 { return vpBuy(cells, n) - vpSell(cells, n); } function vpTotal(cells: StaticArray, n: i32): f64 { return vpBuy(cells, n) + vpSell(cells, n); } // The index of the bucket with the most combined volume, or -1 for an empty block. function vpPocIndex(cells: StaticArray, n: i32): i32 { let best = -1; let bestVolume = -1.0; for (let i = 0; i + 3 < n; i += 4) { const volume = cells[i + 2] + cells[i + 3]; if (volume > bestVolume) { bestVolume = volume; best = i; } } return best; } function vpPoc(cells: StaticArray, n: i32): f64 { const i = vpPocIndex(cells, n); return i < 0 ? NaN : (cells[i] + cells[i + 1]) / 2.0; } function vpPocVolume(cells: StaticArray, n: i32): f64 { const i = vpPocIndex(cells, n); return i < 0 ? NaN : cells[i + 2] + cells[i + 3]; } function vpBucketCount(n: i32): f64 { return n < 0 ? 0.0 : f64(n / 4); } function vpPriceHigh(cells: StaticArray, n: i32): f64 { let top = NaN; for (let i = 0; i + 3 < n; i += 4) { if (isNaN(top) || cells[i + 1] > top) top = cells[i + 1]; } return top; } function vpPriceLow(cells: StaticArray, n: i32): f64 { let bottom = NaN; for (let i = 0; i + 3 < n; i += 4) { if (isNaN(bottom) || cells[i] < bottom) bottom = cells[i]; } return bottom; } function onBar(): void { const n = in_profile_cells(); if (n < 0) return; // no block on this bar: abstain const cells = in_profile_view(); const delta = vpDelta(cells, n); out_poc(vpPoc(cells, n)); out_price_high(vpPriceHigh(cells, n)); out_price_low(vpPriceLow(cells, n)); out_total_buy(vpBuy(cells, n)); out_total_sell(vpSell(cells, n)); out_delta(delta); out_delta_sign(delta >= 0.0 ? 1.0 : 0.0); out_total_volume(vpTotal(cells, n)); out_poc_volume(vpPocVolume(cells, n)); out_bucket_count(vpBucketCount(n)); } ``` The scans run in `onBar()` over the view the build filled before it: a module that reads the count once and computes many things from one block pays for the block once. ### One mark per price level Every output is one number per bar and every renderer draws one thing per bar, so the per-level picture is not a loop of marks. It is a declaration over the same cells: `plot.footprint({ name, cells: "profile" })` draws one footprint column per bar from the buckets, `plot.heatmap({ name, cells: "profile", value: "delta" })` paints them as a time by price heatmap, and `plot.profile({ name, cells: "profile", span: "session" })` sums them into a volume profile per session, range or visible window ([Price canvases](../presentation/price-canvases.md)). A profile the module computes itself (a scan that weights or filters the buckets) goes to a `plot.levels` frame docked on the price axis ([Docked profiles](../presentation/cards-frames-panels.md#docked-profiles)); a fixed set of outputs (the point of control as a line, the top and bottom of the profile as two more, a `render.shape` at the level you care about) draws the levels you name. The [Volume profile and value area](../cookbook/volume-profile-value-area.md) recipe draws its levels that way. ### The worked footprint The `vp-buy-share-codefirst` starter is the footprint loop end to end: a celled input, the buy share of each bar's profile as a numeric output, and a text renderer fed from a string slot on the newest bar. The whole file and how to run it are on [Data sources](../core-concepts/data-sources.md#the-worked-footprint-example); in the editor it is the **VP Buy Share** card under **Order flow** in the template picker (the **Templates** icon beside **New indicator**). After a run on a market with profile data, type `last 5 buy_share` at the Console prompt to read the newest values. ## Book imbalance A `BookImbalance` reads one snapshot per bar: `begin()`, one `add(price, size, side)` per level with `side` exactly as the cell encodes it (`+1` bid, `-1` ask; any other value is ignored, so is a non-finite price or size), then `end()`. It keeps the `levels` highest bids and the `levels` lowest asks, each side in a small array sorted best price first, so the result is the same whatever order the levels arrive in. That matters: the chart hands the asks over from the highest price down, so the first ask tuple is the worst ask, never the best; `bestAsk()` is the lowest ask price whatever the order. After `end()`, `bidSize()` and `askSize()` are the kept sizes summed, `ratio()` is `bid / (bid + ask)` and `imbalance()` `(bid - ask) / (bid + ask)` (both NaN when both sums are 0), `bestBid()`, `bestAsk()` and `spread()` read the touch (NaN when a side is empty). ```typescript param("levels", 10, { min: 1, max: 500, description: "Levels kept per side, counted from the touch" }); param("smooth", 21, { min: 1, max: 200, description: "EMA window over the imbalance" }); input("close", ohlcv.close); input("book", book.cells, { max_cells: 1000, block_size: 10 }); output("imbalance", line, lower, { color: "#94a3b8", description: "(bids - asks) / (bids + asks) over the kept levels, -1..1" }); output("smoothed", line, lower, { color: "#2563eb", width: 2, description: "The imbalance smoothed" }); output("spread", none, lower, { description: "Best ask minus best bid" }); let imbalanceOf = new BookImbalance(10); let ema = new Ema(21); function onStart(): void { imbalanceOf = new BookImbalance(i32(p_levels())); ema = new Ema(i32(p_smooth())); } function onBar(): void { const n = in_book_cells(); if (n <= 0) return; // no snapshot this bar, or an empty one: nothing to measure const cells = in_book_view(); imbalanceOf.begin(); for (let i = 0; i + 2 < n; i += 3) { imbalanceOf.add(cells[i], cells[i + 1], i32(cells[i + 2])); } imbalanceOf.end(); const imbalance = imbalanceOf.imbalance(); const spread = imbalanceOf.spread(); const smoothed = isNaN(imbalance) ? NaN : ema.update(imbalance); out_imbalance(imbalance); out_smoothed(smoothed); out_spread(spread); } ``` The `Ema` from `./sdk/ta` is fed once per bar, in order; on a bar without a snapshot `onBar()` returns early, so the average is left untouched and the three outputs stay `NaN`. Size `max_cells` for the deepest book the market shows (a snapshot over the cap refuses the run); the cap is in tuples. ### Depth window scans by hand The class keeps a count of levels from the touch. A depth window keeps a price band instead: every scan takes a `depthPct` (`10` in the module below) and only levels within that percentage of the mid price count. The mid is the average of the best bid (the highest bid price) and the best ask (the lowest ask price), read from prices rather than positions so any level order works, and a level is inside the window when `abs(price - mid) <= mid * depthPct / 100`. Smaller percentages (1 to 5) focus on top-of-book activity; larger ones (10 to 20) read overall depth. | | `BookImbalance` | Depth window scan | | --- | --- | --- | | Levels kept | the `levels` best on each side | every level within `depthPct` of the mid | | Window | a count, the same whatever the price | a price band that scales with the mid | | Reads | sizes, ratio, imbalance, touch, spread | sums, the largest and smallest order, imbalance | Three scans parameterized by side cover six measures. The sums answer `0` on an empty window, `maxSide` and `minSide` answer `NaN` when no level of that side sits inside it, and all three answer `NaN` when a side of the book is missing (no mid): | Scan | Returns | | --- | --- | | `sumSide(cells, n, 1.0, depthPct)` | total bid size within the window | | `sumSide(cells, n, -1.0, depthPct)` | total ask size within the window | | `maxSide(cells, n, 1.0, depthPct)` | the largest single bid within the window | | `maxSide(cells, n, -1.0, depthPct)` | the largest single ask | | `minSide(cells, n, 1.0, depthPct)` | the smallest bid within the window | | `minSide(cells, n, -1.0, depthPct)` | the smallest ask | What the six measures serve: | Category | What the scans give you | | --- | --- | | Volume analysis | bid and ask size summed within the depth window, and their imbalance | | Order size analysis | the largest and smallest resting orders on each side, to spot size | | Market depth | how far liquidity reaches and where it clusters, for support and resistance reads | The module adds the imbalance the sums are usually combined into, `(bids - asks) / (bids + asks)`, smoothed with the shipped `Ema` so the per-snapshot noise reads as pressure. `depth_pct` is a param, so the window is a setting the chart user can change. ```typescript param("depth_pct", 10, { min: 0.1, max: 50, description: "Depth window as a percent of the mid price" }); param("smooth", 21, { min: 1, max: 200, description: "EMA window over the imbalance" }); input("close", ohlcv.close); input("book", book.cells, { max_cells: 1000, block_size: 10 }); output("bid_volume", line, lower, { color: "#26a69a", width: 2, description: "Bid size within the window" }); output("ask_volume", line, lower, { color: "#ef5350", width: 2, description: "Ask size within the window" }); output("max_bid", line, lower, { color: "#0f766e", width: 1, description: "Largest resting bid within the window" }); output("max_ask", line, lower, { color: "#be123c", width: 1, description: "Largest resting ask within the window" }); output("min_bid", line, lower, { color: "#5eead4", width: 1, description: "Smallest resting bid within the window" }); output("min_ask", line, lower, { color: "#fda4af", width: 1, description: "Smallest resting ask within the window" }); output("imbalance", line, lower, { color: "#94a3b8", width: 1, description: "(bids - asks) / (bids + asks), -1..1" }); output("imbalance_ema", line, lower, { color: "#2563eb", width: 2, description: "Smoothed imbalance" }); // The average of the best bid (the highest bid price) and the best ask (the lowest ask price), // or NaN when either side is missing. Reads prices, not positions, so any level order works. function bookMid(cells: StaticArray, n: i32): f64 { let bestBid = NaN; let bestAsk = NaN; for (let i = 0; i + 2 < n; i += 3) { const price = cells[i]; if (cells[i + 2] > 0.0) { if (isNaN(bestBid) || price > bestBid) bestBid = price; } else if (isNaN(bestAsk) || price < bestAsk) bestAsk = price; } return isNaN(bestBid) || isNaN(bestAsk) ? NaN : (bestBid + bestAsk) / 2.0; } // True when a level sits within depthPct percent of the mid. function inWindow(price: f64, mid: f64, depthPct: f64): bool { return Math.abs(price - mid) <= (mid * depthPct) / 100.0; } function sumSide(cells: StaticArray, n: i32, side: f64, depthPct: f64): f64 { const mid = bookMid(cells, n); if (isNaN(mid)) return NaN; let total = 0.0; for (let i = 0; i + 2 < n; i += 3) { if (cells[i + 2] == side && inWindow(cells[i], mid, depthPct)) total += cells[i + 1]; } return total; } function maxSide(cells: StaticArray, n: i32, side: f64, depthPct: f64): f64 { const mid = bookMid(cells, n); if (isNaN(mid)) return NaN; let best = NaN; for (let i = 0; i + 2 < n; i += 3) { if (cells[i + 2] == side && inWindow(cells[i], mid, depthPct)) { if (isNaN(best) || cells[i + 1] > best) best = cells[i + 1]; } } return best; } function minSide(cells: StaticArray, n: i32, side: f64, depthPct: f64): f64 { const mid = bookMid(cells, n); if (isNaN(mid)) return NaN; let best = NaN; for (let i = 0; i + 2 < n; i += 3) { if (cells[i + 2] == side && inWindow(cells[i], mid, depthPct)) { if (isNaN(best) || cells[i + 1] < best) best = cells[i + 1]; } } return best; } let depthPct: f64 = 10.0; let ema = new Ema(21); function onStart(): void { depthPct = p_depth_pct(); ema = new Ema(i32(p_smooth())); } function onBar(): void { const n = in_book_cells(); if (n <= 0) return; // no snapshot, or an empty one: nothing to measure const cells = in_book_view(); const bids = sumSide(cells, n, 1.0, depthPct); const asks = sumSide(cells, n, -1.0, depthPct); const imbalance = bids + asks > 0.0 ? (bids - asks) / (bids + asks) : NaN; const smoothed = isNaN(imbalance) ? NaN : ema.update(imbalance); out_bid_volume(sumSide(cells, n, 1.0, depthPct)); out_ask_volume(sumSide(cells, n, -1.0, depthPct)); out_max_bid(maxSide(cells, n, 1.0, depthPct)); out_max_ask(maxSide(cells, n, -1.0, depthPct)); out_min_bid(minSide(cells, n, 1.0, depthPct)); out_min_ask(minSide(cells, n, -1.0, depthPct)); out_imbalance(imbalance); out_imbalance_ema(smoothed); } ``` The `Ema` is fed in `onBar()` once per bar, in order, after the early return, so a bar without a snapshot never touches it. ## Absorption and liquidation bursts Both detectors are the cookbook recipes' tests, verbatim, so an indicator built on them flags exactly the bars [Absorption](../cookbook/absorption.md) and [Liquidation bursts](../cookbook/liquidation-bursts.md) flag. `Absorption(deltaWindow, atrLength, deltaSpike, maxRange)` answers, per `update(high, low, close, buy, sell)`: delta = `buy - sell`; norm = the `Sma(deltaWindow)` of `abs(delta)`; range = the `Atr(atrLength)`; heavy = norm > 0 and `abs(delta) >= deltaSpike * norm`; stuck = range > 0 and `high - low <= maxRange * range`; heavy and stuck give `+1` when delta >= 0 (buying absorbed) and `-1` otherwise (selling absorbed), else `0`. `delta()`, `norm()` and `range()` read the bar's three numbers. The answer is `0` while the average or the ATR is warming up. `LiquidationBurst(window, burstZ)` answers, per `update(longLiq, shortLiq)`, side by side: mean = `Sma(window)`, dev = `Stdev(window)`; burst = value > 0 and dev > 0 and `value >= mean + burstZ * dev`. A long burst answers `-1`, a short burst `+1`; when both sides burst the larger value wins, long on a tie; else `0`. `longZ()` and `shortZ()` read `(value - mean) / dev`, NaN while not warm or when dev is 0. The sample draws absorbed bars as dots (above the candle when buying was absorbed, below it when selling was) and writes both answers as data-only outputs an alert can read. ```typescript param("delta_spike", 2.0, { min: 1.0, max: 6.0, description: "Heavy when abs(delta) is this many times its rolling mean" }); param("delta_window", 48, { min: 10, max: 400, description: "Bars the mean of abs(delta) is taken over" }); param("max_range", 0.8, { min: 0.2, max: 2.0, description: "Stuck when the bar's range is under this many ATRs" }); param("atr_length", 14, { min: 2, max: 100, description: "The ATR's Wilder length" }); param("burst_z", 3.0, { min: 1.0, max: 8.0, description: "A burst sits this many deviations above the window's mean" }); param("window", 96, { min: 10, max: 500, description: "Bars the liquidation mean and deviation are taken over" }); input("close", ohlcv.close); input("buy", trades.volume, { side: "BUY", missing: "zero" }); input("sell", trades.volume, { side: "SELL", missing: "zero" }); input("long_liq", liquidations.liquidations, { side: "SELL", missing: "zero" }); input("short_liq", liquidations.liquidations, { side: "BUY", missing: "zero" }); output("absorbed_buying", shape, overlay, { color: "#f86800", description: "A dot above a candle where heavy buying went nowhere" }); output("absorbed_selling", shape, overlay, { color: "#f8c000", description: "A dot below a candle where heavy selling went nowhere" }); output("absorption", none, overlay, { description: "+1 buying absorbed, -1 selling absorbed, 0 none" }); output("burst", none, overlay, { description: "-1 a long burst, +1 a short burst, 0 none" }); let absorption = new Absorption(48, 14, 2.0, 0.8); let bursts = new LiquidationBurst(96, 3.0); function onStart(): void { absorption = new Absorption(i32(p_delta_window()), i32(p_atr_length()), p_delta_spike(), p_max_range()); bursts = new LiquidationBurst(i32(p_window()), p_burst_z()); } function onBar(): void { const close = bar.close(); const high = bar.high(); const low = bar.low(); const absorbed = absorption.update(high, low, close, in_buy(), in_sell()); const burst = bursts.update(in_long_liq(), in_short_liq()); const range = absorption.range(); const dotAbove = absorbed == 1 ? high + range * 0.4 : NaN; const dotBelow = absorbed == -1 ? low - range * 0.4 : NaN; out_absorbed_buying(dotAbove); out_absorbed_selling(dotBelow); out_absorption(f64(absorbed)); out_burst(f64(burst)); } ``` A `shape` output written `NaN` draws nothing on that bar, so the dots appear only where the detector fired. The recipes add a box around the candle, a money tag on a burst and a notice when the market serves no sided prints or no liquidations; those are drawing objects on top of the same two answers. ## Where it runs The chart serves `volume_profile` and `book` over the loaded history and pushes the forming bar's profile and book live; a book module refreshes its newest bar at most about once a second. Cell alignment is an exact join: a primary bar with no observation gets a present empty block (`n == 0`), never a carried-forward one, so volume is never counted twice and a stale book never masquerades as a fresh read ([Data sources](../core-concepts/data-sources.md#celled-sources)). When the market has no profile data, the run stops with a toast instead of drawing zeros: "Volume profile data is unavailable for '' (the volume_profile source lane answered empty or was declined), so the indicator cannot compute." An alert on the indicator runs in OpenMarket's cloud; when it cannot evaluate a data source, saving the alert says so ("This Indicator reads a data source alerts cannot evaluate yet.", [Alerts](alerts.md)). ## Practices - **Scan once.** Read the count and the view once per bar and compute every measure from them. Nine scans over a bar's few hundred tuples is still cheap, but one is cheaper. - **Track the point of control.** The price with the most traded volume often acts as a magnet; `vpPoc` with `vpPocVolume` says how dominant the level is. - **Read delta for pressure.** `vpDelta` summarizes net aggressor flow per bar. Sustained positive delta is buy-side control; feed it to `Cvd` or `Cum` for cumulative delta, or to `Rsi` for delta-RSI ([TA library](ta-library.md)). - **Smooth the imbalance.** A large bid-over-ask imbalance often precedes an upward move and the reverse a downward one; a single snapshot is noisy, so smooth it before alerting on it. - **Watch large orders.** `max_bid` and `max_ask` jumping is size arriving; pair them with `min_bid` / `min_ask` to tell a thin book from a deep one. ## From Pine Nothing on this page maps one to one. Pine has no sided trades, volume profile bands, book levels or liquidations as script inputs, so there is no `ta.*` call to translate: the delta, the profile and the imbalance are computed from cells the chart serves to the indicator, and the detectors are recipes of this chart. Where Pine offers a volume profile, it is a chart-level drawing, not a series a script can read. <!-- source: https://openmarket.xyz/wrun/functions/levels-kit --> # Levels kit Levels are the prices a trader draws before the session starts: today's open, yesterday's high and low, last week's close, the session's range, the pivot point with its supports and resistances, the round numbers near price, and the swing levels price keeps returning to. A wrun indicator sees one bar at a time ([Execution model](../core-concepts/execution-model.md)), so the `./sdk/levels` module keeps the state for you. `PeriodLevels` and `SessionLevels` roll over on the clock from the [clock and sessions kit](time-and-sessions-kit.md), `PivotPoints` computes the standard, Fibonacci, Camarilla and Woodie sets from a previous range, `roundLevels` fills a grid of round numbers around a price, and `SupportResistance` clusters confirmed pivots into levels and counts how often each was touched. Everything is a class or a function you construct in `onStart()`, feed once per bar and read on the same bar; no call allocates after construction, so the module never grows memory per bar. | Export | Signature | Definition | | --- | --- | --- | | `PeriodLevels` | `new PeriodLevels(period, zone = "UTC")`; `.update(t, open, high, low, close)`; `.isNew()`, `.open()`, `.high()`, `.low()`, `.prevOpen()`, `.prevHigh()`, `.prevLow()`, `.prevClose()`, `.reset()` | The running open, high and low of the current `"day"`, `"week"`, `"month"`, `"quarter"` or `"year"` in a time zone (or the period as Pine spells the timeframe: `"D"` / `"1D"`, `"W"` / `"1W"`, `"M"` / `"1M"`, `"3M"`, `"12M"`), and the open, high, low and close of the last completed one (`NaN` until one completes) | | `SessionLevels` | `new SessionLevels(spec, zone = "UTC", days = "0123456")`; `.update(t, open, high, low, close)`; `.isIn()`, `.isNew()`, `.open()`, `.high()`, `.low()`, `.prevOpen()`, `.prevHigh()`, `.prevLow()`, `.prevClose()`, `.reset()` | The same over an `"HHMM-HHMM"` session on the listed weekdays (`0` = Sunday): the running levels while the bar is inside a session instance, the last completed instance as `prev*` | | `PivotPoints` | `new PivotPoints(kind = "standard")`; `.compute(high, low, close, open = NaN)`; `.pp()`, `.r1()`, `.r2()`, `.r3()`, `.s1()`, `.s2()`, `.s3()` | The seven floor-trader levels from one range in the `"standard"`, `"fibonacci"`, `"camarilla"` or `"woodie"` form; every `compute()` overwrites them | | `roundLevels` | `roundLevels(price, step, out): i32` | Fills `out` (a `StaticArray<f64>`) with the multiples of `step` around `price`, the first half at or below it and the rest above, nearest first; returns the count written (`0` for a step of `0` or below, a `NaN` step or a `NaN` price) | | `SupportResistance` | `new SupportResistance(left, right, tolerancePct, maxLevels)`; `.update(high, low, close)`; `.count()`, `.level(i)`, `.touches(i)`, `.kind(i)`, `.nearestAbove(price)`, `.nearestBelow(price)`, `.reset()` | Every confirmed `PivotHigh` / `PivotLow` merges into the nearest level within `tolerancePct` of its price or starts one; at most `maxLevels` are kept, the least touched dropped first | The first argument of `update()` on the two calendar classes is the bar's open time in UTC seconds: pass `bar.time()`. A `period`, `kind`, zone or session spec the module does not know aborts in the constructor, which is why every example constructs in `onStart()`: an abort there shows its message in the Console, one at module start does not. ## Previous day, week and month levels `PeriodLevels(period, zone)` follows the `Clock` of the [clock and sessions kit](time-and-sessions-kit.md): a period is the run of bars sharing the clock's day, week (Monday start), month, quarter (from January, April, July or October 1st) or year key in the zone you name (`"UTC"` by default; `"America/New_York"`, `"Europe/London"` and the other names that page lists, or a fixed `"+05:30"`). `period` is one of the five words `"day"`, `"week"`, `"month"`, `"quarter"` and `"year"`, or the timeframe string a Pine script passes to `request.security`: `"D"` or `"1D"`, `"W"` or `"1W"`, `"M"` or `"1M"`, `"3M"` for the quarter and `"12M"` for the year. Anything else (a lowercase `"d"`, an intraday `"60"`, a `"2D"` the clock has no key for) aborts in the constructor. On each bar: - `isNew()` is true on the first bar of a period, and on the very first bar the object sees. - `open()` is the open of the period's first bar; `high()` and `low()` are the running extremes so far. - On the bar a new period starts, the period that just ended becomes `prevOpen()`, `prevHigh()`, `prevLow()` and `prevClose()` (the close of its last bar). They hold for the whole period and never repaint. - `prev*` are `NaN` until the first period boundary after construction or `reset()`. The period already running when the history starts counts as a period, however much of it the loaded bars cover, the same rule as the [Key levels](../cookbook/key-levels.md) recipe: on an intraday chart the first `prev*` may describe a partial day. - A non-finite `open` makes `open()` `NaN` for the period; a non-finite `high` or `low` makes that extreme `NaN` for the rest of the period (and the `prev*` it becomes); `prevClose()` is the last bar's close. The previous New York day's high and low as lines, with the running day open dashed and a data-only flag on the first bar of each day: ```typescript output("pdh", line, overlay, { color: "#f5a623", width: 1, description: "Previous New York day high" }); output("pdl", line, overlay, { color: "#f5a623", width: 1, description: "Previous New York day low" }); output("day_open", line, overlay, { color: "#94a3b8", width: 1, line_style: "dashed", description: "Open of the running day" }); output("new_day", none, overlay, { description: "1 on the first bar of a New York day" }); let day = new PeriodLevels("day"); function onStart(): void { // Construct in onStart(): an unknown period or zone aborts with a readable message here. day = new PeriodLevels("day", "America/New_York"); } function onBar(): void { day.update(bar.time(), bar.open(), bar.high(), bar.low(), bar.close()); out_pdh(day.prevHigh()); out_pdl(day.prevLow()); out_day_open(day.open()); out_new_day(day.isNew() ? 1.0 : 0.0); } ``` The two level lines are `NaN` until the first New York midnight in the loaded history, then step on each midnight, daylight time included: the clock handles the 23-hour and 25-hour days. Four more `PeriodLevels` objects give the week, the month, the quarter and the year, or the same day in another zone; a yearly open is `new PeriodLevels("12M", zone)` read with `open()`. ## Session levels `SessionLevels(spec, zone, days)` follows the `Session` of the same kit: `spec` is `"HHMM-HHMM"` in the zone's local time, half-open (`"0930-1600"` holds 09:30 and excludes 16:00) and wrapping past midnight when the close is earlier than the open; `days` lists the weekday digits of the instance's opening day, `0` = Sunday through `6` = Saturday, so `"12345"` is Monday to Friday. Pine's session day string `"23456"` (Sunday = 1) is `"12345"` here. - `isIn()` is true while the bar sits inside an instance; `isNew()` on its first in-session bar. - `open()`, `high()` and `low()` describe the instance in progress and read `NaN` outside a session. - On the first bar after an instance ended (outside the hours, or already the first bar of the next instance when the bars skip the close) the ended instance becomes `prevOpen()`, `prevHigh()`, `prevLow()` and `prevClose()`; they hold until the next instance ends, so the previous session's range is available all night and through the weekend. - `prev*` are `NaN` until one instance has ended after construction or `reset()`; an instance already running when the history starts counts from its first loaded bar. A session no bar ever opens inside (a daily chart against an intraday session) never produces levels. `new SessionLevels("0930-1600", "America/New_York", "12345")` is the regular trading session of the US stock exchanges; feed it exactly as the example above feeds `PeriodLevels` and draw `prevHigh()` and `prevLow()` the same way. ## Pivot points `PivotPoints(kind).compute(high, low, close)` takes one range, normally the previous day's from `PeriodLevels`, and fills seven levels. With `H`, `L`, `C` and the range `R = H - L`: | Kind | Pivot | Resistances | Supports | | --- | --- | --- | --- | | `"standard"` | `PP = (H + L + C) / 3` | `R1 = 2 PP - L`, `R2 = PP + R`, `R3 = H + 2 (PP - L)` | `S1 = 2 PP - H`, `S2 = PP - R`, `S3 = L - 2 (H - PP)` | | `"fibonacci"` | `PP` as standard | `R1 = PP + 0.382 R`, `R2 = PP + 0.618 R`, `R3 = PP + R` | `S1 = PP - 0.382 R`, `S2 = PP - 0.618 R`, `S3 = PP - R` | | `"camarilla"` | `PP` as standard | `R1 = C + R * 1.1 / 12`, `R2 = C + R * 1.1 / 6`, `R3 = C + R * 1.1 / 4` | `S1 = C - R * 1.1 / 12`, `S2 = C - R * 1.1 / 6`, `S3 = C - R * 1.1 / 4` | | `"woodie"` | `PP = (H + L + 2 C) / 4` | the standard formulas from that `PP` | the standard formulas from that `PP` | `compute()` accepts a fourth `open` argument for kinds that read it; none of the four does. A non-finite high, low or close makes all seven levels `NaN`, and there is no `reset()`: every `compute()` overwrites every level, so `compute(NaN, NaN, NaN)` clears them. The kind is chosen at construction; to make it a setting, map a numeric param to the name in `onStart()`: ```typescript param("kind", 0, { min: 0, max: 3, description: "0 standard, 1 fibonacci, 2 camarilla, 3 woodie" }); output("pp", line, overlay, { color: "#94a3b8", width: 1, description: "Pivot point from the previous UTC day" }); output("r1", line, overlay, { color: "#dc2626", width: 1, description: "First resistance" }); output("r2", line, overlay, { color: "#dc2626", width: 1, line_style: "dashed", description: "Second resistance" }); output("r3", line, overlay, { color: "#dc2626", width: 1, line_style: "dotted", description: "Third resistance" }); output("s1", line, overlay, { color: "#16a34a", width: 1, description: "First support" }); output("s2", line, overlay, { color: "#16a34a", width: 1, line_style: "dashed", description: "Second support" }); output("s3", line, overlay, { color: "#16a34a", width: 1, line_style: "dotted", description: "Third support" }); function kindName(kind: i32): string { if (kind == 1) return "fibonacci"; if (kind == 2) return "camarilla"; if (kind == 3) return "woodie"; return "standard"; } let day = new PeriodLevels("day"); let pivots = new PivotPoints(); function onStart(): void { day = new PeriodLevels("day"); pivots = new PivotPoints(kindName(i32(p_kind()))); } function onBar(): void { day.update(bar.time(), bar.open(), bar.high(), bar.low(), bar.close()); // The previous day's range stands for the whole day: recompute once, when the day turns. if (day.isNew()) pivots.compute(day.prevHigh(), day.prevLow(), day.prevClose()); out_pp(pivots.pp()); out_r1(pivots.r1()); out_r2(pivots.r2()); out_r3(pivots.r3()); out_s1(pivots.s1()); out_s2(pivots.s2()); out_s3(pivots.s3()); } ``` Weekly pivots are the same file with `new PeriodLevels("week")` (or `"W"`, as Pine spells it); a session's pivots feed `compute()` from `SessionLevels` on the bar its `prev*` change. ## Round numbers `roundLevels(price, step, out)` fills a `StaticArray<f64>` you allocated once (at module level or in `onStart()`) with the multiples of `step` around `price`: with `base` the nearest multiple at or below `price`, the first `out.length / 2` slots (integer division) hold `base`, `base - step`, `base - 2 step`, ... and the remaining slots hold `base + step`, `base + 2 step`, ..., each half nearest first. It returns how many slots it wrote, `out.length` on success. A step of `0` or below, a `NaN` step or a `NaN` price write `NaN` into every slot and return `0`, so a stale grid never survives a bad bar. The values are floating-point products (`3 * 0.1` prints as `0.30000000000000004`): format them for display instead of comparing them with `==`. ```typescript const grid = new StaticArray<f64>(6); // 3 at or below the close, 3 above // In onBar(): grid[0] is the nearest round number at or below the close, // grid[3] the nearest above; count is 6, or 0 when the step was unusable. // const count = roundLevels(bar.close(), 100.0, grid); ``` A step of `100` on a five-figure price gives the hundreds; derive it from the price instead (`Math.pow(10.0, Math.floor(Math.log10(close)) - 1)`) for a grid that scales with the market. ## Clustered support and resistance `SupportResistance(left, right, tolerancePct, maxLevels)` is new: the shipped recipes take calendar extrema ([Key levels](../cookbook/key-levels.md)) or keep one zone per side ([Zone tracker](../cookbook/zone-tracker.md)); this class keeps a small ledger of levels built from every confirmed swing and remembers how often each was hit. It runs a `PivotHigh(left, right)` over the high and a `PivotLow(left, right)` over the low from `./sdk/ta` ([Series functions](series-functions.md)), so a pivot is reported `right` bars after its bar and never repaints. On each confirmed pivot: - It merges into the NEAREST existing level whose price is within `tolerancePct` percent of the pivot's price: the level's price becomes the touch-weighted mean of everything that merged into it and its `touches` grow by one. A tolerance of `0` merges exact matches only. - Otherwise it starts a new level with one touch. When that would exceed `maxLevels`, the existing level with the fewest touches is dropped first, the oldest on a tie; a new level always enters. - When a high and a low confirm on the same bar, the high merges first. `count()` is how many levels are kept, `level(i)` the price of the `i`-th by age (`0` the oldest kept), `touches(i)` its count and `kind(i)` `+1` when more pivot highs than lows merged into it (resistance), `-1` when more lows (support), the latest pivot's side on a tie; an index outside `0..count() - 1` reads `NaN`, `0` and `0`. `nearestAbove(price)` is the lowest level strictly above `price` and `nearestBelow(price)` the highest strictly below, `NaN` when there is none. `update(high, low, close)` takes the close for symmetry with the other kit classes and does not read it. `reset()` empties the ledger and the pivot windows. The levels as line handles, one per kept level, coloured by side and extended to the right, with the nearest level above and below the close as ordinary lines: ```typescript param("left", 5, { min: 1, max: 50, description: "Bars a swing must dominate on its left" }); param("right", 5, { min: 1, max: 50, description: "Bars that confirm the swing on its right" }); param("tolerance_pct", 0.3, { min: 0, max: 5, description: "Merge band as a percent of the pivot's price" }); param("max_levels", 6, { min: 1, max: 12, description: "Levels kept; the least touched goes first" }); output("nearest_above", line, overlay, { color: "#dc2626", width: 1, description: "The nearest level above the close" }); output("nearest_below", line, overlay, { color: "#16a34a", width: 1, description: "The nearest level below the close" }); output("level_count", none, overlay, { description: "Levels kept on this bar" }); handles.line({ color: "#94a3b8", width: 1, lineStyle: "dashed", extend: "right" }); let sr = new SupportResistance(5, 5, 0.3, 6); const lines = new Array<LineHandle>(); let firstT: f64 = NaN; function onStart(): void { sr = new SupportResistance(i32(p_left()), i32(p_right()), p_tolerance_pct(), i32(p_max_levels())); // One handle per kept level, made once here: ids are one space across the handle kinds. for (let i = 0; i < i32(p_max_levels()); i++) lines.push(draw.line(i)); } function onBar(): void { const t = bar.time(); if (isNaN(firstT)) firstT = t; const close = bar.close(); sr.update(bar.high(), bar.low(), close); const count = sr.count(); for (let i = 0; i < lines.length; i++) { if (i < count) { // Levels move as pivots merge, so every bar re-sets each line: same id, same line. const price = sr.level(i); const ink = sr.kind(i) > 0 ? rgba(220, 38, 38, 255) : rgba(22, 163, 74, 255); lines[i].set(firstT, price, t, price).color(ink).width(sr.touches(i) > 1 ? 2.0 : 1.0); } else { // A slot without a level: delete() on an id nobody holds is a no-op. lines[i].delete(); } } out_nearest_above(sr.nearestAbove(close)); out_nearest_below(sr.nearestBelow(close)); out_level_count(f64(count)); } ``` Levels with more than one touch draw thicker. Because a level's index is its age, a level that merges keeps its slot and its line; when a level is dropped the younger levels move down one index and the new level takes the last one, so several lines can change price on that bar. Read `last 20 level_count` at the editor's Console prompt to see the ledger fill, and `nearest_above` or `nearest_below` for the level price itself. ## From Pine - `request.security(syminfo.tickerid, "D", high[1])`: `new PeriodLevels("D")` (the same object as `"day"`) fed each bar, then `.prevHigh()` (and `.prevLow()`, `.prevOpen()`, `.prevClose()` for the other fields). - `request.security(syminfo.tickerid, "W", close[1])`, and the same with `"M"`, `"3M"` or `"12M"`: `new PeriodLevels("W")`, `("M")`, `("3M")`, `("12M")` and `.prevClose()`; the same request without `[1]` is the running period, `.open()`, `.high()` and `.low()`. - `timeframe.change("D")` (or `"W"`, `"M"`, `"3M"`, `"12M"`): `.isNew()` on the `PeriodLevels` built from that word, or `clock.isNewDay()` and its siblings on a `Clock` ([clock and sessions kit](time-and-sessions-kit.md)). - `ta.pivot_point_levels(type, anchor)`: `new PivotPoints(kind)` with `.compute()` called on the bar the anchor period turns, the previous period's high, low and close from `PeriodLevels` as its arguments. <!-- source: https://openmarket.xyz/wrun/functions/market-structure-kit --> # Market structure kit The `./sdk/structure` module reads price as a sequence of swings and gives you, in every file, the bookkeeping every market-structure indicator rewrites by hand: `Swings` finds confirmed swing highs and lows and says whether each is a higher or lower one, `MarketStructure` turns them into breaks of structure and changes of character with a trend, `FairValueGaps` keeps a ring of three-bar gaps and knows when a close filled each, `OrderBlocks` keeps the last opposite candle before every break until price trades back into it, `Divergence` compares price pivots with an oscillator, and the `candles` namespace answers doji, hammer, engulfing, inside, outside and pin bar as plain predicates. Nothing here repaints: a swing is confirmed `right` bars after it prints, exactly like the pivots on [Series functions](series-functions.md), and every other definition is built on that lag, so you do not keep the same state in arrays by hand. Authors disagree on almost every one of these definitions (strict or loose pivots, wick or close breaks, whether a change of character needs a prior trend, where an order block's edges sit), so each section below spells out the one definition its class implements, and that definition is the contract. ## What the module gives you | Export | Constructor and calls | In one line | | --- | --- | --- | | `Swings` | `new Swings(left, right)`; `.update(high, low)`; `.lastHigh()`, `.lastLow()`, `.barsSinceHigh()`, `.barsSinceLow()`, `.newHigh()`, `.newLow()`, `.hh()`, `.hl()`, `.lh()`, `.ll()`; `.reset()` | Strict pivots of the high and low, confirmed `right` bars late, each tagged higher or lower than the previous one | | `MarketStructure` | `new MarketStructure(left, right)`; `.update(high, low, close)`; `.trend()`, `.event()`, `.lastBreakLevel()`; `.reset()` | A close beyond the last unbroken swing: BOS (`1` / `-1`) or CHoCH (`2` / `-2`), each swing breakable once | | `FairValueGaps` | `new FairValueGaps(max, minSizePct = 0)`; `.update(high, low, close)`; `.count()`, `.newest()`, `.top(i)`, `.bottom(i)`, `.direction(i)`, `.age(i)`, `.filled(i)`; `.reset()` | Three-bar gaps in a ring of the newest `max`, filled by a close beyond the far edge | | `OrderBlocks` | `new OrderBlocks(max, left, right)`; `.update(open, high, low, close)`; `.count()`, `.newest()`, `.top(i)`, `.bottom(i)`, `.direction(i)`, `.age(i)`, `.mitigated(i)`; `.reset()` | The last opposite candle before each break, mitigated when a later bar trades into it | | `Divergence` | `new Divergence(left, right)`; `.update(price, osc): i32`; `.reset()` | `1` regular bullish, `2` hidden bullish, `-1` regular bearish, `-2` hidden bearish on a pivot's confirmation bar, else `0` | | `candles.doji(o, h, l, c, bodyPct = 10)` | | Body at most `bodyPct` percent of the range | | `candles.hammer(o, h, l, c)` | | Long lower wick, short upper wick, close in the top third | | `candles.shootingStar(o, h, l, c)` | | The hammer mirrored | | `candles.bullishEngulfing(prevO, prevC, o, c)` | | An up bar whose body covers the previous down bar's body | | `candles.bearishEngulfing(prevO, prevC, o, c)` | | A down bar whose body covers the previous up bar's body | | `candles.insideBar(prevH, prevL, h, l)` | | `h <= prevH` and `l >= prevL` | | `candles.outsideBar(prevH, prevL, h, l)` | | `h > prevH` and `l < prevL` | | `candles.pinBar(o, h, l, c, wickRatio = 2)` | | `1` for a dominant lower wick, `-1` for a dominant upper wick, `0` otherwise | The shared rules: construct in `onStart()` (every class allocates in its constructor only, so per-bar memory stays flat); call `update()` once per bar, then read the getters; `NaN` means "nothing yet" from every price getter and `-1` or `0` from the integer ones; `reset()` restores the freshly built state. Periods and ring sizes below 1 clamp to 1. Bad data never turns into a signal. A value that is `NaN` or an infinity is non-finite, and a bar with any non-finite field its class reads (the high, low or close; the open too for order blocks) breaks nothing, opens nothing, fills nothing and mitigates nothing, while ages still count that bar. Each section below says what that means for its class in one line. ## Swings ```text high[k] strictly above its `left` and `right` neighbours | . . . . H . . . . ^ ^ ^ ^ ^ ^ ^ ^ left = 4 right = 4 -> confirmed here, 4 bars late ``` `new Swings(left, right)` runs a `PivotHigh` over the high and a `PivotLow` over the low. A swing high is a bar whose high is strictly above the `left` highs before it and the `right` highs after it (a tie is not a swing); it is confirmed on the bar `right` bars later and never repainted. On that confirmation bar `newHigh()` is true, `lastHigh()` holds the swing's price, and `barsSinceHigh()` reads `right` (it counts from the swing bar, so it is `-1` before the first swing and grows by one every bar). `hh()` and `lh()` are true on the confirmation bar only, comparing the new swing high with the previous confirmed swing high (strictly higher or strictly lower; the first swing is neither); `hl()` and `ll()` do the same for swing lows. A window that holds a non-finite high confirms no swing high, and one that holds a non-finite low confirms no swing low. The sample tags every confirmed swing at its own bar with a label handle, HH / LH above and HL / LL below: ```typescript param("left", 5, { min: 1, max: 50, description: "Bars a swing must beat on its left" }); param("right", 5, { min: 1, max: 50, description: "Bars a swing must beat on its right: the confirmation lag" }); input("high", ohlcv.high); output("swing_high", none, overlay, { description: "The newest confirmed swing high" }); output("swing_low", none, overlay, { description: "The newest confirmed swing low" }); string("tag", { max_bytes: 24 }); handles.label({ size: 11 }); const MAX_TAGS = 16; // per side, the oldest tag is reused const RED: i32 = rgba(248, 113, 113, 255); const GREEN: i32 = rgba(74, 222, 128, 255); function makeLabels(first: i32, count: i32): LabelHandle[] { const out: LabelHandle[] = []; for (let i = 0; i < count; i += 1) out.push(draw.label(first + i)); return out; } const highTags = makeLabels(0, MAX_TAGS); const lowTags = makeLabels(MAX_TAGS, MAX_TAGS); let swings = new Swings(5, 5); let right: i32 = 5; let highs: i32 = 0; let lows: i32 = 0; let t: f64 = NaN; let prevT: f64 = NaN; let width: f64 = NaN; function onStart(): void { right = i32(p_right()); swings = new Swings(i32(p_left()), right); } function onBar(): void { prevT = t; t = bar.time(); if (!isNaN(prevT) && t > prevT && (isNaN(width) || t - prevT < width)) width = t - prevT; swings.update(bar.high(), bar.low()); out_swing_high(swings.lastHigh()); out_swing_low(swings.lastLow()); if (!isNaN(width)) { // The swing printed `right` bars ago: place the tag on its own bar. const x = t - width * f64(right); if (swings.newHigh()) { const tag = highTags[highs % MAX_TAGS]; highs += 1; sb_clear(); sb_text(swings.hh() ? "HH " : swings.lh() ? "LH " : "H "); sb_f64(swings.lastHigh(), 2); tag.set(x, swings.lastHigh()).text(str_tag_sb).color(RED); } if (swings.newLow()) { const tag = lowTags[lows % MAX_TAGS]; lows += 1; sb_clear(); sb_text(swings.hl() ? "HL " : swings.ll() ? "LL " : "L "); sb_f64(swings.lastLow(), 2); tag.set(x, swings.lastLow()).text(str_tag_sb).color(GREEN); } } } ``` ## Break of structure and change of character ```text close above the last unbroken swing high swing high ---------------------- . <- BOS up (1) with the trend, or /\ / | CHoCH up (2) when the trend was down / \ / / \ / \/ <- swing low: a close below it breaks the other way ``` `new MarketStructure(left, right)` keeps its own `Swings`. The trend starts at `0`. Every confirmed swing can be broken once: a close strictly above the last unbroken swing high is an upward break, reported as BOS up (`event()` is `1`) while the trend is `0` or `+1` and as CHoCH up (`2`) while the trend is `-1`; after it the trend is `+1`. A close strictly below the last unbroken swing low is the mirror image: BOS down (`-1`) while the trend is `0` or `-1`, CHoCH down (`-2`) while it is `+1`, then the trend is `-1`. A newly confirmed swing replaces the previous one of its kind as the level to break. `event()` is non-zero on the breaking bar only, `trend()` holds between events, and `lastBreakLevel()` is the price of the swing the newest break went through. A bar with a non-finite high, low or close breaks nothing: the trend holds and the swing stays breakable by the next finite bar. The sample draws each event as a shape on the breaking bar and the broken level as a line: ```typescript param("left", 5, { min: 1, max: 50, description: "Bars a swing must beat on its left" }); param("right", 5, { min: 1, max: 50, description: "Bars a swing must beat on its right" }); input("high", ohlcv.high); output("bos_up", shape, overlay, { color: "#4ade80", description: "A mark under the bar that closed above the last swing high with the trend" }); output("choch_up", shape, overlay, { color: "#22d3ee", description: "A mark under the bar that turned a downtrend up" }); output("bos_down", shape, overlay, { color: "#f87171", description: "A mark over the bar that closed below the last swing low with the trend" }); output("choch_down", shape, overlay, { color: "#fb923c", description: "A mark over the bar that turned an uptrend down" }); output("level", line, overlay, { color: "#94a3b8", width: 1, description: "The price of the last broken swing" }); output("trend", none, overlay, { description: "1 up, -1 down, 0 before the first break" }); let structure = new MarketStructure(5, 5); function onStart(): void { structure = new MarketStructure(i32(p_left()), i32(p_right())); } function onBar(): void { const high = bar.high(); const low = bar.low(); structure.update(high, low, bar.close()); // A shape output draws where its value is a price and nothing where it is NaN. const event = structure.event(); out_bos_up(event == 1 ? low : NaN); out_choch_up(event == 2 ? low : NaN); out_bos_down(event == -1 ? high : NaN); out_choch_down(event == -2 ? high : NaN); out_level(structure.lastBreakLevel()); out_trend(f64(structure.trend())); } ``` ## Fair value gaps ```text bar 1 bar 2 bar 3 | | | <- low of bar 3 above the high of bar 1: | | ......| a bullish gap from high[1] (bottom) to | .....|.........| low[3] (top), open until a close drops | | below the bottom ``` `new FairValueGaps(max, minSizePct)` watches three bars at a time. A bullish gap opens on the bar whose low is strictly above the high two bars back (top = this low, bottom = that high, `direction(i)` is `1`); a bearish gap opens when this high is strictly below the low two bars back (top = that low, bottom = this high, `direction(i)` is `-1`). A gap whose height is under `minSizePct` percent of this bar's close is ignored. Each gap is created with `age(i)` `0` and ages one per bar; it is filled the first time a later close crosses the far edge (bullish: close below the bottom; bearish: close above the top) and `filled(i)` stays true. The ring keeps the newest `max` gaps, filled or not, dropping the oldest: index `0` is the oldest kept and `newest()` is `count() - 1` (`-1` while empty). A gap needs all three of its bars finite (high, low and close each), and a bar with a non-finite high, low or close fills nothing; every kept gap still ages on it. The sample draws every kept gap as a box handle from its middle bar, extended to the next bar while it is open and left where it was once filled: ```typescript param("min_size_pct", 0.1, { min: 0, max: 10, description: "Smallest gap kept, in percent of the close" }); input("high", ohlcv.high); output("open_gaps", none, overlay, { description: "Gaps kept in the ring that no close has filled yet" }); handles.box({ opacity: 0.15, borderWidth: 1 }); const MAX_GAPS = 12; // the ring size and the box count, so ring index and box stay aligned const GREEN: i32 = rgba(74, 222, 128, 255); const RED: i32 = rgba(248, 113, 113, 255); function makeBoxes(count: i32): BoxHandle[] { const out: BoxHandle[] = []; for (let i = 0; i < count; i += 1) out.push(draw.box(i)); return out; } const boxes = makeBoxes(MAX_GAPS); let gaps = new FairValueGaps(MAX_GAPS, 0.1); let seen: i32 = 0; // gaps created so far: box (seen - count + i) % MAX_GAPS draws ring index i let t: f64 = NaN; let prevT: f64 = NaN; let width: f64 = NaN; function onStart(): void { gaps = new FairValueGaps(MAX_GAPS, p_min_size_pct()); } function onBar(): void { prevT = t; t = bar.time(); if (!isNaN(prevT) && t > prevT && (isNaN(width) || t - prevT < width)) width = t - prevT; gaps.update(bar.high(), bar.low(), bar.close()); const newest = gaps.newest(); if (newest >= 0 && gaps.age(newest) == 0) seen += 1; let open: f64 = 0.0; const count = gaps.count(); if (!isNaN(width)) { for (let i = 0; i < count; i += 1) { const box = boxes[(seen - count + i) % MAX_GAPS]; const filled = gaps.filled(i); if (!filled) open += 1.0; // The gap opened on the bar `age` bars ago; its middle bar is one earlier. const left = t - width * f64(gaps.age(i) + 1); const color = gaps.direction(i) > 0 ? GREEN : RED; if (filled) { box.opacity(0.05); // stops where it was } else { box.set(left, gaps.top(i), t + width, gaps.bottom(i)).color(color).fill(color).opacity(0.15); } } } out_open_gaps(open); } ``` ## Order blocks ```text swing high broken here -> close | | | | | | | | | B | B = the last bearish candle before the | breaking bar (searching back to the swing high's bar): its high and low are the bullish order block ``` `new OrderBlocks(max, left, right)` runs a `MarketStructure` of its own. On every BOS or CHoCH it looks back from the bar before the breaking bar to the broken swing's bar (inclusive) for the last bar whose close is against the break direction: a bearish candle (close strictly below open) for an upward break, a bullish candle for a downward break. That bar's high and low are the block's `top(i)` and `bottom(i)`, `direction(i)` is `1` for an upward break and `-1` for a downward one, and there is at most one block per event (none when no such bar exists in that span). A block is created with `age(i)` `0` and is mitigated the first time a later bar trades back into it: a bar's low at or below the top of a bullish block, or a bar's high at or above the bottom of a bearish block; `mitigated(i)` then stays true. The ring keeps the newest `max` blocks, mitigated or not, oldest first, like the gaps. A bar with a non-finite open, high, low or close is never a block's candle, mitigates nothing and breaks nothing (the break waits for the next finite bar that closes beyond the swing); every kept block still ages on it. Draw them like the gap boxes above: the kit keeps a block's edges, not its bar, so start each box on the breaking bar it was created on (`t - width * f64(age(i))`; the block's own candle sits between the broken swing and that bar) and stop extending a block once it is mitigated. ## Divergence ```text price . . <- lower low in price \ / \ . / \/ \ / \/ RSI . <- higher low in the oscillator on the same \ . two pivot bars: regular bullish (+1) \ / \ / \ / ``` `new Divergence(left, right)` finds strict pivots of the price you feed it (the close, usually) and remembers the oscillator's value on each pivot bar. On the bar a pivot is confirmed it compares the two most recent confirmed pivots of that kind with the oscillator on the same two bars: at pivot lows, a lower low in price with a higher low in the oscillator is regular bullish (`1`) and a higher low in price with a lower low in the oscillator is hidden bullish (`2`); at pivot highs, a higher high in price with a lower high in the oscillator is regular bearish (`-1`) and a lower high in price with a higher high in the oscillator is hidden bearish (`-2`). Every other bar, the first pivot of a kind, and any tie in price or oscillator give `0`. A non-finite oscillator value on either of the two pivot bars never diverges (the warm-up of an `Rsi` is the usual case), and that pivot still becomes the previous one for the next comparison. The sample runs it over the close and a 14-bar `Rsi` from `./sdk/ta` and marks the price bar; the marks carry `displacement_bars: -5` so they draw on the pivot bar itself at the default `right` of 5 (change both together): ```typescript param("rsi_period", 14, { min: 2, max: 200, description: "RSI length" }); param("left", 5, { min: 1, max: 50, description: "Bars a price pivot must beat on its left" }); param("right", 5, { min: 1, max: 50, description: "Bars a price pivot must beat on its right" }); output("rsi", line, lower, { color: "#a78bfa", description: "14-bar RSI of the close" }); output("bullish", shape, overlay, { color: "#4ade80", displacement_bars: -5, description: "Regular bullish: lower low in price, higher low in RSI" }); output("hidden_bullish", shape, overlay, { color: "#86efac", displacement_bars: -5, description: "Hidden bullish: higher low in price, lower low in RSI" }); output("bearish", shape, overlay, { color: "#f87171", displacement_bars: -5, description: "Regular bearish: higher high in price, lower high in RSI" }); output("hidden_bearish", shape, overlay, { color: "#fca5a5", displacement_bars: -5, description: "Hidden bearish: lower high in price, higher high in RSI" }); output("kind", none, overlay, { description: "1, 2, -1, -2 on the confirmation bar, else 0" }); let rsi = new Rsi(14); let divergence = new Divergence(5, 5); function onStart(): void { rsi = new Rsi(i32(p_rsi_period())); divergence = new Divergence(i32(p_left()), i32(p_right())); } function onBar(): void { const close = bar.close(); const high = bar.high(); const low = bar.low(); const value = rsi.update(close); // A NaN oscillator on a pivot bar (the RSI warm-up) compares as no divergence. const kind = divergence.update(close, value); out_rsi(value); out_bullish(kind == 1 ? low : NaN); out_hidden_bullish(kind == 2 ? low : NaN); out_bearish(kind == -1 ? high : NaN); out_hidden_bearish(kind == -2 ? high : NaN); out_kind(f64(kind)); } ``` ## Candle patterns ```text doji hammer shooting star engulfing inside outside | | | | | | | -+- -+- | -+ | -+- -+- | | | | | | | | | | -+- |-+- | | | | | | -+- | ``` The `candles` namespace is stateless: each function takes the prices it needs and answers for that bar. Body is `abs(close - open)`, range is `high - low`, the lower wick is `min(open, close) - low` and the upper wick is `high - max(open, close)`. `doji` is true when the body is at most `bodyPct` percent of the range (a bar with no range counts). `hammer` needs a lower wick at least twice the body, an upper wick at most the body, and a close in the top third of the range; `shootingStar` is the mirror image with the close in the bottom third. `bullishEngulfing` needs this bar closing up, the previous bar closing down, and this body covering the previous body (`o <= prevC` and `c >= prevO`); `bearishEngulfing` is the mirror. `insideBar` is `h <= prevH` and `l >= prevL`; `outsideBar` is `h > prevH` and `l < prevL`. `pinBar` finds the dominant wick: at least `wickRatio` times the body and at least as long as the other wick, and answers `1` when that wick is below the body, `-1` when it is above, `0` otherwise (two equal qualifying wicks read `1`). Two rules keep degenerate bars out. `hammer`, `shootingStar` and `pinBar` need a positive range (`h > l`) and a positive dominant wick, so a flat bar (open, high, low and close all equal) is a doji and nothing else: not a hammer, not a shooting star, and `pinBar` answers `0`. And any non-finite argument, a `NaN` or an infinity in any price, in `bodyPct` or in `wickRatio`, makes every predicate answer false (`0` from `pinBar`). Feed the two-bar patterns the previous bar's values you keep yourself at module level, as the swings sample keeps `prevT`: ```typescript // In onBar(), with prevOpen / prevClose / prevHigh / prevLow kept from the last bar: // const engulfed = candles.bullishEngulfing(prevOpen, prevClose, open, close); // const pin = candles.pinBar(open, high, low, close, 2.5); // 1, -1 or 0 // const quiet = candles.insideBar(prevHigh, prevLow, high, low) && candles.doji(open, high, low, close, 15.0); ``` ## From Pine Only the pivot maps one to one: `ta.pivothigh(left, right)` and `ta.pivotlow(left, right)` are `PivotHigh` and `PivotLow` on the [TA library](ta-library.md) page, and `Swings` is the pair of them with the bookkeeping. Structure breaks, gaps, order blocks, divergence and the candle predicates have no Pine builtin; they are the definitions above. <!-- source: https://openmarket.xyz/wrun/functions/options-kit --> # Options kit The chart serves a coin's listed options as `options_chain` cells, one ten-value tuple per contract, on the live bar only ([Data sources](../core-concepts/data-sources.md)). The `./sdk/options` module is the arithmetic a gamma map runs over that block, with nothing to import: `OptionsChain` takes the chain, filters it by expiry and strike window, aggregates every contract into a strike table sorted ascending, and answers gamma exposure by strike and for the chain, the zero-gamma flip, the call and put walls, max pain and the put/call open-interest ratio. Its numbers are the [Gamma map](../cookbook/gamma-map.md) recipe's numbers: the same operations in the same order, so a chain loaded here and measured by the template agree to the last bit, with one exception: when the running net sum crosses zero more than once (the far wings' few dollars can flip it back and forth), the template's flip is the crossing nearest spot, where `zeroGamma()` names the first one walking up. Construct the object in `onStart()`, load it once on the live bar, read it in the same bar; no call allocates after construction, so the module never grows memory per bar. | Export | Signature | Definition | | --- | --- | --- | | `OPTIONS_TUPLE` | `const OPTIONS_TUPLE: i32 = 10` | f64 values per contract in an `options_chain` block: `[strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega]`, `side` `+1` for a call and `-1` for a put | | `OptionsChain` | `new OptionsChain(maxStrikes)`; `.load(cells, count, nowMs, spot, nearest = 0, windowPct = 0.0): i32`; `.strikes()`, `.strike(i)`, `.callOi(i)`, `.putOi(i)`, `.callGex(i)`, `.putGex(i)`, `.netGex(i)`; `.totalCallGex()`, `.totalPutGex()`, `.totalNetGex()`; `.zeroGamma()`, `.callWall()`, `.putWall()`, `.maxPain()`, `.putCallRatio()` | One chain measured by strike: `load()` keeps the contracts expiring after `nowMs` (the `nearest` soonest expiries when `nearest > 0`, the strikes within `windowPct` percent of `spot` when `windowPct > 0`), sums open interest and gamma exposure per strike and side, and returns the strike count kept; the readers answer for the chain last loaded | ![The strike table on a price axis: call exposure drawn to the right of each strike and put exposure to the left, the call wall above spot and the put wall below it, the running net sum climbing from the lowest strike and crossing the axis at the zero-gamma point, and max pain circled among the strikes](/wrun/images/diagrams/options-strike-table.svg) 1. **Strikes** on the price axis, ascending from the bottom: `strike(i)`, with `i` `0` the lowest. 2. **Call GEX** to the right of each strike and **put GEX** to the left: `callGex(i)` and `putGex(i)`, both stored positive. 3. **The running net sum**, calls minus puts from the lowest strike up: `zeroGamma()` is where it crosses zero, placed between two strikes by the two sums' magnitudes. 4. **Call wall**, the strike at or above spot holding the most call open interest; **put wall**, the strike at or below spot holding the most put open interest. 5. **Max pain**, the listed strike that pays holders the least, circled among the strikes. Per contract, with `spot_c` the contract's `underlying` when above 0 and otherwise the `spot` you passed, and `mult` its `multiplier` when above 0 and otherwise 1: - `callGex(i)` and `putGex(i)` sum `gamma * oi * mult * spot_c * spot_c * 0.01` over the calls and the puts at the `i`-th strike: the dollar change of the open interest's delta for a 1% move of spot, in USD per 1% move. Both are stored positive (a listed option's gamma is positive). - `netGex(i)` is `callGex(i) - putGex(i)`: the dealer-naive sign convention, calls positive and puts negative, taking dealers as long the calls customers sold them and short the puts customers bought. `totalCallGex()`, `totalPutGex()` and `totalNetGex()` sum the table from the lowest strike up. - `callOi(i)` and `putOi(i)` are the open interest summed per side at the strike, in the chain's own unit (coins on Deribit, contracts on CME). `putCallRatio()` is total put over total call open interest, `NaN` when the call side holds none. - `callWall()` is the strike at or above `spot` holding the most call open interest, `putWall()` the strike at or below `spot` holding the most put open interest; `NaN` when no strike on that side has any. Ties keep the lower strike. - `maxPain()` is the listed strike `S` minimising `sum(callOi * max(0, S - K)) + sum(putOi * max(0, K - S))` over the table's strikes `K`, the settlement price that pays holders the least; ties keep the lower strike, `NaN` on an empty table. - `zeroGamma()` walks the strikes upward summing `netGex`: the strike where the running sum lands on exactly 0, or the point between the two strikes where it changes sign, placed by the two sums' magnitudes (`prev + (next - prev) * |cum_prev| / (|cum_prev| + |cum_next|)`); `NaN` when the sum never changes sign. - `strikes()` is the table size, `strike(i)` the `i`-th strike ascending (`0` the lowest); every indexed reader is `NaN` outside `0..strikes() - 1`. Before the first `load()` the table is empty: the totals read `0`, everything else `NaN`. ## Reading the chain on the live bar History bars carry an empty block and the chain arrives on the newest row, so the read sits under `bar.isLast()`: `in_chain_cells()` is the f64 count the bar carries (`-1` on a bar without a block), `in_chain_view()` is the block itself, read in place from the one buffer the build reserves at module start, and `load()` takes that array, the count, the time in milliseconds and the chart's close as spot. The numbers hold on the following bars, so an output or a card written in `onBar()` reads the same chain until the next live bar refreshes it. ```typescript param("nearest_expiries", 0, { min: 0, max: 24, description: "Expiries counted: 0 = every listed expiry, N = the N nearest" }); param("window_pct", 0, { min: 0, max: 50, description: "Strikes kept: percent around spot each side, 0 = the whole chain" }); input("close", ohlcv.close); // the chart's own close: the grid, and spot input("chain", options_chain.cells, { max_cells: 4000, venue: "auto" }); output("net_gex", none, overlay, { description: "Net gamma exposure, USD per 1% move of spot; live bar only" }); output("flip", none, overlay, { description: "Zero gamma: where cumulative net GEX crosses zero" }); output("call_wall", none, overlay, { description: "The heaviest call strike at or above spot" }); output("put_wall", none, overlay, { description: "The heaviest put strike at or below spot" }); output("max_pain", none, overlay, { description: "The strike that pays option holders the least" }); let chain = new OptionsChain(1); let nearest: i32 = 0; let windowPct: f64 = 0.0; function onStart(): void { // Construct here: the per-strike buffers are the module's only allocation. chain = new OptionsChain(512); nearest = i32(p_nearest_expiries()); windowPct = p_window_pct(); } function onBar(): void { if (bar.isLast()) { const n = in_chain_cells(); // The live chain in place: the first n values of the build's own buffer. if (n >= OPTIONS_TUPLE) chain.load(in_chain_view(), n, bar.time() * 1000.0, bar.close(), nearest, windowPct); } out_net_gex(chain.totalNetGex()); out_flip(chain.zeroGamma()); out_call_wall(chain.callWall()); out_put_wall(chain.putWall()); out_max_pain(chain.maxPain()); } ``` Read `last 1 net_gex` at the editor's Console prompt for the chain's total, or loop `chain.strikes()` and draw `chain.netGex(i)` at `chain.strike(i)` into a frame for the docked profile the Gamma Map recipe shows. ## Expiries, the strike window and the table size ![The three load() filters as a funnel: every contract in the block enters at the top, about 1,550 on a BTC chain; the first cut drops the contracts whose expiry is not after now and keeps the N nearest expiries when asked; the second drops the strikes outside the window around spot; the third drops the strikes farthest from spot once the table holds maxStrikes; what comes out of the neck is the strike table, sorted ascending](/wrun/images/diagrams/options-load-funnel.svg) 1. **Every contract** in the block enters at the top, about 1,550 on a BTC chain. 2. **Expiry**: a contract whose `expiry_ms` is not after `nowMs` drops out; `nearest > 0` keeps only the N soonest expiries. 3. **Strike window**: `windowPct > 0` drops the strikes outside the window around `spot`. 4. **Table size**: once `maxStrikes` strikes are in, the strikes farthest from `spot` drop first. 5. **The strike table** comes out of the neck, sorted ascending; `load()` returns its size. `load(cells, count, nowMs, spot, nearest, windowPct)` applies three filters before anything is summed: - **Expiry.** Only contracts with `expiry_ms > nowMs` count; pass the bar's open time times 1000 (`bar.time() * 1000.0`), the recipe's clock. `nearest > 0` keeps the `nearest` soonest of those expiries, so `nearest = 1` is the front expiry alone and `nearest = 2` the front two; asking for more expiries than the chain lists keeps them all, and `0` keeps every live expiry. There is no cap on distinct expiries. - **Strike window.** `windowPct > 0` keeps the strikes within `windowPct` percent of `spot` on each side, bounds included (`spot * (1 - windowPct / 100)` to `spot * (1 + windowPct / 100)`); `0` keeps every strike. The window narrows the whole chain: the walls, the totals, the flip and max pain are all measured over the strikes inside it. The Gamma Map template narrows only its walls, so its net GEX, flip and max pain are the readings of a chain loaded with `windowPct` `0` (the flip with the one exception above), and its walls those of a chain loaded with its window; an indicator that wants both constructs two `OptionsChain` objects in `onStart()` and loads each. - **Table size.** `maxStrikes` (clamped to at least 1) is the most distinct strikes the table holds; the Gamma Map keeps 512. When a chain lists more, the strikes farthest from `spot` are dropped first, whatever order the contracts arrive in (two strikes at the same distance: the lower goes first), so the table is always the `maxStrikes` strikes nearest the chart. The template instead refuses the strikes that arrive after its table is full. A contract whose strike or open interest is not above 0 is skipped, so a strike only enters the table through live open interest; a contract whose expiry is not a number is skipped too. A gamma that is not a number poisons its strike's exposure and the totals, exactly as the template's sums do, and the walls, max pain and the open-interest ratio never read gamma, so they stay finite. ## A strike matrix on the price axis The per-strike readings are a table whose rows are prices, which is what `plot.matrix` draws: one row per strike docked on the price axis, one column per expiry, each cell filled from a diverging palette ([Price canvases](../presentation/price-canvases.md#strike-matrices)). Load one `OptionsChain` per expiry window on the live bar, write the board into a frame (prices ascending, one cell row per price, the ATM strike in `highlight`) and declare the matrix over it; the chart keeps the rows on their prices as you pan and zoom. The frame rides `wrun-4`, which a `frame(...)` declaration stamps for you. ```typescript input("close", ohlcv.close); input("chain", options_chain.cells, { max_cells: 4000, venue: "auto" }); const board = frame("board", { max_bytes: 65536 }); plot.matrix({ name: "gex_board", frame: board, dock: "right", columns: ["Front", "Next", "Third"], price_column: true, palette: ["theme.down", "theme.bg", "theme.up"], center: 0, format: "si", tooltip: "{{column}} net GEX {{value:usd}} at {{price}}" }); ``` The [Strike Matrix](../cookbook/strike-matrix.md) recipe is the whole file: net gamma exposure by strike across the four nearest expiries (a setting), the ATM row highlighted, rewritten on every live bar. ## Limits - **The chain is the live row's only.** `options_chain` fills the newest row and leaves every history row an empty block ([Limitations](../reference/limitations.md)); read it under `bar.isLast()` and keep the readings for the bars that follow. A gamma map over past chains has no form yet. - **The chains are the chart's.** `venue: "auto"` reads the chart's own options venue when it lists options, else the coin's Deribit chain; a venue's name (`deribit`, `cme`, ...) pins one. Open interest and the multiplier arrive as the venue reports them: Deribit in coins with a multiplier of 1, CME in contracts with the point value as the multiplier, which is why the exposure is in USD on both. A venue the chart cannot serve is refused by name. - **Size `max_cells` for the chain.** A BTC chain is about 1,550 contracts; a block over `max_cells` refuses the run instead of being truncated, and the `cells` array costs `max_cells * 10 * 8` bytes of module memory ([Limits](../reference/limits.md)). - **Browser lane.** `options_chain` is served by the chart; any other host refuses an indicator that declares it, by name. ## From Pine Nothing on this page maps to Pine. Pine cannot read an options chain, so there is no `request.*` call or `ta.*` function to translate: gamma exposure, the walls and max pain are computed from cells the chart serves to the indicator, and the class is this chart's arithmetic for them. <!-- source: https://openmarket.xyz/wrun/functions/alerts --> # Alerts An alert runs your published indicator in OpenMarket's cloud and tells you when a condition on one of its outputs, or a signal its file declares, comes true. You set it from the chart: pick an output or a declared signal, choose a condition, and OpenMarket's alerts engine evaluates it on the chart's market with the overlay's settings. There are two ways in: every drawn output is a value a condition can follow, and `alert(name, { when, message })` in the file names a ready-made signal. ## Set an alert from the chart 1. Publish the indicator. An alert needs a published version; on a draft the chart says "Publish the Indicator before adding an alert." ([Publishing](publishing.md)). 2. Add it to a chart and open the alert dialog from the indicator's bell in the legend, from the right-click menu, or from **Alert on** > **Indicators** in the sidebar's alert menu. 3. Pick the output or a declared signal, pick the condition, and save. The alert runs the version and the settings the overlay carries. The conditions on an output: ```text Crossing Crosses above, Crosses below, Greater than, Less than Channel Entering channel, Exiting channel, Inside channel, Outside channel Moving Moving up, Moving down, Moving up %, Moving down % ``` A data-only output (declared `none`) is not offered as a condition target; give it a declared signal instead, as below. ## Declare a signal in the file ```typescript param("fast", 21, { min: 1, max: 200, description: "Fast EMA length" }); param("slow", 55, { min: 2, max: 400, description: "Slow EMA length" }); output("fast", line, overlay, { color: "#2563eb", description: "Fast EMA of the close" }); output("slow", line, overlay, { color: "#f97316", description: "Slow EMA of the close" }); // Data-only: 1 on the bar the fast EMA crosses above the slow one, else 0. const goldenCross = output("golden_cross", none); // A ready-made signal in the chart's alert dialog: it fires when the gate turns from 0 to 1. alert("golden_cross_up", { when: goldenCross, message: "{{symbol}} golden cross at {{close}}", description: "Fast EMA crosses above slow EMA" }); let fast = new Ema(21); let slow = new Ema(55); let cross = new Cross(); function onStart(): void { fast = new Ema(i32(p_fast())); slow = new Ema(i32(p_slow())); cross = new Cross(); } function onBar(): void { const close = bar.close(); const fastValue = fast.update(close); const slowValue = slow.update(close); const crossed = cross.update(fastValue, slowValue); if (isNaN(slowValue)) return; out_fast(fastValue); out_slow(slowValue); out_golden_cross(crossed == 1 ? 1.0 : 0.0); } ``` `alert(name, { when, message?, description?, text?, every_bar?, title? })` declares a signal over an output handle bound by a top-level `const`. It fires on the bar the `when` output turns from false to true (true is finite and nonzero; a `NaN` row is false and re-arms it), `message` is the text the alert sends (placeholders such as `{{symbol}}` and `{{close}}` fill in when it fires, and `{{<output>}}` reads a declared output's value on the fired bar), and `description` is the line under the signal in the alert dialog. Both are at most 200 characters. The `when` output may be data-only, as here: the file writes `golden_cross` from `onBar()` like any other output, and the declaration adds nothing to the compiled module. Declared signals appear in the chart's alert dialog beside the conditions, and an indicator that declares only signals offers only those. The grammar is on [Declarations and the sheet](../reference/declarations.md#alert). ## Alert words and every bar An alert can send words your file writes on the bar it fires, and it can fire on every bar its condition holds instead of only the first. Both are options on `alert(...)`. ```typescript // Two alerts on an EMA pair: one writes its own message on the cross, one fires on every bar the fast line stays above. param.int("fast", 21, { min: 1, max: 200 }); param.int("slow", 55, { min: 2, max: 400 }); output("fast", line, overlay, { color: "#2563eb" }); output("slow", line, overlay, { color: "#f97316" }); // Data-only gates: 1 on the bar the condition is true, else 0. const crossedUp = output("crossed_up", none); const above = output("above", none); // The slot the file writes the cross message into. const crossWords = string("cross_words", { max_bytes: 96 }); // Fires once, on the bar of the cross, with the words the file wrote on that bar. The dialog lists it as "EMA cross up". alert("cross_up", { when: crossedUp, text: crossWords, title: "EMA cross up", description: "Fast EMA crosses above slow EMA" }); // Fires on every bar the gate is true. {{fast}} and {{slow}} read those outputs on the fired bar. alert("still_above", { when: above, message: "{{symbol}}: fast {{fast}} is above slow {{slow}}", every_bar: true, description: "Every bar the fast EMA is above the slow EMA" }); let fast = new Ema(21); let slow = new Ema(55); let cross = new Cross(); function onStart(): void { fast = new Ema(i32(p_fast())); slow = new Ema(i32(p_slow())); cross = new Cross(); } function onBar(): void { const close = bar.close(); const fastValue = fast.update(close); const slowValue = slow.update(close); const crossed = cross.update(fastValue, slowValue); if (isNaN(slowValue)) return; out_fast(fastValue); out_slow(slowValue); out_crossed_up(crossed == 1 ? 1.0 : 0.0); out_above(fastValue > slowValue ? 1.0 : 0.0); // Write the words on the bar the alert fires. On a bar that writes nothing, the alert sends its message, else its name. if (crossed == 1) { sb_clear(); sb_text("Fast EMA "); sb_auto(fastValue); sb_text(" crossed above slow EMA "); sb_auto(slowValue); str_cross_words_sb(); } } ``` - `cross_up` sends the words the file wrote into `cross_words` on the bar of the cross, such as "Fast EMA 103.71 crossed above slow EMA 103.7". - `still_above` fires on every bar the fast line is above the slow one, at most once per bar. Its message fills `{{fast}}` and `{{slow}}` with those outputs' values on the fired bar. ### Options | Option | What it does | Default | | --- | --- | --- | | `when` | the output that turns the alert on: true is finite and not 0 | required | | `text` | a string slot; its words on the fired bar are the message | none | | `message` | fixed words with placeholders: `{{symbol}}`, `{{exchange}}`, `{{interval}}`, `{{open}}`, `{{high}}`, `{{low}}`, `{{close}}`, `{{volume}}`, `{{value}}`, `{{time}}`, `{{alert.name}}`, and `{{<output>}}` for any output you declared | the alert's name | | `every_bar` | `true` fires on every bar `when` is true, once per bar at most; `false` fires only on the bar it turns true | `false` | | `description` | the line under the signal in the alert dialog | none | | `title` | the words the alert dialog, an armed alert and its notification show for the signal: any characters, spaces included, 1 to 120 of them on one line (the name itself takes letters, digits, `.`, `_` and `-` only); a newline, a control character or an invisible one (a bidi control, a zero-width space) is refused, as in a text setting's default | the alert's name | ### Which words are sent 1. The message the user types in the alert dialog, when there is one. Its placeholders fill in as always. 2. Otherwise the `text` slot's words on the fired bar. A bar that wrote nothing there, or an empty line, falls through. 3. Otherwise `message`. Each `{{<output>}}` becomes that output's value on the fired bar: a whole number as it is, any other number to 10 significant digits, `n/a` when the bar has no value. `{{close}}` and the other placeholders above keep their own meaning, even when an output has the same name. 4. Otherwise the alert's name. Words written by the file arrive as plain text. They are one line of at most 200 characters (longer words end with an ellipsis), a link in them is never clickable and never previewed, and mentions such as `@everyone` and `@here` are removed. They are never read as a template, so a `{{close}}` the file writes arrives exactly as written. ### Every bar, and the user's choice The alert dialog shows the file's choice under **Fire**: "When it turns true" or "Every bar while true". The user can change it for one alert there ("The script's author set the default; your choice applies to this alert."). The repeat and the tick-or-close choices still apply, so a tick alert can fire on the forming bar before it closes. ### Limits and refusals | Rule | What the build says | | --- | --- | | `text` names a string slot | `alert 'cross_up' option 'text' references output 'above' where a string slot is needed` | | `text` names something declared | `alert 'cross_up' option 'text' references 'ghost', which is neither a declared output nor a declared string slot; bind the output first (const h = output("...", ...)) or the slot (const s = string("...", { max_bytes: 32 })) and pass that const, or name it as a string literal` | | `every_bar` is `true` or `false` | `option 'every_bar' takes true or false` | | `message` and `description` are at most 200 characters | `message must be at most 200 characters` | | `title` is 1 to 120 characters | `alert 'cross_up' title is 121 characters; it takes at most 120`, `alert 'cross_up' title is empty; it names the alert in the dialog (leave it out to show the name)` | | `title` is one line of plain text | `alert 'cross_up' title carries a newline, a control or an invisible character; shown text is stripped of bidi controls, zero-width characters and control characters, so a title cannot carry them` | | 64 alerts per indicator | `alerts must declare at most 64 entries` | ## Strategy alerts An indicator that trades adds four choices of its own to the alert dialog: "Strategy order placed", "Strategy trade opened or closed", "Strategy position" and "Strategy equity" ([Strategies overview](../strategies/overview.md)). ## What an alert can evaluate An alert evaluates the indicator on the chart's market at the chart's interval, which must be 1m, 5m, 15m, 30m, 1h, 4h or 1d ("Alerts support 1m, 5m, 15m, 30m, 1h, 4h and 1d intervals. Switch the chart interval to arm this indicator."). It serves these inputs: | The indicator reads | An alert serves | | --- | --- | | Feeds | `ohlcv`, `trades`, `oi`, `liquidations`, `funding` and `time`, plus `book` and `volume_profile` cells | | Another market | its candles, through a secondary `ohlcv` input pinned with `symbol` and `exchange`: a crypto venue, a stock or ETF on `POLYGON`, forex, gold or silver on `FX_OTC` | | A coarser timeframe | a secondary `ohlcv`, `funding` or `oi` input pinned to `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d` or `1w`, a whole multiple of the chart's interval; a pinned candle counts only once it has closed | | A view of a pin | `confirmed` (the default), `forming` and `is_new_period` | An indicator it cannot evaluate is refused by name when you save the alert: - "This Indicator is pinned to a different interval than the chart." for a pin on the first input, a pin finer than the chart's interval or not a whole multiple of it, a custom timeframe such as `2h` or `45m`, and a `forming` view or `1w` candle too long for the 600 bars an alert keeps (below). - "This Indicator reads a data source alerts cannot evaluate yet." for every feed the table does not list, `odds`, `implied_volatility` and `skew` included, and for `trade_volume_by_size` cells. - "Alerts cannot use the Timeframe setting yet. Set it back to its default to arm." for a source, timeframe or symbol setting moved off its default; the sentence names the setting. - "This Indicator reads a market alerts cannot evaluate yet (an index)." for an input pinned to an index (`POLYGON_INDICES`); pin an ETF on the same market instead (`SPY/USD`). - "Indicator alerts are not allowed on this venue." for an indicator whose input pins a CME market. On CME markets only price alerts are available for now ("Only price alerts are available on CME markets right now."), and where wrun indicator alerts are switched off the chart says "Indicator alerts are not enabled yet." `intrabar` cells, `candles` streams, `options_chain` cells and an `offset` view are refused as well. ### How much history an alert reads An alert evaluates over at most 600 bars of the chart's interval. The window is the larger of the warm-up the file declares and the largest maximum among its settings, counted up to 500. A warm-up is one top-level line, `warmup({ terms: [{ param: length, times: 2 }], min: 40 })`: with `length` a setting's handle, it asks for twice that setting's value in bars, and at least 40. An indicator that reads a coarser pinned timeframe gets all 600 bars, even when it reads only that pin's `is_new_period` pulse; one that also reads `book` or `volume_profile` cells keeps the window its warm-up and settings give it. 600 chart bars hold 600 / (leg / chart) candles of a pin: a 4h pin holds 150 candles on a 1h chart, 37 on 15m and 12 on 5m. A higher-timeframe average longer than that arms but stays empty, so arm such alerts on a coarser chart: an average of 20 4h candles fills on 15m and stays empty on 5m. A `forming` view needs its whole candle inside the 600 bars, so a `1d` one needs a 5m chart or coarser and a `1w` one 30m or coarser, and a `1w` pin's closed candle needs 1h or coarser; on a finer chart the alert is refused with "This Indicator is pinned to a different interval than the chart." An indicator that builds a higher timeframe from the chart's own bars, with a `Resampler` and no pin, must declare `warmup()` long enough for it: 20 1h candles on a 5m chart are 20 x 12 = 240 bars. Otherwise its alert can arm and stay empty. ## Next - **Publishing:** the published version an alert runs ([Publishing](publishing.md)) - **Declarations and the sheet:** the full `alert(...)` grammar ([Declarations and the sheet](../reference/declarations.md#alert)) - **Multi-source:** pin a stock, forex or gold so an alert reads it ([Multi-source](../core-concepts/multi-source.md#stocks-forex-and-gold)) - **Execution model:** where each part of an indicator runs ([Execution model](../core-concepts/execution-model.md)) <!-- source: https://openmarket.xyz/wrun/functions/publishing --> # Publishing Publishing turns the wrun indicator in your editor into a versioned package on the OpenMarket registry, under your username, that you, the people you invite, or everyone can add to a chart. In the editor, **Publish** opens the **Publish Indicator** dialog once the current code runs with no errors (until then the editor says "Publish is available once the Indicator has no errors"); you need to be signed in, and the draft is saved first. Whether readers get the code, a compiled module, or neither is the dialog's **Code** choice, and it also decides where the indicator runs: on OpenMarket's servers, or in each person's browser. Publishing is also how code is reused between indicators: there is no import between them ([Reuse and libraries](#reuse-and-libraries)). Who can use it, inviting people and keeping a member list in sync from your own site are the [Sharing](../sharing/overview.md) section. ## The Publish Indicator dialog ```text Publish Indicator Name @yourname/ followed by the name: lowercase letters, digits and dashes Version 0.1.0 on the first release; Patch, Minor or Major after that What changed one line people read before they update (from the second version) Who can use it Everyone (Listed on OpenMarket on or off), Invite only, Just me Code Protected, Compiled, Open source Description what this Indicator shows Cover a picture of this pane, taken now ``` - **Name.** The scope is your OpenMarket username, lowercased, and the dialog sets it in front of the name; you type only the name part. After the first publish the name is read-only: a rename is a new indicator. The name shown in lists is the editor tab's title. - **Version.** The first release is always 0.1.0. Every later publish is **Publish a new version**: pick **Patch**, **Minor** or **Major** (the dialog starts on the next patch). A published version never changes, so revising an indicator means publishing the next version. - **What changed.** From the second version, one line of up to 280 characters that people read before they update. - **Description.** What the indicator shows, prefilled from the draft. - **Cover.** When the draft is on the active chart, publishing takes a picture of its pane and uses it as the package's cover. There is no picker; without a picture the registry shows a stock one. ![The Publish Indicator dialog on a first publish: the name after the scope, the 0.1.0 line, Who can use it with Invite only and Just me greyed without a direct session, Code, Description, Cancel and Publish](/wrun/images/publish-dialog.png) - The scope (`@yourname/`) is fixed in front of the name field; the name is prefilled from the tab's title. - "Version 0.1.0, the first release" replaces the version chips until the second publish. - **Everyone** is on with **Listed on OpenMarket**; **Invite only** and **Just me** are greyed here because this session was not a direct one. - **Code** shows the current choice, with its one-line meaning under the row. With a direct OpenMarket session the dialog publishes as you on the spot ("Publishing directly as @yourname: no popup."). Otherwise it opens a sign-in window that finishes by itself; if the browser blocks it, the dialog says "The publish window was blocked. Allow popups for this site and try again." A refusal from the registry, such as a version that already exists, reads "Publish failed:" followed by the registry's reason. ## Who can use it **Who can use it** picks **Everyone**, **Invite only** or **Just me**. A new publish starts on **Everyone**, listed. Invite only and Just me need a direct OpenMarket session; without one they are greyed out. You can change who can use a published indicator at any time without publishing a new version. What each choice means, how to invite people, and how to keep a member list in sync from your own site: [Sharing](../sharing/overview.md). ## Code: who reads it and where it runs | Choice | What the dialog says | Who gets what | | --- | --- | --- | | **Protected** | Runs on OpenMarket servers. Your code never leaves them: nobody can download, read or copy it. | the chart shows the result; nothing of yours downloads | | **Compiled** | Runs in each person's browser. They get a compiled module, never your source. | everyone with access downloads the compiled module, and a downloaded module can be copied | | **Open source** | Anyone who can use it can read the code and fork it. | everyone with access gets the code, in each person's browser | - The row opens the three choices; the tick marks the current one. - The sentence under the row changes with the choice and says who gets the code and where the indicator runs. - **Invite only** starts on **Protected**: the choice for anything you sell. Only Protected keeps the code on OpenMarket's servers; a Compiled module is downloaded by everyone with access and can be copied. Invite only greys out **Open source** ("Invite only keeps the code private."). A **Protected** indicator is marked Protected in the Indicators dialog. OpenMarket's servers compute it over the chart's candles and stream the result to the chart; its settings dialog is the file's own (a source, timeframe or symbol setting keeps its declared default there for now, and there is no Style page), and it cannot be forked. In the editor, once its current version is published, you can run the published indicator in the cloud instead of in your browser. Protected is free to run for your community within fair use ([Fair use](../sharing/overview.md#fair-use)). Open source code is capped at 256 KB. Protected is set at the first publish and cannot change later. A name first published as Compiled or Open source stays in the browser, and a name first published as Protected stays on OpenMarket's servers; Compiled and Open source can alternate from one version to the next. To move an indicator to the other side, publish it under a new name. ## After publishing - **Find it.** Published wrun indicators are in the chart's **Indicators** dialog, on its **Indicators** tab: **OpenMarket** (the built-in and official indicators), **Community**, and **Mine** (yours, drafts included, when you are signed in), with indicators others invited you to under **Shared with you**. The chart's search finds them too. - **Add it.** Add a row, or open it and press **Apply**. The overlay keeps the exact version it was added at and reloads that version, so a newer publish never changes a chart that already carries the older one. - **Indicator page.** In the detail panel, **Indicator page** opens the package's public page in a new tab. - **Fork.** On desktop, **Fork** copies an Open source indicator's code into a new draft in your editor, and publishing the draft records "Forked from" the original. A Compiled indicator has nothing to fork ("The author published this Indicator without its source, so there is nothing to fork."), and a Protected indicator offers no Fork. - **Alerts.** A published indicator can carry alerts from the chart; a draft cannot ([Alerts](alerts.md)). - **Invite people.** After an Invite only publish, **Invite people…** on the receipt opens the indicator's People panel ([Invite your community](../sharing/invite-your-community.md)). ## Manage a published indicator **Manage Indicator**, on the indicator's page under **Mine** in the Indicators dialog, is the owner's place for removal. **Delete Indicator** removes every version for good, and the name can never be used again. A chart that already carries it keeps an overlay that no longer loads and says so on its legend. On an Invite only indicator, **People · N** on the same page opens its People panel: the people with access, the invite link, imports and access keys ([Sharing](../sharing/overview.md)). ## Reuse and libraries Sharing code between wrun indicators is publishing one: the unit of reuse on OpenMarket is a whole indicator, `@yourname/name`, that people add to their charts, and an Open source one can be forked into a new draft in your editor. There is no source import between indicators, and no `import "@scope/lib"` from another indicator's source. Inside one indicator, shared code is ordinary AssemblyScript in the same file: an indicator is one file. ### Shared code in the one file Put functions, classes, and constants in the editor tab beside the declarations and `onBar()`. One file per indicator: the kit's classes and functions and the readers and writers Run generates from your declarations are simply there, with nothing to import, and there is no second file to import from (a second source file is refused: "one file per indicator for now: move src/helpers.ts into src/indicator.ts"). A set of normalization helpers, in the indicator that uses them, which owns the data, the TA objects, and the declarations, beside the kit's own `Sma` and `Stdev`: ```typescript param("period", 20, { min: 2, max: 400 }); output("z", line, lower, { color: "#4f8cff", description: "Z-score of the close" }); output("regime", line, lower, { color: "#f59e0b", description: "+1 stretched up, -1 down, 0 inside the band" }); // The shared logic: plain functions and a class, no declarations. function zscore(sample: f64, mean: f64, dev: f64): f64 { if (dev == 0.0) return 0.0; return (sample - mean) / dev; } // +1 stretched up, -1 stretched down, 0 neutral. function regime(z: f64, hi: f64, lo: f64): f64 { if (z > hi) return 1.0; if (z < lo) return -1.0; return 0.0; } class Band { top: f64; bottom: f64; constructor(top: f64, bottom: f64) { this.top = top; this.bottom = bottom; } contains(price: f64): bool { return price <= this.top && price >= this.bottom; } } let sma = new Sma(20); let stdev = new Stdev(20); function onStart(): void { sma = new Sma(i32(p_period())); stdev = new Stdev(i32(p_period())); } function onBar(): void { const close = bar.close(); const mean = sma.update(close); const dev = stdev.update(close); const z = zscore(close, mean, dev); const band = new Band(mean + dev, mean - dev); if (isNaN(mean) || isNaN(dev)) return; out_z(z); out_regime(band.contains(z) ? 0.0 : regime(z, 2.0, -2.0)); } ``` In the editor, open a **New indicator** tab, paste it in, and press **Run**: it builds like any other indicator. `Band` is a class the indicator constructs; a function is a function everywhere in the file, and there are no placement rules. The `new Band(...)` on every bar allocates, and the module's runtime never frees; keep one `Band` and update its fields when the history is long ([Collections](../core-concepts/collections.md)). ### Reuse across indicators What sharing code between indicators looks like on the chart: | You want | wrun form | | --- | --- | | use another indicator's functions | copy them into your tab, or add that indicator to the chart beside yours as its own overlay | | a shared type | a class in your tab | | reuse a series another indicator computed | not on the chart: each indicator runs its own module and cannot read another's outputs, so compute the series in the consumer | | a draft that resolves live in the editor | the editor builds the current source as you type, and **Run** puts it on the chart | | publish an immutable version | **Publish** in the editor; a published version never changes ([The Publish Indicator dialog](#the-publish-indicator-dialog)) | | pin an exact version | a chart keeps the exact version it added and reloads that version ([After publishing](#after-publishing)) | | start from someone else's code | **Fork** (on desktop) copies an Open source indicator's code into a new draft in your editor ([After publishing](#after-publishing)) | ## Good to know - Official indicators publish under `@om-core`: they list under **OpenMarket** with an **Official** tag. - The dialog has no price. Paid indicators show their price in the listing and cannot be added from the chart. - On CME markets, community indicators and drafts cannot read CME data; official indicators can. - The chart publishes to the OpenMarket registry; there is no other registry to choose. <!-- source: https://openmarket.xyz/wrun/sharing/overview --> # Who can use it When you publish, you choose who can use your indicator and who can read its code. This section is everything after that: inviting people, keeping a member list in sync from your own site, and what your members see. ```text Publish Indicator Who can use it Everyone Anyone on OpenMarket can find it and add it. Invite only Only the people you invite. Your community, your list. Just me Only on your charts. Nobody else sees it. Code Protected Runs on OpenMarket servers. Your code never leaves them: nobody can download, read or copy it. Compiled Runs in each person's browser. They get a compiled module, never your source. Open source Anyone who can use it can read the code and fork it. ``` ## Three choices | Choice | Who can find and add it | | --- | --- | | **Everyone** | anyone on OpenMarket | | **Invite only** | only the people you invite: your community, your list | | **Just me** | only you, on your own charts | **Everyone** comes with a **Listed on OpenMarket** switch. On, the indicator shows up when people search. Off, it stays out of search and opens from its link. **Invite only** is the one for a community you run, paid or not. You decide who is on the list, and nobody else can add it ([Invite your community](invite-your-community.md)). You can switch between the three at any time, without publishing a new version. Moving to **Just me** removes everyone who had access ([Switch to Just me](invite-your-community.md#switch-to-just-me)). ## Code: who can read it | Choice | Where it runs | What people get | | --- | --- | --- | | **Protected** | on OpenMarket's servers | the result on their chart; nothing of yours downloads | | **Compiled** | in each person's browser | a compiled module, never your source | | **Open source** | in each person's browser | the code, to read and fork | **Invite only** starts on **Protected**, the choice for anything you sell. A Compiled module is downloaded by everyone with access, and a downloaded module can be copied. Only Protected keeps the code on OpenMarket's servers. **Invite only** cannot be **Open source**: with Invite only picked, the dialog greys Open source out ("Invite only keeps the code private."). Where it runs is set at the first publish and cannot change later. A Protected indicator stays on OpenMarket's servers, and one first published Compiled or Open source stays in the browser. To move an indicator to the other side, publish it under a new name ([Publishing](../functions/publishing.md#code-who-reads-it-and-where-it-runs)). ## Fair use Protected is free to run for your community within fair use: 1,000 people with access per Protected indicator. Past that, the next person you add, from the chart or from your own site, is refused with: ```text Fair use is 1,000 people. Talk to us for more. ``` ## Where you manage people An Invite only indicator has a **People** panel: everyone with access, the invite link, imports and access keys. Three buttons open it: - **Invite people…** on the receipt, right after you publish. - **People · N** on the indicator's page in the Indicators dialog, under **Mine**, where N is how many people have access. - **People** beside **Publish** in the editor, on the indicator's tab. ## Move a community in three steps 1. Publish the indicator **Invite only**, with **Protected** code. 2. Bring your member list ([Import a list](invite-your-community.md#import-a-list)). 3. Hand out one invite link ([The invite link](invite-your-community.md#the-invite-link)). Your members sign in, press **Add to chart**, and your code stays on OpenMarket's servers. If you sell through your own site, it can keep the list in sync as people pay and cancel ([Access keys and the API](access-keys-and-api.md)). ## In this section - [Invite your community](invite-your-community.md): invite people by name, share one invite link, import a list you already have, and keep the people list tidy. - [Access keys and the API](access-keys-and-api.md): let your own site add and remove people as they pay and cancel. - [Connect Discord or Whop](connect-discord-or-whop.md): keep the list in sync with a Whop store or a paid Discord role, with a small script you run. - [What members see](what-members-see.md): your invite from the other side, from the link to **Add to chart**. <!-- source: https://openmarket.xyz/wrun/sharing/invite-your-community --> # Invite your community Add people to an Invite only indicator by name, with one invite link, or from a list you already keep, all from its **People** panel. The panel opens from the receipt after you publish, from the indicator's page, or from the editor ([Where you manage people](overview.md#where-you-manage-people)). ## Invite by name **Invite people…** opens a search: "Search followers, tiers or a username". Pick the people, add a note if you like, and press **Send invites**. Each one has access at once. Someone who does not follow you is found by their full username: "Someone else? Type their full username to find anyone on OpenMarket." **Tiers.** A tier is a named group, such as Premium, that you invite to several indicators at once. **Manage groups** keeps your tiers, and a tier holds at most 200 members. ## The invite link One link for your whole community: post it where your members already are, and everyone who opens it can get in. **Invite link** is in the People panel's corner menu, and **People who open it** decides what happens: | Choice | What happens | | --- | --- | | **Ask to join** (the default) | each person asks, and you approve each request in the People panel | | **Join right away** | anyone with the link gets access when they sign in | Either way, the link can carry a limit (how many people may join with it) and an end date (**Works until**). Both are optional. **Make a new link** retires the old one: the old link stops working, so a link that leaked is closed in one click. A request shows in the People panel as "maya asked to join", with **Approve** and **Ignore**. ## Import a list **Import a list** takes the list you already have. Paste names and emails into the search, or drop a .csv or .txt file on the sheet. A file names each person by **Username** or by **Email**, and **Ends** is the day their access ends: ```text Username,Email,Ends maya,,2026-12-31 ,sam@example.com,2026-11-04 ``` Rows with a username get access at once. Rows with only an email become personal links: the import gives you a CSV with one link per address, and you send each one yourself. ```text email,url,until sam@example.com,<the personal link>,2026-11-04 ``` OpenMarket never emails your customers, and never tells you whether an email has an account. Your own name is skipped. When the import is done it counts who was invited, who already had access, and who is not on OpenMarket yet. That last group can join with your invite link. A person who opens their personal link signs in or signs up and lands on **Add to chart** ([What members see](what-members-see.md)). ## The people list The People panel lists everyone with access, with a search box and three views: | View | Shows | | --- | --- | | **All** | everyone with access | | **Ending soon** | people whose access ends soon | | **Not joined yet** | people who have not opened their personal link yet | Each row says since when and how the person got access, or until when it lasts: ```text Since <date> · Invited by you Since <date> · Invite link Since <date> · Access key Until <date> · Imported ``` A row's menu sets an end date, removes it, or removes the person. The list follows the account, not the name, so a member who renames their account keeps access. The corner menu holds **Invite link**, **Access key**, **Export list** and **History**. **Export list** downloads the list as a CSV. **History** shows who added, changed and removed whom, and when someone joined. ## Remove people **Remove access** on a row ends that person's access. The indicator stops loading for them, a Protected one within about 15 minutes ([When access ends](what-members-see.md#when-access-ends)). ## Switch to Just me Switching an Invite only indicator to **Just me** removes everyone and retires its links. Before it does, the prompt counts the people and the personal links nobody has opened yet separately, so you see both numbers before you confirm. <!-- source: https://openmarket.xyz/wrun/sharing/access-keys-and-api --> # Access keys and the API Let your own site keep the people list: an access key lets your server add and remove people on one Invite only indicator as they pay and cancel. ## Make a key In the People panel's corner menu, **Access key** opens the key sheet. Give the key a label (where it lives: your site, a script) and press **Create key**. - The key belongs to your account and to this one indicator. It can add, remove and list people there, and nothing else. - It is shown once. Copy it into your server's secret store. - **Revoke** stops it at once: calls with it fail from then on. - Make as many as you need, one per system, so each can be revoked on its own. Keep the key on your server. Never put it in a web page, an app or a public repository. ## The calls Every call goes to your indicator's address on the registry and carries the key as a bearer token: ```text Base URL https://registry.openmarket.xyz/v1/packages/@you/your-indicator Authorization Bearer <access key> PUT /access/<username> add someone, or change their end date DELETE /access/<username> remove someone GET /access/<username> check one person GET /access everyone with access, a page at a time POST /access/batch up to 500 adds and removes in one call POST /access/links/batch a personal link for each of your members GET /access/links.csv the personal links nobody has opened yet ``` `@you/your-indicator` is your indicator's full name: your username, then the name you published it under. The key sheet shows the address under **Calls go to**. ## Add someone ```text curl -X PUT "https://registry.openmarket.xyz/v1/packages/@you/your-indicator/access/maya" \ -H "Authorization: Bearer $ACCESS_KEY" \ -H "Content-Type: application/json" \ -d '{"until": "2027-01-01T00:00:00Z"}' ``` The body is optional: leave it out and access has no end date. A second PUT replaces the end date, so a renewal sends the new one. The answer is `200` with the person's row: ```json { "username": "maya", "account_id": "<account id>", "until": "2027-01-01T00:00:00Z", "since": "2026-10-05T09:12:33Z", "via": "api" } ``` Keep the `account_id`. It still finds the person after they rename their account. ## Remove someone ```text curl -X DELETE "https://registry.openmarket.xyz/v1/packages/@you/your-indicator/access/maya" \ -H "Authorization: Bearer $ACCESS_KEY" ``` The answer is `204` whether or not they were on the list, so a cancellation that arrives twice does no harm. ## Check and list `GET /access/<username>` answers `200` with the person's row, or `404` when they are not on the list. `GET /access` reads everyone, 50 rows a page by default and up to 200 with `limit`. Pass a page's `next_cursor` back as `cursor` to read the next one. `q` filters by name, and `view` picks `all`, `ending` (access ending soon) or `waiting` (personal links nobody has opened yet). ```text curl "https://registry.openmarket.xyz/v1/packages/@you/your-indicator/access?limit=200&view=all" \ -H "Authorization: Bearer $ACCESS_KEY" ``` ## Many at once `POST /access/batch` takes up to 500 adds and removes in one call: the first import of your member list, or a nightly check that the list still matches yours. Each `add` row works like a PUT (an `until` of `null` means no end date), and each `remove` like a DELETE. ```text curl -X POST "https://registry.openmarket.xyz/v1/packages/@you/your-indicator/access/batch" \ -H "Authorization: Bearer $ACCESS_KEY" \ -H "Content-Type: application/json" \ -d '{"add":[{"subject":"maya","until":null}],"remove":["sam"]}' ``` ## Personal links A member with no OpenMarket account yet gets a personal link instead. Ask for one per member, keyed by your own id for them: ```text curl -X POST "https://registry.openmarket.xyz/v1/packages/@you/your-indicator/access/links/batch" \ -H "Authorization: Bearer $ACCESS_KEY" \ -H "Content-Type: application/json" \ -d '{"links":[{"ref":"member-1042","until":null}]}' ``` Send each member their link yourself. When they open it, they sign in or sign up and get access. `GET /access/links.csv` downloads the links nobody has opened yet. ## Naming a person Wherever a call takes a username, it also takes two other forms. In a batch they go in `subject`. | You write | Means | | --- | --- | | `maya` | an OpenMarket username | | `id:<account id>` | the `account_id` a call returned; it still works after a rename | | `ref:<your reference>` | your own id for a member you sent a personal link | ## Errors An error comes back as `{"error": {"code": "...", "message": "..."}}`: | Status | Code | What it means | | --- | --- | --- | | 404 | `user_not_found` | No one on OpenMarket has that username. | | 422 | `not_invite_only` | The indicator is not Invite only. | | 403 | `fair_use` | The Protected indicator is at 1,000 people ([Fair use](overview.md#fair-use)). | | 429 | `rate_limited` | More than 600 writes in a minute on this key. Wait the seconds the `retry-after` header names. | ## Wire it to your billing Two calls cover a membership, keyed by the OpenMarket username you collect at checkout: - **On signup**, `PUT /access/<username>`, with `until` set to the date they have paid through. Send it again on each renewal, and access lapses on its own if a renewal never comes. - **On cancellation or refund**, `DELETE /access/<username>`. No username at checkout? Make each customer a personal link instead, and send it with your welcome email. Selling through Whop, or behind a paid Discord role? [Connect Discord or Whop](connect-discord-or-whop.md) has a small script for each. Selling with Stripe, on Patreon or through a paid Telegram group? [Ready-made recipes](recipes.md) has a complete file for each, ready to run. Webhooks get missed now and then. A nightly batch that compares the list with your own member table catches whatever slipped through. <!-- source: https://openmarket.xyz/wrun/sharing/recipes --> # Ready-made recipes Sell your Invite only indicator through Stripe, Patreon or a paid Telegram group, and one small file keeps its people list in step with who pays. Each recipe is one file: copy it to any server with Node 20 or later, set a few variables, and run it, with nothing to install. It calls your indicator's Access API with an access key ([Access keys and the API](access-keys-and-api.md)) and gives each paying member a personal link, keyed by the service's own id for them ([Personal links](access-keys-and-api.md#personal-links)). They open it, sign in or sign up, and have the indicator. When they stop paying, the file takes it away. | Recipe | You sell through | It runs as | | --- | --- | --- | | [Stripe](#stripe) | a Payment Link or Checkout, one-time or as a subscription | a small web server | | [Patreon](#patreon) | your Patreon tiers | a small web server your patrons log in through | | [Telegram bot](#telegram-bot) | a paid Telegram group or channel | a bot, with no public address needed | The code on this page is the code OpenMarket's tests run, byte for byte. ## Stripe Sell through a Stripe Payment Link or Checkout, one-time or as a subscription, and each buyer gets your indicator as soon as they pay. Only the prices you name count, so the same Stripe account can sell other things too. ### What it does - **Pays**: after paying, Stripe sends the buyer to your server, which sends them on to their personal link. They sign in or sign up on OpenMarket and have the indicator. - **Renews**: each paid invoice moves their end date to the new paid-through date, plus 3 days in case a renewal runs late. - **Cancels**: when Stripe ends the subscription, the indicator goes with it, unless another subscription of theirs still holds one of your prices. A one-time payment keeps it for good. ### Set it up 1. In the People panel's corner menu, make an **Access key** for your indicator ([Make a key](access-keys-and-api.md#make-a-key)). 2. In Stripe, on the **API keys** page, click **Create restricted key** and give it **Read** on Checkout Sessions, Subscriptions and Invoices. 3. On your Payment Link, under **After the payment**, redirect customers to your website at `https://<your server>/thanks?session_id={CHECKOUT_SESSION_ID}`. If your own code makes the Checkout Session, use that address as its `success_url`. Then copy the id of each price that gives the indicator (`price_...`): **More** > **Product catalog**, the product, its price. 4. In Workbench (in the **Developers** menu), on the **Webhooks** tab, add a destination for the events `invoice.paid` and `customer.subscription.deleted`, of type **Webhook endpoint**, at `https://<your server>/webhook`. Click **Reveal secret** and copy the signing secret. 5. Save the code below as `stripe.mjs`, set the variables in the table, and run `node stripe.mjs` (Node 20 or later, nothing to install) on a server that Stripe and your buyers reach over HTTPS. | Variable | What it is | | --- | --- | | `STRIPE_SECRET_KEY` | the restricted key from step 2 (`rk_live_...`) | | `STRIPE_WEBHOOK_SECRET` | the signing secret from step 4 (`whsec_...`) | | `STRIPE_PRICE_IDS` | the price ids from step 3, comma separated (`price_...`); only these give the indicator | | `OPENMARKET_KEY` | the access key from step 1 | | `OPENMARKET_INDICATOR` | your indicator's full name, like `@you/your-indicator` | | `PORT` | the port to listen on (3000 if unset) | ```js // Stripe: sell your Invite only Indicator with a Payment Link or Checkout, // one-time or as a subscription. // // A buyer pays, Stripe sends them to /thanks, and this server sends them on // to their personal link. They sign in on OpenMarket and have the Indicator. // Each paid renewal moves their end date. A subscription that ends takes the // Indicator away. A one-time payment keeps it for good. Only the prices you // list count: anything else you sell on the same Stripe account never does. // // Set these, then run `node stripe.mjs` (Node 20 or later, nothing to install): // STRIPE_SECRET_KEY a restricted key that can read Checkout Sessions, // Subscriptions and Invoices // STRIPE_WEBHOOK_SECRET your webhook's signing secret (whsec_...) // STRIPE_PRICE_IDS the prices that give the Indicator, comma separated // (price_...) // OPENMARKET_KEY your Indicator's access key // OPENMARKET_INDICATOR your Indicator's full name, like @you/your-indicator // PORT the port to listen on (3000 if unset) // // In Stripe, redirect the Payment Link's buyers after payment to // https://<your server>/thanks?session_id={CHECKOUT_SESSION_ID} // and send the webhook events invoice.paid and customer.subscription.deleted to // https://<your server>/webhook import { createHmac, timingSafeEqual } from "node:crypto"; import { realpathSync } from "node:fs"; import { createServer } from "node:http"; import { fileURLToPath } from "node:url"; const GRACE_DAYS = 3; // a late renewal never cuts a paying member off const TOLERANCE_SECONDS = 5 * 60; // the replay window Stripe's own libraries use /** Subscription statuses that keep the Indicator (past_due: Stripe is retrying a renewal). */ const HOLDING = ["active", "trialing", "past_due"]; // ── Stripe ────────────────────────────────────────────────────────────── /** Answers the buyer's redirect after paying, and Stripe's webhook. */ export async function handle(request) { const url = new URL(request.url); if (request.method === "GET" && url.pathname === "/thanks") { return thanks(url.searchParams.get("session_id")); } if (request.method === "POST" && url.pathname === "/webhook") return webhook(request); return new Response("Not found.", { status: 404 }); } /** The buyer lands here after paying, and goes on to their personal link. */ async function thanks(sessionId) { const session = /^cs_\w+$/.test(sessionId ?? "") ? await stripe( `checkout/sessions/${sessionId}?expand[]=line_items&expand[]=subscription.latest_invoice`, ) : null; if (session === null) return page(404, "We could not find this payment."); const prices = configuredPrices(); const paid = session.status === "complete" && ["paid", "no_payment_required"].includes(session.payment_status) && (session.line_items?.data ?? []).some((item) => prices.has(item.price?.id)); // A one-time payment never ends. A subscription runs to what its latest invoice paid // for, and while that invoice is unpaid, /thanks gives out nothing. const subscription = session.subscription; // expanded, with its latest invoice let until; if (session.mode === "payment") until = null; else if (HOLDING.includes(subscription?.status)) until = paidThrough(subscription); if (!paid || until === undefined) { return page(402, "Payment not confirmed yet, or it has ended. Just paid? Reload in a minute."); } // A subscriber is their Stripe customer. A one-time payment through a Payment Link // usually has no customer (Stripe keeps a guest instead), so the session is the ref. const ref = `stripe-${session.customer ?? session.id}`; return Response.redirect(await personalLink(ref, until), 303); } /** A paid invoice moves the member's end date (and makes their link, if they closed * the tab before /thanks). An ended subscription takes the Indicator away. Both read * the subscription from Stripe now rather than trust the event: Stripe does not send * events in order, and a late one must not undo a newer one. */ async function webhook(request) { const body = await request.text(); if (!signedByStripe(request.headers.get("stripe-signature"), body)) { return new Response("Bad signature.", { status: 400 }); } const { type, data } = JSON.parse(body); const object = data.object; if (type === "invoice.paid") { // From API version 2025-03-31 (basil) on, an invoice names its subscription under // `parent`; before, at the top. Read both. Neither means a one-off invoice. const id = object.parent?.subscription_details?.subscription ?? object.subscription; const subscription = id ? await stripe(`subscriptions/${id}?expand[]=latest_invoice`) : null; if (subscription !== null && holds(subscription)) await moveEndDate(subscription); } if (type === "customer.subscription.deleted" && sells(object)) { // The customer may hold one of your prices on another subscription: then they keep it. const customer = encodeURIComponent(object.customer); const theirs = await stripe(`subscriptions?customer=${customer}&expand[]=data.latest_invoice`); const other = (theirs?.data ?? []).find((sub) => sub.id !== object.id && holds(sub)); if (other) await moveEndDate(other); else await removeMember(`stripe-${object.customer}`); } return new Response("OK"); // every other event is ignored } /** Moves the member's end date to what `subscription` is paid through. While its * latest invoice is unpaid, the end date stays where it is. */ async function moveEndDate(subscription) { const until = paidThrough(subscription); if (until !== undefined) await personalLink(`stripe-${subscription.customer}`, until); } /** What a subscription is paid through, plus the grace days: the end of the period its * latest invoice paid for. Undefined while that invoice is unpaid (a renewal Stripe is * still collecting, though it already opened the next period). */ function paidThrough(subscription) { const invoice = subscription.latest_invoice; if (invoice?.status !== "paid") return undefined; const ends = (invoice.lines?.data ?? []) .filter((line) => lineSubscription(line) === subscription.id) .map((line) => line.period?.end); return endDate(ends); } /** The subscription an invoice line bills for. From API version 2025-03-31 (basil) on it * sits under the line's `parent`; before, at the top of the line. */ function lineSubscription(line) { return ( line.parent?.subscription_item_details?.subscription ?? line.parent?.invoice_item_details?.subscription ?? line.subscription ); } /** A subscription that holds one of your prices, in a status that keeps the Indicator. */ function holds(subscription) { return HOLDING.includes(subscription.status) && sells(subscription); } /** Whether a subscription holds one of your prices. */ function sells(subscription) { const prices = configuredPrices(); return (subscription.items?.data ?? []).some((item) => prices.has(item.price?.id)); } /** STRIPE_PRICE_IDS: the prices that give the Indicator. Nothing else on the account counts. */ function configuredPrices() { const ids = (process.env.STRIPE_PRICE_IDS ?? "") .split(",") .map((id) => id.trim()) .filter(Boolean); if (ids.length === 0) throw new Error("Set STRIPE_PRICE_IDS to the prices that give the Indicator"); return new Set(ids); } /** GET from Stripe's API with the restricted key; null when Stripe has no such object. */ async function stripe(path) { const res = await fetch(`https://api.stripe.com/v1/${path}`, { headers: { authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` }, }); if (res.status === 404) return null; if (!res.ok) throw new Error(`Stripe ${path} answered ${res.status}: ${await res.text()}`); return res.json(); } /** Stripe's check, as its docs lay it out: Stripe-Signature holds `t=<unix seconds>` and * one `v1=<hex>` per active signing secret, each an HMAC-SHA256 of `<t>.<raw body>` * keyed by the whole whsec_ secret. A timestamp more than 5 minutes off is refused, so * a captured event cannot be replayed later. */ function signedByStripe(header, body) { const pairs = (header ?? "").split(",").map((part) => part.split("=")); const t = pairs.find(([key]) => key === "t")?.[1]; if (!(Math.abs(Date.now() / 1000 - Number(t)) <= TOLERANCE_SECONDS)) return false; const hmac = createHmac("sha256", process.env.STRIPE_WEBHOOK_SECRET).update(`${t}.${body}`); const expected = Buffer.from(hmac.digest("hex")); return pairs.some(([key, value = ""]) => { const given = Buffer.from(value); return key === "v1" && given.length === expected.length && timingSafeEqual(given, expected); }); } /** The latest of `periodEnds` (Unix seconds) plus the grace days, as an ISO date. */ function endDate(periodEnds) { const end = Math.max(...periodEnds.filter(Number.isFinite)); if (!Number.isFinite(end)) throw new Error("Stripe sent no period end"); return new Date((end + GRACE_DAYS * 24 * 60 * 60) * 1000).toISOString(); } /** A short page for a buyer with no link to go to. */ function page(status, text) { return new Response(`<!doctype html><meta charset="utf-8"><title>Payment

${text}

`, { status, headers: { "content-type": "text/html; charset=utf-8" }, }); } // ── OpenMarket ────────────────────────────────────────────────────────── // Your Indicator's Access API, called with its access key. function openmarketUrl(path) { const registry = process.env.OPENMARKET_REGISTRY ?? "https://registry.openmarket.xyz"; return `${registry}/v1/packages/${process.env.OPENMARKET_INDICATOR}${path}`; } async function openmarket(method, path, body) { const res = await fetch(openmarketUrl(path), { method, headers: { authorization: `Bearer ${process.env.OPENMARKET_KEY}`, ...(body === undefined ? {} : { "content-type": "application/json" }), }, body: body === undefined ? undefined : JSON.stringify(body), }); if (!res.ok) throw new Error(`OpenMarket ${method} ${path} answered ${res.status}: ${await res.text()}`); return res.status === 204 ? null : res.json(); } /** The member's personal link, keyed by your own id for them. The same ref always * answers the same link, and a new `until` moves their end date, joined or not. */ async function personalLink(ref, until) { const { links } = await openmarket("POST", "/access/links/batch", { links: [{ ref, until }] }); return links[0].url; } /** Take the Indicator away from the member behind `ref` (harmless if they never joined). */ async function removeMember(ref) { await openmarket("DELETE", `/access/ref:${encodeURIComponent(ref)}`); } // ── Server ────────────────────────────────────────────────────────────── // `node .mjs` serves `handle` on $PORT (3000 by default). const MAX_BODY_BYTES = 1024 * 1024; // a webhook or a redirect is a few KB if (isMainModule()) { const port = Number(process.env.PORT ?? 3000); createServer(async (req, res) => { // Until the request is read and built, a failure is the caller's (400): a method // fetch refuses, a target it cannot parse, a caller who hung up midway. From then // on it is this server's (500). Either way the server stays up. let failure = 400; try { const chunks = []; let size = 0; for await (const chunk of req) { size += chunk.length; if (size <= MAX_BODY_BYTES) chunks.push(chunk); // past the cap, read on and keep nothing } if (size > MAX_BODY_BYTES) { res.writeHead(413).end("Too large."); return; } const hasBody = req.method !== "GET" && req.method !== "HEAD"; const request = new Request(`http://localhost${req.url}`, { method: req.method, headers: Object.entries(req.headers).flatMap(([k, v]) => Array.isArray(v) ? v.map((x) => [k, x]) : [[k, v]], ), body: hasBody ? Buffer.concat(chunks) : undefined, }); failure = 500; const response = await handle(request); res.writeHead(response.status, Object.fromEntries(response.headers)); res.end(Buffer.from(await response.arrayBuffer())); } catch (error) { console.error(error); if (res.headersSent) res.destroy(); else res.writeHead(failure).end(failure === 400 ? "Bad request." : "Something went wrong."); } }).listen(port, () => console.log(`Listening on :${port}`)); } /** Whether `node` was asked to run this file, by its own path or through a link * (on macOS, /tmp itself is a link to /private/tmp). */ function isMainModule() { try { return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); } catch { return false; // no file to run: a REPL, or node -e } } ``` ### Try it before you go live 1. In a Stripe sandbox, repeat steps 2 and 3 with a test key and a test Payment Link that redirects to where the file runs. Run `stripe listen --forward-to localhost:3000/webhook`, and start the file with the `whsec_...` secret it prints. 2. Pay through the test link with the card `4242 4242 4242 4242`, any future date and any CVC. You land on your personal link: open it, and the indicator is yours. 3. `stripe trigger customer.subscription.created --override "subscription:items[0].price="` sends a test subscriber's first paid invoice, and the same with `customer.subscription.deleted` a cancellation. `stripe listen` shows your server answer `[200]` to each event. ### Good to know - A subscriber who closes the tab before the redirect still has a link waiting, made by their first paid invoice. It is under **Not joined yet** in the People panel and in `GET /access/links.csv`: send it to them. A one-time buyer's link comes only from the redirect, so add that buyer with **Invite people…** instead. - Each end date comes from an invoice Stripe has been paid, read when the event arrives, so events that come late or out of order change nothing, and a renewal still being collected moves nothing until it is paid. - Selling the indicator both one-time and as a subscription? A buyer who does both as the same Stripe customer gets one link, and the end of their subscription ends it. ## Patreon Give your Invite only indicator to your Patreon patrons: each one logs in with Patreon on a small server you run and lands on their own personal link. ### What it does - **Pledges**: a patron opens the link in your tier's welcome note, logs in with Patreon and lands on their personal link. They sign in or sign up on OpenMarket and have the indicator. - **Renews**: each charge moves their end date to 3 days past the next one. - **Stops paying**: a declined charge, an ended pledge or a deleted membership takes the indicator away. ### Set it up 1. In the Patreon Platform Portal, open **Clients & API Keys** and press **Create Client**. Set **Client API Version** to 2, add `https:///patreon/callback` under **Redirect URIs**, and copy the **Client ID** and **Client Secret**. 2. Find your campaign id with the new client's **Creator's Access Token**. The same call lists your tiers, for when only some of them come with the indicator: ```text curl -H "Authorization: Bearer " \ "https://www.patreon.com/api/oauth2/v2/campaigns?include=tiers&fields%5Btier%5D=title" ``` `data[0].id` is your campaign id, and each tier under `included` shows its `id` and `title`. 3. In **My Webhooks**, paste `https:///patreon/webhook` where it says "Create a new webhook by pasting your URL here" and press **+**. Turn on **Create Member**, **Update Member** and **Delete Member**, and copy the webhook's secret. 4. Save the code below as `patreon.mjs` on a server Patreon can reach over HTTPS, set the variables in the table, and run `node patreon.mjs` (Node 20 or later, nothing to install). 5. Add `https:///patreon` to the welcome note of each tier that comes with the indicator. | Variable | What it is | | --- | --- | | `PATREON_CLIENT_ID` | your client's **Client ID** | | `PATREON_CLIENT_SECRET` | your client's **Client Secret** | | `PATREON_CAMPAIGN_ID` | your campaign id, from step 2 | | `PATREON_WEBHOOK_SECRET` | your webhook's secret, from **My Webhooks** | | `PATREON_TIER_IDS` | optional: the ids of the tiers that come with the indicator, comma separated. Leave it out and every active patron gets it | | `PUBLIC_URL` | your server's HTTPS address, like `https://patrons.example.com` | | `OPENMARKET_KEY` | your indicator's access key, from **Access key** in its People panel | | `OPENMARKET_INDICATOR` | your indicator's full name, like `@you/your-indicator` | | `PORT` | the port to listen on (3000 if unset) | ```js // Patreon: give your Invite only Indicator to your patrons. // // Patreon does not know your patrons' OpenMarket accounts, so a patron // proves who they are with "Log in with Patreon" on this small server, and // it sends them on to their own personal link. A webhook keeps their end // date in step with Patreon: a renewal moves it, and a declined, ended or // deleted pledge takes the Indicator away. // // Put /patreon in your tier's welcome note. Patreon sends // patrons back to /patreon/callback, and your webhook points at // /patreon/webhook. // // Set: // PATREON_CLIENT_ID your client's Client ID (Clients & API Keys) // PATREON_CLIENT_SECRET your client's Client Secret // PATREON_CAMPAIGN_ID your campaign's id // PATREON_WEBHOOK_SECRET your webhook's secret (My Webhooks) // PATREON_TIER_IDS optional: only these tiers get the Indicator, // as a comma separated list of tier ids // PUBLIC_URL this server's https address // OPENMARKET_KEY your Indicator's access key // OPENMARKET_INDICATOR your Indicator's full name, @you/your-indicator // PORT 3000 by default // // Run: node patreon.mjs (Node 20 or later, nothing to install). import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { realpathSync } from "node:fs"; import { createServer } from "node:http"; import { fileURLToPath } from "node:url"; /** Days added to Patreon's next charge, so a late renewal never cuts a paying patron off. */ const GRACE_DAYS = 3; /** The patron's memberships, each with its campaign, its tiers, its status and its charges. */ const IDENTITY_URL = "https://www.patreon.com/api/oauth2/v2/identity" + "?include=memberships.campaign,memberships.currently_entitled_tiers" + "&fields%5Bmember%5D=patron_status,next_charge_date,last_charge_date,pledge_cadence"; /** Every request this server answers. */ export async function handle(request) { const { pathname } = new URL(request.url); if (request.method === "GET" && pathname === "/patreon") return logIn(); if (request.method === "GET" && pathname === "/patreon/callback") return callback(request); if (request.method === "POST" && pathname === "/patreon/webhook") return webhook(request); return new Response("Not found.", { status: 404 }); } // ── Log in with Patreon ───────────────────────────────────────────────── /** Send the patron to Patreon, with a one-time state kept in a cookie. */ function logIn() { const state = randomBytes(16).toString("hex"); const authorize = new URL("https://www.patreon.com/oauth2/authorize"); authorize.search = new URLSearchParams({ response_type: "code", client_id: process.env.PATREON_CLIENT_ID, redirect_uri: redirectUri(), scope: "identity identity.memberships", state, }).toString(); const secure = process.env.PUBLIC_URL?.startsWith("https:") ? "; Secure" : ""; return new Response(null, { status: 302, headers: { location: authorize.href, "set-cookie": `patreon_state=${state}; Path=/patreon; HttpOnly; SameSite=Lax; Max-Age=600${secure}`, }, }); } /** Patreon sends the patron back here: find their pledge and send them to their link. */ async function callback(request) { const url = new URL(request.url); const code = url.searchParams.get("code"); const cookie = /(?:^|;\s*)patreon_state=([0-9a-f]+)/.exec(request.headers.get("cookie") ?? ""); if (!code || !sameText(url.searchParams.get("state"), cookie?.[1])) { return page(400, "Patreon did not sign you in. Open the link from Patreon and try again."); } const token = await patreon("https://www.patreon.com/api/oauth2/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ code, grant_type: "authorization_code", client_id: process.env.PATREON_CLIENT_ID, client_secret: process.env.PATREON_CLIENT_SECRET, redirect_uri: redirectUri(), }), }); const identity = await patreon(IDENTITY_URL, { headers: { authorization: `Bearer ${token.access_token}` }, }); const member = identity.included?.find((item) => item.type === "member" && entitled(item)); if (member === undefined) { return page(403, "Your Patreon account has no active pledge that includes this Indicator."); } const ref = `patreon-${identity.data.id}`; const link = await personalLink(ref, await endDate(member, ref)); return new Response(null, { status: 303, headers: { location: link } }); } // ── Webhook ───────────────────────────────────────────────────────────── /** A renewal moves the end date; a declined, ended or deleted pledge removes the Indicator. */ async function webhook(request) { const body = Buffer.from(await request.arrayBuffer()); const secret = process.env.PATREON_WEBHOOK_SECRET; // Patreon signs the raw body: HMAC-MD5 keyed by the webhook's secret, as hex. const expected = secret ? createHmac("md5", secret).update(body).digest("hex") : ""; if (!sameText(request.headers.get("x-patreon-signature")?.toLowerCase(), expected)) { return new Response("Bad signature.", { status: 400 }); } const event = request.headers.get("x-patreon-event") ?? ""; const member = JSON.parse(body.toString()).data; const userId = member?.relationships?.user?.data?.id; if (!event.startsWith("members:") || !userId || !inCampaign(member)) { return new Response("Ignored."); } const ref = `patreon-${userId}`; if (event.endsWith(":delete") || !entitled(member)) await removeMember(ref); else await personalLink(ref, await endDate(member, ref)); return new Response("Done."); } // ── Patreon ───────────────────────────────────────────────────────────── async function patreon(url, init) { const res = await fetch(url, init); if (!res.ok) { throw new Error(`Patreon ${new URL(url).pathname} answered ${res.status}: ${await res.text()}`); } return res.json(); } /** A membership of your campaign. */ function inCampaign(member) { return member.relationships?.campaign?.data?.id === process.env.PATREON_CAMPAIGN_ID; } /** An active patron of your campaign, in one of PATREON_TIER_IDS when you set it. */ function entitled(member) { if (!inCampaign(member) || member.attributes?.patron_status !== "active_patron") return false; const tiers = (process.env.PATREON_TIER_IDS ?? "") .split(",") .map((id) => id.trim()) .filter(Boolean); const held = member.relationships?.currently_entitled_tiers?.data ?? []; return tiers.length === 0 || held.some((tier) => tiers.includes(tier.id)); } /** The end date to send, never none, so access always runs out without a renewal: * Patreon's next charge plus GRACE_DAYS. Without one (Patreon leaves it out on an * annual downgrade), the last charge plus one pledge cadence (in months) plus * GRACE_DAYS. Without either, a patron who already joined keeps the end date they * have (undefined leaves it as it is), and a new one gets a month plus GRACE_DAYS. */ async function endDate(member, ref) { const { next_charge_date: next, last_charge_date: last, pledge_cadence: cadence, } = member.attributes ?? {}; let paidThrough = next ? new Date(next) : null; if (!paidThrough && last && Number(cadence) > 0) { paidThrough = addMonths(new Date(last), Number(cadence)); } if (!paidThrough) { if (await joined(ref)) return undefined; paidThrough = addMonths(new Date(), 1); } return new Date(paidThrough.getTime() + GRACE_DAYS * 24 * 60 * 60 * 1000).toISOString(); } function addMonths(date, months) { const later = new Date(date); later.setUTCMonth(later.getUTCMonth() + months); return later; } /** Whether the patron behind `ref` already opened their link and holds the Indicator. */ async function joined(ref) { const path = `/access/ref:${encodeURIComponent(ref)}`; const res = await fetch(openmarketUrl(path), { headers: { authorization: `Bearer ${process.env.OPENMARKET_KEY}` }, }); if (res.status === 404) return false; if (!res.ok) throw new Error(`OpenMarket GET ${path} answered ${res.status}: ${await res.text()}`); return true; } function redirectUri() { return `${(process.env.PUBLIC_URL ?? "").replace(/\/+$/, "")}/patreon/callback`; } /** Compares two secrets in constant time. */ function sameText(a, b) { if (!a || !b) return false; const left = Buffer.from(a); const right = Buffer.from(b); return left.length === right.length && timingSafeEqual(left, right); } /** A short plain page for the patron. */ function page(status, message) { return new Response( `\n\nPatreon\n

${message}

\n`, { status, headers: { "content-type": "text/html; charset=utf-8" }, }, ); } // ── OpenMarket ────────────────────────────────────────────────────────── // Your Indicator's Access API, called with its access key. function openmarketUrl(path) { const registry = process.env.OPENMARKET_REGISTRY ?? "https://registry.openmarket.xyz"; return `${registry}/v1/packages/${process.env.OPENMARKET_INDICATOR}${path}`; } async function openmarket(method, path, body) { const res = await fetch(openmarketUrl(path), { method, headers: { authorization: `Bearer ${process.env.OPENMARKET_KEY}`, ...(body === undefined ? {} : { "content-type": "application/json" }), }, body: body === undefined ? undefined : JSON.stringify(body), }); if (!res.ok) throw new Error(`OpenMarket ${method} ${path} answered ${res.status}: ${await res.text()}`); return res.status === 204 ? null : res.json(); } /** The member's personal link, keyed by your own id for them. The same ref always * answers the same link, and a new `until` moves their end date, joined or not. */ async function personalLink(ref, until) { const { links } = await openmarket("POST", "/access/links/batch", { links: [{ ref, until }] }); return links[0].url; } /** Take the Indicator away from the member behind `ref` (harmless if they never joined). */ async function removeMember(ref) { await openmarket("DELETE", `/access/ref:${encodeURIComponent(ref)}`); } // ── Server ────────────────────────────────────────────────────────────── // `node .mjs` serves `handle` on $PORT (3000 by default). const MAX_BODY_BYTES = 1024 * 1024; // a webhook or a redirect is a few KB if (isMainModule()) { const port = Number(process.env.PORT ?? 3000); createServer(async (req, res) => { // Until the request is read and built, a failure is the caller's (400): a method // fetch refuses, a target it cannot parse, a caller who hung up midway. From then // on it is this server's (500). Either way the server stays up. let failure = 400; try { const chunks = []; let size = 0; for await (const chunk of req) { size += chunk.length; if (size <= MAX_BODY_BYTES) chunks.push(chunk); // past the cap, read on and keep nothing } if (size > MAX_BODY_BYTES) { res.writeHead(413).end("Too large."); return; } const hasBody = req.method !== "GET" && req.method !== "HEAD"; const request = new Request(`http://localhost${req.url}`, { method: req.method, headers: Object.entries(req.headers).flatMap(([k, v]) => Array.isArray(v) ? v.map((x) => [k, x]) : [[k, v]], ), body: hasBody ? Buffer.concat(chunks) : undefined, }); failure = 500; const response = await handle(request); res.writeHead(response.status, Object.fromEntries(response.headers)); res.end(Buffer.from(await response.arrayBuffer())); } catch (error) { console.error(error); if (res.headersSent) res.destroy(); else res.writeHead(failure).end(failure === 400 ? "Bad request." : "Something went wrong."); } }).listen(port, () => console.log(`Listening on :${port}`)); } /** Whether `node` was asked to run this file, by its own path or through a link * (on macOS, /tmp itself is a link to /private/tmp). */ function isMainModule() { try { return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); } catch { return false; // no file to run: a REPL, or node -e } } ``` ### Try it before you go live Patreon has no test mode, so use its test send and a real pledge of your own: 1. In **My Webhooks**, press **Send test** on **Update Member**. Patreon signs the sample event with your secret, and your server answers 200 when `PATREON_WEBHOOK_SECRET` matches it (400 when it does not). 2. From a second Patreon account, join your lowest tier. Open `https:///patreon`, log in with Patreon as that account, and sign in on OpenMarket with a second account: the indicator is under **Shared with you**. 3. Cancel that pledge. When Patreon ends it, the second account drops off your People list. ### Good to know - A patron who closed the tab opens `https:///patreon` again and lands on the same link. One who lost the indicator to a declined charge does the same after paying, and gets a fresh link. - Webhooks get missed now and then. The end date is your safety net: without a renewal, access ends 3 days after the charge that never came. - Logging in asks each patron to share their Patreon memberships, so the server can find the one in your campaign. It keeps nothing, and uses the Patreon login once. ## Telegram bot Sell access through a paid Telegram group or channel, and a small bot gives each member your indicator while they are in it. ### What it does - **Joins**: a member of your paid group or channel sends your bot `/start` and gets a personal link. Opening it gives them the indicator. - **Stays**: they keep it while they stay in the group or channel. - **Leaves**: they lose it when they leave or you remove them. ### Set it up 1. In Telegram, open @BotFather, send `/newbot`, and pick a name and a username. BotFather answers with your bot's token. Keep it secret: anyone with it controls your bot. 2. Add the bot to your paid group or channel and make it an admin. Only an admin sees who leaves, so this step matters. 3. Right after, open `https://api.telegram.org/bot/getUpdates` in your browser. The negative number after `"chat":{"id":`, like `-1001234567890`, is your chat's id. 4. In your indicator's People panel, open **Access key** in the corner menu and press **Create key**. 5. Save the code below as `telegram-bot.mjs`, set the variables in the table, and run `node telegram-bot.mjs` (Node 20 or later, nothing to install) on any computer or server that stays on. It needs no public address. | Variable | What it is | | --- | --- | | `TELEGRAM_BOT_TOKEN` | your bot's token, from @BotFather | | `TELEGRAM_CHAT_ID` | your paid group's or channel's id, like `-1001234567890` | | `OPENMARKET_KEY` | the access key from step 4 | | `OPENMARKET_INDICATOR` | your indicator's full name, like `@you/your-indicator` | ```js // Telegram bot: the members of your paid Telegram group or channel get your // Invite only Indicator, and lose it when they leave. // // A member sends your bot /start. While they are in the paid group or channel, // the bot answers with their personal link: they open it, sign in or sign up // on OpenMarket, and the Indicator is theirs. When they leave or are removed, // the bot takes it away. // // Set these, then run `node telegram-bot.mjs` (Node 20 or later): // TELEGRAM_BOT_TOKEN your bot's token, from @BotFather // TELEGRAM_CHAT_ID the paid group or channel, like -1001234567890 // (make the bot an admin there: only admins see who leaves) // OPENMARKET_KEY an access key on your Indicator // OPENMARKET_INDICATOR your Indicator's full name, like @you/your-indicator // // The bot asks Telegram for its updates (long polling), so it needs no public // address and runs anywhere. import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; // ── OpenMarket ────────────────────────────────────────────────────────── // Your Indicator's Access API, called with its access key. function openmarketUrl(path) { const registry = process.env.OPENMARKET_REGISTRY ?? "https://registry.openmarket.xyz"; return `${registry}/v1/packages/${process.env.OPENMARKET_INDICATOR}${path}`; } async function openmarket(method, path, body) { const res = await fetch(openmarketUrl(path), { method, headers: { authorization: `Bearer ${process.env.OPENMARKET_KEY}`, ...(body === undefined ? {} : { "content-type": "application/json" }), }, body: body === undefined ? undefined : JSON.stringify(body), }); if (!res.ok) throw new Error(`OpenMarket ${method} ${path} answered ${res.status}: ${await res.text()}`); return res.status === 204 ? null : res.json(); } /** The member's personal link, keyed by your own id for them. The same ref always * answers the same link, and a new `until` moves their end date, joined or not. */ async function personalLink(ref, until) { const { links } = await openmarket("POST", "/access/links/batch", { links: [{ ref, until }] }); return links[0].url; } /** Take the Indicator away from the member behind `ref` (harmless if they never joined). */ async function removeMember(ref) { await openmarket("DELETE", `/access/ref:${encodeURIComponent(ref)}`); } // ── Telegram ──────────────────────────────────────────────────────────── // The Bot API, called with your bot's token. async function telegram(method, params) { const url = `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/${method}`; const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(params), }); const { ok, result, description } = await res.json(); if (!ok) throw new Error(`Telegram ${method}: ${description}`); return result; } /** Whether a chat member is in the chat now: the owner, an admin, a member, * or a restricted member who has not left. */ function isIn(member) { return ( ["creator", "administrator", "member"].includes(member.status) || (member.status === "restricted" && member.is_member) ); } // ── The bot ───────────────────────────────────────────────────────────── /** /start: the member's personal link, while they are in the paid chat. */ async function start(message) { const reply = (text, reply_markup) => telegram("sendMessage", { chat_id: message.chat.id, text, reply_markup }); try { const member = await telegram("getChatMember", { chat_id: process.env.TELEGRAM_CHAT_ID, user_id: message.from.id, }); if (isIn(member)) { const url = await personalLink(`telegram-${message.from.id}`, null); await reply("Open your personal link and sign in to OpenMarket to get the Indicator.", { inline_keyboard: [[{ text: "Get the Indicator", url }]], }); } else { await reply("Join the paid group or channel first, then send /start again."); } } catch (error) { // The member can send /start again, so a failure here holds up nothing behind it. console.error(error); await reply("Something went wrong. Send /start again in a minute.").catch(console.error); } } /** One update: answer /start in a private chat, and take the Indicator from * whoever leaves the paid chat or is removed from it. A removal that fails * throws, so Telegram hands the update over again on the next poll. */ export async function handleUpdate(update) { const { message, chat_member: change } = update; if (message?.chat.type === "private" && message.text?.startsWith("/start")) { await start(message); } if (change && String(change.chat.id) === process.env.TELEGRAM_CHAT_ID) { const { new_chat_member: member } = change; if (!isIn(member)) await removeMember(`telegram-${member.user.id}`); } } /** One long poll: waits up to 50 seconds for updates, handles each, and returns * the offset for the next poll. Leaves go first: a removal that fails throws * before any reply goes out, so the retry repeats nothing a member sees. */ export async function pollOnce(offset) { const updates = await telegram("getUpdates", { offset, timeout: 50, allowed_updates: ["message", "chat_member"], }); for (const update of updates) if (update.chat_member) await handleUpdate(update); for (const update of updates) if (!update.chat_member) await handleUpdate(update); return Math.max(offset, ...updates.map((update) => update.update_id + 1)); } // ── Run ───────────────────────────────────────────────────────────────── // `node telegram-bot.mjs` checks the bot is an admin of the paid chat, then // polls until you stop it. if (isMainModule()) { const bot = await telegram("getMe", {}); const self = await telegram("getChatMember", { chat_id: process.env.TELEGRAM_CHAT_ID, user_id: bot.id, }); if (self.status !== "administrator") { console.error(`Make @${bot.username} an admin of the paid chat: only admins see who leaves.`); process.exit(1); } console.log(`@${bot.username} is answering /start`); let offset = 0; for (;;) { try { offset = await pollOnce(offset); } catch (error) { console.error(error); await new Promise((resolve) => setTimeout(resolve, 5000)); } } } /** Whether `node` was asked to run this file, by its own path or through a link * (on macOS, /tmp itself is a link to /private/tmp). */ function isMainModule() { try { return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); } catch { return false; // no file to run: a REPL, or node -e } } ``` ### Try it before you go live 1. Make a test group in Telegram, add your bot to it as an admin, and run the bot with `TELEGRAM_CHAT_ID` set to the test group's id. 2. From a second Telegram account, join the group and send your bot `/start`. Open the link signed in to a second OpenMarket account: the indicator is under **Shared with you** in the Indicators dialog. 3. Leave the group from that account. They drop off your People panel. ### Good to know - The Bot API cannot list a group's members, so the bot follows leaves as they happen. People already in the group when you start the bot send `/start` like anyone else. - While the bot is down, Telegram keeps its updates for up to 24 hours. Restart it within a day: anyone who left during a longer outage keeps the indicator until you remove them in the People panel. - A personal link works for the first OpenMarket account that opens it. Someone who leaves and comes back sends `/start` again. # Connect Discord or Whop Sell access through Whop or a paid Discord role, and a small script you run uses an access key to keep your indicator's people list in sync. There is no official connector. This is a small script you run yourself, on your own server, and OpenMarket's side is two calls. ## The idea When someone gains access on your side, add them. When they lose it, remove them: ```text Base URL https://registry.openmarket.xyz/v1/packages/@you/your-indicator Authorization Bearer PUT /access/ they paid, or got the role: 200 DELETE /access/ they left, or lost the role: 204, always ``` A PUT can carry `{"until": ""}` to end access on a date. Without it, access lasts until your DELETE. Both calls are safe to repeat, so an event that arrives twice does no harm. The key comes from the People panel ([Make a key](access-keys-and-api.md#make-a-key)). ## Collect each member's username The calls name a person by their OpenMarket username, so that is the one thing you must collect. Ask once, and keep it beside the customer: - **Whop**: add a custom field to your checkout that asks for it. The answer arrives with the membership. - **Discord**: members type `/openmarket ` in your server, and the bot below saves it. Or skip usernames: make each customer a personal link keyed by your own `ref` ([Personal links](access-keys-and-api.md#personal-links)) and send it to them yourself. They join by opening it, and `DELETE /access/ref:` removes them later. ## Whop Whop calls your server with a webhook when a membership changes. Subscribe it to `membership.updated`: Whop sends it when a membership activates and when it deactivates, with the membership's current `status`. The handler checks Whop's signature, adds the member while the status keeps access (`trialing`, `active`, `past_due`, `completed`), and removes them when it is `canceled` or `expired`. 1. In the Developer tab of your Whop dashboard, create a webhook for `membership.updated` that points at your server, and copy its secret. 2. Save the handler as `whop.mjs`. Set `OPENMARKET_ACCESS_KEY`, and `WHOP_WEBHOOK_SECRET` to the `ws_` secret exactly as Whop shows it. 3. Run `node whop.mjs` (Node 18 or later) where Whop can reach it. ```javascript import { createHmac, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; const PACKAGE = "@you/your-indicator"; // your indicator's full name const QUESTION = "OpenMarket username"; // the label of your Whop checkout field const KEEP = ["trialing", "active", "past_due", "completed"]; // Whop statuses with access const END = ["canceled", "expired"]; // Whop statuses without it const ACCESS = `https://registry.openmarket.xyz/v1/packages/${PACKAGE}/access`; // PUT or DELETE one person. false: no OpenMarket account has that username. async function access(method, username) { const res = await fetch(`${ACCESS}/${encodeURIComponent(username)}`, { method, headers: { Authorization: `Bearer ${process.env.OPENMARKET_ACCESS_KEY}` }, }); if (res.ok) return true; const { error } = await res.json().catch(() => ({})); if (error?.code === "user_not_found") return false; throw new Error(`${method} ${username}: ${res.status} ${error?.code ?? ""}`); } // Whop signs ".." with HMAC-SHA256, keyed // by the whole ws_ secret, and sends "v1," in webhook-signature. function signedByWhop(headers, body) { const { "webhook-id": id, "webhook-timestamp": sent, "webhook-signature": signatures = "" } = headers; if (!id || !(Math.abs(Date.now() / 1000 - Number(sent)) <= 300)) return false; const hmac = createHmac("sha256", process.env.WHOP_WEBHOOK_SECRET).update(`${id}.${sent}.${body}`); const expected = Buffer.from(`v1,${hmac.digest("base64")}`); return signatures.split(" ").some((entry) => { const given = Buffer.from(entry); return given.length === expected.length && timingSafeEqual(given, expected); }); } // The member's answer to your checkout field. function usernameOf(membership) { return membership.custom_field_responses?.find((field) => field.question === QUESTION)?.answer.trim(); } createServer(async (req, res) => { const chunks = []; for await (const chunk of req) chunks.push(chunk); const body = Buffer.concat(chunks).toString(); if (!signedByWhop(req.headers, body)) return res.writeHead(401).end(); const { type, data } = JSON.parse(body); const username = type === "membership.updated" ? usernameOf(data) : undefined; try { if (username && KEEP.includes(data.status) && !(await access("PUT", username))) { console.warn(`${username} is not an OpenMarket username (Whop membership ${data.id})`); } if (username && END.includes(data.status)) await access("DELETE", username); res.writeHead(200).end(); } catch (error) { console.error(error); res.writeHead(500).end(); // Whop sends the event again later } }).listen(Number(process.env.PORT ?? 3000)); ``` If your webhook's payload carries no `custom_field_responses` (newer Whop API versions leave them out), `usernameOf` is the one function to change. A username that matches no OpenMarket account is logged, so you can ask that member again. Any other failure answers Whop with an error, and Whop sends the event again later. ## Discord A bot watches your paid role. A member who gets the role is added; one who loses it, or leaves the server, is removed. `/openmarket` saves the member's username and, if they already have the role, adds them at once. 1. In the Discord Developer Portal, on your app's **Bot** page, turn on **Server Members Intent**. The bot needs it to see role changes. 2. Run `npm install discord.js@14`, and save the bot as `bot.mjs`. 3. Set `OPENMARKET_ACCESS_KEY` and `DISCORD_TOKEN`, and run `node bot.mjs`. ```javascript import { Client, Events, GatewayIntentBits, MessageFlags, SlashCommandBuilder } from "discord.js"; const PACKAGE = "@you/your-indicator"; // your indicator's full name const PAID_ROLE = "Premium"; // the role your members pay for const ACCESS = `https://registry.openmarket.xyz/v1/packages/${PACKAGE}/access`; const usernames = new Map(); // Discord user id to OpenMarket username // PUT or DELETE one person. false: no OpenMarket account has that username. async function access(method, username) { const res = await fetch(`${ACCESS}/${encodeURIComponent(username)}`, { method, headers: { Authorization: `Bearer ${process.env.OPENMARKET_ACCESS_KEY}` }, }); if (res.ok) return true; const { error } = await res.json().catch(() => ({})); if (error?.code === "user_not_found") return false; throw new Error(`${method} ${username}: ${res.status} ${error?.code ?? ""}`); } const paid = (member) => member.roles.cache.some((role) => role.name === PAID_ROLE); const command = new SlashCommandBuilder() .setName("openmarket") .setDescription("Link your OpenMarket username") .addStringOption((option) => option.setName("username").setDescription("Your OpenMarket username").setRequired(true), ); const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] }); client.once(Events.ClientReady, async () => { for (const guild of client.guilds.cache.values()) { await guild.members.fetch(); // load everyone, so each role change arrives with the roles before it await guild.commands.set([command]); } }); client.on(Events.GuildMemberUpdate, async (before, after) => { const username = usernames.get(after.id); if (username && paid(before) !== paid(after)) await access(paid(after) ? "PUT" : "DELETE", username); }); client.on(Events.GuildMemberRemove, async (member) => { const username = usernames.get(member.id); if (username && paid(member)) await access("DELETE", username); }); client.on(Events.InteractionCreate, async (interaction) => { if (!interaction.isChatInputCommand() || interaction.commandName !== "openmarket") return; await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const username = interaction.options.getString("username", true).trim(); const previous = usernames.get(interaction.user.id); usernames.set(interaction.user.id, username); let reply = `Saved. Your access starts when you get the ${PAID_ROLE} role.`; try { if (paid(interaction.member)) { if (previous && previous !== username) await access("DELETE", previous); reply = (await access("PUT", username)) ? "You're in. Find it under Shared with you in Indicators on OpenMarket." : "No OpenMarket account has that username. Check it and try again."; } } catch (error) { console.error(error); reply = "Something went wrong. Try again in a minute."; } await interaction.editReply(reply); }); process.on("unhandledRejection", console.error); client.login(process.env.DISCORD_TOKEN); ``` The sample keeps usernames in memory, so a restart forgets them. Keep them in a database. ## Checks and limits | Status | Code | What to do | | --- | --- | --- | | 404 | `user_not_found` | The username is wrong. Ask the member for it again. | | 403 | `fair_use` | The Protected indicator is at 1,000 people ([Fair use](overview.md#fair-use)). | | 429 | `rate_limited` | More than 600 writes in a minute on this key. Wait the seconds the `retry-after` header names. | Webhooks get missed and bots restart. Once a night, read the list with `GET /access`, a page at a time ([Check and list](access-keys-and-api.md#check-and-list)), compare it with your own, and PUT or DELETE the difference, or send it as one batch ([Many at once](access-keys-and-api.md#many-at-once)). The key works for this one indicator and nothing else. **Revoke** on its sheet in the People panel stops it at once. # What members see Your members never type a name or a code: they open your link, sign in, and land on **Add to chart**, or on a request you approve. ## Opening your link Signed out, your link opens sign-in, headed with your name: ```text @yourname invited you to an Indicator ``` After they sign in or sign up, a **Join right away** link or a personal link lands them on your indicator with **Add to chart**, without typing anything. The invite survives the sign-up, email check included. A personal link works for one account. Opened by a second account, it says "This personal link was already used by another account." ## An Ask to join link An **Ask to join** link, the default, shows a request card instead: ```text Invite only Only people @yourname invites can find and add it. Ask to join ``` They press **Ask to join** and see "Requested. You'll hear when @yourname says yes." You approve the request in the People panel ([The invite link](invite-your-community.md#the-invite-link)), and from then on they can add it. ## After they join The indicator is under **Shared with you** in the Indicators dialog, and the chart's search finds it. They add it like any other indicator ([After publishing](../functions/publishing.md#after-publishing)). With **Protected** code, OpenMarket's servers run the indicator over the chart's candles and send back only what it draws. Your members see the plots and nothing else: there is no code to download, read or fork. ## When access ends When you remove someone, or their end date passes, the indicator stops loading for them. A Protected one stops within about 15 minutes. A Compiled module their browser already downloaded cannot be called back: it stops updating, but it does not vanish from a page that is already open. That is one more reason to publish anything you sell as Protected. ## When the link cannot open An account that cannot use Indicators yet gets one sentence and nothing else: ```text This Indicator link isn't available on your account yet. ``` A wrong link, or one to an indicator you deleted, says "This Indicator isn't available. The link may be wrong, or the author removed it." # Cookbook Complete, working wrun indicators you can copy, run, and adapt, in six groups: what to draw on price, how to read order flow, prediction-market odds read against the markets they move with, HUD cards that read a market at a glance, views beyond the time axis (profiles, volatility curves and surfaces, fund flows, the order book, cross-market maps, calendars, the yield curve), and strategies that trade. Forty of them are also the templates the chart's **New indicator** starter list offers, one card each, under the same group names; the other six are shorter recipes that build up the loop one idea at a time, each with the indicator in full and the reasoning behind it. Every recipe page is a full indicator, not a snippet. Pick its card in the editor's starter list, or paste it into a blank indicator tab under the language marker, press **Run**, and it works. Then read "How it works" for the moving parts, "Where it runs" and "When data is missing" for the markets it serves and what it says on the ones it does not, and "Customize it" for the variations worth trying. Each recipe ends with the concept pages behind it, so you can go deeper wherever something catches your eye. Inside a group the shorter recipes come first, easy to advanced, then the picker's templates in the picker's order. The first one is the cleanest introduction to the loop: load a series, compute a number, draw it. ![The cookbook's six groups, one small drawing each: On price, candles with a zone boxed on them; Order flow, buyers up and sellers down on every bar; Prediction markets, an odds line over the market it moves with; HUDs, a card of readings; Beyond the time axis, a profile docked on the price axis; Strategies, the trades marked on price with equity rising above them](/wrun/images/diagrams/cookbook-map.svg) 1. **On price**: marks, zones and tints drawn on the chart's own bars. 2. **Order flow**: buyers and sellers, open interest and liquidations, bar by bar. 3. **Prediction markets**: a Polymarket market's odds against the market they move with. 4. **HUDs**: one reading of the whole market, kept beside the candles. 5. **Beyond the time axis**: profiles, curves, chains and fund flows on a second axis. 6. **Strategies**: packages that trade, replayed by the Strategy Tester. ## On price Marks, zones and tints drawn on the candles themselves, from the chart's own bars. | Recipe | What it does | What it teaches | | --- | --- | --- | | [Volume spike detector](volume-spike.md) | Flags bars whose volume blows past its trailing average, scored as a z-score | one input, a TA class, a gated mark | | [Typed inputs tour](typed-inputs-tour.md) | Every kind of setting but text on one small channel indicator, with two pages, presets, a hover card, a HUD card and legend entries | fourteen of the fifteen setting kinds, the layout words and the "@name" bindings, the block kit | | [Anchored VWAP](anchored-vwap.md) | Weekly and daily VWAP that reset on UTC session boundaries, with a shaded band and a stretch readout | `bar.time()`, state that resets, a box as a channel | | [Regime filter](regime-filter.md) | A no-repaint 4h trend gate that tints a line by regime and marks entries only when the higher timeframe agrees | higher timeframes without lookahead, `color_by`, `shape_where` | | [Key levels](key-levels.md) | Prior-day and prior-week highs and lows plus the day open, as horizontal levels on any interval | segments as levels, a param as a gate | | [Zone tracker](zone-tracker.md) | Supply and demand zones drawn as boxes that switch off when price mitigates them | boxes with a `when` gate, ring-buffer windows | | [Session map](session-map.md) | Asia, London and New York each boxed around the session's high and low with a tag, the live session growing bar by bar | box and label handles keyed by the UTC clock, a ring of ids reused by day, one text slot for many tags | | [Volume heat hours](volume-heat-hours.md) | Every clock hour boxed over its range and tinted by its volume against the last day's average hour, hot hours tagged with their multiple | folding hours from the chart's own bars, a rolling baseline, opacity as the reading | | [Supply and demand zones](supply-demand-zones.md) | A zone at every confirmed swing, extended right until a close breaks it, with breakout marks and the nearest zone on each side tagged with its price | `PivotHigh` and `PivotLow`, zones as box handles that stop extending, right-anchored price tags | | [Trend alignment](trend-alignment.md) | The chart's EMA beside the 1h and 4h EMAs as steps, the background tinted while all three agree, a three-cell strip reading the state | closed 1h and 4h `candles` streams, `render.bgcolor` gated by a data-only output, an anchored strip | | [Trend candles](trend-candles.md) | The chart's own candles tinted by where the close sits against two averages, a dot on each flip, a counter of how long the state has held | `render.barcolor`, `shape` outputs for events, a counter in an anchored label | | [Strike matrix](strike-matrix.md) | The live options chain as net gamma exposure by strike across the four nearest expiries, docked on the price axis with the ATM row outlined, the per-strike bars beside the board, and the largest GEX strike, the gamma flip, max pain and the put wall drawn across the chart as labelled levels | `options_chain.cells` on the live bar, a `plot.matrix` and a `plot.levels` docked on one side and kept apart by an `offset`, `draw.line` levels with knockout label handles, the `OptionsChain` kit | | [Session OI levels](session-oi-levels.md) | Open interest by strike drawn as one profile per session, each anchored inside its own span of time | `plot.levels` with `span: "time"`, a spans frame, segments per series | ## Order flow Who traded, who is positioned and who was forced out, from sided trades, open interest and liquidations. | Recipe | What it does | What it teaches | | --- | --- | --- | | [Aggregated CVD](aggregated-cvd.md) | Aggressive buying minus selling on the chart's market, accumulated, with volatility bands and sign coloring | the `trades` source, accumulators, `missing` policies, which inputs the chart pins to another market | | [CVD divergence](cvd-divergence.md) | Cumulative volume delta in its own pane, price swings dotted on it, and the evidence drawn when price and CVD disagree at two swings | side-split `trades.volume`, a displaced `shape` output, line and polyline handles across two panes | | [Positioning regimes](positioning-regimes.md) | Each bar's open-interest change in coins colored by who moved, a key card with each regime's share, a dot where the leading regime changed hands | `oi.close` reading NaN, one histogram output per colour, `draw.card` with text slots | | [Absorption](absorption.md) | Bars where heavy one-sided volume failed to move price, lit in the delta pane, boxed on the chart and dotted on the absorbed side | a ratio against a rolling norm, two histograms sharing a pane, sign palettes | | [Liquidation bursts](liquidation-bursts.md) | Long and short liquidations in USD stacked in one column per bar in their own pane, a burst tagged on price with its size in money | the `liquidations` source by side, a z-score gate, label handles with money text | | [Book heat](book-heat.md) | The order book painted as a heatmap behind the candles, resting size per level, the scale read off the run | `book.cells`, `plot.heatmap` over cells, a palette and a quantile scale | | [Liquidation heat](liquidation-heat.md) | Estimated liquidation levels as a grid of 64 rows around the close, hot rows lit, rewritten every bar | `out.grid`, `plot.heatmap` over a grid, a diverging palette with a floor | | [OI liquidation heat](liquidation-heat-oi.md) | The Liquidation Map's estimate drawn through time: positions opened from open interest, folded every bar onto a grid of 128 rows around the close and swept where price trades through | `oi.close` and side-split `trades.volume` read as positions, `out.grid` and `plot.heatmap` with a dollar tooltip, data-only outputs for alerts | | [Volume footprint](volume-footprint.md) | One footprint column per bar from the profile buckets, imbalances marked and stacked, the point of control per column | `volume_profile.cells`, `plot.footprint`, imbalance words | | [TPO letters](tpo-letters.md) | Thirty-minute letters per day on the chart's own candles, the initial balance bracketed, single prints lit | `plot.tpo`, the letter interval rule, `initial_balance` | | [Session volume profile](session-volume-profile.md) | A volume profile per session from the profile buckets, the value area shaded, each session boxed | `plot.profile` with `span: "session"`, `value_area_shade`, `background` | ## Prediction markets A Polymarket market's odds set against the market they move with. | Recipe | What it does | What it teaches | | --- | --- | --- | | [Odds vs price](odds-vs-price.md) | A Polymarket market's odds against a pinned asset: rolling correlation, a divergence histogram, the asset's close in a window below the chart, a verdict card | a leg pinned to another market, `Correlation` and `Zscore`, a four-colour ladder on a histogram | ## HUDs One card pinned to the price pane, each in its own look, that answers one question first and reads the numbers behind it underneath, kept current on the live bar. | Recipe | What it does | What it teaches | | --- | --- | --- | | [Market HUD](market-hud.md) | The trend in a word, then RSI, the taker buy share and the last closed bar's volume against its average on three rings, ATR percent and the open-interest change in coins as a sparkline, in the glass look | one `render.hud` card in a look, a headline pill coloured by a ladder output, a rings tile, data-only outputs read by the tiles | | [Decision board](decision-board.md) | A headline written from six factors ("Buyers Ahead, 3 to 1"), the net score as a stippled line and each factor's reading in a row with a dotted leader, in the broadsheet look | each factor as a rule with its thresholds as settings, counts as data-only outputs, a headline written into a string slot | | [Order flow HUD](order-flow-hud.md) | Who is pushing price in a word, each bar's taker buy share as bars from the 50 mark, the window's buy share, the net taker delta in dollars and the liquidations on each side, in the chart desk look | side-split `trades` and `liquidations`, rolling windows kept as rings, a value tile with this bar's delta beside it | | [Order book HUD](order-book-hud.md) | The heavier side of the book in a word, the bid share within 1% of the mid in segments, the dollars resting by distance and the biggest walls, dotted on price, in the cockpit look | `book.cells` read in two passes, depth bands summed by distance, line handles set on the live bar | | [Session HUD](session-hud.md) | The session trading now, how much of it has passed and the session up next, the share of an average day's range used, the close in today's range, the distance from the VWAP and the prior day's high and low, in the stage look | `param.session` windows read with `inSession`, a daily `candles` stream, a countdown written into a string slot | | [Gamma map](gamma-map.md) | Dealer gamma at spot in a word, the chain's gamma exposure by strike docked on the price axis, the walls and the flip drawn across the chart, the levels as numbered rows in the terminal look | `options_chain.cells` on the live bar, exposure math in the indicator, `plot.levels` docked on the axis | ## Beyond the time axis Views whose second axis is price, tenor or the calendar. | Recipe | What it does | What it teaches | | --- | --- | --- | | [Volume profile and value area](volume-profile-value-area.md) | The rolling volume profile docked on the price axis with POC and the value area lit, the three levels drawn with price tags, a heatmap of volume by UTC hour below the chart | `volume_profile.cells` as a block, `plot.levels` docked on the axis, `panel.heatmap` | | [Volatility term structure](vol-term-structure.md) | Implied volatility at three tenors in a pane, the gap tinted while the curve is inverted, flip dots on price, a curve of now against a week ago, a regime card | `implied_volatility` and `skew` inputs by tenor, a `range` tinted by a ladder, `panel.line` on a category axis | | [Options dashboard](options-dashboard.md) | Eight tiles over the live options chain (net GEX, the flip, max pain, the dealer regime, the put/call ratio, the skew, vega and dealer delta), the gamma exposure curve over hypothetical spots with a signed fill, a badge and callouts, and a rolling CVD pane | `options_chain.cells` on the live bar, the `OptionsChain` kit, `panel.tiles` and `panel.line` over frames with markers and a badge, a named pane | | [ETF flows](etf-flows.md) | The chart coin's daily spot-ETF net flow as one bar per day, the running sum in a strip on the price pane, a wash behind each day's candles, a card | `etf_flow.flow_usd` landing once per day, lower-pane box handles, `out.inset`, `render.bgcolor` | | [Liquidation map](liquidation-map.md) | An estimate of where open positions would be liquidated, docked on the price axis as a two-sided profile, the largest rows drawn across the chart with tags, a cockpit card | open interest split by taker side into leverage tiers, `plot.levels` with `baseline: "center"`, line and label handles | | [Options odds cloud](options-odds-cloud.md) | Where the options market expects price to settle at an expiry, docked on the price axis as one hump with the most likely row marked, the 68% and 95% bands drawn across the chart, a glass card | `options_chain.cells` turned into odds per price row, per-row colours and a `gradient` on `plot.levels`, declared `draw.line` and knockout `draw.label` drawings | | [Who is trading](whos-trading.md) | The window's dollar volume shared out by trade size as a donut, bought against sold per size as bars with the verdict as a chip, a stage card with the whales' buy share | `trade_volume_by_size.cells` over a rolling window, `panel.pie`, `panel.bars` with a `badge` | | [Rotation map](rotation-map.md) | Eight coins placed by strength against BTC and its momentum on a four-quadrant map with trails, the leader named in a chip and a terminal card | nine hourly `candles` streams on Binance Futures smoothed in days, `panel.scatter` with `guides`, `quadrants` and `trails`, `param.color` inks written into the frame | | [Volatility smile](vol-smile.md) | Implied volatility by strike for three expiries as smooth curves over a strike axis, a spot marker, ATM callouts, a skew chip, a broadsheet card | `options_chain.cells` read out of the money, `panel.line` on a number axis, point callouts and a `badge` in the frame | | [Correlation matrix](correlation-matrix.md) | How the chart's market moved with eight others as a market by market heatmap with an average row, a signal card with the most tied and most free markets | eight pins with `missing: "nan"`, Pearson correlation on the live bar, `panel.heatmap` on category axes with a palette scale, a `highlight` row and a `summary` row | | [Venue share](venue-share.md) | Each venue's share of the last 24 hours of BTC dollar volume as a donut, a table of venue, last, premium, volume and share, a dial card with the widest premium | pinned close and volume on four venues, `panel.pie`, a styled `panel.table`, `param.text` and `param.color` settings, `chart.interval_sec()` | | [Volatility surface](iv-surface.md) | Implied volatility across six expiries and nine moneyness buckets as a heatmap with the ATM row outlined, the term structure as tiles, a glass card | `options_chain.cells` interpolated to buckets, `panel.heatmap` with `highlight`, `panel.tiles`, a `render.legend` entry | | [Seasonality grid](seasonality-grid.md) | A year by month grid of returns on daily charts, a weekday by hour grid of the average move on intraday charts, the best cell named, a broadsheet card | two `candles` streams of the chart's own market (26 weeks of 1h, 12 years of 1d) picked by `chart.interval_sec()`, one `panel.heatmap` with a `highlight` and an Avg `summary` row, a signed scale | | [Depth curve](depth-curve.md) | The order book as two cumulative staircases meeting at the mid, the biggest walls called out, the heavier side in a chip, a phosphor card with the ratio, the wall and the spread | `book.cells` summed outward from the mid, `panel.line` step series with fills, `markers` with an exact `y`, a `caption` | | [Yield curve](yield-curve.md) | The Treasury curve today against a week and a month ago over a tenor axis, 2Y and 10Y callouts, the 2s10s spread in a chip and a broadsheet card | nine `economic.value` pins, prints kept in a ring, days turned into bars with `chart.interval_sec()`, `panel.line` with three series | ## Strategies Packages that trade: orders placed in the file, replayed by the Strategy Tester under the chart. | Recipe | What it does | What it teaches | | --- | --- | --- | | [Moving-average cross](strategy-ma-cross.md) | Long when the fast average crosses above the slow one, flat when it crosses back under, with a tinted ribbon and a mark on each cross | `strategy(...)` in the source, orders in `onBar()`, a `range` ribbon | | [Risk-sized reversion](strategy-risk-reversion.md) | Buys the dip as RSI leaves oversold, protects it with an ATR-sized stop and target, leaves on recovery; each trade's rails drawn on price | an entry sized from its stop with `qty(...)` and `strategy.equity()`, a protective exit re-armed each bar, rails as line handles | ## How to use a recipe ![The loop every recipe shares: open the Editor, pick the recipe's card from Templates, press Run, change a setting and it reruns, read a value at the Console prompt, and Publish when it is yours; in the middle, the three verbs every recipe teaches: load a series, compute a number, draw it](/wrun/images/diagrams/cookbook-loop.svg) - **The ring** is the loop: the Editor, Templates, the recipe's card, Run, a setting changed, a value read at the Console prompt, and back to the editor. - **Publish** leaves the ring: the indicator goes to other charts, to readers and to alerts. - **The three verbs** in the middle are what every recipe does: load a series, compute a number, draw it. 1. Open the editor: the chart toolbar's **Editor** button, or the **Build** icon in the **Indicators** dialog. 2. For a recipe that is also a template, press the **Templates** icon ("Browse starter templates") in the editor's Explorer and pick the recipe's card under its group. The pick fills an untouched draft, or opens in a tab of its own, with the `//@lang=wrun-ts` line on top. For any other recipe, press **New indicator** in the Explorer, pick **Blank indicator** (a tab holding the `//@lang=wrun-ts` line alone), and paste the recipe's indicator block under that line. 3. Press **Run** on a liquid symbol. Run compiles the file in your browser and draws it on the chart. The order-flow recipes want a market where the venue reports buy and sell volume; each page's "Where it runs" names the markets it serves. 4. Open the overlay's settings. Every setting is a row there, labelled by its description and drawn as the control its kind names (a number field bounded by its `min` and `max` for `param(...)` and `param.int`, a toggle for `param.bool`, a menu for `param.choice`), so lookbacks and thresholds change without touching code, and a change reruns the indicator over the loaded bars. Colors are declared on the outputs, boxes, and segments: edit the hex strings and Run again. 5. Read a value at the editor's Console prompt, which reads the last run without a new build (below). 6. Publish it from the editor to add it to other charts, share it, or put an alert on it ([Publishing](../functions/publishing.md), [Alerts](../functions/alerts.md)). A published indicator runs in each reader's browser, or on OpenMarket's servers when its code is **Protected**. What the Console prompt answers after a Run, with the [Volume spike detector](volume-spike.md)'s `z` as the output: ```text outputs every output with its value on the newest bar params the settings the last run used rows the bars the run covered, the first and last bar time, the warm-up bars z the output's value on the newest bar z[-3] the same output three bars back last 20 z its last 20 bars; a data-only output reads the same way ``` Most recipes read the chart's own market and interval ([Execution model](../core-concepts/execution-model.md)). The ones that pin say so on their page. The chart serves a pin on a secondary `ohlcv` input: an interval coarser than the chart's and a whole multiple of it ([Multi-timeframe](../core-concepts/multi-timeframe.md#the-interval-pin-a-real-coarser-feed)), or another market at the chart's interval ([Odds vs price](odds-vs-price.md)). A market pin on any other source is refused by name, which is why [Aggregated CVD](aggregated-cvd.md) reads the chart's own venue. Stocks, forex and gold pin the same way ([Multi-source](../core-concepts/multi-source.md#stocks-forex-and-gold)). On CME markets the editor's Run is paused, and a community indicator cannot read CME data; OpenMarket's official wrun indicators can. ## What the recipes share A few shapes come back in every recipe: - **State lives in module-level variables.** A value that must survive from bar to bar is a module-level `let`; a window of past values you index into is a `StaticArray` ring buffer, which `History` packages as a class ([Stats, history and lists](../functions/stats-history-lists.md#history-xn-from-pine)). - **Decisions are numbers.** A condition the chart should act on is a data-only `none` output written as `1` or `0`; the declarations turn it into a look through `shape_where`, `color_by`, or a box's `when` gate. - **Calendar math comes from the bar's open time.** `bar.time()` is each bar's open in epoch seconds; dividing by 86400 gives the UTC day, and a boundary in that index is where sessions reset. - **An output can carry an alert.** Once the indicator is published and on a chart, the chart's alert dialog offers every drawn output with conditions such as **Crosses above** and **Greater than**, and a data-only gate becomes a ready-made signal through `alert(name, { when })` in the file ([Alerts](../functions/alerts.md)). # Typed inputs tour ![The tour's settings dialog open on its Signal page: the rail, the presets strip, a row of each kind](/wrun/images/wrun-dialog.svg) Every kind of setting the dialog can draw but text, on one small channel indicator: an average of a picked price field with percent bands, an offset in a picked unit, a reference close on a picked timeframe, a ratio against a picked market, a floor line and a session filter over picked weekdays. The dialog has two pages, **Signal** and **Bands**, a folded section and a section that carries its own toggle, two presets, and a Style page the chart adds by itself. On the chart, the basis line carries a hover card of four block kinds, a two-column HUD card of four tiles sits at the top left under the legend, clear of the newest candles, momentum and the ratio each draw in a pane of their own below the chart, and the legend reads the length setting after the indicator's name with the session's words beside it. The parts are fourteen of the fifteen setting kinds and the layout words ([Setting kinds](../settings/kinds.md), [Pages, sections, dividers, notes](../settings/layout.md)), the `"@name"` bindings that paint the basis line and pin two inputs, the session and unit helpers `inSession` and `unitToPrice` ([Sessions and units](../settings/sessions-and-units.md)), the block kit behind the hover card and the HUD ([HUD and hover cards](../presentation/hud-and-hover-cards.md)), and the `Sma`, `Ema`, `Atr` and `Roc` helpers ([TA library](../functions/ta-library.md)). Every setting is read once in `onStart()` and kept in a module variable. This is also the `typed-inputs-tour` template: the **Typed inputs tour** card under **Starters** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Typed inputs tour: every kind of setting the dialog can draw, on one small channel Indicator. Read the declarations top to bottom, then the two functions. // Each setting is declared by its kind, so the dialog knows the control to draw; the page, section, divider and note words lay the rows out in this order. page("Signal"); // the dialog's first page: the rows declared below land on it, in this order param.int("length", 20, { min: 2, max: 500, label: "Length in bars", hint: "Bars in the average" }); // a whole number: p_length() reads it in onStart() param.choice("kind", ["Simple", "Exponential"], "Simple", { label: "Average" }); // one of two choices: p_kind() reads the index, 0 for Simple param.source("src", ohlcv.close, { label: "Source" }); // the price field the average reads, picked in the dialog: in_src() reads it per bar param.bool("show_reference", true, { label: "Reference", row: "reference", hint: "The close on the timeframe picked beside it" }); // a toggle; rows naming the same row word share one line of the dialog param.timeframe("htf", "chart", { label: "Timeframe", row: "reference", when: "show_reference" }); // a timeframe pick on that line ("chart" follows the chart until a coarser one is picked), dimmed while the toggle it names is off ... input("close_htf", ohlcv.close, { interval: "@htf" }); // ... and bound to this input by name: the host fetches the picked timeframe param.symbol("pair", "BINANCE_FUTURES:ETHUSDT", { label: "Ratio against" }); // a market pick ... input("close_pair", ohlcv.close, { symbol: "@pair" }); // ... bound to this input the same way param.list("lookbacks", [5.0, 20.0], { max: 4, label: "Momentum lookbacks in bars" }); // a growable list of numbers, four at most: p_lookbacks() reads the filled slots param.time("since", "2024-01-01 00:00", { label: "Start", hint: "Earlier bars stay empty" }); // a point in time, pickable on the chart: p_since() reads epoch seconds param.price("floor", 0.0, { label: "Floor price", hint: "0 draws no floor" }); // a price level, pickable on the chart section("Session", { collapsed: true }); // a folded section: its header shows the row count param.session("rth", "09:30-16:00", { tz: "America/New_York", label: "Active hours" }); // a window and its zone: start, end and zone readers param.multi("days", ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], ["Mon", "Tue", "Wed", "Thu", "Fri"], { label: "Active days" }); // any number of picks: p_days() reads a bitmask page("Bands"); // the second page const showBands = param.bool("show_bands", true, { label: "Show bands" }); // a toggle, bound to a const so the section below can carry it in its header section("Bands", { toggle: showBands }); // the section's header carries the toggle param.range("band_pct", [0.5, 2.0], { min: 0, max: 10, step: 0.1, label: "Band width in percent" }); // a low..high pair on one slider: p_band_pct_lo() and p_band_pct_hi() param.number("offset", 0.0, { min: -100, max: 100, unit: ["price", "ticks", "%", "atr"], unit_default: "%", label: "Basis offset" }); // a number with a unit picker: p_offset_unit() reads the unit's code param.int("atr_length", 14, { min: 2, max: 50, slider: true, label: "ATR length", hint: "The ATR an atr-unit offset scales by" }); // a bounded whole number, drawn as a slider param.color("basis_color", "#2962ff", { label: "Basis colour" }); // a colour, painted onto the basis line below by name divider(); // a rule between rows note("Bands are **percent** distances from the basis: the low end below it, the high end above it."); // a line of words between rows market.tick_size(); // the host writes the market's tick size into a hidden setting: p_market_tick_size() reads it in onStart() const basisLine = output("basis", line, overlay, { color: "@basis_color", label: "Basis", format: "price", tooltip: "{{label}} {{value:price}}" }); // the colour setting paints this line; label, format and tooltip shape its legend entry; the handle names it below output("upper", line, overlay, { color: "#26a69a", label: "Upper band", format: "price" }); output("lower", line, overlay, { color: "#ef5350", label: "Lower band", format: "price" }); output("reference", line, overlay, { color: "#94a3b8", width: 1, line_style: "dashed", label: "Reference", format: "price" }); // the close on the picked timeframe: the chart's own until a coarser one is picked, then stepped output("floor", line, overlay, { color: "#f59e0b", label: "Floor", format: "price", legend: false }); // the picked price, kept out of the legend pane("momentum", { height_frac: 0.15 }); // a pane of its own below the chart, so the momentum never shares an axis with the ratio output("momentum", line, lower, { pane: "momentum", color: "#8b5cf6", label: "Momentum", format: "0.00", tooltip: "{{label}} {{value:0.00}} %" }); // the average rate of change over the listed lookbacks, in the pane declared above output("ratio", line, lower, { color: "#0ea5e9", label: "Ratio", format: "0.000" }); // the close over the picked market's close, in the default lower pane output("basis_chg", none); // data-only: the basis change per bar, the delta a tile shows beside the value output("band_pos", none); // data-only: where the close sits in the band, 0 at the lower band, 100 at the upper output("state", none); // data-only: 0 outside the session, 1 inside and under the basis, 2 inside and over it string("session", { max_bytes: 32 }); // the words the legend and the HUD show: the zone and whether the session is open legend({ title: "({{length}})" }); // the words after the Indicator's name in the legend: a setting read by name render.legend("session_entry", { text: "session", color_by: "state", colors: ["#94a3b8", "#ef5350", "#26a69a"] }); // a text entry in the legend, coloured by the state ladder render.hud("tour", { position: "top_left", safe_area: true, title: "Tour", columns: 2, tiles: [tile.value("Basis", "basis", { format: "price", delta: "basis_chg" }), tile.spark("Momentum", "momentum", { bars: 32 }), tile.gauge("Band position", "band_pos", { min: 0, max: 100, format: "int" }), tile.pill("Session", "session", { color_by: "state", colors: ["#94a3b8", "#ef5350", "#26a69a"] })] }); // a card of four tiles pinned under the legend, clear of the newest candles hover(basisLine, [block.value("Value", "basis", { format: "price", delta: "basis_chg" }), block.rows([["Upper", "upper", "price"], ["Lower", "lower", "price"], ["Reference", "reference", "price"]]), block.meter("Band position", "band_pos", { min: 0, max: 100, marks: [50] }), block.chips("session")]); // the card the basis shows on hover, titled by the line's label: four block kinds presets({ Scalp: { length: 9, kind: "Exponential", band_pct_lo: 0.2, band_pct_hi: 1, show_reference: false }, Swing: { length: 50, kind: "Simple", band_pct_lo: 1, band_pct_hi: 3, show_reference: true } }); // named settings sets the dialog offers as chips let basisSma = new Sma(20); // the two averages; onStart() rebuilds both at the chosen length and the kind picks one let basisEma = new Ema(20); let atr = new Atr(14); // the ATR an atr-unit offset scales by, rebuilt in onStart() at its own length let rocs: Roc[] = []; // one rate of change per listed lookback, built in onStart() let useEma = false; let bandsOn = true; let referenceOn = true; // the settings, read once in onStart() and kept here let lowPct = 0.5; let highPct = 2.0; let offset = 0.0; let offsetUnit = 0; let tick = 0.0; let since = 0.0; let floorPrice = 0.0; let dayMask = 0.0; let sessionStart = 570.0; let sessionEnd = 960.0; let zone = 0; let basis: f64 = NaN; // the basis, kept between bars: each bar measures its change against the last one // onStart() runs once before the first bar: every setting is read here, through its generated reader, and kept in module state. function onStart(): void { const n = i32(p_length()); basisSma = new Sma(n); basisEma = new Ema(n); useEma = p_kind() == 1.0; bandsOn = pb_show_bands(); referenceOn = pb_show_reference(); lowPct = p_band_pct_lo(); highPct = p_band_pct_hi(); offset = p_offset(); offsetUnit = i32(p_offset_unit()); atr = new Atr(i32(p_atr_length())); tick = p_market_tick_size(); // 0 where a host has no tick size: the helper then leaves a ticks value as it is since = p_since(); floorPrice = p_floor(); dayMask = p_days(); sessionStart = p_rth_start(); sessionEnd = p_rth_end(); zone = i32(p_rth_tz()); if (zone < 0 || zone >= SESSION_TZ_LABELS.length) zone = 0; const lookbacks = p_lookbacks(); rocs = []; for (let i = 0; i < lookbacks.length; i += 1) rocs.push(new Roc(i32(lookbacks[i]))); } // onBar() runs once per bar: read the bar, fold it into the averages, place it in the session; then write every output and the legend's words. function onBar(): void { const t = bar.time(); const close = in_src(); const reference = in_close_htf(); const pair = in_close_pair(); // the bar's open in epoch seconds, the picked price field and the two pinned closes const prevBasis = basis; const sma = basisSma.update(close); const ema = basisEma.update(close); basis = useEma ? ema : sma; const atrValue = atr.update(bar.high(), bar.low(), close); // the bar's high and low feed the ATR an atr-unit offset scales by if (isFinite(basis)) basis += unitToPrice(offset, offsetUnit, close, tick, atrValue); // the offset in the unit the dialog picked, as a price distance const ratio = isFinite(pair) && pair != 0.0 ? close / pair : NaN; let sum = 0.0; let count = 0; for (let i = 0; i < rocs.length; i += 1) { const r = rocs[i].update(close); if (isFinite(r)) { sum += r; count += 1; } } const momentum = count > 0 ? sum / f64(count) : NaN; const weekday = i32((Math.floor(t / 86400.0) + 3.0) % 7.0); // 0 = Monday, from the bar's UTC day: the bit the days setting is tested by const active = inSession(t, sessionStart, sessionEnd, zone) && multiHas(dayMask, weekday); // the window in its zone, daylight saving included const started = t >= since; if (!isFinite(basis) || !started) return; // nothing to draw yet (before Start, or while the average warms up): every output stays NaN on this bar const upper = basis * (1.0 + highPct / 100.0); const lowerBand = basis * (1.0 - lowPct / 100.0); out_basis(basis); out_basis_chg(basis - prevBasis); out_upper(bandsOn ? upper : NaN); out_lower(bandsOn ? lowerBand : NaN); // a hidden line is NaN: nothing is drawn out_reference(referenceOn ? reference : NaN); out_floor(floorPrice > 0.0 ? floorPrice : NaN); out_momentum(momentum); out_ratio(ratio); const span = upper - lowerBand; out_band_pos(span > 0.0 ? Math.min(100.0, Math.max(0.0, ((close - lowerBand) / span) * 100.0)) : NaN); out_state(active ? (close > basis ? 2.0 : 1.0) : 0.0); sb_clear(); sb_text(SESSION_TZ_LABELS[zone]); sb_text(active ? " open" : " closed"); str_session_sb(); // "New York open", coloured by the state ladder } ``` ## How it works Every setting is declared by its kind, and `onStart()` reads each one once through its reader and keeps it in a module variable ([Setting kinds](../settings/kinds.md#read-it-in-onstart)). The source, timeframe and symbol picks have no reader: the chart applies them, and `"@htf"` and `"@pair"` pin the two inputs ([Picks, lanes, the cap](../settings/picks-and-lanes.md#read-it-in-onstart)). `color: "@basis_color"` paints the basis line from the color setting ([The Style page](../settings/style-page.md#declare-it)). The unit code and the session's three parts are integers; `unitToPrice` and `inSession` turn them into a price distance and an in-session test per bar ([Sessions and units](../settings/sessions-and-units.md#read-it-in-onstart)). `basis_chg`, `band_pos` and `state` are data-only outputs. `pane("momentum", { height_frac: 0.15 })` gives momentum a pane of its own, so a ratio near 30 never flattens a momentum near 1 on a shared axis; the ratio keeps the default lower pane ([Plotting](../presentation/plotting.md#panes)). The hover card is titled by the basis line's label, so its first block reads `Value` rather than repeating `Basis`. Every tile and block reads the newest row, so a value written per bar is how a delta or a regime reaches the HUD card, the hover card and the legend's color ladder ([HUD and hover cards](../presentation/hud-and-hover-cards.md), [Legend](../presentation/legend.md)). ![The HUD card: a value with its delta, a sparkline, a gauge and a pill](/wrun/images/wrun-hud.svg) ![The legend: the title suffix read from the length setting, labelled values, the session entry colored by the state ladder](/wrun/images/wrun-legend.svg) ## Settings The dialog lays the rows out in source order: **Signal** and **Bands** on the rail, the folded **Session** section with its row count, the **Bands** section carrying its toggle, and **Scalp** and **Swing** as chips after **Default**. Each row is the control its kind names, shaped by `row`, `when`, `hint`, `slider` and `unit`. The **Style** page adds a row per drawn output; the basis line's color is the `basis_color` setting's row instead. What each control looks like and why: [Setting kinds](../settings/kinds.md#what-the-dialog-draws), [Options on a setting](../settings/options.md#what-the-dialog-draws), [Pages, sections, dividers, notes](../settings/layout.md#what-the-dialog-draws), [Presets](../settings/presets.md#what-the-dialog-draws), [The Style page](../settings/style-page.md#what-the-dialog-draws). ![The Signal page: a number field with a hint glyph, a menu, a source menu, a toggle and a timeframe sharing a line, a market button, a list, a time and a price with Pick, a folded Session section](/wrun/images/wrun-dialog.svg) ![The Bands page: a section carrying its toggle in the header, a two-handle slider, a number with a unit picker, a slider, a color picker, a divider and a note](/wrun/images/wrun-dialog-bands.svg) ![Style rows: visible, width, line style and color per output](/wrun/images/wrun-style-rows.svg) ## Where it runs Every market with candles, on any interval: the timeframe defaults to the chart's own. The source, timeframe and symbol picks are applied by the chart only when the indicator runs in your browser; on OpenMarket's servers the three keep their defaults and there is no Style page ([Picks, lanes, the cap](../settings/picks-and-lanes.md#where-a-setting-applies)). The legend, the HUD and the hover card are drawn where the indicator runs in the browser. ## When data is missing A hidden line is NaN and draws nothing: the bands while **Show bands** is off, the reference while **Reference** is off, the floor while **Floor price** is 0. Bars before **Start** return from `onBar()` before any output is written, so they stay empty. The ratio is NaN where the picked market has no close for the bar, and momentum stays NaN until the first lookback is warm. Where a host has no tick size the hidden setting reads 0 and `unitToPrice` leaves a ticks value as it is; an ATR of 0 does the same for an atr-unit offset. ## Customize it - **Pick a preset.** **Scalp** sets the length to 9, the average to Exponential, the band to 0.2 and 1 percent and hides the reference; **Swing** sets 50, Simple, 1 and 3 percent with the reference on. A range is set through its parts, `band_pct_lo` and `band_pct_hi` ([Presets](../settings/presets.md#declare-it)). - **Another reference.** Pick a coarser timeframe in the **Timeframe** menu and the reference line steps at that timeframe's closes; pick another market with **Ratio against** and the ratio pane follows it. - **Add a setting.** Declare it with its kind after the page it should land on and read it in `onStart()`, or name the page and section with `{ group: "Signal/Session" }` ([Pages, sections, dividers, notes](../settings/layout.md#declare-it)). - **Move the card.** `position` on `render.hud` is one of the nine anchors: `top_left`, `top_center`, `top_right`, `middle_left`, `middle_center`, `middle_right`, `bottom_left`, `bottom_center`, `bottom_right`. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Typed inputs tour** under **Starters**. 2. Press **Run**: the basis and its bands draw on price with the dashed reference, momentum and the ratio draw in two panes below, the legend reads `(20)` after the name, and the **Tour** card appears at the top left, under the legend. 3. Open the indicator's settings on the chart. **Signal** and **Bands** sit on the rail above the **Chart** caption, the presets strip offers **Default**, **Scalp** and **Swing**, and every row is the control its kind names. Switch **Average** to Exponential and the indicator reruns over the loaded bars without recompiling. 4. At the editor's Console prompt, type `outputs` to read the newest value of every output, the three data-only ones included. ## Concepts used - [Setting kinds](../settings/kinds.md) for the fifteen kinds, their controls and their readers - [Options on a setting](../settings/options.md) for `row`, `when`, `hint`, `slider` and `unit` - [Pages, sections, dividers, notes](../settings/layout.md) and [Presets](../settings/presets.md) for the layout words and the chips - [The Style page](../settings/style-page.md) for the rows the dialog adds and the `"@name"` binding - [Sessions and units](../settings/sessions-and-units.md) for `inSession`, the zone table and `unitToPrice` - [Picks, lanes, the cap](../settings/picks-and-lanes.md) for what the chart applies in the browser - [HUD and hover cards](../presentation/hud-and-hover-cards.md) and [Legend](../presentation/legend.md) for the three surfaces - [TA library](../functions/ta-library.md) for `Sma`, `Ema`, `Atr` and `Roc` # Volume spike detector Flag bars whose volume blows past its trailing average, scored as a z-score against a rolling mean and standard deviation. This recipe spots unusual volume. Instead of a fixed "alert above 1M" threshold that means nothing across different symbols, it measures how far the current bar's volume sits above its own recent average, in standard deviations. A reading of 3 means "three sigma above normal" whatever the symbol or timeframe. It is the cleanest possible introduction to the indicator loop: read one input, fold it into one class, write a few outputs. ## The wrun indicator ```typescript param("lookback", 50, { min: 10, max: 300, description: "Bars of history that define normal volume" }); param("threshold", 2.5, { min: 1, max: 6, description: "Z-score that counts as a spike" }); input("volume", ohlcv.volume); // the first input is the bar grid: the chart's own candles output("z", line, lower, { color: "#64748b", width: 1, description: "Volume z-score" }); output("threshold", line, lower, { color: "#ef4444", width: 1 }); output("spike", shape, lower, { color: "#ef4444", shape_where: "is_spike" }); output("is_spike", none); let zscore = new Zscore(50); let threshold: f64 = 2.5; function onStart(): void { zscore = new Zscore(i32(p_lookback())); threshold = p_threshold(); } function onBar(): void { const z = zscore.update(bar.volume()); if (isNaN(z)) return; out_z(z); out_threshold(threshold); out_spike(z); out_is_spike(z >= threshold ? 1.0 : 0.0); } ``` ## How it works **The data.** One `input("volume", ohlcv.volume)` names the chart's own volume as the first input, the grid the run walks, and `bar.volume()` reads it in `onBar()`, one value per bar. That is the only feed the detector needs, and `onBar()` sees this bar's value and nothing else. **The score.** A z-score answers "how surprising is this number?" You need two reference points: where volume usually sits, and how much it normally wobbles. `Zscore` is both reference points in one class: `update(x)` folds one bar into its window and returns `(x - mean) / stdev` over that window, or `0` when the window is flat. Because it is a ratio, it is comparable across any symbol or timeframe. A whale print on BTC and a thin altcoin both light up at the same number. **The warm-up guard.** Until `lookback` bars have loaded, the class returns `NaN`, and `onBar()` returns before writing anything: every output stays `NaN` on that bar, and a `NaN` is not drawn, so the pane stays empty there instead of drawing noise ([Execution model](../core-concepts/execution-model.md)). **The output.** `z` plots as a line in its own pane (`lower`). `threshold` is a line too, written to the same value on every bar, so you can see at a glance how close any bar is to firing. `spike` is a `shape` output whose value is the z-score (where the mark sits) and whose `shape_where` gate is the data-only `is_spike` output: the mark draws only on bars where the gate is `1`. Decisions are numbers here, and the sheet turns them into looks. ## Design notes - The mark is two outputs: its position (`spike`) and the decision that gates it (`is_spike`). An output cannot gate itself, so the gate is its own `none` output. - The threshold line is an output that never changes; its value is the param, written every bar. - Settings take any value inside `min`..`max`; the `description` is the label the settings dialog shows beside the field. - Every output has a value on every bar. After a Run the editor's Console prompt reads any of them (`last 20 z`, `is_spike`), and once the indicator is published, `z` can carry an alert set from the chart. ## Customize it - **Sensitivity.** `threshold` is the main knob. Drop it toward `1.5` to catch milder bursts, raise it toward `4` to keep only genuine anomalies. The red line moves with it. - **Memory.** `lookback` sets how much history "normal" is measured against. A short window (20) reacts to recent conditions and treats a busy session as the new baseline; a long window (200) compares against a calmer, broader average and flags more. - **Two-sided.** As written, only high-volume bars fire because the gate is `z >= threshold`. To catch unusually quiet bars too, add a second gate output (`z <= -threshold`) and a second `shape` output gated by it. - **Color and size.** The circle and line colors are hex strings on the declarations. `width` on the line output resizes it; `render.shape` picks a different mark kind than the chart's default ([Plotting](../presentation/plotting.md#text-labels-tables-strips-tints)). - **Turn it into an alert.** Publish it, add it to a chart, and set an alert from the chart on `z` with **Crosses above** at 3. OpenMarket's alerts engine evaluates it in the cloud with the overlay's settings; **Once per bar** lets it fire again on later spikes, at most once per bar ([Alerts](../functions/alerts.md)). ## Run it 1. In the editor's Explorer, press **New indicator** and pick **Blank indicator**: the tab holds the `//@lang=wrun-ts` line alone. 2. Paste the indicator block above under that line and press **Run**. 3. At the editor's Console prompt, type `last 20 z` to read the score of the last 20 bars. ## Concepts used - [Series functions](../functions/series-functions.md) and [TA library](../functions/ta-library.md) for `Zscore` over a trailing window - [Setting kinds](../settings/kinds.md) for the `lookback` and `threshold` params - [Missing values (NaN)](../core-concepts/na-and-scalar-types.md) for the `NaN` warm-up rule - [Plotting](../presentation/plotting.md) and [Cards, frames and panels](../presentation/cards-frames-panels.md#marks-tints-and-fills) for the `line` outputs and the gated `shape` # Anchored VWAP Weekly and daily VWAP that reset on real UTC session boundaries, with a shaded band and a stretch readout for mean-reversion context. VWAP is the volume-weighted average price, the level large players benchmark fills against. A plain cumulative VWAP drifts: it averages from wherever the chart happened to start loading, so the line you see depends on how much history your browser fetched. This recipe pins VWAP to calendar sessions instead. The weekly line resets every Monday at 00:00 UTC and the daily line resets every midnight, so two traders looking at the same symbol see the same level. It adds a percentage band and a "stretch" number that tells you how far price has pulled from fair value. ## The wrun indicator ```typescript param("band_pct", 0.5, { min: 0.1, max: 3, description: "Band distance from the weekly VWAP, in percent" }); output("week_vwap", line, overlay, { color: "#f5a623", width: 2, description: "VWAP anchored to the UTC week" }); output("day_vwap", line, overlay, { color: "#4a90d9", width: 1, description: "VWAP anchored to the UTC day" }); const bandTop = output("week_upper", line, overlay, { color: "#f5a623", opacity: 0.3 }); const bandBottom = output("week_lower", line, overlay, { color: "#f5a623", opacity: 0.3 }); box("week_band", { top: bandTop, bottom: bandBottom, color: "#f5a623", opacity: 0.08, borderWidth: 0 }); output("stretch_pct", line, lower, { unit: "%", color: "#f5a623", description: "Close distance from the weekly VWAP" }); let bandPct: f64 = 0.5; let weekIdx: f64 = NaN; let weekPv: f64 = 0.0; let weekVol: f64 = 0.0; let weekComplete: bool = false; let dayIdx: f64 = NaN; let dayPv: f64 = 0.0; let dayVol: f64 = 0.0; let dayComplete: bool = false; function onStart(): void { bandPct = p_band_pct(); } function onBar(): void { const close = bar.close(); const typical = (bar.high() + bar.low() + close) / 3.0; const volume = bar.volume(); // UTC day index of this bar; epoch day 0 was a Thursday, so +3 makes weeks start on Monday 00:00 UTC. const day = Math.floor(bar.time() / 86400.0); const week = Math.floor((day + 3.0) / 7.0); if (week != weekIdx) { if (!isNaN(weekIdx)) weekComplete = true; weekIdx = week; weekPv = 0.0; weekVol = 0.0; } if (day != dayIdx) { if (!isNaN(dayIdx)) dayComplete = true; dayIdx = day; dayPv = 0.0; dayVol = 0.0; } weekPv += typical * volume; weekVol += volume; dayPv += typical * volume; dayVol += volume; // A session that was already running when the history starts is incomplete: stay NaN until the first boundary. const weekVwap = weekComplete && weekVol > 0.0 ? weekPv / weekVol : NaN; const dayVwap = dayComplete && dayVol > 0.0 ? dayPv / dayVol : NaN; out_week_vwap(weekVwap); out_day_vwap(dayVwap); out_week_upper(weekVwap * (1.0 + bandPct / 100.0)); out_week_lower(weekVwap * (1.0 - bandPct / 100.0)); out_stretch_pct(isNaN(weekVwap) || weekVwap <= 0.0 ? NaN : ((close - weekVwap) / weekVwap) * 100.0); } ``` ## How it works **The anchor is the whole trick.** `bar.time()` hands the module each bar's open time in epoch seconds. Divide by 86400 and you have a UTC day index; shift by three days and divide by seven and you have a week index whose boundary is Monday 00:00 UTC. When the index changes, the price-times-volume and volume sums reset. The reset is tied to the calendar, not to your scroll position or how much history loaded, which is exactly why the level is stable and shared. It is eight lines of arithmetic you can read. **Warm-up is honest.** A session that was already running when the loaded history starts is incomplete, so both lines stay `NaN` until their first boundary passes. The daily line fills in within the first day of loaded data, the weekly line needs about a week. `onBar()` still writes every output on those bars: the row exists, and the lines that are ready draw while the ones that are warming do not. That is the warm-up rule from the [Execution model](../core-concepts/execution-model.md): an output written `NaN` draws nothing on that bar, and the others draw as usual. **The band frames the move.** `week_upper` and `week_lower` are the weekly VWAP scaled by a percentage, drawn faint with `opacity: 0.3`, and `box("week_band", ...)` shades between them: with `from` and `to` left at `0` every bar contributes a one-bar-wide slice and the slices tile into a channel. Its `borderWidth: 0` keeps the slices from showing seams. The two band outputs are bound to consts because the box names them by handle. When price rides the upper band it is stretched rich versus the week's fair value; when it sags to the lower band it is cheap. **The stretch readout.** `(close - week_vwap) / week_vwap * 100` is the most actionable number, so it gets its own pane as `stretch_pct` with a `%` unit, `NaN` while the weekly line is warming. The indicator has it on every bar: a series you can read back in the legend, and a value an alert from the chart can compare. ## Design notes - There is no anchored-VWAP builtin: `bar.time()` and a reset-on-boundary sum are the building blocks, and they work for any anchor. - `color` is declared on each output, and the band tint is a box color with its own `opacity`. There is no color picker param: a `param(...)` declares a number, with a `min`, a `max` and a `description` ([Styling](../presentation/styling.md)). - The band is a box on every bar, one bar wide, tiling into a channel. `range("week_upper", "week_lower", { ... })` is the other form: the chart draws it as a filled band between the two outputs, with a colour ladder or a gradient when you want one ([Styling](../presentation/styling.md)). - The stretch readout is a lower-pane series: visible on every bar, and a value an alert from the chart can follow. A one-cell table is possible with a string slot and `render.table` ([Drawing objects](../presentation/drawing-objects.md)), at the cost of switching the file to the second runtime contract. ## Customize it - **Band width.** `band_pct` sets how far the band sits from the VWAP. Tighten it toward `0.2` on calm majors, widen it toward `2` on volatile names so the band actually contains the noise. - **Anchor period.** For a monthly anchor, compute the month index from the day index (a lookup over cumulative month lengths, leap years included); the reset logic does not change. Monthly needs about a month of loaded history before it shows anything. - **Band the daily line too.** Duplicate the two band outputs and the box against `day_vwap` if you trade the intraday session instead. - **Stretch as a signal.** Add a data-only gate output (`stretch_pct > 1.5`) and a `shape` output with `shape_where` on it, the way the [volume spike](volume-spike.md) marks its bars. Or skip the mark: publish the indicator, add it to a chart, and set an alert from the chart on `stretch_pct` with **Greater than** at 1.5; **Once only** fires it a single time ([Alerts](../functions/alerts.md)). ## Run it 1. In the editor's Explorer, press **New indicator** and pick **Blank indicator**: the tab holds the `//@lang=wrun-ts` line alone. 2. Paste the indicator block above under that line and press **Run**. The daily line fills in within the first loaded day, the weekly line within the first loaded week. 3. At the editor's Console prompt, type `last 20 stretch_pct` to read the stretch of the last 20 bars. ## Concepts used - [Time and sessions](../core-concepts/time-and-sessions.md) for the day and week indexes from `bar.time()` - [Volume and VWAP](../functions/volume-indicators.md#vwap-anchors-side-by-side) for the anchored-VWAP pattern as a worked function - [Drawing objects](../presentation/drawing-objects.md) for the per-bar box that shades the band - [Missing values (NaN)](../core-concepts/na-and-scalar-types.md) for the `NaN` warm-up on each line # Regime filter (4h trend gate) A no-repaint higher-timeframe trend gate that tints a line by regime and only marks entries when the confirmed 4h trend agrees with the signal. The fastest way to cut false signals is to stop trading against the higher timeframe. This recipe reads a confirmed 4h trend, tints its 4h line green or red by that regime, and only marks an entry when a fast/slow EMA cross on your chart timeframe lines up with the 4h direction. The key word is confirmed: a 4h candle contributes only once it has closed, so a signal that shows up in history would have shown up live, at the same bar. No repaint, no hindsight. ## The wrun indicator ```typescript param("fast_len", 9, { min: 2, max: 100, description: "Fast EMA length, on the chart bars and on the 4h buckets" }); param("slow_len", 21, { min: 5, max: 200, description: "Slow EMA length" }); output("h4_fast", line, overlay, { width: 1, color_by: "regime", colors: ["#94a3b8", "#22d3a5", "#ff5b7f"], description: "Confirmed 4h fast EMA, tinted by regime", }); output("h4_slow", line, overlay, { color: "#94a3b8", width: 1, description: "Confirmed 4h slow EMA" }); output("regime", none, overlay, { description: "0 warming, 1 bull, 2 bear" }); output("long", shape, overlay, { color: "#22d3a5", shape_where: "is_long" }); output("short", shape, overlay, { color: "#ff5b7f", shape_where: "is_short" }); output("is_long", none); output("is_short", none); let h4Fast = new Ema(9); let h4Slow = new Ema(21); let fast = new Ema(9); let slow = new Ema(21); let cross = new Cross(); let bucket: f64 = NaN; let bucketClose: f64 = NaN; let h4FastValue: f64 = NaN; let h4SlowValue: f64 = NaN; function onStart(): void { h4Fast = new Ema(i32(p_fast_len())); h4Slow = new Ema(i32(p_slow_len())); fast = new Ema(i32(p_fast_len())); slow = new Ema(i32(p_slow_len())); cross = new Cross(); } function onBar(): void { const close = bar.close(); const high = bar.high(); const low = bar.low(); // 4h buckets on UTC boundaries. A bucket's close folds into the 4h EMAs only once the NEXT bucket // has started, so the regime never uses a 4h candle that is still forming: no repaint. const b = Math.floor(bar.time() / 14400.0); if (b != bucket) { if (!isNaN(bucketClose)) { h4FastValue = h4Fast.update(bucketClose); h4SlowValue = h4Slow.update(bucketClose); } bucket = b; } bucketClose = close; const regime = isNaN(h4FastValue) || isNaN(h4SlowValue) ? 0.0 : h4FastValue > h4SlowValue ? 1.0 : h4FastValue < h4SlowValue ? 2.0 : 0.0; const crossed = cross.update(fast.update(close), slow.update(close)); if (isNaN(h4SlowValue)) return; out_h4_fast(h4FastValue); out_h4_slow(h4SlowValue); out_regime(regime); out_long(low); out_short(high); out_is_long(crossed == 1 && regime == 1.0 ? 1.0 : 0.0); out_is_short(crossed == -1 && regime == 2.0 ? 1.0 : 0.0); } ``` ## How it works **The higher-timeframe view.** `Math.floor(bar.time() / 14400)` puts every chart bar in a UTC 4h bucket. The module remembers the last close it saw in the current bucket, and when a bar from the NEXT bucket arrives it folds that remembered close into the two 4h EMAs. A 4h candle therefore contributes exactly once, after it closed. That is what makes the gate trustworthy: the tints you see on old bars are what you would have seen live. The promise is fourteen lines you can read, and the [Execution model](../core-concepts/execution-model.md) explains why an indicator cannot break it by accident. **The regime.** Fast 4h EMA above slow is bull (`1`), below is bear (`2`), and warming is `0`. It is a data-only output, and the `h4_fast` line indexes its three-entry palette with it: grey, green, red. Both 4h EMAs are guarded with `isNaN` so the warm-up region counts as neither regime, rather than a coin flip. **The trigger.** Separately, the module computes the same fast/slow EMAs on the chart timeframe and detects the moment they cross. `Cross.update(fast, slow)` returns `+1` on the one bar where the fast EMA moves from at-or-below to above the slow one, `-1` for the opposite, `0` otherwise, including on any bar where either side is still `NaN`. That is the entry idea on its own. **The gate.** `is_long` is `1` only when the cross and the regime agree; `long` is a `shape` output at the bar's low, gated by it. A bullish cross during a 4h downtrend is silently dropped, which is the whole point: most chop happens when you fight the higher timeframe. **Two ways to see the state.** The 4h line carries the regime through `color_by`, so the context is always visible, and the two `shape` outputs drop a mark only on confirmed, gated entries. The 4h EMA lines are drawn even before a single cross fires, so there is always something on screen. ## Design notes - The higher timeframe is built in the module from the chart's own bars, and the confirm-on-next-bucket rule is the no-repaint contract written out. The chart also serves the direct form, an `interval: "4h"` pin on a second `close` input: a 4h leg of closed candles fetched on its own, read as of each row's close, on any chart whose interval divides 4h evenly. It sees each 4h close one bar earlier than the bucketing does, on the bar whose close ends the 4h candle, and both are causal ([Multi-timeframe](../core-concepts/multi-timeframe.md)). - The regime tint is a choice. This indicator tints the 4h line through `color_by`; `render.barcolor` tints the candles themselves with the same gate and ladder options, and a background tint per bar is `render.bgcolor` ([Plotting](../presentation/plotting.md#text-labels-tables-strips-tints)). - A `shape` output's value is where its mark sits: `long` is written the bar's low, so the mark hangs below the candle, and `short` the bar's high, so it sits above. - The colors are `colors` and `color` on the declarations: a palette on the line that `color_by` indexes, one color on each mark. ## Customize it - **EMA speed.** `fast_len` and `slow_len` drive both the regime and the trigger. Widen the gap (21 / 55) for fewer, slower signals, tighten it for more. Because the same lengths feed the 4h regime, changing them reshapes the whole gate. - **Regime timeframe.** Change `14400.0` to `86400.0` for a daily regime (stricter, fewer entries) or `3600.0` for a looser gate on lower-timeframe charts. Bucketing works for any multiple of the chart interval. - **Loaded history.** The 4h regime needs enough loaded bars for its slow EMA to warm up, roughly `slow_len * 4` chart hours on a 1h chart. If the line never tints, pan back: the indicator reruns over the longer window. - **Different trigger.** Swap the cross for an RSI level or a breakout and keep the `regime == 1.0` / `regime == 2.0` gates to filter it by trend. - **Make it actionable.** `is_long` is data-only, so the alert dialog does not offer it as a condition. Bind it to a `const` (`const isLong = output("is_long", none);`) and declare `alert("trend_long", { when: isLong, message: "{{symbol}} trend-aligned long" })`: once the indicator is published and on a chart, that signal is in the chart's alert dialog and fires only on trend-aligned entries ([Alerts](../functions/alerts.md)). ## Run it 1. In the editor's Explorer, press **New indicator** and pick **Blank indicator**: the tab holds the `//@lang=wrun-ts` line alone. 2. Paste the indicator block above under that line and press **Run** on a chart finer than 4h, such as 15m or 1h. 3. At the editor's Console prompt, type `last 20 regime` to read the regime of the last 20 bars. ## Concepts used - [Multi-timeframe](../core-concepts/multi-timeframe.md) for the no-repaint bucket fold and the `interval` pin the chart serves - [Moving averages](../functions/moving-averages.md) for `Ema` and [Series functions](../functions/series-functions.md) for `Cross` - [Styling](../presentation/styling.md) for `color_by` over a data-only output - [Repainting](../core-concepts/repainting.md) for why a bucket folds in only on the next bucket's first bar # Key levels Prior-day and prior-week highs and lows plus the running day open, drawn as horizontal levels that are calendar-correct on any chart interval. Yesterday's high and low, last week's high and low, and today's open are the levels the market keeps reacting to, and half the annotations on any trading chart are someone maintaining them by hand. This recipe maintains them for you: five levels computed from calendar-anchored sessions, drawn as thin lines that are exactly the same on a 1-minute chart and a 4-hour chart, and that never repaint: a prior day high is always the prior completed UTC day. ## The wrun indicator ```typescript param("show_open", 1, { min: 0, max: 1, description: "1 draws the running day open, 0 hides it" }); input("open", ohlcv.open); // the first input is the bar grid: the chart's own candles const pdh = output("pdh", none, overlay, { description: "Prior UTC day high" }); const pdl = output("pdl", none, overlay, { description: "Prior UTC day low" }); const pwh = output("pwh", none, overlay, { description: "Prior UTC week high" }); const pwl = output("pwl", none, overlay, { description: "Prior UTC week low" }); const dayOpen = output("day_open", none, overlay, { description: "Open of the running UTC day" }); const openVisible = output("open_visible", none); // Each bar draws its level from itself to the next bar; consecutive bars chain into one line that // steps to the new level on the first bar of each period. segment("pdh_line", { yFrom: pdh, yTo: pdh, from: 0, to: 1, color: "#f5a623", width: 1 }); segment("pdl_line", { yFrom: pdl, yTo: pdl, from: 0, to: 1, color: "#f5a623", width: 1 }); segment("pwh_line", { yFrom: pwh, yTo: pwh, from: 0, to: 1, color: "#4a90d9", width: 1 }); segment("pwl_line", { yFrom: pwl, yTo: pwl, from: 0, to: 1, color: "#4a90d9", width: 1 }); segment("open_line", { yFrom: dayOpen, yTo: dayOpen, from: 0, to: 1, when: openVisible, color: "#f5a623", width: 1, lineStyle: "dashed" }); let showOpen: f64 = 1.0; let dayIdx: f64 = NaN; let dayHigh: f64 = NaN; let dayLow: f64 = NaN; let runningOpen: f64 = NaN; let priorDayHigh: f64 = NaN; let priorDayLow: f64 = NaN; let weekIdx: f64 = NaN; let weekHigh: f64 = NaN; let weekLow: f64 = NaN; let priorWeekHigh: f64 = NaN; let priorWeekLow: f64 = NaN; function onStart(): void { showOpen = p_show_open(); } function onBar(): void { const open = bar.open(); const high = bar.high(); const low = bar.low(); const day = Math.floor(bar.time() / 86400.0); const week = Math.floor((day + 3.0) / 7.0); if (day != dayIdx) { // The day that just closed becomes the prior day. A day already running when the history // starts is incomplete and never becomes a level. if (!isNaN(dayIdx)) { priorDayHigh = dayHigh; priorDayLow = dayLow; } dayIdx = day; dayHigh = high; dayLow = low; runningOpen = open; } else { if (high > dayHigh) dayHigh = high; if (low < dayLow) dayLow = low; } if (week != weekIdx) { if (!isNaN(weekIdx)) { priorWeekHigh = weekHigh; priorWeekLow = weekLow; } weekIdx = week; weekHigh = high; weekLow = low; } else { if (high > weekHigh) weekHigh = high; if (low < weekLow) weekLow = low; } out_pdh(priorDayHigh); out_pdl(priorDayLow); out_pwh(priorWeekHigh); out_pwl(priorWeekLow); out_day_open(runningOpen); out_open_visible(showOpen != 0.0 ? 1.0 : 0.0); } ``` ## How it works **The levels come from completed sessions.** The module keeps the running high and low of the current UTC day and week from the day index of `bar.time()`. On the first bar of a new day, the day that just closed becomes the prior day: `pdh` and `pdl` step to its high and low, and hold there for the whole day. Weeks work the same way. A day or week already running when the loaded history starts is incomplete and never becomes a level. This is the same no-repaint contract as the [Regime filter](regime-filter.md): a level is only ever built from bars that have closed. **The day open is the one level that should move.** It is the open of the first bar of the running day, refreshed once at the boundary and held: a variable assigned on the boundary bar. **Drawing is a segment per bar.** Each level is a segment from this bar to the next (`from: 0, to: 1`) with both ends on the same output. The pieces chain into one horizontal line that steps to the new level on the first bar of each period. Every level output is `none`: the numbers are computed and readable at the editor's Console prompt, but the segments are what draws, so the levels never appear twice. The `open_line` segment is gated by `open_visible`, which is the `show_open` param turned into an output: a setting becomes a gate. **Nothing waits for the last bar.** There is no last-bar special case: the level exists on every bar it applies to, so each level has a value on every bar. ## Design notes - The levels are per-bar segments. Offsets clamp to the loaded range, so a line ends at the newest bar, never past it. A line handle drawn under `bar.isLast()` and extended right is the other form, the one [Volume profile and value area](volume-profile-value-area.md) draws its three levels with ([Drawing objects](../presentation/drawing-objects.md)). - The levels carry no price tags. A tag per level is a `render.label` over a string slot ([Drawing objects](../presentation/drawing-objects.md)), which switches the file to the second runtime contract; the level values themselves are readable at the editor's Console prompt. - The show-open switch is a numeric param (`0` or `1`), and each level's color is a `color` option on its segment. - There is no history depth to declare: state lives in a few module-level variables, however much history is loaded. ## Customize it - **Add monthly levels.** Compute a month index from the day index and keep a third running high and low; two more outputs and two more segments (7 of the 16 allowed). - **Prior close and midpoint.** Keep the last close of each day for a `pdc` level; `(pdh + pdl) / 2` is the day's midpoint, a favorite mean-reversion magnet. - **Style per period.** `lineStyle: "dotted"` on the weekly segments keeps them quieter than the daily ones; `width: 2` makes a level louder. - **Alert on a level.** The levels are data-only, so the alert dialog does not offer them as conditions. Read `bar.close()` and write a data-only gate `1` on the bar the close crosses above `pdh`, bind the gate to a `const`, and declare `alert("pdh_break", { when: ... })`: once the indicator is published and on a chart, the signal is in the chart's alert dialog ([Alerts](../functions/alerts.md)). ## Run it 1. In the editor's Explorer, press **New indicator** and pick **Blank indicator**: the tab holds the `//@lang=wrun-ts` line alone. 2. Paste the indicator block above under that line and press **Run** on any interval. The prior-day levels appear once a full UTC day has loaded, the prior-week ones once a full week has. 3. At the editor's Console prompt, type `pdh` for the prior day's high on the newest bar, or `last 5 pwh` for the prior week's high over the last five bars. ## Concepts used - [Time and sessions](../core-concepts/time-and-sessions.md) for the day and week indexes and why the prior day is always a completed UTC day - [Drawing objects](../presentation/drawing-objects.md) for segments with `from: 0, to: 1` as horizontal levels - [Options on a setting](../settings/options.md) for the boolean-as-param that becomes a `when` gate - [Core variables](../core-concepts/core-variables.md) for the running highs and lows kept in module-level variables # Zone tracker Supply and demand zones drawn as boxes that switch themselves off when price mitigates them, with a live count of the zones still active. Supply and demand zones are price areas where a strong move began. They stay interesting until price trades back through them, at which point they are mitigated and no longer matter. Tracking them by hand means juggling a list of rectangles and remembering to remove each one when it gets invalidated. This recipe does it for you: it finds zones at confirmed pivots, draws each one as a box, and switches the box off the moment price mitigates the zone. It is a tour of the wrun drawing model: state in module-level variables, boxes that read their bounds from outputs, and a gate that switches a drawing off. ## The wrun indicator ```typescript param("lookback", 20, { min: 5, max: 100, description: "Bars a swing must dominate to count as a pivot" }); input("open", ohlcv.open); // the first input is the bar grid: the chart's own candles const demandTop = output("demand_top", none); const demandBottom = output("demand_bottom", none); const demandLive = output("demand_live", none); const supplyTop = output("supply_top", none); const supplyBottom = output("supply_bottom", none); const supplyLive = output("supply_live", none); output("active_zones", none, overlay, { description: "Zones alive on this bar, 0 to 2" }); // One bar wide (from 0 to 0) on every bar the zone is alive: the bars tile into a band that starts // at the pivot and stops on the bar that mitigates it. box("demand_zone", { top: demandTop, bottom: demandBottom, when: demandLive, color: "#22d3a5", opacity: 0.18, borderWidth: 0 }); box("supply_zone", { top: supplyTop, bottom: supplyBottom, when: supplyLive, color: "#ff5b7f", opacity: 0.18, borderWidth: 0 }); const MAX_LOOKBACK = 100; const highs = new StaticArray(MAX_LOOKBACK); const lows = new StaticArray(MAX_LOOKBACK); let n: i32 = 20; let cursor: i32 = 0; let count: i32 = 0; let windowHigh: f64 = NaN; let windowLow: f64 = NaN; let prevOpen: f64 = NaN; let prevHigh: f64 = NaN; let prevLow: f64 = NaN; let prevClose: f64 = NaN; let dTop: f64 = NaN; let dBottom: f64 = NaN; let dLive: f64 = 0.0; let sTop: f64 = NaN; let sBottom: f64 = NaN; let sLive: f64 = 0.0; function onStart(): void { n = i32(p_lookback()); } function onBar(): void { const open = bar.open(); const high = bar.high(); const low = bar.low(); const close = bar.close(); // A pivot is confirmed one bar after it prints: the previous bar led its window and this bar turned back. const pivotHigh = !isNaN(windowHigh) && prevHigh >= windowHigh && high < prevHigh; const pivotLow = !isNaN(windowLow) && prevLow <= windowLow && low > prevLow; // One live zone per side: a new pivot is ignored while the side's zone is still alive. if (pivotHigh && sLive == 0.0) { sTop = prevHigh; sBottom = Math.max(prevOpen, prevClose); sLive = 1.0; } if (pivotLow && dLive == 0.0) { dTop = Math.min(prevOpen, prevClose); dBottom = prevLow; dLive = 1.0; } // Mitigation: a close through the far edge retires the zone. if (dLive == 1.0 && close < dBottom) dLive = 0.0; if (sLive == 1.0 && close > sTop) sLive = 0.0; // Push this bar into the window and recompute the window extremes. highs[cursor] = high; lows[cursor] = low; cursor = (cursor + 1) % n; if (count < n) count += 1; windowHigh = NaN; windowLow = NaN; if (count == n) { let h = -Infinity; let l = Infinity; for (let i = 0; i < n; i++) { if (highs[i] > h) h = highs[i]; if (lows[i] < l) l = lows[i]; } windowHigh = h; windowLow = l; } prevOpen = open; prevHigh = high; prevLow = low; prevClose = close; out_demand_top(dTop); out_demand_bottom(dBottom); out_demand_live(dLive); out_supply_top(sTop); out_supply_bottom(sBottom); out_supply_live(sLive); out_active_zones(dLive + sLive); } ``` ## How it works **State shaped like the problem.** Each side has three numbers: the zone's top, its bottom, and whether it is alive. They are outputs, so the sheet can draw from them, and they are module-level variables, so they survive from bar to bar. The indicator spreads them over named outputs because the box has to read them by name. **Birth.** The window's highest high and lowest low come from a ring buffer of highs and lows plus a scan: the two `StaticArray` buffers are allocated once, at module start, sized from the param's `max`, and `cursor` walks them. A pivot is confirmed one bar after it prints: the previous bar led its window and the current bar turned back. When a pivot fires and that side has no live zone, the zone takes its bounds from the pivot bar's body and wick and its `live` flag flips to `1`. **Death is a gate turning off.** Every bar checks whether the close went through the zone's far edge. A mitigated zone sets `live` to `0`, and the box's `when` gate stops drawing from that bar on. Nothing is deleted: the bars the zone was alive on keep their slices. **The box.** `box("demand_zone", ...)` with `from` and `to` at `0` draws one bar-wide slice on every bar where `demand_live` is `1`; the slices tile into a band that starts at the pivot and ends on the mitigation bar. `borderWidth: 0` keeps the slices seamless. The coordinates are output handles bound to consts, which is how a box names an output. **The dashboard.** `active_zones` is `demand_live + supply_live`, a data-only count. Every bar carries it, readable at the editor's Console prompt. ## Design notes - The zones are a fixed set of declared boxes gated per bar: what a bar draws is decided on that bar ([Drawing objects](../presentation/drawing-objects.md)). That is why this indicator keeps one zone per side. More zones are more declared boxes, up to 16 per sheet. Box handles, which the file creates, moves and deletes by id, are the other form: [Supply and demand zones](supply-demand-zones.md) keeps a ring of them per side. - A mitigated zone stays visible on the bars it was alive on. For a zone that vanishes from the chart when it breaks, draw it as a box handle and delete the handle on the mitigation bar. - Each zone's facts are module-level variables and its mitigation check is one `if` line. An AssemblyScript `class` would carry the same fields ([User-defined types](../core-concepts/user-defined-types.md)); the outputs are the reason the flat form is simpler here. ## Customize it - **Zone frequency.** `lookback` controls how significant a swing must be to spawn a zone. A small value (10) marks many minor pivots, a large one (50) keeps only major swing points. This is the main dial between "lots of zones" and "only the big ones". - **Mitigation rule.** Right now a single close beyond the zone mitigates it. For a stricter definition, require the close to pass fully through to the far edge, or two consecutive closes, by adding a counter beside each `live` flag. - **Zone thickness.** The bounds come from the pivot bar's body (`open`/`close`) and wick (`high`/`low`). Use the full candle range for thicker zones by reading `prevHigh` and `prevLow` for both edges, or the body only for tighter ones. - **More zones.** Duplicate the three outputs and the box per extra slot and fill the first free slot at each pivot; 16 boxes per sheet is the ceiling. - **Alert on a mitigation.** `active_zones` is data-only, so the alert dialog does not offer it as a condition. Add a data-only gate written `1` on the bar a zone dies, bind it to a `const`, and declare `alert("zone_died", { when: ... })`: once the indicator is published and on a chart, the signal is in the chart's alert dialog ([Alerts](../functions/alerts.md)). ## Run it 1. In the editor's Explorer, press **New indicator** and pick **Blank indicator**: the tab holds the `//@lang=wrun-ts` line alone. 2. Paste the indicator block above under that line and press **Run**. 3. At the editor's Console prompt, type `last 20 active_zones` to read how many zones were alive on each of the last 20 bars. ## Concepts used - [Collections](../core-concepts/collections.md) for the `StaticArray` ring buffers sized from a param's `max` - [Series functions](../functions/series-functions.md) for `Highest` and `Lowest`, the one-call form of the window scan - [Drawing objects](../presentation/drawing-objects.md) for boxes with a `when` gate and why nothing is deleted - [User-defined types](../core-concepts/user-defined-types.md) for the `class` that carries a zone's fields when you want one - [Core variables](../core-concepts/core-variables.md) for the module-level state that survives from bar to bar # Session map ![Asia, London and New York session boxes on a BTC chart](/wrun/images/session-map.png) Asia, London and New York each drawn as a box around the session's high and low, with a small tag inside the box's left edge (Asia violet, London sky, New York amber). The live session's box grows with every bar. Where two sessions overlap (London into New York, Asia into London) the boxes overlap too. The last five days stay on the chart; older boxes are reused. The parts are box and label handles in chart time and price ([Drawing objects](../presentation/drawing-objects.md)), one bounded text slot every tag is written through, and the bar's open time, `bar.time()`, placed in each window's own zone by a session setting ([Time and sessions](../core-concepts/time-and-sessions.md), [Sessions and units](../settings/sessions-and-units.md)). Nothing is drawn per bar beyond moving the open session's box, so a closed day costs nothing. This is also the `session-map` template: the **Session Map** card under **On price** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Session Map: Asia, London and New York boxed around each session's high and low, a small tag on each box, the live session growing bar by bar. // The trader sees which session set the range, where the overlaps traded, and how far the live session has run. Each session is a window in its own zone. section("Sessions"); // the dialog's first section: a window and its zone per session; daylight saving follows the zone, bar by bar param.session("asia", "00:00-08:00", { tz: "UTC", label: "Asia" }); param.session("london", "07:00-16:00", { tz: "UTC", label: "London" }); param.session("new_york", "13:30-20:00", { tz: "UTC", label: "New York" }); section("Boxes"); param.int("days_kept", 5, { min: 1, max: 10, label: "Days kept", description: "Days of sessions kept on the chart; the oldest box is reused" }); param.bool("show_tags", true, { label: "Show tags", description: "Tag each box with its session name" }); input("high", ohlcv.high); // the bar's high and low grow the open session's box output("sessions_open", none, overlay, { description: "Sessions open on this bar: 0, 1, or 2 where two sessions overlap" }); // data-only, so the Console can read it string("tag", { max_bytes: 48 }); // one bounded slot every tag is written through handles.box({ opacity: 0.08, borderWidth: 1 }); // boxes in chart time and price: one per session per day handles.label({ size: 11, align: "left" }); // the tags, and the one line of words on a coarse chart const SESSIONS = 3; // Asia, London, New York const MAX_DAYS = 10; // the ring's ceiling: 30 boxes and 30 tags at most, ids in one space const NAMES = ["Asia", "London", "New York"]; const inks: i32[] = [rgba(167, 139, 250, 255), rgba(56, 189, 248, 255), rgba(248, 192, 0, 255)]; // violet, sky, amber const SLATE: i32 = rgba(148, 163, 184, 255); const starts = new StaticArray(SESSIONS); // each window's start and end in minutes from midnight, and its zone, read from the settings in onStart() const ends = new StaticArray(SESSIONS); const zones = new StaticArray(SESSIONS); const dayKey = new StaticArray(SESSIONS); // the day the open instance's window opened, in its zone (NaN = none yet): a new day opens a new box whatever bars the feed has let clocks: Clock[] = []; // one bar clock per session, built in onStart() for the zone each window is set in const slot = new StaticArray(SESSIONS); // the ring slot that instance sits in const instances = new StaticArray(SESSIONS); // instances seen so far, per session const startT = new StaticArray(SESSIONS); // the open instance's first bar open const endT = new StaticArray(SESSIONS); // the open instance's right edge (the newest bar's close) const hiP = new StaticArray(SESSIONS); // the open instance's high and low so far const loP = new StaticArray(SESSIONS); const touched = new StaticArray(SESSIONS); // 1 when this bar sits inside the session const fresh = new StaticArray(SESSIONS); // 1 on the bar that opens a new instance const grew = new StaticArray(SESSIONS); // 1 when this bar lifted the instance's high (a top tag moves up) const sank = new StaticArray(SESSIONS); // 1 when this bar pushed the instance's low down (a bottom tag moves down) function makeBoxes(first: i32, count: i32): BoxHandle[] { const out: BoxHandle[] = []; for (let i = 0; i < count; i += 1) out.push(draw.box(first + i)); return out; } function makeLabels(first: i32, count: i32): LabelHandle[] { const out: LabelHandle[] = []; for (let i = 0; i < count; i += 1) out.push(draw.label(first + i)); return out; } const boxes = makeBoxes(0, SESSIONS * MAX_DAYS); // handle objects allocate once, at module load const tags = makeLabels(SESSIONS * MAX_DAYS, SESSIONS * MAX_DAYS); const words = draw.label(90); // the one line of words when the chart is too coarse let daysKept = 5; let showTags = true; let t: f64 = NaN; let prevT: f64 = NaN; let width: f64 = NaN; // width = the smallest gap between bar opens seen, so a weekend gap never widens a box let wordsShown = false; // onStart() runs once before the first bar: read the settings, three readers per session, and build a clock per zone. function onStart(): void { starts[0] = p_asia_start(); ends[0] = p_asia_end(); zones[0] = i32(p_asia_tz()); starts[1] = p_london_start(); ends[1] = p_london_end(); zones[1] = i32(p_london_tz()); starts[2] = p_new_york_start(); ends[2] = p_new_york_end(); zones[2] = i32(p_new_york_tz()); daysKept = i32(p_days_kept()); if (daysKept < 1) daysKept = 1; if (daysKept > MAX_DAYS) daysKept = MAX_DAYS; showTags = pb_show_tags(); clocks = [new Clock(SESSION_TZ_IDS[zones[0]]), new Clock(SESSION_TZ_IDS[zones[1]]), new Clock(SESSION_TZ_IDS[zones[2]])]; for (let s = 0; s < SESSIONS; s += 1) dayKey[s] = NaN; } // onBar() runs once per bar: place the bar in its sessions, open a new instance on a new day, grow the open ones; // then write the output, move the boxes this bar touched, and place a tag when a box opens or grows past its tag's edge. function onBar(): void { prevT = t; t = bar.time(); const high = bar.high(); const low = bar.low(); if (!isNaN(prevT) && t > prevT && (isNaN(width) || t - prevT < width)) width = t - prevT; for (let s = 0; s < SESSIONS; s += 1) { touched[s] = 0; fresh[s] = 0; grew[s] = 0; sank[s] = 0; } if (isNaN(width)) return; // the first bar has no width yet: nothing to draw const coarse = width >= 14400.0; // 4h and coarser: a session is one or two bars, the boxes would say nothing if (coarse) { out_sessions_open(0.0); if (!wordsShown) { // once: the handle stays sb_clear(); sb_text("Session Map needs a chart finer than 4h"); words.set(56, 14).text(str_tag_sb).anchor(ANCHOR_TOP_RIGHT).align(ALIGN_RIGHT).color(SLATE); wordsShown = true; } return; } let openCount = 0; for (let s = 0; s < SESSIONS; s += 1) { clocks[s].update(t); // the bar in the window's zone, daylight saving included // A bar counts when its span reaches into the window: its open inside it, or the window opening before its close (New // York's 13:30 open inside the 13:00 bar of a 1h chart); lead = minutes from the bar's open to the window's start. const early = !inSession(t, unchecked(starts[s]), unchecked(ends[s]), unchecked(zones[s])); const lead = (unchecked(starts[s]) - f64(clocks[s].hour() * 60 + clocks[s].minute()) + 1440.0) % 1440.0; if (early && !(lead > 0.0 && lead * 60.0 < width)) continue; openCount += 1; touched[s] = 1; const key = Math.floor((t + (early ? lead * 60.0 : 0.0) + f64(clocks[s].offsetSec()) - unchecked(starts[s]) * 60.0) / 86400.0); // the local day the window opened, read at its open (a wrapped window keeps its opening day) if (key != unchecked(dayKey[s])) { // a new session day: the next ring slot (the oldest box moves here) dayKey[s] = key; slot[s] = unchecked(instances[s]) % daysKept; instances[s] = unchecked(instances[s]) + 1; startT[s] = t; hiP[s] = high; loP[s] = low; fresh[s] = 1; } else { if (high > unchecked(hiP[s])) { hiP[s] = high; grew[s] = 1; } if (low < unchecked(loP[s])) { loP[s] = low; sank[s] = 1; } } endT[s] = t + width; // the box ends at this bar's close } out_sessions_open(f64(openCount)); for (let s = 0; s < SESSIONS; s += 1) { if (unchecked(touched[s]) == 0) continue; const id = s * MAX_DAYS + unchecked(slot[s]); const box = boxes[id]; box.set(unchecked(startT[s]), unchecked(hiP[s]), unchecked(endT[s]), unchecked(loP[s])); if (unchecked(fresh[s]) == 1) box.color(inks[s]).fill(inks[s]).opacity(0.08).border(1.0); // the session's colour, set when the box is (re)opened const bottom = s == 1; // the tag sits inside its box's left edge, clear of the chart's High and Low labels: London's on the bottom, Asia's and New York's under the top, so overlapping sessions never share an edge if (showTags && (unchecked(fresh[s]) == 1 || (bottom ? unchecked(sank[s]) : unchecked(grew[s])) == 1)) { sb_clear(); sb_text(NAMES[s]); const tag = tags[id]; tag.set(unchecked(startT[s]), bottom ? unchecked(loP[s]) : unchecked(hiP[s])).text(str_tag_sb); if (unchecked(fresh[s]) == 1) tag.color(inks[s]).size(11).align(ALIGN_LEFT).valign(bottom ? VALIGN_BOTTOM : VALIGN_TOP); } } } ``` ## How it works **Sessions are cut from the clock.** `bar.time()` gives each bar's open in epoch seconds, UTC. Each session is a `param.session` (Asia 00:00 to 08:00, London 07:00 to 16:00, New York 13:30 to 20:00, all in UTC by default), read in `onStart()` as a start, an end and a zone; per bar, a bar belongs to a session when its span reaches into the window in that zone, daylight saving included: `inSession(...)` says whether the bar's open falls inside the window, and a `Clock` in that zone says whether the window opens before the bar closes, so on a 1h chart New York's 13:30 open puts the 13:00 bar in its box. The same clock keys each day's instance, read at the window's open. The bar's high and low grow the open session's box; a box is created on the session's first bar and moved with `set()` on every bar after that, so the live session's box grows as the bars arrive. **A ring of boxes, reused.** `days_kept` (5, up to 10) times three sessions is the number of box handles in play; each day's session takes the slot of the same session `days_kept` days earlier, so the oldest box is reused rather than a new one made. The handle objects are made once, at module start. **Tags sit inside their box.** Each tag hangs inside its box's left edge, so the chart's own High and Low labels, drawn outside the candles' extremes, never sit on one: Asia's and New York's under the top edge (`valign(VALIGN_TOP)`), London's on the bottom edge (`valign(VALIGN_BOTTOM)`), so the two sessions that overlap London never put their tags on the same edge. A tag moves when its edge does: up with a new session high, down with a new session low. **One slot, many tags.** `string("tag", { max_bytes: 48 })` is the one bounded slot; each tag is built with the allocation-free builder (`sb_clear`, `sb_text`, `str_tag_sb`) and sent to its label handle in turn, so the three session names never allocate a string on the live bar. `show_tags` off leaves the boxes bare. **The count is data.** `sessions_open` is a data-only output: 0, 1, or 2 where two sessions overlap. It draws nothing; the editor's Console prompt reads it after a Run. ## Where it runs Every market with candles, on charts finer than 4h. Sessions are cut in the zone each one is set to, UTC by default. ## When data is missing On a 4h or coarser chart a session is one or two bars, so the example draws one slate line of words at the top-right ("Session Map needs a chart finer than 4h") and nothing else. At 1h a session is eight or nine bars wide, and a box starts on the bar its session opens in (New York's at 13:00 for the 13:30 open). ## Customize it - **Other sessions.** The three windows are settings, each with its own zone; a window that crosses midnight (`22:00-04:00`) wraps on its own. - **More days.** `days_kept` goes up to 10; past that, raise the ring's ceiling where the handles are made (each day is three box and three label ids). - **Quieter boxes.** `handles.box({ opacity: 0.08, borderWidth: 1 })` sets the fill and the border for every box; the per-session colours are set where each box is created. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Session Map** under **On price**. 2. Press **Run** on a chart finer than 4h, such as 15m: the boxes draw on the chart and the live session's box grows as bars arrive. 3. At the editor's Console prompt, type `last 60 sessions_open` to read how many sessions were open on each of the last 60 bars. ## Concepts used - [Drawing objects](../presentation/drawing-objects.md) for box and label handles, chart coordinates and ids reused by slot - [Time and sessions](../core-concepts/time-and-sessions.md) for `bar.time()` and UTC day math - [Execution model](../core-concepts/execution-model.md) for module-level state and where `onStart()` and `onBar()` run # Volume heat hours ![Hour boxes tinted by volume with hot-hour multiplier tags](/wrun/images/volume-heat-hours.png) Every clock hour boxed over its high and low, tinted by that hour's volume against the average of the last day's completed hours: quiet hours are nearly invisible slate, busy hours turn amber and get darker, and an hour past the hot multiple gets an amber border and a tag such as "2.3x" above it (whole numbers from 10x up). The live hour fills in as its bars arrive. The parts are one box handle per clock hour and a label handle for the tag ([Drawing objects](../presentation/drawing-objects.md)), the bar's open time, `bar.time()`, for the hour boundary ([Time and sessions](../core-concepts/time-and-sessions.md)), and a rolling average of completed hours kept in module-level state. Hours are folded from the chart's own bars, so no pinned leg is needed and the example runs on stocks too. This is also the `volume-heat-hours` template: the **Volume Heat Hours** card under **On price** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Volume Heat Hours: every clock hour boxed over its range and tinted by its volume against the average of the last day's hours, // from near-invisible slate (quiet) to amber (hot); hot hours get a border and a "2.3x" tag. Hours are folded from the chart's own bars. param.int("baseline_hours", 24, { min: 6, max: 168, label: "Baseline in hours", description: "Completed hours the average volume is taken over" }); param.number("hot_multiple", 2.0, { min: 1.2, max: 5, step: 0.1, label: "Hot multiple", description: "An hour at this multiple of the average is hot: it gets a border and a tag" }); param.int("hours_kept", 60, { min: 6, max: 60, label: "Hours kept", description: "Hours kept on the chart; the oldest box is reused" }); legend({ title: "({{baseline_hours}}h)" }); // the words after the name in the legend: the baseline, read by name input("high", ohlcv.high); // the hour's box spans its high and low output("hour_ratio", none, overlay, { description: "This hour's volume so far against the average completed hour" }); // data-only, so the Console can read it string("tag", { max_bytes: 48 }); // one bounded slot every tag is written through handles.box({ opacity: 0.04, borderWidth: 0 }); // boxes in chart time and price: one per hour, quiet by default handles.label({ size: 11 }); // the "2.3x" tags (centred over their box) and the one line of words when the example cannot run const MAX_HOURS = 60; // the ring's ceiling: 60 boxes and 60 tag ids (only hot hours draw theirs) const MAX_BASE = 168; // the baseline ring's ceiling: a week of hours const AMBER: i32 = rgba(248, 192, 0, 255); const SLATE: i32 = rgba(148, 163, 184, 255); function makeBoxes(first: i32, count: i32): BoxHandle[] { const out: BoxHandle[] = []; for (let i = 0; i < count; i += 1) out.push(draw.box(first + i)); return out; } function makeLabels(first: i32, count: i32): LabelHandle[] { const out: LabelHandle[] = []; for (let i = 0; i < count; i += 1) out.push(draw.label(first + i)); return out; } const boxes = makeBoxes(0, MAX_HOURS); // handle objects allocate once, at module load const tags = makeLabels(MAX_HOURS, MAX_HOURS); // tag i belongs to box i, so a recycled hour never leaves a stale tag behind const words = draw.label(200); // the one line of words const hourVolumes = new StaticArray(MAX_BASE); // completed hours' volumes, the baseline ring let baselineHours = 24; let hot = 2.0; let hoursKept = 60; let baseHead = 0; let baseCount = 0; let baseSum = 0.0; // the baseline ring's cursor, fill and running sum let t: f64 = NaN; let prevT: f64 = NaN; let width: f64 = NaN; // width = the smallest gap between bar opens seen let hourKey: f64 = NaN; let hourSlot = 0; let hours = 0; // the hour being drawn, its ring slot, hours seen let hStart: f64 = NaN; let hEnd: f64 = NaN; let hHi: f64 = NaN; let hLo: f64 = NaN; let hVol = 0.0; // the open hour let bars = 0; let sawVolume = false; let wordsShown = false; let wordsText = ""; // the line of words to show function showWords(): void { sb_clear(); sb_text(wordsText); words.set(56, 14).text(str_tag_sb).anchor(ANCHOR_TOP_RIGHT).align(ALIGN_RIGHT).color(SLATE); wordsShown = true; } // onStart() runs once before the first bar: read the settings. function onStart(): void { baselineHours = i32(p_baseline_hours()); if (baselineHours < 1) baselineHours = 1; if (baselineHours > MAX_BASE) baselineHours = MAX_BASE; hot = p_hot_multiple(); if (hot < 1.01) hot = 1.01; hoursKept = i32(p_hours_kept()); if (hoursKept < 1) hoursKept = 1; if (hoursKept > MAX_HOURS) hoursKept = MAX_HOURS; } // onBar() runs once per bar: fold the bar into its hour (when a new hour opens, the finished one joins the baseline), write the output, // then paint this hour's box from its ratio; a hot hour gets a border and a tag. function onBar(): void { prevT = t; t = bar.time(); const high = bar.high(); const low = bar.low(); const volume = bar.volume(); if (!isNaN(prevT) && t > prevT && (isNaN(width) || t - prevT < width)) width = t - prevT; bars += 1; if (isNaN(width)) return; // the first bar has no width yet const coarse = width >= 3600.0; // an hourly or coarser chart: an hour is one bar, the boxes would say nothing new if (coarse) { if (!wordsShown) { wordsText = "Volume Heat Hours needs a chart finer than 1h"; showWords(); } return; } const key = Math.floor(t / 3600.0); let fresh = false; if (key != hourKey) { if (!isNaN(hourKey)) { // the finished hour joins the baseline ring (its oldest entry drops out of the sum) baseSum += hVol - unchecked(hourVolumes[baseHead]); hourVolumes[baseHead] = hVol; baseHead = (baseHead + 1) % baselineHours; if (baseCount < baselineHours) baseCount += 1; } hourKey = key; hourSlot = hours % hoursKept; hours += 1; hStart = t; hHi = high; hLo = low; hVol = 0.0; fresh = true; } else { if (high > hHi) hHi = high; if (low < hLo) hLo = low; } if (!isNaN(volume) && volume > 0.0) { hVol += volume; sawVolume = true; } hEnd = t + width; const average = baseCount >= 3 ? baseSum / f64(baseCount) : NaN; // three completed hours before the first tint const ratio = average > 0.0 ? hVol / average : NaN; out_hour_ratio(ratio); if (!sawVolume) { if (bar.isLast() && bars >= 8 && !wordsShown) { wordsText = "No volume on this market"; showWords(); } return; } if (wordsShown) { words.delete(); wordsShown = false; } // volume arrived after all if (fresh) tags[hourSlot].delete(); // a recycled slot starts without the old hour's tag if (isNaN(ratio)) return; // no baseline yet: nothing drawn for this hour let u = (ratio - 0.75) / (hot - 0.75); if (u < 0.0) u = 0.0; if (u > 1.0) u = 1.0; // 0 at three quarters of the average and below, 1 at the hot multiple const r = i32(148.0 + (248.0 - 148.0) * u); const g = i32(163.0 + (192.0 - 163.0) * u); const b = i32(184.0 - 184.0 * u); // slate to amber const isHot = ratio >= hot; boxes[hourSlot].set(hStart, hHi, hEnd, hLo).fill(rgba(r, g, b, 255)).opacity(0.04 + 0.41 * u * u).color(AMBER).border(isHot ? 1.0 : 0.0); // alpha 0.04 to 0.45, rising with the square so average hours stay faint if (isHot) { // the tag sits over the box and re-reads its multiple while the hour is still filling sb_clear(); sb_f64(ratio, ratio >= 10.0 ? 0 : 1); sb_text("x"); // "2.3x", "220x" tags[hourSlot].set((hStart + hEnd) * 0.5, hHi + (hHi - hLo) * 0.35).text(str_tag_sb).color(AMBER).size(11); } } ``` ## How it works **An hour is folded from the bars.** `bar.time()` gives each bar's open; a new hour starts when the bar's hour of day changes. The hour's box spans its high and low (`bar.high()`, `bar.low()`) and its volume is the sum of its bars' `bar.volume()`. On the live bar the open hour's box is moved with `set()` so it fills in as the bars arrive. **The baseline is the completed hours.** `baseline_hours` (24, 6 to 168) completed hours feed the average; the ratio of this hour's volume so far against that average is `hour_ratio`, a data-only output the editor's Console prompt reads after a Run. The first three completed hours are the warm-up, so nothing is tinted before them. **Tint by ratio, border past the multiple.** The box's fill opacity follows the ratio: nearly invisible slate for a quiet hour, amber that darkens as the ratio climbs. An hour at or past `hot_multiple` (2.0) gets an amber border and a tag ("2.3x", whole numbers from 10x up) written through the one bounded slot with the allocation-free builder (`sb_clear`, `sb_f64`, `sb_text`, `str_tag_sb`). **A ring of hours.** `hours_kept` (60, the ceiling the handle ring allows) boxes stay on the chart; the oldest is reused. The handle objects are made once, at module start. ## Where it runs Every market with volume, on charts finer than 1h. ## When data is missing On an hourly or coarser chart it draws one slate line of words at the top-right ("Volume Heat Hours needs a chart finer than 1h"). A market without volume (gold on the FX venue) gets "No volume on this market" and nothing else. ## Customize it - **A longer baseline.** `baseline_hours` up to 168 (a week of hours) smooths the average over the weekly cycle. - **Stricter tags.** Raise `hot_multiple` to tag only the busiest hours; the tint still grades every hour. - **Session hours only.** Gate the fold on the hour of day (the [Session map](session-map.md) recipe's window test) to box the cash session alone. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Volume Heat Hours** under **On price**. 2. Press **Run** on a chart finer than 1h, such as 5m or 15m. 3. At the editor's Console prompt, type `last 24 hour_ratio` to read, for each of the last 24 bars, its hour's volume so far against the average completed hour. ## Concepts used - [Drawing objects](../presentation/drawing-objects.md) for box and label handles and ids reused by slot - [Time and sessions](../core-concepts/time-and-sessions.md) for `bar.time()` and the hour boundary - [Execution model](../core-concepts/execution-model.md) for module-level state and where `onStart()` and `onBar()` run # Supply and demand zones ![Supply and demand zone boxes with nearest level price tags](/wrun/images/supply-demand-zones.png) A zone at every confirmed swing high (supply, orange) and swing low (demand, sky), one zone height of ATR deep, anchored on the swing's own bar and extended right bar by bar until a close breaks through it. A broken zone fades to slate, stops extending, and a small rose mark sits on the breakout bar (under the bar that closed up through a supply zone, over the bar that closed down through a demand zone). At the right edge the nearest live zone above and below the close is tagged with its price: "S 84,210" above, "D 82,950" below (four decimals on sub-dollar markets: "S 0.1398"). The parts are the `PivotHigh`, `PivotLow` and `Atr` helpers from the TA library ([TA library](../functions/ta-library.md)), box handles for the zones and right-anchored label handles for the two price tags ([Drawing objects](../presentation/drawing-objects.md)), and two `shape` outputs for the breakout marks. This is also the `supply-demand-zones` template: the **Supply and Demand Zones** card under **On price** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Supply and Demand Zones: a zone at every confirmed swing high (supply, orange) and swing low (demand, sky), extended right until a close // breaks through it; a broken zone fades to slate and stops; a rose mark sits on the breakout bar; the nearest live zone above and below is tagged with its price. param.int("swing_strength", 8, { min: 2, max: 30, label: "Swing strength in bars", description: "Bars on each side a swing high or low must beat" }); param.number("zone_atr", 0.25, { min: 0.05, max: 2, step: 0.01, label: "Zone height in ATR", description: "Zone height in ATR(14) measured at the swing" }); param.int("zones_per_side", 6, { min: 1, max: 12, label: "Zones per side", description: "Zones kept per side; the oldest is replaced" }); param.color("mark_color", "#fb7185", { label: "Break mark" }); // a colour setting: painted onto both break marks below by name, so the Style page shows one control for it legend({ title: "({{swing_strength}})" }); // the words after the name in the legend: the swing strength, read by name input("high", ohlcv.high); // swing highs come from the highs, swing lows from the lows output("break_up", shape, overlay, { color: "@mark_color", description: "A mark at the bar's low on a bar that closed up through a supply zone" }); // a rose mark under the breakout bar, in the colour setting output("break_down", shape, overlay, { color: "@mark_color", description: "A mark at the bar's high on a bar that closed down through a demand zone" }); // and over the breakdown bar output("supply_near", none, overlay, { description: "Bottom of the nearest live supply zone above the close" }); // data-only, for the Console output("demand_near", none, overlay, { description: "Top of the nearest live demand zone below the close" }); string("tag", { max_bytes: 48 }); // one bounded slot both tags are written through handles.box({ opacity: 0.14, borderWidth: 1 }); // zones in chart time and price handles.label({ anchor: "right", size: 11, align: "right" }); // the two price tags: pixels from the right edge, price on the y axis const MAX_ZONES = 12; // per side: 24 boxes at most, ids 0..23 const RING = 64; // bars of history kept for the swing's own time and ATR (swing_strength is at most 30) const ORANGE: i32 = rgba(248, 104, 0, 255); const SKY: i32 = rgba(56, 189, 248, 255); const SLATE: i32 = rgba(148, 163, 184, 255); const TAG_X = 56.0; // the tags end 56 px from the pane's right edge, clear of the price-axis pills function makeBoxes(first: i32, count: i32): BoxHandle[] { const out: BoxHandle[] = []; for (let i = 0; i < count; i += 1) out.push(draw.box(first + i)); return out; } const supplyBoxes = makeBoxes(0, MAX_ZONES); const demandBoxes = makeBoxes(MAX_ZONES, MAX_ZONES); // handle objects allocate once const supplyTag = draw.label(30); const demandTag = draw.label(31); const words = draw.label(32); // the one line of words while no swing has confirmed // One zone per ring slot, per side: its top and bottom, its first bar, whether it is still live, and what changed this bar. const sTop = new StaticArray(MAX_ZONES); const sBottom = new StaticArray(MAX_ZONES); const sStart = new StaticArray(MAX_ZONES); const sLive = new StaticArray(MAX_ZONES); const sUsed = new StaticArray(MAX_ZONES); const sEvent = new StaticArray(MAX_ZONES); // event: 1 opened, 2 broke const dTop = new StaticArray(MAX_ZONES); const dBottom = new StaticArray(MAX_ZONES); const dStart = new StaticArray(MAX_ZONES); const dLive = new StaticArray(MAX_ZONES); const dUsed = new StaticArray(MAX_ZONES); const dEvent = new StaticArray(MAX_ZONES); const times = new StaticArray(RING); const atrs = new StaticArray(RING); // the last bars' opens and ATRs, so a swing is anchored on its own bar let strength = 8; let zoneAtr = 0.25; let zonesPerSide = 6; let pivotHigh = new PivotHigh(8, 8); let pivotLow = new PivotLow(8, 8); const atr = new Atr(14); let head = 0; let supplyCount = 0; let demandCount = 0; let bars = 0; let wordsShown = false; // the history ring's cursor, the zones seen per side, bars seen let t: f64 = NaN; let prevT: f64 = NaN; let width: f64 = NaN; // A price in words: thousands grouped, cents on small prices, four decimals under one. function sbGrouped(n: i64): void { if (n >= 1000) { sbGrouped(n / 1000); sb_text(","); const r = n % 1000; if (r < 100) sb_text("0"); if (r < 10) sb_text("0"); sb_int(r); } else sb_int(n); } function sbPrice(v: f64): void { const a = Math.abs(v); if (a >= 1000.0) sbGrouped(i64(Math.round(a))); else if (a >= 1.0) sb_f64(a, 2); else sb_f64(a, 4); } // onStart() runs once before the first bar: read the settings and size the swing helpers. function onStart(): void { strength = i32(p_swing_strength()); if (strength < 1) strength = 1; if (strength > RING - 2) strength = RING - 2; zoneAtr = p_zone_atr(); zonesPerSide = i32(p_zones_per_side()); if (zonesPerSide < 1) zonesPerSide = 1; if (zonesPerSide > MAX_ZONES) zonesPerSide = MAX_ZONES; pivotHigh = new PivotHigh(strength, strength); pivotLow = new PivotLow(strength, strength); } // onBar() runs once per bar: fold the bar into the helpers, open a zone on a confirmed swing, break zones the close went through, write the outputs; // then open or fade the zones that changed, extend the live ones to this bar, and tag the nearest two on the live bar. function onBar(): void { prevT = t; t = bar.time(); const high = bar.high(); const low = bar.low(); const close = bar.close(); bars += 1; if (!isNaN(prevT) && t > prevT && (isNaN(width) || t - prevT < width)) width = t - prevT; const atrNow = atr.update(high, low, close); times[head] = t; atrs[head] = atrNow; head = (head + 1) % RING; const swingHigh = pivotHigh.update(high); const swingLow = pivotLow.update(low); // reported on the bar that confirms them, strength bars after the swing for (let i = 0; i < MAX_ZONES; i += 1) { sEvent[i] = 0; dEvent[i] = 0; } if (isNaN(width)) return; // the first bar has no width yet const at = (head - 1 - strength + RING) % RING; // the swing's own bar in the ring let swingAtr = unchecked(atrs[at]); if (isNaN(swingAtr)) swingAtr = atrNow; // early in the run the ATR at the swing may not exist yet if (!isNaN(swingHigh) && !isNaN(swingAtr) && swingAtr > 0.0) { // a supply zone hangs down from the swing high let overlaps = false; for (let i = 0; i < MAX_ZONES; i += 1) if (unchecked(sUsed[i]) == 1 && unchecked(sLive[i]) == 1 && swingHigh <= unchecked(sTop[i]) && swingHigh >= unchecked(sBottom[i])) overlaps = true; if (!overlaps) { const k = supplyCount % zonesPerSide; supplyCount += 1; sTop[k] = swingHigh; sBottom[k] = swingHigh - zoneAtr * swingAtr; sStart[k] = unchecked(times[at]); sLive[k] = 1; sUsed[k] = 1; sEvent[k] = 1; } } if (!isNaN(swingLow) && !isNaN(swingAtr) && swingAtr > 0.0) { // a demand zone stands up from the swing low let overlaps = false; for (let i = 0; i < MAX_ZONES; i += 1) if (unchecked(dUsed[i]) == 1 && unchecked(dLive[i]) == 1 && swingLow <= unchecked(dTop[i]) && swingLow >= unchecked(dBottom[i])) overlaps = true; if (!overlaps) { const k = demandCount % zonesPerSide; demandCount += 1; dBottom[k] = swingLow; dTop[k] = swingLow + zoneAtr * swingAtr; dStart[k] = unchecked(times[at]); dLive[k] = 1; dUsed[k] = 1; dEvent[k] = 1; } } let breakUp: f64 = NaN; let breakDown: f64 = NaN; let supplyNear: f64 = NaN; let demandNear: f64 = NaN; for (let i = 0; i < MAX_ZONES; i += 1) { if (unchecked(sUsed[i]) == 1 && unchecked(sLive[i]) == 1 && unchecked(sEvent[i]) == 0) { if (close > unchecked(sTop[i])) { sLive[i] = 0; sEvent[i] = 2; breakUp = low; } // closed up through supply: spent, marked under the bar else if (unchecked(sBottom[i]) > close && (isNaN(supplyNear) || unchecked(sBottom[i]) < supplyNear)) supplyNear = unchecked(sBottom[i]); } if (unchecked(dUsed[i]) == 1 && unchecked(dLive[i]) == 1 && unchecked(dEvent[i]) == 0) { if (close < unchecked(dBottom[i])) { dLive[i] = 0; dEvent[i] = 2; breakDown = high; } // closed down through demand else if (unchecked(dTop[i]) < close && (isNaN(demandNear) || unchecked(dTop[i]) > demandNear)) demandNear = unchecked(dTop[i]); } } out_break_up(breakUp); out_break_down(breakDown); out_supply_near(supplyNear); out_demand_near(demandNear); const right = t + width; for (let i = 0; i < MAX_ZONES; i += 1) { if (unchecked(sUsed[i]) == 1) { const box = supplyBoxes[i]; const e = unchecked(sEvent[i]); if (e == 1 || unchecked(sLive[i]) == 1) box.set(unchecked(sStart[i]), unchecked(sTop[i]), right, unchecked(sBottom[i])); // a live zone grows to the newest bar if (e == 1) box.color(ORANGE).fill(ORANGE).opacity(0.14).border(1.0); if (e == 2) { box.set(unchecked(sStart[i]), unchecked(sTop[i]), right, unchecked(sBottom[i])).color(SLATE).fill(SLATE).opacity(0.04); } // spent: ends on the breakout bar } if (unchecked(dUsed[i]) == 1) { const box = demandBoxes[i]; const e = unchecked(dEvent[i]); if (e == 1 || unchecked(dLive[i]) == 1) box.set(unchecked(dStart[i]), unchecked(dTop[i]), right, unchecked(dBottom[i])); if (e == 1) box.color(SKY).fill(SKY).opacity(0.14).border(1.0); if (e == 2) { box.set(unchecked(dStart[i]), unchecked(dTop[i]), right, unchecked(dBottom[i])).color(SLATE).fill(SLATE).opacity(0.04); } } } if (bar.isLast() && supplyCount + demandCount == 0 && bars > 2 * strength + 20) { // a flat market (tied highs and lows never confirm a swing): say so instead of an empty pane if (!wordsShown) { sb_clear(); sb_text("No confirmed swings on this chart yet"); words.set(56, 14).text(str_tag_sb).anchor(ANCHOR_TOP_RIGHT).align(ALIGN_RIGHT).color(SLATE); wordsShown = true; } } else if (wordsShown) { words.delete(); wordsShown = false; } if (bar.isLast()) { // the two tags: the nearest live zone above and below the close, on the live bar only if (!isNaN(supplyNear)) { sb_clear(); sb_text("S "); sbPrice(supplyNear); supplyTag.set(TAG_X, supplyNear).text(str_tag_sb).color(ORANGE).size(11).anchor(ANCHOR_RIGHT).align(ALIGN_RIGHT); } else supplyTag.delete(); if (!isNaN(demandNear)) { sb_clear(); sb_text("D "); sbPrice(demandNear); demandTag.set(TAG_X, demandNear).text(str_tag_sb).color(SKY).size(11).anchor(ANCHOR_RIGHT).align(ALIGN_RIGHT); } else demandTag.delete(); } } ``` ## How it works **A swing confirms late.** `swing_strength` (8, 2 to 30) is the number of bars on each side a swing high or low must beat, so a swing is known `swing_strength` bars after it happened; the zone is anchored back on the swing's own bar through `bar.time()`. A swing must beat its neighbours strictly. **One zone height of ATR.** `zone_atr` (0.25, in ATR(14) measured at the swing) sets the zone's depth: a supply zone hangs from the swing high, a demand zone stands on the swing low. Every bar the live zones are extended right with `set()`; a close through a zone breaks it, the box fades to slate and stops extending, and the bar's low or high is written to `break_up` or `break_down`, the two rose `shape` outputs (NaN on every other bar, so nothing is drawn there). **A ring per side.** `zones_per_side` (6, up to 12) zones stay per side; the oldest is replaced. The handle objects are made once, at module start. **The nearest zone is data.** `supply_near` (the bottom of the nearest live supply zone above the close) and `demand_near` (the top of the nearest live demand zone below it) are data-only outputs, one value per bar, readable at the editor's Console prompt. The same two prices are the tags at the right edge, written through the one bounded slot with the allocation-free builder; the tag's decimals follow the price (four on sub-dollar markets). ## Where it runs Every market with candles. The first zones appear once a swing has confirmed (about twice the swing strength in bars) and the ATR has warmed up (14 bars). ## When data is missing A swing must beat its neighbours strictly, so a flat, tick-quantized market (a prediction market pinned at one price) may confirm none: the example then says "No confirmed swings on this chart yet" at the top-right instead of leaving the pane empty. ## Customize it - **Bigger structures.** Raise `swing_strength`; the zones confirm later and there are fewer of them. - **Deeper zones.** `zone_atr` up to 2 ATRs; a zone is broken by a close through its far edge. - **Keep broken zones out.** Delete a broken zone's handle instead of fading it to slate, and its slot frees for the next swing. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Supply and Demand Zones** under **On price**. 2. Press **Run**. The first zones appear once a swing has confirmed and the ATR has warmed up. 3. At the editor's Console prompt, type `supply_near` and `demand_near` to read the two tagged prices on the newest bar. ## Concepts used - [TA library](../functions/ta-library.md) for `PivotHigh`, `PivotLow` and `Atr` - [Drawing objects](../presentation/drawing-objects.md) for box handles, right-anchored labels and chart coordinates - [Plotting](../presentation/plotting.md) for `shape` outputs that draw only where they are finite # Trend alignment ![Three EMAs, regime tint and the 15m/1h/4h state strip](/wrun/images/trend-alignment.png) Three averages on the price pane: the chart's own EMA (sky) beside the 1h EMA (violet) and the 4h EMA (teal), the higher-timeframe ones drawn as steps that move only when their candle closes. The background tints amber while price and both higher timeframes point up, orange while all three point down, and stays clear when they disagree; a new state has to hold for two bars before the tint flips, so there are no one-bar stripes. A three-cell strip at the top-right reads the state in words: "15m up 1h up 4h down", each cell in its own tone. The parts are two `candles` streams of closed 1h and 4h candles with the history to seed their averages ([Multi-timeframe](../core-concepts/multi-timeframe.md#history-the-candles-stream)), a `render.bgcolor` tint gated and coloured by a data-only output ([Styling](../presentation/styling.md)), and a strip of anchored box and label handles at the top-right corner ([Drawing objects](../presentation/drawing-objects.md)). This is also the `trend-alignment` template: the **Trend Alignment** card under **On price** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Trend Alignment: the chart's EMA beside the 1h and 4h EMAs as stepped lines; the background tints only while price and both higher // timeframes agree (amber up, orange down, held two bars before it flips); a three-cell strip at the top-right reads "15m up 1h up 4h down". param.int("ema_chart", 21, { min: 2, max: 200, label: "Chart EMA", row: "chart", description: "EMA length on the chart's own bars" }); // each length shares a dialog line with its line's colour: rows naming the same row word sit side by side param.color("chart_color", "#38bdf8", { label: "Chart line", row: "chart" }); // a colour setting, painted onto the output below by name param.int("ema_1h", 21, { min: 2, max: 200, label: "1h EMA", row: "1h", description: "EMA length on 1h closes" }); param.color("color_1h", "#a78bfa", { label: "1h line", row: "1h" }); param.int("ema_4h", 9, { min: 2, max: 200, label: "4h EMA", row: "4h", description: "EMA length on 4h closes" }); param.color("color_4h", "#2dd4bf", { label: "4h line", row: "4h" }); input("close", ohlcv.close); // the chart's own close: the primary grid input("h1", candles.cells, { interval: "1h", bars: 200 }); // the closed 1h candles: the first bar carries the 200 before it, so the 1h EMA is warm from the first bar; each later bar the ones that closed since input("h4", candles.cells, { interval: "4h", bars: 200 }); // the same for 4h const chartLine = output("ema_chart", line, overlay, { color: "@chart_color", width: 2, label: "Chart EMA", format: "price", description: "EMA on the chart's bars" }); // the colour setting paints it; the handle names it for the hover card output("ema_1h", line, overlay, { color: "@color_1h", width: 2, label: "1h EMA", format: "price", description: "EMA on 1h closes; steps once per hour" }); output("ema_4h", line, overlay, { color: "@color_4h", width: 2, label: "4h EMA", format: "price", description: "EMA on 4h closes; steps once per four hours" }); output("aligned", none, overlay, { description: "+1 while all three point up, -1 while all three point down, 0 mixed; a new state must hold two bars" }); // data-only, for the Console hover(chartLine, [block.value("Chart EMA", "ema_chart", { format: "price" }), block.rows([["1h EMA", "ema_1h", "price"], ["4h EMA", "ema_4h", "price"], ["Aligned", "aligned", "int"]])]); // the card the chart EMA opens under the cursor: its value, then the other two averages and the state render.bgcolor("alignment", { where: "aligned", color_by: "aligned", colors: ["#f868001a", "#f8c0001a"] }); // alpha 0.10; -1 clamps to colors[0] (orange), +1 reads colors[1] (amber); 0 draws nothing string("cell", { max_bytes: 80 }); // one bounded slot every strip cell is written through handles.box({ anchor: "top_right", color: "#0f172a", borderColor: "#334155", opacity: 0.85, borderWidth: 1 }); // the strip's backdrop, in pane pixels from the top-right corner handles.label({ anchor: "top_right", align: "right", color: "#e2e8f0", size: 11 }); // the strip's cells, right-aligned in pane pixels const AMBER: i32 = rgba(248, 192, 0, 255); const ORANGE: i32 = rgba(248, 104, 0, 255); const SLATE: i32 = rgba(148, 163, 184, 255); const CELL_W = 74.0; const RIGHT = 56.0; // cell width in pixels; the strip stays clear of the price-axis tags (about 46 px into the pane) const backdrop = draw.box(0); // handle objects allocate once const cells: LabelHandle[] = [draw.label(1), draw.label(2), draw.label(3)]; // chart, 1h, 4h const words = draw.label(4); // the one line of words on an hourly or coarser chart let emaChart = new Ema(21); let avg1h = new Ema(21); let avg4h = new Ema(9); // each higher-timeframe average folds one closed candle at a time: NaN until it has folded its length let ema1h: f64 = NaN; let ema4h: f64 = NaN; let close1h: f64 = NaN; let close4h: f64 = NaN; // the two averages and the newest closed 1h and 4h closes let t: f64 = NaN; let prevT: f64 = NaN; let width: f64 = NaN; let foldedClose: f64 = NaN; // fold()'s second answer: the newest close it folded let candidate = 0; let candidateRun = 0; let held = 0; // the alignment state, and the state waiting two bars to take over function direction(price: f64, average: f64): i32 { return isNaN(price) || isNaN(average) ? 0 : price > average ? 1 : price < average ? -1 : 0; } // The chart interval in words from the bar width: 15m, 1h, 4h, 1d. function sbInterval(): void { if (width < 3600.0) { sb_int(i64(Math.round(width / 60.0))); sb_text("m"); } else if (width < 86400.0) { sb_int(i64(Math.round(width / 3600.0))); sb_text("h"); } else { sb_int(i64(Math.round(width / 86400.0))); sb_text("d"); } } function sbDirection(dir: i32, known: bool): void { sb_text(!known ? " -" : dir > 0 ? " up" : dir < 0 ? " down" : " flat"); } // "-" while the average warms // Fold a block of closed candles into an average, oldest first: returns the average after the newest (the one before when the block is empty) and leaves that candle's close in foldedClose. function fold(block: StaticArray, cells: i32, avg: Ema, value: f64, close: f64): f64 { const n = cells < block.length ? cells : block.length; // -1 (no candle yet) and 0 fold nothing let v = value; foldedClose = close; for (let i = 0; i + 6 <= n; i += 6) { const c = unchecked(block[i + 4]); if (!isFinite(c)) continue; v = avg.update(c); foldedClose = c; } // [offset_ms, open, high, low, close, volume] per candle return v; } function tone(dir: i32): i32 { return dir > 0 ? AMBER : dir < 0 ? ORANGE : SLATE; } // onStart() runs once before the first bar: read the settings. function onStart(): void { emaChart = new Ema(i32(p_ema_chart())); avg1h = new Ema(i32(p_ema_1h())); avg4h = new Ema(i32(p_ema_4h())); } // onBar() runs once per bar: feed the chart EMA every bar, fold the 1h and 4h candles that closed by this bar into their averages, read the // three directions, then write the lines and the state; on the live bar only, redraw the strip. function onBar(): void { prevT = t; t = bar.time(); const close = bar.close(); if (!isNaN(prevT) && t > prevT && (isNaN(width) || t - prevT < width)) width = t - prevT; const fast = emaChart.update(close); ema1h = fold(in_h1_view(), in_h1_cells(), avg1h, ema1h, close1h); close1h = foldedClose; // a candle arrives on the bar whose close is at or after its own, so each average steps once per candle ema4h = fold(in_h4_view(), in_h4_cells(), avg4h, ema4h, close4h); close4h = foldedClose; const dirChart = direction(close, fast); const dir1h = direction(close1h, ema1h); const dir4h = direction(close4h, ema4h); // +1 above its average, -1 below, 0 unknown const next = dirChart != 0 && dirChart == dir1h && dir1h == dir4h ? dirChart : 0; if (next == candidate) candidateRun += 1; else { candidate = next; candidateRun = 1; } if (candidateRun >= 2) held = candidate; // a state has to hold two bars before the tint flips: no one-bar stripes if (isNaN(fast)) return; out_ema_chart(fast); out_ema_1h(ema1h); out_ema_4h(ema4h); out_aligned(f64(held)); if (bar.isLast()) { backdrop.set(RIGHT + 3.0 * CELL_W + 16.0, 12.0, RIGHT, 36.0); // pixels from the top-right corner: left, top, right, bottom sb_clear(); sbInterval(); sbDirection(dirChart, true); cells[0].set(RIGHT + 8.0 + 2.0 * CELL_W, 24.0).text(str_cell_sb).color(tone(dirChart)).align(ALIGN_RIGHT); sb_clear(); sb_text("1h"); sbDirection(dir1h, !isNaN(ema1h)); cells[1].set(RIGHT + 8.0 + CELL_W, 24.0).text(str_cell_sb).color(tone(dir1h)).align(ALIGN_RIGHT); sb_clear(); sb_text("4h"); sbDirection(dir4h, !isNaN(ema4h)); cells[2].set(RIGHT + 8.0, 24.0).text(str_cell_sb).color(tone(dir4h)).align(ALIGN_RIGHT); if (width >= 3600.0) { sb_clear(); sb_text(width > 3600.0 ? "Made for charts finer than 1h" : "Made for charts finer than 1h: here the 1h average is the chart's own"); words.set(RIGHT, 50.0).text(str_cell_sb).color(SLATE).align(ALIGN_RIGHT); } } } ``` ## How it works **Closed candles step the averages.** `input("h1", candles.cells, { interval: "1h", bars: 200 })` and its 4h twin are streams of closed candles: the first bar carries the 200 that closed before it, and every later bar carries the candles that closed since, on the bar whose close is at or after the candle's own. `fold()` feeds each candle's close, oldest first, to a kit `Ema` of the setting's length, so each higher-timeframe EMA steps once per closed candle and is flat in between, and it is already warm on the chart's first bar: on a 1m chart the 1h EMA (21) has folded about 190 hourly closes before the first minute, so it reads the same value as an EMA over the market's whole history. An `Ema` is NaN until it has folded its length (its first value is the average of those closes), so a length deeper than the history served draws nothing rather than a guess, and the strip reads "-" for that timeframe. The chart's own EMA (`ema_chart`, 21) reads the bar's close. **Agreement is a number.** `aligned` is +1 while the close sits above all three averages and every average points up, -1 while all three point down, 0 otherwise; a new state must hold two bars before it is written, which is what keeps one-bar stripes off the chart. It is data-only: the tint reads it, and so does the editor's Console prompt. **The tint reads the number.** `render.bgcolor("alignment", { where: "aligned", color_by: "aligned", colors: [orange, amber] })` gates on the output and picks its colour from it: -1 clamps to the first entry, +1 reads the second, 0 draws nothing. **The strip is three cells.** A backdrop box and three right-aligned labels are anchored to the pane's top-right corner in pixels; each cell is written through the one bounded slot on the live bar and names the chart interval from the bar spacing. ## Where it runs Crypto and prediction markets on charts finer than 1h, such as 1m, 5m, 15m or 30m: the chart fetches each stream on its own, 200 closed candles deep, so the averages are right from the first bar on every one of them. On a 1h chart the 1h stream holds the chart's own candles; a chart coarser than 1h still runs, reading every 1h candle inside each of its bars, and says the example is made for finer charts. OpenMarket's alerts engine does not read `candles` streams yet, so an alert on this indicator is refused when you save it ([Alerts](../functions/alerts.md)). ## When data is missing On an hourly chart the 1h average is the chart's own, so the example adds one slate line of words under the strip ("Made for charts finer than 1h: here the 1h average is the chart's own") and keeps the lines and the tint; on a coarser chart the line reads "Made for charts finer than 1h". Where a market's history is shorter than an average's length, that average stays empty and its strip cell reads "-". ## Customize it - **Other timeframes.** Change the two `interval` words (declaration literals, not settings) and the strip's two labels; raise `bars` for an average longer than 200 candles. - **Slower flips.** Raise the two-bar hold where `aligned` is written to demand a longer agreement. - **Lines only.** Delete the `render.bgcolor` line to keep the three averages and the strip without the tint. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Trend Alignment** under **On price**. 2. Press **Run** on a chart finer than 1h, such as 15m: the three averages draw, the background tints while they agree, and the strip reads the state. 3. At the editor's Console prompt, type `last 20 aligned` to read the state of the last 20 bars. ## Concepts used - [Multi-timeframe](../core-concepts/multi-timeframe.md#history-the-candles-stream) for the `candles` stream, its `bars` depth and when a candle arrives - [Styling](../presentation/styling.md) for `render.bgcolor`, gates and colour ladders - [Drawing objects](../presentation/drawing-objects.md) for anchored box and label handles in pane pixels # Trend candles ![Candles tinted by two averages with flip dots and bar counter](/wrun/images/trend-candles.png) The chart's own candles tinted by where the close sits against two moving averages. Above both averages the candle turns sky, below both it turns violet, between them it keeps the chart's own amber and orange. The two averages are thin lines (fast teal, slow slate). A dot marks each bar where the close crossed from one side of both averages to the other: under the bar for a flip up (sky), over it for a flip down (violet); crossing into the gap between the lines and back to the same side is not a flip. A counter at the top right reads how long the current state has held: "12 bars up", "7 bars down", "3 bars between". The parts are `render.barcolor`, the per-bar candle tint gated and coloured by two data-only outputs ([Styling](../presentation/styling.md)), two `shape` outputs for the flip dots, and one label handle anchored to the top-right corner for the counter ([Drawing objects](../presentation/drawing-objects.md)). This is also the `trend-candles` template: the **Trend Candles** card under **On price** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Trend Candles: the chart's own candles tinted by where the close sits against two moving averages. Above both, // the candle turns sky; below both, violet; between them it keeps the chart's own colours. The two averages are drawn // as thin lines (fast teal, slow slate), a dot marks each bar where the trend flipped from one side to the other // (under the bar for a flip up, over it for a flip down), and a counter at the top right reads how long the current // state has held ("12 bars up"). param.int("fast_len", 21, { min: 2, max: 200, label: "Fast EMA", row: "lengths", description: "Fast EMA length in bars" }); // two whole numbers on one dialog line param.int("slow_len", 50, { min: 2, max: 400, label: "Slow EMA", row: "lengths", description: "Slow EMA length in bars" }); param.color("up_color", "#38bdf8", { label: "Up tint", row: "tints" }); // two colour settings on one line: each paints the dot and the candle tint of its side below, by name param.color("down_color", "#a78bfa", { label: "Down tint", row: "tints" }); const fastLine = output("fast_ema", line, overlay, { color: "#2dd4bf", width: 1, label: "Fast EMA", format: "price", description: "EMA of the close over fast_len bars" }); // the handle names it for the hover card output("slow_ema", line, overlay, { color: "#94a3b8", width: 1, label: "Slow EMA", format: "price", description: "EMA of the close over slow_len bars" }); output("trend", none, overlay, { description: "+1 above both averages, -1 below both, 0 between" }); output("tinted", none, overlay, { description: "1 when the candle is tinted (above or below both averages): the tint's gate" }); output("tint", none, overlay, { description: "1 above both averages (sky), 0 below both (violet): the tint's colour index" }); output("run", none, overlay, { description: "Bars the current state has held" }); output("flip_up", shape, overlay, { color: "@up_color", description: "A dot under the bar where the trend turned up" }); // the up tint setting paints it output("flip_down", shape, overlay, { color: "@down_color", description: "A dot over the bar where the trend turned down" }); string("text", { max_bytes: 24 }); // the counter's one line handles.label({ anchor: "top_right", align: "right", size: 12 }); // the counter, in pane pixels from the chart's top-right corner render.barcolor("trend_tint", { where: "tinted", color_by: "tint", colors: ["@down_color", "@up_color"] }); // the bar's own candle, body and wick, in the two tint settings hover(fastLine, [block.value("Fast EMA", "fast_ema", { format: "price" }), block.rows([["Slow EMA", "slow_ema", "price"], ["Trend", "trend", "int"], ["Run", "run", "int"]])]); // the card the fast average opens under the cursor const SLATE = rgba(148, 163, 184, 255); const counter = draw.label(0); let fastLen = 21; // settings, read in onStart() let slowLen = 50; let upInk = rgba(56, 189, 248, 255); // the two tint settings: the counter reads in the side's tint let downInk = rgba(167, 139, 250, 255); let fastEma = new Ema(21); // rebuilt in onStart() let slowEma = new Ema(50); let trend = 0; // +1 above both, -1 below both, 0 between let lastTinted = 0; // the last side the close was on (+1 or -1): a flip is a change of side let run = 0; // bars the current state has held let rangeAvg: f64 = NaN; // a running mean of high - low: the dot's distance from the bar function onStart(): void { fastLen = i32(p_fast_len()); slowLen = i32(p_slow_len()); fastEma = new Ema(fastLen); slowEma = new Ema(slowLen); upInk = fromPacked(p_up_color()); downInk = fromPacked(p_down_color()); } // onBar() runs once per bar: feed both averages, place the close against them, count the run, catch a flip; // then write the averages, the state and the dots on every bar, and the counter on the live bar only. function onBar(): void { const close = bar.close(); const high = bar.high(); const low = bar.low(); const fast = fastEma.update(close); const slow = slowEma.update(close); const range = high - low; rangeAvg = isNaN(rangeAvg) ? range : rangeAvg + (range - rangeAvg) * 0.1; let flipUpY: f64 = NaN; // this bar's dots, NaN when the side held let flipDownY: f64 = NaN; if (isNaN(fast) || isNaN(slow)) return; // warm-up rows draw nothing const next = close > fast && close > slow ? 1 : close < fast && close < slow ? -1 : 0; run = next == trend ? run + 1 : 1; trend = next; if (trend != 0 && trend != lastTinted) { // the close crossed to the other side of both averages if (lastTinted != 0) { if (trend > 0) flipUpY = low - rangeAvg * 0.6; else flipDownY = high + rangeAvg * 0.6; } lastTinted = trend; } out_fast_ema(fast); out_slow_ema(slow); out_trend(f64(trend)); out_tinted(trend != 0 ? 1.0 : 0.0); out_tint(trend > 0 ? 1.0 : 0.0); out_run(f64(run)); out_flip_up(flipUpY); out_flip_down(flipDownY); if (bar.isLast()) { sb_clear(); sb_int(run); sb_text(run == 1 ? " bar " : " bars "); sb_text(trend > 0 ? "up" : trend < 0 ? "down" : "between"); counter.set(60, 14).text(str_text_sb).align(ALIGN_RIGHT).color(trend > 0 ? upInk : trend < 0 ? downInk : SLATE); // inward past the price-axis tags, in the side's tint } } ``` ## How it works **Two averages, three states.** `fast_len` (21) and `slow_len` (50) are the EMA lengths; `trend` is +1 while the close sits above both averages, -1 below both, 0 between. It is data-only; the editor's Console prompt reads it after a Run. **The candle reads two numbers.** `render.barcolor("trend_tint", { where: "tinted", color_by: "tint", colors: [violet, sky] })` tints the bar's own candle, body and wick: `tinted` (1 above or below both averages) is the gate, `tint` (1 above, 0 below) picks the colour. Between the lines the gate is 0 and the candle keeps the chart's own colours. **A flip is a change of side.** `flip_up` carries the bar's low on a bar where `trend` went from -1 to +1 (a sky dot under the bar), `flip_down` the bar's high on the way back (a violet dot over it); both are NaN elsewhere. Crossing into the gap and back to the same side is not a flip. **The counter is a slot.** `run` counts the bars the current state has held; on the live bar it is written through the one bounded slot ("12 bars up") to a label anchored at the top-right corner in pixels. It reads in the side's tint: `onStart()` turns the **Up tint** and **Down tint** settings into colours with `fromPacked(p_up_color())` and `fromPacked(p_down_color())`, so a changed tint recolours the candles, the dots and the counter together (slate between the averages). ## Where it runs Every market with candles: crypto, stocks, FX and prediction markets. CME markets are the exception: the editor's Run is paused there, and a community indicator cannot read CME data. ## When data is missing The first `slow_len` bars are warm-up and draw nothing; a bar without a close keeps the chart's own colours. ## Customize it - **Other averages.** Swap `Ema` for `Sma` from the TA library; the lengths stay settings. - **A one-line filter.** Drop the slow average and the "between" state to tint by one line. - **Tint the wick only.** `render.barcolor` colours body and wick together; for the body alone, draw the tint as a `render.bgcolor` band instead. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Trend Candles** under **On price**. 2. Press **Run**: the candles take the tint, and the counter appears at the top right. 3. At the editor's Console prompt, type `last 20 trend` to read the state of the last 20 bars. ## Concepts used - [Styling](../presentation/styling.md) for `render.barcolor`, gates and colour ladders - [Plotting](../presentation/plotting.md) for `shape` outputs that draw only where they are finite - [Drawing objects](../presentation/drawing-objects.md) for the anchored label and the text slot # Strike Matrix ![The options desk on two chart cells, the left cell first: BTCUSDT 5m with the GEX by expiry board docked against the price axis, teal and violet cells with signed dollars, the at-the-money row outlined in amber, the per-strike net GEX bars in a narrow column left of the board, and the dashed level lines with their labels at the newest bar; on the right, the Options Dashboard's tiles, gamma curve and CVD pane](/wrun/images/style-anything/overview.png) The chart coin's live options chain as a board docked to the price axis: one row per strike inside a window around spot, one column per expiry (the nearest first, four by default, the front expiry labelled `0DTE` while it expires inside the day), each cell that expiry's net gamma exposure at the strike, calls minus puts in USD per 1% move of spot. The cells are tinted on a diverging scale centred on zero, in the chart's up colour where calls carry the strike and its down colour where puts do, the tint deepening with the square root of the size, signed dollars printed in every cell (`+$84.3M`), the row at the money outlined in amber, the expiry dates as a header. Left of the board, one bar per strike reads the row's net GEX from a centre baseline, the up colour one way and the down colour the other, with a hover card naming the call and put halves. Four dashed levels run across the whole chart, each labelled just left of the bars, clear of the board: the strike inside the board's window holding the largest positive net GEX (in the up colour), the put wall inside the window (in the down colour), the gamma flip (in the text ink) and max pain (in the muted ink), the labels ranking the three levels by how much net GEX each carries. The legend names the board's window (the expiries and the percent around spot, read from the settings) and reads the live spot. The rows sit on their prices, so the board reads against the candles beside it; on a short timeframe the candles cover few strikes, so drag the price axis to zoom it out and more rows appear. The parts are a celled `options_chain.cells` input read as a block on the live bar ([Data sources](../core-concepts/data-sources.md)), two frames written from the generated frame buffer, a `plot.matrix` over the first and a `plot.levels` over the second, both docked right and kept apart by an `offset` ([Price canvases](../presentation/price-canvases.md), [Docked profiles](../presentation/cards-frames-panels.md#docked-profiles)), the `OptionsChain` kit for the flip, max pain and the walls ([Options kit](../functions/options-kit.md)), and `draw.line` drawings placed from the newest bar with label handles pinned in pixels from the price axis ([Drawing objects](../presentation/drawing-objects.md)). It is the left cell of the options desk that [Style anything](../presentation/style-anything.md) walks through part by part; the [Options Dashboard](options-dashboard.md) recipe is its right cell. This is also the `strike-matrix` template: the **Strike Matrix** card under **On price** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Strike Matrix: the chart coin's live options chain as a strike by expiry board docked on the price axis (net gamma // exposure per cell, in the chart's up colour where calls carry the strike and its down colour where puts do, the tint // deepening with size), a column of per-strike net GEX bars beside it, and four dashed level lines across the chart, // each labelled just left of the bars: the strike inside the board's window holding the largest positive net GEX, the // gamma flip, max pain and the put wall inside the window. The chain is live only: history bars cost one read and draw // nothing; the boards, the levels and their labels are rewritten on the last bar as the chain moves. param.number("window_pct", 7, { min: 1, max: 30, label: "Strike window, percent", description: "Strikes on the board: percent around spot, each side" }); param.int("expiries", 4, { min: 1, max: 12, label: "Expiry columns", description: "Expiries on the board, nearest first" }); input("close", ohlcv.close); // the chart's own close: the grid, and spot for the strike window input("chain", options_chain.cells, { max_cells: 4000, venue: "auto", description: "The live options chain of the chart's coin (venue auto: the chart's own market when it lists options, else the coin's Deribit chain)" }); // [strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega] per contract output("spot", none, overlay, { description: "The chart's last close: the board's centre and the levels' spot" }); output("net_gex", none, overlay, { description: "Net gamma exposure of the board's cells, USD per 1% move of spot; live bar only" }); output("t_prev", none, overlay, { description: "The previous bar's open time, epoch seconds: the level lines' first point" }); output("t_last", none, overlay, { description: "This bar's open time, epoch seconds: the level lines' second point" }); output("wall_price", none, overlay, { description: "The strike inside the board's window holding the largest positive net GEX; live bar only" }); output("flip_price", none, overlay, { description: "The gamma flip: where cumulative net GEX over the whole chain crosses zero, the crossing nearest spot; live bar only" }); output("pain_price", none, overlay, { description: "Max pain: the strike of the whole chain that pays option holders the least; live bar only" }); output("put_wall_price", none, overlay, { description: "The put wall: the heaviest put strike at or below spot inside the board's window; live bar only" }); string("spot_text", { max_bytes: 32 }); // the legend entry: "live spot $84.6K" string("wall_text", { max_bytes: 32 }); // the level labels, one slot each, written on the live bar string("flip_text", { max_bytes: 32 }); string("pain_text", { max_bytes: 32 }); string("put_wall_text", { max_bytes: 32 }); legend({ title: "{{expiries}} expiries, ±{{window_pct}}%" }); // the words after the name in the legend: the board's window, read from the settings by name, short enough to show whole render.legend("spot_entry", { text: "spot_text", color: "theme.muted" }); const board = frame("board", { max_bytes: 32768 }); // the matrix: one row per strike, one column per expiry const gexRows = frame("gex_rows", { max_bytes: 16384 }); // the bars: net GEX per strike, the board's row sums // The strike by expiry board against the price axis: a diverging palette centred on zero, puts in the chart's down // colour and calls in its up colour, the tint by the square root of the cell's size, signed dollars in every cell, the // at-the-money row outlined in amber. plot.matrix({ name: "gex_board", frame: board, dock: "right", column_width: 66, row_max_px: 22, price_column: true, header: true, palette: ["theme.down", "theme.bg", "theme.up"], center: 0, scale: "sqrt", opacity: 0.95, format: "usd", signed: true, decimals: 1, font_size: 10, font_weight: "medium", align: "center", text_color: "theme.text", cell_padding: 3, grid_color: "theme.bg", grid_width: 2, grid_style: "solid", grid_lines: "all", highlight_color: "#f8c000", label: "GEX by expiry", tooltip: "{{column}}: {{value:usd}} net GEX at {{price}}" }); // The per-strike net GEX bars beside the board: zero mid-dock, positives one way and negatives the other, each row // in its own colour from the frame, a hover card per row, pushed inward past the board (both dock right), 100 px wide. plot.levels({ name: "gex_by_strike", frame: gexRows, dock: "right", width_px: 100, baseline: "center", labels: false, opacity: 0.95, thickness_px: 10, format: "usd", signed: true, hover: true, offset: [340, 0] }); // offset: the board's width (4 columns of 66 px plus the price column), so the bars sit left of it instead of under it // Four dashed levels across the whole pane, placed from the newest bar: the largest positive GEX strike in the chart's // up colour, the put wall in its down colour, the gamma flip in the text ink and max pain in the muted ink. Their // knockout labels are label handles placed in pixels from the price axis (x) at the level's price (y), their right // edge just left of the bars, so a label never covers a cell of the board or runs into the axis. draw.line("wall_line", { x1: "t_prev", y1: "wall_price", x2: "t_last", y2: "wall_price", color: "theme.up", width: 1, line_style: "dashed", extend: "both" }); draw.line("flip_line", { x1: "t_prev", y1: "flip_price", x2: "t_last", y2: "flip_price", color: "theme.text", width: 1, line_style: "dashed", extend: "both" }); draw.line("pain_line", { x1: "t_prev", y1: "pain_price", x2: "t_last", y2: "pain_price", color: "theme.muted", width: 1, line_style: "dashed", extend: "both" }); draw.line("put_wall_line", { x1: "t_prev", y1: "put_wall_price", x2: "t_last", y2: "put_wall_price", color: "theme.down", width: 1, line_style: "dashed", extend: "both" }); handles.label({ anchor: "right", align: "right", style: "knockout", font_weight: "medium" }); const MAX_ROWS = 128; // strikes on the board (the frame's cap) const MAX_COLS = 12; // expiries on the board (the setting's cap) const TUPLE = 10; // f64s per contract: strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega const MAX_EXPIRIES = 64; // distinct live expiries the chain may list const DAY_MS = 86400000.0; const strikePrice = new StaticArray(MAX_ROWS); // the strike table, sorted ascending, one row per strike inside the window const cellGex = new StaticArray(MAX_ROWS * MAX_COLS); // net GEX per row and column const cellSeen = new StaticArray(MAX_ROWS * MAX_COLS); // 1 where a contract landed in the cell const rowCall = new StaticArray(MAX_ROWS); // the row's call GEX across the board's columns (the hover card) const rowPut = new StaticArray(MAX_ROWS); // the row's put GEX across the board's columns, stored positive const expiryMs = new StaticArray(MAX_EXPIRIES); // the live expiries, sorted ascending const MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; const tags: LabelHandle[] = [draw.label(0), draw.label(1), draw.label(2), draw.label(3)]; // the wall, the flip, max pain, the put wall; handle objects allocate once let chain = new OptionsChain(1); // the whole chain measured by strike: the flip and max pain let windowChain = new OptionsChain(1); // the strikes inside the board's window: the two walls, as Gamma Map reads them let windowPct = 0.07; // settings, read in onStart() let columns = 4; let close: f64 = NaN; // this bar let t: f64 = NaN; let prevT: f64 = NaN; let nowMs: f64 = 0.0; // the chain's clock, read on the live bar let rowCount = 0; // strikes in the table let expiryCount = 0; // live expiries seen let netGex: f64 = NaN; // the board's net GEX, measured on the live bar let wallPrice: f64 = NaN; // the levels, measured on the live bar let wallGex: f64 = NaN; let flipPrice: f64 = NaN; let flipStrike: f64 = NaN; let painPrice: f64 = NaN; let putWallPrice: f64 = NaN; let flipRank = 0; let painRank = 0; let putWallRank = 0; function onStart(): void { chain = new OptionsChain(512); windowChain = new OptionsChain(512); windowPct = p_window_pct() / 100.0; columns = i32(p_expiries()); if (columns > MAX_COLS) columns = MAX_COLS; } // ── The chain's clock: when its gammas were priced, not the bar's open ── // The chart prices each contract's gamma by Black-Scholes from its mark IV and underlying at the moment it reads the // chain, so the contract nearest the money (|delta| nearest 0.5), solved for the time to expiry its gamma implies, // names that moment. A venue that serves its own greeks gives no such answer: the bar's open stands in. function chainClockMs(cells: StaticArray, n: i32, barOpenMs: f64): f64 { let best = -1; let bestGap = 0.25; // |delta| within 0.25 of 0.5 let nearest = Infinity; // the nearest listed expiry: the chain lists none that has passed for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { if (cells[i + 1] < nearest) nearest = cells[i + 1]; const gap = Math.abs(Math.abs(cells[i + 5]) - 0.5); if (cells[i] > 0.0 && cells[i + 4] > 0.0 && cells[i + 6] > 0.0 && cells[i + 7] > 0.0 && gap < bestGap) { bestGap = gap; best = i; } } if (best < 0) return barOpenMs; const sigma = cells[best + 6] > 3.0 ? cells[best + 6] / 100.0 : cells[best + 6]; // percent a year, or a fraction const m = Math.log(cells[best + 7] / cells[best]); const q = cells[best + 4] * cells[best + 7]; // gamma times spot = pdf(d1) / u, u = sigma * sqrt(T), d1 = m / u + u / 2 let lo = Math.sqrt(2.0 * (Math.sqrt(1.0 + m * m) - 1.0)); // where pdf(d1) / u peaks: past it the gamma falls as T grows let hi = 10.0; for (let k = 0; k < 80; k += 1) { // bisection on the falling side const u = 0.5 * (lo + hi); const d1 = m / u + 0.5 * u; if (Math.exp(-0.5 * d1 * d1) / (2.5066282746310002 * u) > q) lo = u; else hi = u; } const u = 0.5 * (lo + hi); const clock = cells[best + 1] - ((u * u) / (sigma * sigma)) * 31536000000.0; return clock > barOpenMs - 86400000.0 && clock < nearest ? clock : barOpenMs; // anything else is no reading } // ── The expiry list: sorted insertion of a distinct live expiry ── function noteExpiry(ms: f64): void { let lo = 0; let hi = expiryCount; while (lo < hi) { const mid = (lo + hi) >> 1; if (expiryMs[mid] < ms) lo = mid + 1; else hi = mid; } if (lo < expiryCount && expiryMs[lo] == ms) return; if (expiryCount >= MAX_EXPIRIES) return; for (let i = expiryCount; i > lo; i -= 1) expiryMs[i] = expiryMs[i - 1]; expiryMs[lo] = ms; expiryCount += 1; } function columnOf(ms: f64): i32 { // the board column of an expiry: its rank among the nearest `columns`, or -1 for (let c = 0; c < columns && c < expiryCount; c += 1) if (expiryMs[c] == ms) return c; return -1; } // ── The strike table: sorted insertion, one row per strike; when full, the end farther from spot gives way ── function clearRow(row: i32): void { for (let c = 0; c < MAX_COLS; c += 1) { cellGex[row * MAX_COLS + c] = 0.0; cellSeen[row * MAX_COLS + c] = 0; } rowCall[row] = 0.0; rowPut[row] = 0.0; } function moveRow(from: i32, to: i32): void { strikePrice[to] = strikePrice[from]; rowCall[to] = rowCall[from]; rowPut[to] = rowPut[from]; for (let c = 0; c < MAX_COLS; c += 1) { cellGex[to * MAX_COLS + c] = cellGex[from * MAX_COLS + c]; cellSeen[to * MAX_COLS + c] = cellSeen[from * MAX_COLS + c]; } } function strikeSlot(strike: f64): i32 { // the row of this strike, inserted when new; -1 when the table is full and this strike is the farthest from spot let lo = 0; let hi = rowCount; while (lo < hi) { const mid = (lo + hi) >> 1; if (strikePrice[mid] < strike) lo = mid + 1; else hi = mid; } if (lo < rowCount && strikePrice[lo] == strike) return lo; if (rowCount >= MAX_ROWS) { // full: the end farther from spot is dropped, unless this strike is farther still const lowGap = Math.abs(strikePrice[0] - close); const highGap = Math.abs(strikePrice[rowCount - 1] - close); if (Math.abs(strike - close) >= Math.max(lowGap, highGap)) return -1; if (lowGap >= highGap) { // drop the lowest row: every row shifts down one for (let i = 0; i < rowCount - 1; i += 1) moveRow(i + 1, i); if (lo > 0) lo -= 1; } rowCount -= 1; if (lo > rowCount) lo = rowCount; } for (let i = rowCount; i > lo; i -= 1) moveRow(i - 1, i); strikePrice[lo] = strike; clearRow(lo); rowCount += 1; return lo; } function atmStrike(): f64 { // the strike nearest spot: the highlighted row let best = strikePrice[0]; for (let r = 1; r < rowCount; r += 1) if (Math.abs(strikePrice[r] - close) < Math.abs(best - close)) best = strikePrice[r]; return best; } // ── The chain, measured on the live bar ── function measureChain(n: i32): void { // n = f64 cells in the live block const cells = in_chain_view(); // the live chain in place: the first n values of the build's own buffer rowCount = 0; expiryCount = 0; netGex = 0.0; for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) if (cells[i + 1] > nowMs) noteExpiry(cells[i + 1]); // the live expiries, nearest first if (expiryCount == 0) return; const lo = close * (1.0 - windowPct); const hi = close * (1.0 + windowPct); for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { const strike = cells[i]; const oi = cells[i + 3]; if (!(strike > 0.0) || !(oi > 0.0) || strike < lo || strike > hi) continue; const col = columnOf(cells[i + 1]); if (col < 0) continue; // an expiry past the board's columns, or already expired const multiplier = cells[i + 8] > 0.0 ? cells[i + 8] : 1.0; // Deribit open interest is in coin units (multiplier 1); CME open interest is contracts times the point value const spot = cells[i + 7] > 0.0 ? cells[i + 7] : close; const gexUsd = cells[i + 4] * oi * multiplier * spot * spot * 0.01; // the USD delta change of the open interest for a 1% move of spot const isCall = cells[i + 2] > 0.0; const signed = isCall ? gexUsd : -gexUsd; // calls positive, puts negative: the dealer-naive sign convention const row = strikeSlot(strike); if (row < 0) continue; cellGex[row * MAX_COLS + col] += signed; cellSeen[row * MAX_COLS + col] = 1; if (isCall) rowCall[row] += gexUsd; else rowPut[row] += gexUsd; netGex += signed; } } // ── The levels through the options kit: the flip and max pain over the whole chain, the walls inside the window ── function nearestStrikeIndex(price: f64): i32 { // the kit table's strike nearest a price, -1 on an empty table const n = chain.strikes(); let best = -1; let bestGap = Infinity; for (let i = 0; i < n; i += 1) { const gap = Math.abs(chain.strike(i) - price); if (gap < bestGap) { bestGap = gap; best = i; } } return best; } function rankOf(value: f64, a: f64, b: f64): i32 { // 1 when this level carries the most |net GEX| of the three, 3 the least let rank = 1; if (a > value) rank += 1; if (b > value) rank += 1; return rank; } function flipNearSpot(): f64 { // cumulative net GEX walked up the strikes: its zero crossing nearest spot (the far wings' few dollars can flip the sum back and forth); a sum landing exactly on zero crosses at that strike and starts a new segment const count = chain.strikes(); let best: f64 = NaN; let cum = 0.0; let prevCum = 0.0; let prevStrike: f64 = NaN; for (let i = 0; i < count; i += 1) { cum += chain.netGex(i); let cross: f64 = NaN; if (!isNaN(prevStrike) && prevCum != 0.0) { if (cum == 0.0) cross = chain.strike(i); else if ((cum > 0.0) != (prevCum > 0.0)) cross = prevStrike + (chain.strike(i) - prevStrike) * (Math.abs(prevCum) / (Math.abs(prevCum) + Math.abs(cum))); } if (!isNaN(cross) && (isNaN(best) || Math.abs(cross - close) < Math.abs(best - close))) best = cross; if (cum == 0.0) prevStrike = NaN; // a zero ends the segment: no later crossing is interpolated across it else { prevCum = cum; prevStrike = chain.strike(i); } } return best; } function measureLevels(n: i32): void { chain.load(in_chain_view(), n, nowMs, close, 0, 0.0); // every live expiry, every strike: the flip and max pain windowChain.load(in_chain_view(), n, nowMs, close, 0, windowPct * 100.0); // the board's window: the walls a chart of this range trades against const count = windowChain.strikes(); wallPrice = NaN; wallGex = NaN; for (let i = 0; i < count; i += 1) { const g = windowChain.netGex(i); if (g > 0.0 && (isNaN(wallGex) || g > wallGex)) { wallGex = g; wallPrice = windowChain.strike(i); } } flipPrice = flipNearSpot(); painPrice = chain.maxPain(); putWallPrice = windowChain.putWall(); const fi = isNaN(flipPrice) ? -1 : nearestStrikeIndex(flipPrice); const pi = isNaN(painPrice) ? -1 : nearestStrikeIndex(painPrice); const wi = isNaN(putWallPrice) ? -1 : nearestStrikeIndex(putWallPrice); flipStrike = fi < 0 ? NaN : chain.strike(fi); const fg = fi < 0 ? 0.0 : Math.abs(chain.netGex(fi)); const pg = pi < 0 ? 0.0 : Math.abs(chain.netGex(pi)); const wg = wi < 0 ? 0.0 : Math.abs(chain.netGex(wi)); flipRank = rankOf(fg, pg, wg); painRank = rankOf(pg, fg, wg); putWallRank = rankOf(wg, fg, pg); } // ── Text: the legend entry and the level labels, built without allocating ── function sbUsd(v: f64): void { // "+$84.3M", "-$4.5M", "+$950.0K" if (!isFinite(v)) { sb_text("n/a"); return; } sb_text(v < 0.0 ? "-$" : "+$"); const a = Math.abs(v); if (a >= 1.0e9) { sb_f64(a / 1.0e9, 2); sb_text("B"); } else if (a >= 1.0e6) { sb_f64(a / 1.0e6, 1); sb_text("M"); } else if (a >= 1.0e3) { sb_f64(a / 1.0e3, 1); sb_text("K"); } else sb_f64(a, 0); } function sbStrike(p: f64): void { // "$86K", "$2.4K", "$0.42": the strike as a short price sb_text("$"); if (p >= 10000.0) { sb_f64(p / 1000.0, 0); sb_text("K"); } else if (p >= 1000.0) { sb_f64(p / 1000.0, 1); sb_text("K"); } else sb_f64(p, 2); } function sbPrice(p: f64): void { // "$85.7K", "$2.44K", "$0.42" sb_text("$"); if (p >= 1000.0) { sb_f64(p / 1000.0, 1); sb_text("K"); } else sb_f64(p, 2); } function clearTags(): void { for (let k = 0; k < 4; k += 1) tags[k].delete(); // no-ops when never drawn } function writeTexts(): void { // the legend entry, then each label built in the line buffer and sent with its handle sb_clear(); sb_text("live spot "); sbPrice(close); str_spot_text_sb(); const cols = columns < expiryCount ? columns : expiryCount; const inset = Math.max(66.0 * f64(cols) + 76.0, 340.0) + 108.0; // past the board (66 px a column plus the price column, the bars' offset at least) and the 100 px of bars left of it if (isNaN(wallPrice)) tags[0].delete(); else { sb_clear(); sbUsd(wallGex); tags[0].set(inset, wallPrice).text(str_wall_text_sb).color(theme.UP); } if (isNaN(flipPrice)) tags[1].delete(); else { sb_clear(); sb_text("#"); sb_int(flipRank); sb_text(" "); sbStrike(flipStrike); sb_text(" | GF "); sbPrice(flipPrice); tags[1].set(inset, flipPrice).text(str_flip_text_sb).color(theme.TEXT); } if (isNaN(painPrice)) tags[2].delete(); else { sb_clear(); sb_text("#"); sb_int(painRank); sb_text(" "); sbStrike(painPrice); sb_text(" | MP "); sbPrice(painPrice); tags[2].set(inset, painPrice).text(str_pain_text_sb).color(theme.MUTED); } if (isNaN(putWallPrice)) tags[3].delete(); else { sb_clear(); sb_text("#"); sb_int(putWallRank); sb_text(" "); sbStrike(putWallPrice); tags[3].set(inset, putWallPrice).text(str_put_wall_text_sb).color(theme.DOWN); } } // ── Frames: the board and the bars, appended as JSON text without allocating ── let civilMonth = 0; let civilDay = 0; function civilFromDays(days: i64): void { // days since 1970-01-01 -> month and day of month (proleptic Gregorian) const z = days + 719468; const era = (z >= 0 ? z : z - 146096) / 146097; const doe = z - era * 146097; const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; const doy = doe - (365 * yoe + yoe / 4 - yoe / 100); const mp = (5 * doy + 2) / 153; civilDay = i32(doy - (153 * mp + 2) / 5 + 1); civilMonth = i32(mp < 10 ? mp + 3 : mp - 9); } function fbExpiryLabel(ms: f64, nearest: bool): void { // "0DTE" for an expiry inside the day of the chain's clock, else "27JUN" fb_text("\""); if (nearest && ms - nowMs < DAY_MS) fb_text("0DTE"); else { civilFromDays(i64(Math.floor(ms / DAY_MS))); if (civilDay < 10) fb_text("0"); fb_int(civilDay); fb_text(MONTHS[civilMonth - 1]); } fb_text("\""); } function fbUsd(v: f64): void { // "+$84.3M" inside a frame string if (!isFinite(v)) { fb_text("n/a"); return; } fb_text(v < 0.0 ? "-$" : "+$"); const a = Math.abs(v); if (a >= 1.0e9) { fb_f64(a / 1.0e9, 2); fb_text("B"); } else if (a >= 1.0e6) { fb_f64(a / 1.0e6, 1); fb_text("M"); } else if (a >= 1.0e3) { fb_f64(a / 1.0e3, 1); fb_text("K"); } else fb_f64(a, 0); } function priceDecimals(v: f64): i32 { // decimals follow the price's size: strikes at 84,000 / 2,437.5 / 12.5 / 0.42 return v >= 1000.0 ? 1 : v >= 10.0 ? 2 : v >= 1.0 ? 3 : 5; } function writeBoard(): void { // strikes ascending, one cell per expiry column, the row of the strike nearest spot highlighted const cols = columns < expiryCount ? columns : expiryCount; const decimals = priceDecimals(close); fb_clear(); fb_text("{\"prices\":["); for (let r = 0; r < rowCount; r += 1) { if (r > 0) fb_text(","); fb_f64(strikePrice[r], decimals); } fb_text("],\"cells\":["); for (let r = 0; r < rowCount; r += 1) { if (r > 0) fb_text(","); fb_text("["); for (let c = 0; c < cols; c += 1) { if (c > 0) fb_text(","); if (cellSeen[r * MAX_COLS + c] == 0) fb_text("null"); // no open interest in this cell: no fill, no text else fb_f64(cellGex[r * MAX_COLS + c], 0); } fb_text("]"); } fb_text("],\"cols\":["); for (let c = 0; c < cols; c += 1) { if (c > 0) fb_text(","); fbExpiryLabel(expiryMs[c], c == 0); } fb_text("],\"title\":\"GEX by expiry\",\"highlight\":{\"price\":"); // the row alone: a col here would outline the whole column too fb_f64(atmStrike(), decimals); fb_text("}}"); writeFrameBuffer(board); } function writeBars(): void { // the board's row sums as bars, positive in the chart's up colour and negative in its down colour, a hover hint per row const decimals = priceDecimals(close); fb_clear(); fb_text("{\"prices\":["); for (let r = 0; r < rowCount; r += 1) { if (r > 0) fb_text(","); fb_f64(strikePrice[r], decimals); } fb_text("],\"values\":["); for (let r = 0; r < rowCount; r += 1) { if (r > 0) fb_text(","); fb_f64(rowCall[r] - rowPut[r], 0); } fb_text("],\"colors\":["); for (let r = 0; r < rowCount; r += 1) { if (r > 0) fb_text(","); fb_text(rowCall[r] - rowPut[r] >= 0.0 ? "\"theme.up\"" : "\"theme.down\""); } fb_text("],\"tooltips\":["); for (let r = 0; r < rowCount; r += 1) { if (r > 0) fb_text(","); fb_text("\"C "); fbUsd(rowCall[r]); fb_text(" | P "); fbUsd(-rowPut[r]); fb_text("\""); } fb_text("]}"); writeFrameBuffer(gexRows); } // onBar() runs once per bar: history rows carry an empty block and cost one read; the live bar measures the chain, // writes the readings, places the labels and writes the two boards. function onBar(): void { prevT = t; t = bar.time(); close = bar.close(); out_spot(close); out_t_prev(prevT); out_t_last(t); if (!bar.isLast() || isNaN(close) || isNaN(t)) { if (bar.isLast()) clearTags(); out_net_gex(NaN); out_wall_price(NaN); out_flip_price(NaN); out_pain_price(NaN); out_put_wall_price(NaN); return; } const n = in_chain_cells(); if (n < TUPLE) { clearTags(); out_net_gex(NaN); out_wall_price(NaN); out_flip_price(NaN); out_pain_price(NaN); out_put_wall_price(NaN); return; } nowMs = chainClockMs(in_chain_view(), n, t * 1000.0); // the chain's clock: the expiries still live and the 0DTE label measureChain(n); measureLevels(n); out_net_gex(rowCount > 0 ? netGex : NaN); out_wall_price(wallPrice); out_flip_price(flipPrice); out_pain_price(painPrice); out_put_wall_price(putWallPrice); writeTexts(); if (rowCount > 0) { writeBoard(); writeBars(); } } ``` ## How it works **The chain is live only.** `input("chain", options_chain.cells, { max_cells: 4000, venue: "auto" })` delivers the chart coin's listed contracts as `[strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega]` tuples on the live row (a BTC chain is about 1,550); history bars carry an empty block and cost one read, so the boards and the levels are measured on the last bar and rewritten as the chain moves. `venue: "auto"` reads the chart's own market when it lists options, else the coin's Deribit chain. **GEX per cell.** Gamma times open interest times the multiplier times spot squared times 0.01 (Deribit open interest is in coin units with multiplier 1; CME open interest is contracts times the point value), calls positive and puts negative; each contract lands in the cell of its strike row and its expiry column. `window_pct` (7) keeps the strikes within that percent of spot each side and `expiries` (4) the nearest expiries; a strike table of 128 rows holds the window, and when a window is wider than that the strike farthest from spot gives way. Each row also keeps its call and put halves for the bars' hover card, and `net_gex` sums the board's cells as a data-only output, live bar only. **The chain's clock.** Which expiries are still live, and which column reads `0DTE`, count from the moment the chart priced the chain, never from the live bar's open: the chart prices each contract's gamma by Black-Scholes from its mark IV and underlying when it reads the chain, so `chainClockMs()` solves the gamma of the contract whose delta sits nearest 0.5 for the time to expiry it implies. A venue that serves its own greeks gives no such answer, and the bar's open stands in there. **The levels.** The chain goes through the `OptionsChain` kit twice. The whole chain, every live expiry and every strike, gives `maxPain()` and the gamma flip: cumulative net GEX from `netGex(i)` walked up the strikes, its zero crossing interpolated between two strikes, and when the far wings' few dollars flip the sum back and forth, the crossing nearest spot (the rule Gamma Map reads too); a sum landing exactly on zero crosses at that strike and the walk starts afresh after it, so no crossing is interpolated across the zero. A second load with the board's `window_pct` gives the walls a chart of this range trades against, read as Gamma Map reads them: `netGex(i)` finds the strike inside the window holding the largest positive net GEX and `putWall()` the heaviest put strike at or below spot inside it, so the two templates name the same put wall on the same chain. The labels rank the flip, max pain and the put wall 1 to 3 by the absolute net GEX at the strike nearest each (`#1 $86K | GF $85.7K`), so the heaviest level reads first. The level prices are data-only outputs, NaN on history bars, and `t_prev` and `t_last` carry the two newest bar times the lines are placed from. **Two frames, two canvases.** The board frame carries `prices` (the strikes, ascending), `cells` (one row per price, one number per column, `null` where the expiry lists no open interest at the strike), `cols` (the expiry labels, `0DTE` for the front expiry inside its last day, else `27JUN`), a `title` and a `highlight` naming the price of the strike nearest spot, so the board outlines that one row. The bars frame carries the same `prices`, the row sums as `values`, a colour per row (`theme.up` for a positive sum, `theme.down` for a negative one) and a `tooltips` entry naming the call and put halves. Both are appended with `fb_text` and `fb_f64` into the generated frame buffer and sent with `writeFrameBuffer`, so the live bar allocates nothing. `plot.matrix({ frame: board, dock: "right" })` docks the board at the price axis, each row on its strike's price, the header sticky at the top; `palette: ["theme.down", "theme.bg", "theme.up"]` with `center: 0` and `scale: "sqrt"` tints the cells on a diverging ramp from the chart's down colour through its background to its up colour, `format: "usd"` with `signed: true` prints them as signed dollars, `grid_lines: "all"` rules every cell, `highlight_color: "#f8c000"` outlines the at-the-money row in amber and `tooltip` puts a readout under the cursor. `plot.levels({ frame: gexRows, dock: "right", baseline: "center" })` draws the bars from a zero line mid-dock, `width_px: 100` and `thickness_px: 10` size them, and `hover: true` opens the row's card. Two canvases docked on the same side overprint and no word stacks them: `offset: [340, 0]`, the board's width (four columns of 66 px plus the price column), pushes the bars inward so they sit left of the board instead of under it. **The legend.** `legend({ title: "{{expiries}} expiries, ±{{window_pct}}%" })` puts the board's window after the indicator's name (`4 expiries, ±7%`, short enough to show whole), each `{{name}}` filled from the setting of that name, so the words stay true on every coin; `render.legend("spot_entry", { text: "spot_text" })` adds the live spot from a string slot written on the live bar (`live spot $86.5K`: the live bar's reading, which a crosshair on an older bar leaves as it is). **The lines and their labels.** Each `draw.line` runs from `t_prev` to `t_last` at its level's price with `extend: "both"`, so the dashed line spans the whole pane. The labels are label handles declared once with `handles.label({ anchor: "right", align: "right", style: "knockout", font_weight: "medium" })`: `tags[k].set(inset, price)` puts a label's right edge `inset` pixels in from the price axis at its level's price, and `inset` is the board's width (66 px a column plus the 76 px price column, at least the bars' 340 px `offset`) plus the 100 px of bars and an 8 px gap, so a label never covers a cell or a bar or runs into the axis whatever `expiries` reads. Its text is built with `sb_text`, `sb_f64` and `sb_int` and sent with its own slot's sender (`.text(str_flip_text_sb)`), and `.color(theme.TEXT)` and its siblings ink it in the same theme words as its line. A level that is NaN deletes its label and draws no line. ## Where it runs Charts of a coin Deribit lists options for (BTC, ETH, SOL and the rest of its list), on any venue: Binance Futures BTCUSDT, Binance spot ETHUSDT and so on. The chart serves the newest chain on the live bar only, every history bar an empty block, and refreshes it with a snapshot about every 30 seconds. On a 1m or 5m chart the candles span a few strikes, so drag the price axis to zoom it out and the board shows more rows; the picture above is a 5m chart zoomed out that way. A CME futures chart has its own chain, but the editor's Run is paused on CME markets and a community indicator cannot read CME data; OpenMarket's official wrun indicators can. ## When data is missing Other markets are refused by name before any fetch, naming the input and the way out: `Input 'chain' reads options_chain cells, but the chart market HYPERLIQUID_FUTURES/PURR has no option chain (wrun_options_chain_unavailable): coin 'PURR' is not listed on Deribit and the market is not an options venue; open a CME futures chart or a chart of a coin Deribit lists (BTC, ETH, SOL, ...)`. While the chain has not arrived yet the chart shows nothing but the candles; a chain with no live expiry writes no board and no bars; a cell with no open interest prints nothing and takes no tint; a level the kit cannot find (no zero crossing, no put strike at or below spot inside the window) draws no line and no label. ## Customize it - **A narrower board.** Lower `window_pct` for the strikes nearest spot, or `expiries` to 2 for the front of the curve. - **More columns.** Raise `expiries` (up to 12) and add 66 px to the `offset` of `plot.levels` for every added column, so the bars keep clear of the wider board; the labels move left with the board on their own. - **Bars on the other side.** `dock: "left"` on `plot.levels` puts the bars at the left edge of the pane; drop the `offset` then. - **Fewer levels.** Delete a `draw.line`, its label's block in `writeTexts()`, the output the line reads and the string slot the label sends; the rest keeps drawing. - **Pin the venue.** Change `venue: "auto"` to `"deribit"` and Run again to read the coin's Deribit chain even where the chart's market lists options of its own; `"cme"` reads a CME futures chart's chain. - **Your own colours.** The palette's two ends, the bars and the four levels are theme words (`theme.up`, `theme.down`, `theme.text`, `theme.muted`), so they follow the chart's theme and the user's up and down colours; a hex word in their place fixes a colour ([Style anything](../presentation/style-anything.md)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Strike Matrix** under **On price**. 2. Press **Run** on a BTC or ETH chart: the board docks at the price axis once the chain arrives, the bars beside it, the four dashed levels across the chart with their labels just left of the bars. Drag the price axis to zoom it out if the board shows only a few rows. 3. Hover a cell for its net GEX or a bar for the row's call and put halves; at the editor's Console prompt, type `net_gex` to read the board's sum, or `flip_price` for the gamma flip. ## Concepts used - [Data sources](../core-concepts/data-sources.md) for the `options_chain` celled class, its tuple and the `venue` word - [Price canvases](../presentation/price-canvases.md) for `plot.matrix`, the matrix frame, the colour scale and the hover readout - [Docked profiles](../presentation/cards-frames-panels.md#docked-profiles) for `plot.levels` over a frame, `baseline`, `hover` and `offset` - [Drawing objects](../presentation/drawing-objects.md) for `draw.line` placed from the newest bar and label handles pinned in pixels from the price axis - [Options kit](../functions/options-kit.md) for `OptionsChain`, the flip, max pain and the walls - [Style anything](../presentation/style-anything.md) for the whole desk, part by part # Session OI Levels ![Open-interest profiles anchored to each day's span with the bar change below](/wrun/images/session-oi-levels.png) Where open interest was built and unwound in each day, as a profile anchored to the day's own time span. Each bar's change in open interest is spread over the price rows its range covered, so a row grows where positions were opened or closed while price traded there: green where they were opened on balance, red where they were closed, slate where nothing moved. Every day gets its own profile, its rows scaled to that day's biggest and drawn from the day's first bar, and hovering a row reads the open interest moved there. Under the chart, the bar's open-interest change as a histogram. The trader sees the prices the positioning was decided at, day by day. The parts are an `oi.close` feed input beside the chart's candles ([Data sources](../core-concepts/data-sources.md)), a frame of time spans feeding `plot.levels` with `span: "time"` ([Drawing primitives](../presentation/cards-frames-panels.md)), one histogram output under the chart ([Plotting](../presentation/plotting.md)), and a label handle for the one sentence shown on a market without open interest ([Drawing objects](../presentation/drawing-objects.md)). This is also the `session-oi-levels` template: the **Session OI Levels** card under **On price** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Session OI Levels: where open interest was built and unwound in each day, as a profile anchored to the day's own // time span. Each bar's change in open interest is spread over the price rows its range covered, so a row grows where // positions were opened (green) or closed (red) while price traded there; every day gets its own profile, its rows // scaled to that day's biggest row and drawn by the chart from the day's first bar. The bar's open-interest change // rides along as a histogram under the chart. section("Sessions"); // the dialog's one section param.int("sessions", 10, { min: 2, max: 30, label: "Days shown", description: "Days drawn, the newest last" }); param.int("rows", 32, { min: 8, max: 64, label: "Rows per day", description: "Price rows per day" }); input("close", ohlcv.close); // the primary input: the chart's own candles define the grid input("oi", oi.close, { missing: "nan" }); // open interest at the bar's close; a bar without a reading, or a market without open interest, reads NaN output("oi_change", histogram, lower, { color_by: "oi_tone", colors: ["#f87171", "#34d399"], description: "Open interest change on the bar" }); // green where positions were opened, red where they were closed output("oi_tone", none, overlay, { description: "1 while open interest rose on the bar, 0 while it fell" }); // data-only: the histogram's colour index const oi_spans = frame("oi_spans", { max_bytes: 65536 }); // one span per day: its start and end, a price per row, the open interest moved on the row, a colour per row plot.levels({ name: "oi_by_price", frame: oi_spans, dock: "left", span: "time", width_frac: 0.5, labels: false, color: "#38bdf8", opacity: 0.85, format: "si", hover: true }); // each day's profile grows from the day's first bar; hover a row for the open interest moved there string("note", { max_bytes: 40 }); // the one sentence shown when the market has no open interest handles.label({ anchor: "top_right", align: "right", color: "#94a3b8", size: 12, style: "plain", safe_area: true }); // that sentence: plain slate words at the pane's top right, clear of the chart's buttons const MAX_BARS = 8192; const MAX_ROWS = 64; const MAX_DAYS = 30; // the bar ring (8192 bars is 28 days of 5m bars) and the finished days kept, for the largest settings const barT = new StaticArray(MAX_BARS); const barLo = new StaticArray(MAX_BARS); const barHi = new StaticArray(MAX_BARS); const barOi = new StaticArray(MAX_BARS); // each ring bar's open time, price span and open-interest change const dayStart = new StaticArray(MAX_DAYS + 1); const dayLo = new StaticArray(MAX_DAYS + 1); const dayStep = new StaticArray(MAX_DAYS + 1); // each folded day's span start (NaN: it draws nothing), bottom and row height; slot MAX_DAYS is the open day const dayNet = new StaticArray((MAX_DAYS + 1) * MAX_ROWS); const dayMoved = new StaticArray((MAX_DAYS + 1) * MAX_ROWS); // per day and row: the net open-interest change, and the open interest moved, built or unwound const note = draw.label(0); // the one slate sentence; a handle allocates once at module load let sessions = 10; let rows = 32; // the params, read in onStart() let seq = -1; let openSeq = 0; let openDay: i64 = -1; let doneHead = -1; let doneCount = 0; // the bars counted (bar s sits in ring slot s % MAX_BARS), the open day and its first bar, the finished days' ring: its newest slot and the days in it let prevOi: f64 = NaN; // the last open-interest reading let t: f64 = NaN; // this bar's open time function dayOf(time: f64): i64 { return i64(Math.floor(time / 86400.0)); } // the UTC day number of an open time function priceDecimals(step: f64): i32 { // enough decimals that the rounded row prices stay evenly spaced (the docked grid is contiguous only within 0.1% of its step) const d = i32(Math.ceil(Math.log(2000.0 / step) / Math.LN10)); return d < 2 ? 2 : d > 9 ? 9 : d; } // ── One day's profile: its bars folded into `rows` price rows across the day's span, kept in a day slot ── function spread(a: f64, b: f64, amount: f64, gridLo: f64, width: f64, base: i32): void { // [a, b]'s open-interest change over the rows it overlaps, in proportion let first = i32(Math.floor((a - gridLo) / width)); let last = i32(Math.floor((b - gridLo) / width)); if (first < 0) first = 0; if (last > rows - 1) last = rows - 1; if (first > rows - 1) first = rows - 1; if (last < 0) last = 0; if (first >= last || b - a <= 0.0) { dayNet[base + first] += amount; dayMoved[base + first] += Math.abs(amount); return; } // one row, or a bar thinner than the grid for (let k = first; k <= last; k += 1) { const cellLo = gridLo + f64(k) * width; const cellHi = cellLo + width; const overlap = Math.min(b, cellHi) - Math.max(a, cellLo); if (overlap > 0.0) { const share = (amount * overlap) / (b - a); dayNet[base + k] += share; dayMoved[base + k] += Math.abs(share); } } } function foldDay(from: i32, to: i32, slot: i32): void { // the bars from..to (counted, oldest first) are one day: its rows into day slot `slot` if (to - from >= MAX_BARS) from = to - MAX_BARS + 1; // bars older than the ring are gone let lo = Infinity; let hi = -Infinity; for (let s = from; s <= to; s += 1) { const i = s % MAX_BARS; if (barLo[i] < lo) lo = barLo[i]; if (barHi[i] > hi) hi = barHi[i]; } dayStart[slot] = NaN; if (!(hi > lo)) return; // a day with no price span const rowHeight = (hi - lo) / f64(rows); const base = slot * MAX_ROWS; for (let r = 0; r < rows; r += 1) { dayNet[base + r] = 0.0; dayMoved[base + r] = 0.0; } let moved = 0.0; for (let s = from; s <= to; s += 1) { const i = s % MAX_BARS; const change = barOi[i]; if (isNaN(change) || change == 0.0) continue; spread(barLo[i], barHi[i], change, lo, rowHeight, base); moved += Math.abs(change); } if (moved <= 0.0) return; // a day with no open-interest change dayStart[slot] = f64(dayOf(barT[from % MAX_BARS])) * 86400.0; dayLo[slot] = lo; dayStep[slot] = rowHeight; // the span: the UTC day the bars belong to, in epoch seconds } function writeDay(slot: i32, first: bool): bool { // one folded day appended to the frame as a span; false when the day draws nothing const start = dayStart[slot]; if (isNaN(start)) return false; const lo = dayLo[slot]; const rowHeight = dayStep[slot]; const base = slot * MAX_ROWS; const decimals = priceDecimals(rowHeight); if (!first) fb_text(","); fb_text("{\"start\":"); fb_num(start); fb_text(",\"end\":"); fb_num(start + 86400.0); fb_text(",\"prices\":["); // row centres, strictly increasing for (let r = 0; r < rows; r += 1) { if (r > 0) fb_text(","); fb_f64(lo + (f64(r) + 0.5) * rowHeight, decimals); } fb_text("],\"values\":["); // the open interest moved on the row (0 where nothing moved, so the grid stays whole) for (let r = 0; r < rows; r += 1) { if (r > 0) fb_text(","); fb_f64(dayMoved[base + r], 0); } fb_text("],\"colors\":["); // green where positions were opened on balance, red where they were closed, slate where nothing moved for (let r = 0; r < rows; r += 1) { if (r > 0) fb_text(","); const net = dayNet[base + r]; fb_text(net > 0.0 ? "\"#34d399\"" : net < 0.0 ? "\"#f87171\"" : "\"#64748b\""); } fb_text("]}"); return true; } function writeSpans(): void { // the newest `sessions` days, oldest first, as one spans frame: the finished days as folded when they ended, the open day folded now foldDay(openSeq, seq, MAX_DAYS); // only the open day changes on a new bar or a live tick, so it is the one day folded again fb_clear(); fb_text("{\"spans\":["); let written = 0; const kept = doneCount < sessions - 1 ? doneCount : sessions - 1; for (let k = kept - 1; k >= 0; k -= 1) if (writeDay((doneHead + MAX_DAYS - k) % MAX_DAYS, written == 0)) written += 1; if (writeDay(MAX_DAYS, written == 0)) written += 1; fb_text("]}"); if (written > 0) writeFrameBuffer(oi_spans); // an unwritten frame leaves the profiles absent; an empty spans list would be refused } // onStart() runs once before the first bar: read the params. function onStart(): void { sessions = i32(p_sessions()); rows = i32(p_rows()); } // onBar() runs once per bar: the bar's open-interest change against the last reading, the bar into the ring, a finished // day folded once where the UTC day changes, the change as a number on every bar, and the day profiles on the live bar only. function onBar(): void { t = bar.time(); const oiNow = in_oi(); const lo = bar.low(); const hi = bar.high(); const change = isNaN(oiNow) || isNaN(prevOi) ? NaN : oiNow - prevOi; // unknown until two readings exist if (!isNaN(oiNow)) prevOi = oiNow; if (bar.isLast() && isNaN(prevOi)) { sb_clear(); sb_text("No open interest on this market"); note.set(16.0, 12.0).text(str_note_sb); } // no reading on any bar (spot, FX): one sentence, not an empty pane if (isNaN(t) || !(hi >= lo)) return; // a bar with no span draws nothing and is not counted const day = dayOf(t); if (day != openDay) { // a new UTC day: the day before is finished, folded once into the finished ring and kept as it ended if (openDay >= 0) { doneHead = (doneHead + 1) % MAX_DAYS; if (doneCount < MAX_DAYS) doneCount += 1; foldDay(openSeq, seq, doneHead); } openDay = day; openSeq = seq + 1; } seq += 1; const slot = seq % MAX_BARS; barT[slot] = t; barLo[slot] = lo; barHi[slot] = hi; barOi[slot] = change; out_oi_change(change); out_oi_tone(isNaN(change) ? NaN : change >= 0.0 ? 1.0 : 0.0); if (bar.isLast()) writeSpans(); } ``` ## How it works **The change, not the level.** `input("oi", oi.close, { missing: "nan" })` reads open interest at each bar's close; the bar's change is the difference from the last reading (NaN until two readings exist, and on a market without open interest). `oi_change` is that number as a histogram under the chart, green while open interest rose and red while it fell. **Rows per day.** Every bar lands in a ring with its open time, its high and low and its change. Where the UTC day changes, `foldDay` folds the day that just ended once: it finds the day's price span, splits it into `rows` (32) rows, and spreads each bar's change over the rows its range overlapped, in proportion; a row keeps the open interest moved on it (built or unwound, always positive: the bar's length) and the net change (its colour), and the folded day waits in a ring of 30 finished days. On the live bar only the open day is folded again, so a live tick costs one day's bars, never every day's. A day with no span or no change is left out. **One span per day.** `frame("oi_spans")` carries `{ "spans": [{ "start", "end", "prices", "values", "colors" }, ...] }`, one entry per day with its bounds in epoch seconds (the newest `sessions` (10) days: the finished ones as they were folded, then the open day), and feeds `plot.levels({ span: "time" })`: the chart draws every span's rows from the day's first bar, growing right across `width_frac` (0.5) of the day's width, each day scaled to its own biggest row. `hover: true` with `format: "si"` reads a row's open interest as `1.2M` in the hover card. The frame is built with the generated `fb_*` writer, so the live bar allocates nothing. ## Where it runs Perpetual futures with an open-interest feed: Binance Futures, Hyperliquid and the other venues that serve `oi` ([Data sources](../core-concepts/data-sources.md) lists them). A day is folded from the ring of the newest 8192 bars when it ends, and the ring holds a whole day down to 11-second bars, so a 1m chart shows its `sessions` days whole, as far back as the chart has loaded, like a 5m one. ## When data is missing Spot, stocks, FX and prediction markets carry no open interest: every bar's change reads NaN, the histogram stays empty and no profile is drawn (nothing moved), and one line of words shows at the top right of the chart, `No open interest on this market`, in plain slate: the `note` label handle, written on the live bar while no bar has brought a reading, pinned to the pane's top right corner, right-aligned and `safe_area: true`, so it clears the chart's own buttons. A bar without a reading keeps the last one, so the next reading's change covers both bars. The first bar of a run has no change. ## Customize it - **More days, finer rows.** `sessions` up to 30 and `rows` up to 64 (64 rows across 30 days is 1920 rows, inside the frame's 65536 bytes). - **Grow from the right.** `dock: "right"` grows each day's rows from the day's last bar instead. - **Rows with labels.** `labels: true` prints each row's value beside it (`format: "si"` keeps them short); `thickness_px` caps the rows on a coarse grid. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Session OI Levels** under **On price**. 2. Press **Run** on a perpetual chart such as BTCUSDT on Binance Futures at 15m: a profile grows inside each of the last ten days and the open-interest change appears under the chart. 3. Hover a row for the open interest moved there; at the editor's Console prompt, type `oi_change` to read the newest bar's change. ## Concepts used - [Data sources](../core-concepts/data-sources.md) for the `oi` feed and the `missing: "nan"` policy - [Drawing primitives](../presentation/cards-frames-panels.md) for frames, `plot.levels` and the `span: "time"` frame shape - [Plotting](../presentation/plotting.md) for the histogram output and its colour index # Aggregated CVD One cumulative volume-delta line with volatility bands and sign coloring, on the chart's own market. Cumulative volume delta tracks the running difference between aggressive buying and aggressive selling. On a single venue it tells a partial story, because flow splits across exchanges. The chart serves `trades` on the chart's own market only: it pins another market on a secondary `ohlcv` input alone, and a `trades` input pinned to another venue is refused by name. So the indicator is the single-venue line, framed with volatility bands, on whichever market the chart shows; to read another venue's flow, open the chart on that venue. ## The wrun indicator ```typescript param("band_len", 60, { min: 10, max: 500, description: "Bars in the volatility band" }); // The chart's own candles are the grid every other input lines up on. input("close", ohlcv.close); input("buy", trades.volume, { side: "BUY", missing: "zero", description: "Aggressive buy volume" }); input("sell", trades.volume, { side: "SELL", missing: "zero", description: "Aggressive sell volume" }); output("cvd", line, lower, { width: 2, color_by: "sign", colors: ["#ff5b7f", "#22d3a5"], description: "Cumulative volume delta on the chart's market", }); output("upper_band", line, lower, { color: "#22d3a5", opacity: 0.35, description: "CVD plus one standard deviation" }); output("lower_band", line, lower, { color: "#ff5b7f", opacity: 0.35, description: "CVD minus one standard deviation" }); output("sign", none, lower); let stdev = new Stdev(60); let cvd: f64 = 0.0; function onStart(): void { stdev = new Stdev(i32(p_band_len())); } function onBar(): void { // A module-level variable is the accumulator: it survives from bar to bar. cvd += in_buy() - in_sell(); const band = stdev.update(cvd); const width = isNaN(band) ? 0.0 : band; out_cvd(cvd); out_upper_band(cvd + width); out_lower_band(cvd - width); out_sign(cvd >= 0.0 ? 1.0 : 0.0); } ``` ## How it works **The timeline spine.** `input("close", ohlcv.close)` is never read by the code. It is the first input, and the first input defines the grid every other input lines up on: one row per candle, always. Without it, the timeline would depend on the trade feed resolving, and on a market where it is missing the module would have no bars to compute. Load the chart series first, always. **Buy and sell volume.** `trades.volume` with `side: "BUY"` and `side: "SELL"` are the two halves of the tape, as two inputs. `missing: "zero"` makes a bar with no trade observation contribute zero rather than carrying the last value forward, which would double-count. **Cumulative means module-level.** `cvd` is a module-level variable and `cvd += buy - sell` adds each bar's net flow to the running total. That persistence is what turns a per-bar delta into a cumulative line. **Sign coloring and the band.** `sign` is a data-only `1` or `0`, and the `cvd` line indexes its two-entry palette with it: green when the running total is positive, red when negative. `Stdev` over the running total gives the one-sigma envelope; while it is warming the band width is `0`, so the edges sit on the line instead of vanishing. ## Design notes - One venue, the chart's own. A `trades` input pinned to another venue is refused by name ("the browser lane serves market pins on secondary ohlcv inputs only"), so a multi-venue sum has no chart form; open the chart on another venue to read that venue's flow. - The sign tint is `color_by` over a data-only output. To shade between the bands, declare a `range()` between the two band outputs, which the chart draws as a filled band, or a one-bar box between them (the [Anchored VWAP](anchored-vwap.md) recipe shades its band that way). - No symbol picker drives this Indicator: a `param.symbol` pick re-pins a secondary candle input only, and a `trades` pin to another venue is refused, so the market is always the chart's own. ## Customize it - **Another market.** Switch the chart: the indicator reads whichever market the chart shows. On a market with no sided trades the two inputs read zero under `missing: "zero"`, so the line stays flat. - **Band width.** `band_len` sets the volatility lookback. Shorten it for a reactive envelope that hugs the line, lengthen it for a smoother, slower band. - **Colors.** The palette on `cvd` and the `color` on each band are hex strings on the declarations; recolor there. - **Alert on the line.** `cvd` is a drawn line, so once the indicator is published and on a chart, the alert dialog offers it with conditions such as **Crosses above** and **Moving up %** ([Alerts](../functions/alerts.md)). ## Run it 1. In the editor's Explorer, press **New indicator** and pick **Blank indicator**: the tab holds the `//@lang=wrun-ts` line alone. 2. Paste the indicator block above under that line and press **Run** on a crypto perp or spot market. 3. At the editor's Console prompt, type `last 20 cvd` to read the running total over the last 20 bars. ## Concepts used - [Multi-source](../core-concepts/multi-source.md) for which inputs the chart pins to another market - [Data sources](../core-concepts/data-sources.md) for `trades.volume` with a `side` and the `missing` policies - [Execution model](../core-concepts/execution-model.md) for the module-level accumulator - [Volume and VWAP](../functions/volume-indicators.md) for buy and sell volume as inputs # CVD divergence ![CVD pane under BTC with divergence lines and div tags](/wrun/images/cvd-divergence.png) Cumulative volume delta (aggressive buys minus aggressive sells, summed bar after bar) in coins, in its own pane under the chart, with its own axis. Every confirmed swing high and swing low of price is dotted on the CVD line in the pane. When price makes a higher high while CVD makes a lower high, or a lower low while CVD makes a higher low, the example draws the evidence: a line joining the two price swings on the chart (orange for the bearish case, amber for the bullish one), a dashed line joining the two CVD swings in the pane, and a small "div" tag beside the price line. Nothing is drawn on ordinary bars. The parts are side-split `trades.volume` inputs ([Data sources](../core-concepts/data-sources.md)), a `shape` output displaced back to the swing bar, and three handle kinds: line handles on price, a polyline handle in the lower pane and label handles for the tags ([Drawing objects](../presentation/drawing-objects.md)). This is also the `cvd-divergence` template: the **CVD Divergence** card under **Order flow** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // CVD Divergence: cumulative volume delta, in coins, in its own pane. When price makes a higher high while CVD makes // a lower high (or a lower low while CVD makes a higher low), a line joins the two price swings on the chart, a dashed // line joins the two CVD swings in the pane, every confirmed swing is dotted in the pane, and a small "div" tag names it. param.int("swing", 8, { min: 3, max: 30, label: "Swing strength in bars", description: "Bars on each side a swing high or low must beat; a swing is confirmed this many bars later" }); param.bool("cvd_reset", false, { label: "Reset at midnight", description: "Restart CVD at every UTC midnight; off, it runs from the first loaded bar" }); // a toggle: pb_cvd_reset() reads it in onStart() param.int("lines_kept", 12, { min: 1, max: 60, label: "Divergence lines kept", description: "Divergence lines kept on the chart; the oldest is recycled" }); legend({ title: "({{swing}})" }); // the words after the name in the legend: the swing strength, read by name input("close", ohlcv.close); // the primary input: the chart's own candles define the grid every other input lines up on input("buy", trades.volume, { side: "BUY", missing: "zero" }); // aggressive buy volume; a bar with no prints reads 0 input("sell", trades.volume, { side: "SELL", missing: "zero" }); // aggressive sell volume, the other half of the tape output("cvd", line, lower, { color: "#38bdf8", width: 2, label: "CVD", format: "si", unit: " coins" }); // cumulative volume delta in the base asset (trades.volume reads coins), in its own pane under the chart output("cvd_swing", shape, lower, { color: "#a78bfa", shape_where: "swing_here", displacement_bars: -8, displacement_bars_by: { param: "swing", scale: -1 }, label: "CVD swing", format: "si", unit: " coins" }); // a dot on the CVD at each confirmed swing, drawn back at the swing bar: `swing` bars before the bar that confirms it (-8 is the default strength's shift) output("swing_here", none); // data-only: 1 on the bar that confirms a swing (the dot's gate) output("divergence", none); // data-only: -1 bearish, +1 bullish, 0 none; read it in the Console or fire an alert(...) on it string("text", { max_bytes: 40 }); // one bounded slot every label is written through handles.line({ width: 2 }); // the price link between the two swings (chart time and price) handles.polyline({ panel: "lower", width: 1, lineStyle: "dashed" }); // the CVD link between the two swings, in the pane handles.label({ size: 11, color: "#e2e8f0" }); // the "div" tag on price, and the one line of words when the market has no sided trades const RING = 64; // the swing window: 2 x 30 + 1 bars at the largest strength const MAX_LINES = 60; // handles per kind at the largest setting const highs = new StaticArray(RING); // the last RING bars, rings the swing test reads const lows = new StaticArray(RING); const cvds = new StaticArray(RING); const times = new StaticArray(RING); const points = new StaticArray(4); // x0, y0, x1, y1 for the pane link const lines: LineHandle[] = []; // handle objects allocate once; ids are one space across kinds const links: PolylineHandle[] = []; const tags: LabelHandle[] = []; for (let i = 0; i < MAX_LINES; i += 1) { lines.push(draw.line(i)); links.push(draw.polyline(100 + i)); tags.push(draw.label(200 + i)); } const notice = draw.label(300); const ORANGE = rgba(248, 104, 0, 255); // bearish: price up, CVD down const AMBER = rgba(248, 192, 0, 255); // bullish: price down, CVD up const SLATE = rgba(148, 163, 184, 255); let swing = 8; // settings, read in onStart() let resetDaily = false; let linesKept = 12; let barCount = 0; // bars seen let cvd = 0.0; // the running delta let sidedVolume = 0.0; // every sided print seen: 0 means the market has no sided trades let prevDay = -1.0; // the UTC day of the previous bar, for the daily reset let rangeAvg = NaN; // a running mean of high - low: the tag's distance from the line let lastHighPrice = NaN; // the previous confirmed swing high and the CVD at it let lastHighCvd = NaN; let lastHighTime = NaN; let lastLowPrice = NaN; // the previous confirmed swing low and the CVD at it let lastLowCvd = NaN; let lastLowTime = NaN; let divergences = 0; // how many have been drawn: the ring position let swingHere = 0.0; // this bar's results, set by testSwing() let swingCvd = NaN; let pendingKind = 0; // 0 none, -1 bearish, +1 bullish: a divergence to draw this bar let x1 = NaN; // the two price swings and the CVD at each let y1 = NaN; let x2 = NaN; let y2 = NaN; let c1 = NaN; let c2 = NaN; // testSwing() tests the bar `swing` bars back for a swing, then the swing against the previous one of its kind for a divergence. function testSwing(): void { swingHere = 0.0; swingCvd = NaN; pendingKind = 0; if (barCount < 2 * swing + 1) return; // not enough bars on both sides yet const center = barCount - 1 - swing; // the bar under test, `swing` bars back const c = center % RING; let isHigh = true; let isLow = true; for (let k = 1; k <= swing; k += 1) { const leftSlot = (center - k) % RING; const rightSlot = (center + k) % RING; if (!(highs[c] > highs[leftSlot]) || !(highs[c] >= highs[rightSlot])) isHigh = false; // above every bar before it, at or above every bar after it (a flat top counts once, on its first bar) if (!(lows[c] < lows[leftSlot]) || !(lows[c] <= lows[rightSlot])) isLow = false; // the mirror for a swing low } if (!isHigh && !isLow) return; swingHere = 1.0; swingCvd = cvds[c]; if (isHigh) { if (!isNaN(lastHighPrice) && highs[c] > lastHighPrice && cvds[c] < lastHighCvd) { // higher high in price, lower high in CVD pendingKind = -1; x1 = lastHighTime; y1 = lastHighPrice; c1 = lastHighCvd; x2 = times[c]; y2 = highs[c]; c2 = cvds[c]; } lastHighPrice = highs[c]; lastHighCvd = cvds[c]; lastHighTime = times[c]; } if (isLow) { if (!isNaN(lastLowPrice) && lows[c] < lastLowPrice && cvds[c] > lastLowCvd) { // lower low in price, higher low in CVD pendingKind = 1; x1 = lastLowTime; y1 = lastLowPrice; c1 = lastLowCvd; x2 = times[c]; y2 = lows[c]; c2 = cvds[c]; } lastLowPrice = lows[c]; lastLowCvd = cvds[c]; lastLowTime = times[c]; } } function onStart(): void { swing = i32(p_swing()); resetDaily = pb_cvd_reset(); linesKept = i32(p_lines_kept()); } // onBar() runs once per bar: fold the bar into CVD and the rings, test for a swing, write the outputs on every bar, and // draw a divergence on the bar that confirms it. function onBar(): void { const high = bar.high(); const low = bar.low(); const barTime = bar.time(); const buy = in_buy(); const sell = in_sell(); if (resetDaily) { const day = Math.floor(barTime / 86400.0); if (day != prevDay) cvd = 0.0; prevDay = day; } cvd += buy - sell; sidedVolume += buy + sell; const range = high - low; rangeAvg = isNaN(rangeAvg) ? range : rangeAvg + (range - rangeAvg) * 0.1; const slot = barCount % RING; highs[slot] = high; lows[slot] = low; cvds[slot] = cvd; times[slot] = barTime; barCount += 1; testSwing(); out_cvd(cvd); out_swing_here(swingHere); out_cvd_swing(sidedVolume > 0.0 ? swingCvd : NaN); // no dots on a market with no sided trades: the pane holds a flat zero line and the words out_divergence(f64(pendingKind)); if (pendingKind != 0) { const k = divergences % linesKept; // the ring position: the oldest line, link and tag are reused divergences += 1; const tone = pendingKind < 0 ? ORANGE : AMBER; lines[k].set(x1, y1, x2, y2).color(tone); points[0] = x1; points[1] = c1; points[2] = x2; points[3] = c2; links[k].setPoints(points, 2).color(tone); const offset = (isNaN(rangeAvg) ? 0.0 : rangeAvg) * 0.6; // the tag sits just outside the line, on the swing side const tagY = pendingKind < 0 ? Math.max(y1, y2) + offset : Math.min(y1, y2) - offset; sb_clear(); sb_text("div"); tags[k].set((x1 + x2) * 0.5, tagY).text(str_text_sb); tags[k].color(tone); } if (bar.isLast() && sidedVolume <= 0.0) { // the market served no sided prints at all: say so once, top right sb_clear(); sb_text("No sided trades on this market"); notice.set(16, 14).text(str_text_sb); notice.anchor(ANCHOR_TOP_RIGHT).align(ALIGN_RIGHT).color(SLATE); } } ``` ## How it works **CVD is a running sum.** `input("buy", trades.volume, { side: "BUY", missing: "zero" })` and its SELL twin deliver the bar's aggressive volume per side (a bar with no prints counts as zero); their difference summed bar after bar is `cvd`, a line in the lower pane labelled CVD. `trades.volume` reads the base asset, so the sum is in coins: `format: "si"` with `unit: " coins"` prints it in the legend as "CVD -17.6K coins". `cvd_reset` (off) runs it from the first loaded bar; on, it restarts at every UTC midnight. **Swings confirm late.** `swing` (8, 3 to 30) is the number of bars on each side a swing must beat, so a swing is confirmed `swing` bars after itself. A swing must beat every bar before it and at least match every bar after it, so a flat top on an odds chart counts once, on its first bar. `swing_here` is 1 on a confirming bar and gates the `cvd_swing` dot, which `displacement_bars_by: { param: "swing", scale: -1 }` draws back on the swing bar itself, `swing` bars before the confirming bar at whatever strength is set (`displacement_bars: -8`, the default's shift, stays for a host that reads only the literal). **A divergence is two swings that disagree.** A higher price high against a lower CVD high is bearish (-1), a lower price low against a higher CVD low is bullish (+1); `divergence` carries the sign on the confirming bar and 0 elsewhere, a data-only signal the editor's Console prompt reads and a declared alert can fire on. On that bar a line handle joins the two price swings (x from `bar.time()`), a dashed polyline handle joins the two CVD swings in the pane, and a label handle writes "div" beside the price line through the one bounded slot. `lines_kept` (12, 1 to 60) lines stay on the chart; the oldest is recycled. ## Where it runs Markets with sided trades: crypto perps and spot, prediction markets. A market where one side does all the trading (CVD climbing the whole window) shows swings and no divergence, which is the honest reading. ## When data is missing On a market with no sided trades (FX, stocks) CVD stays flat at zero, no swing is dotted, and one slate line of words sits at the top right of the chart: "No sided trades on this market". ## Customize it - **Faster swings.** Lower `swing`; more swings confirm and more pairs qualify. - **A session CVD.** Switch `cvd_reset` on to restart the sum at every UTC midnight. - **Dollars instead of coins.** Add `currency: "USD"` to both `trades.volume` inputs and declare `format: "usd"` without the unit on `cvd` and `cvd_swing`: the sum is then each trade's notional, as the native Cumulative Volume Delta reads by default. - **Alert on a divergence.** Bind `divergence` to a `const` and declare `alert("cvd_divergence", { when: ... })`: it fires on the bar a divergence confirms, since the output turns from 0 to -1 or +1 there. Once the indicator is published and on a chart, the signal is in the chart's alert dialog ([Alerts](../functions/alerts.md)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **CVD Divergence** under **Order flow**. 2. Press **Run** on a crypto perp or spot market: CVD draws in its own pane, and the evidence lines appear where a divergence confirmed. 3. At the editor's Console prompt, type `last 60 divergence` to read the sign on each of the last 60 bars. ## Concepts used - [Data sources](../core-concepts/data-sources.md) for the `trades` source, its sides and the `missing` policy - [Drawing objects](../presentation/drawing-objects.md) for line, polyline and label handles and the lower-pane placement - [Plotting](../presentation/plotting.md) for `shape_where` gates and `displacement_bars_by` # Positioning regimes ![Open-interest regime histogram with key card and flip dots](/wrun/images/positioning-regimes.png) Each bar's open-interest change, counted in contracts and as a percent, in its own pane under the chart, colored by who moved: amber for price up with open interest up (new longs), orange for price down with open interest up (new shorts), sky for price up with open interest down (short covering), violet for price down with open interest down (long closing). A key card at the top right of the chart names the four colors and gives each regime's share of the last `key_window` bars, so the colors explain themselves. On price, a small dot marks the bar where the window's leading regime changed hands: amber under the bar when buying pressure took over, orange over the bar when selling pressure did. The parts are an `oi.close` input that reads NaN where the market has none and a `funding.rate_close` input that reads NaN off perpetuals ([Data sources](../core-concepts/data-sources.md)), one histogram output per regime so the legend names every colour ([Plotting](../presentation/plotting.md)), a `draw.card` with one text slot per value cell ([Cards, frames and panels](../presentation/cards-frames-panels.md)), and two `shape` outputs for the leader-flip dots. This is also the `positioning-regimes` template: the **Positioning Regimes** card under **Order flow** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Positioning Regimes: each bar's open-interest change in its own pane, colored by who moved. On a perpetual, open // interest is counted in coins (the dollar reading over the bar's close), so a price move alone never changes it; a // prediction market's reading is used as served (already a count of YES and NO pairs). Price up with OI up is // new longs (amber), price down with OI up is new shorts (orange), price up with OI down is short covering (sky), // price down with OI down is long closing (violet). A key card at the top right gives each regime's share of the // last bars, so the colors explain themselves; a dot on price marks the bar where the window's leading regime changed // hands and held for three bars, amber for buying pressure, orange for selling pressure. param.int("smoothing", 1, { min: 1, max: 20, label: "Smoothing, bars", description: "Bars the OI change and the price change are averaged over before coloring; 1 = raw" }); param.int("key_window", 24, { min: 4, max: 200, label: "Key window, bars", description: "Bars the key card's shares are counted over" }); input("close", ohlcv.close); // the primary input: the chart's own candles define the grid every other input lines up on input("oi", oi.close, { missing: "nan" }); // open interest at the bar's close, as served; a bar without a reading, or a market without open interest, reads NaN input("funding", funding.rate_close, { missing: "nan" }); // served on perpetuals only (NaN elsewhere): their open interest is dollars, read in coins output("new_longs", histogram, lower, { unit: "%", color: "#f8c000" }); // the bar's OI change in contracts as a percent, drawn by the regime that owns the bar: price up, OI up output("new_shorts", histogram, lower, { unit: "%", color: "#f86800" }); // price down, OI up output("short_covering", histogram, lower, { unit: "%", color: "#38bdf8" }); // price up, OI down output("long_closing", histogram, lower, { unit: "%", color: "#a78bfa" }); // price down, OI down output("regime", none); // data-only: 0 new longs, 1 new shorts, 2 short covering, 3 long closing; the Console can read it output("share_new_longs", none); // data-only: each regime's share of the key window, 0..1, read in the Console output("share_new_shorts", none); output("share_short_covering", none); output("share_long_closing", none); output("buyers_lead", shape, overlay, { color: "#f8c000" }); // a dot under the bar where the window's leading regime turned to buying pressure (new longs or short covering) output("sellers_lead", shape, overlay, { color: "#f86800" }); // a dot over the bar where it turned to selling pressure (new shorts or long closing) output("leader", none); // data-only: the regime with the most bars in the window, 0..3 string("new_longs", { max_bytes: 8 }); // the card's value cells, one slot each, written on the live bar string("new_shorts", { max_bytes: 8 }); string("short_covering", { max_bytes: 8 }); string("long_closing", { max_bytes: 8 }); string("window", { max_bytes: 16 }); string("text", { max_bytes: 40 }); // the one line of words when the market has no open interest handles.label({ anchor: "top_right", align: "right", color: "#94a3b8", size: 11 }); // the notice, in pane pixels from the chart's top-right corner draw.card("positioning_key", { title: "Positioning", anchor: "top_right", offset: [56, 0], // inward past the price-axis tags (High, Low, last price reach about 46 px into the pane) rows: [ { label: "New longs", value: { text: "new_longs" }, color: "#f8c000" }, { label: "New shorts", value: { text: "new_shorts" }, color: "#f86800" }, { label: "Short covering", value: { text: "short_covering" }, color: "#38bdf8" }, { label: "Long closing", value: { text: "long_closing" }, color: "#a78bfa" }, { label: "Window", value: { text: "window" } }, ], }); const MAX_WINDOW = 200; // the ring is sized for the largest window; onStart() reads the one in use const REGIMES = 4; const regimeRing = new StaticArray(MAX_WINDOW); // the last window bars' regimes, -1 where the bar had no reading const counts = new StaticArray(REGIMES); // how many bars of the window sit in each regime const notice = draw.label(0); let smoothing = 1; // settings, read in onStart() let keyWindow = 24; let oiChangeAvg = new Sma(1); // the smoothers, rebuilt in onStart() let priceChangeAvg = new Sma(1); let prevClose = NaN; // the previous bar's close and OI, as served let prevOi = NaN; let perp = false; // funding seen: a perpetual, whose dollar open interest moves with price let oiSeen = false; // any finite OI reading at all: false means the market has no open interest let head = 0; // the ring's write cursor and how many slots hold a bar let filled = 0; let leader = -1; // the regime holding the most bars of the window, once it has held for HOLD_BARS bars let candidate = -1; // the regime leading right now, and how many bars in a row it has led let candidateRun = 0; const HOLD_BARS = 3; // a new leader is marked once it has led three bars in a row let rangeAvg = NaN; // a running mean of high - low: the dot's distance from the bar function share(index: i32): f64 { // a regime's share of the bars in the window that had a reading let counted = 0; for (let i = 0; i < REGIMES; i += 1) counted += counts[i]; return counted == 0 ? NaN : f64(counts[index]) / f64(counted); } function sendShare(value: f64, send: () => i32): void { // "31%" into one card cell sb_clear(); sb_f64(value * 100.0, 0); sb_text("%"); send(); } function onStart(): void { smoothing = i32(p_smoothing()); keyWindow = i32(p_key_window()); oiChangeAvg = new Sma(smoothing); priceChangeAvg = new Sma(smoothing); for (let i = 0; i < REGIMES; i += 1) counts[i] = 0; } // onBar() runs once per bar: the OI change in contracts as a percent, the price change, the regime from their signs, the window counts; // then the histogram and the shares on every bar, the card's cells and the notice on the live bar only. function onBar(): void { const close = bar.close(); const high = bar.high(); const low = bar.low(); const oiNow = in_oi(); const range = high - low; rangeAvg = isNaN(rangeAvg) ? range : rangeAvg + (range - rangeAvg) * 0.1; if (!isNaN(oiNow)) oiSeen = true; if (!isNaN(in_funding())) perp = true; const now = perp ? oiNow / close : oiNow; // both readings in one unit: coins on a perpetual, as served elsewhere const before = perp ? prevOi / prevClose : prevOi; const rawOiChange = isNaN(before) || before <= 0.0 ? NaN : ((now - before) / before) * 100.0; const rawPriceChange = isNaN(prevClose) ? NaN : close - prevClose; prevOi = oiNow; prevClose = close; const oiChange = oiChangeAvg.update(rawOiChange); // NaN until the smoothing window is full or while a reading is missing const priceChange = priceChangeAvg.update(rawPriceChange); let regime = -1; if (!isNaN(oiChange) && !isNaN(priceChange)) { if (oiChange >= 0.0) regime = priceChange >= 0.0 ? 0 : 1; // OI rising: new longs when price rose, new shorts when it fell else regime = priceChange >= 0.0 ? 2 : 3; // OI falling: short covering when price rose, long closing when it fell } const leaving = regimeRing[head]; // the bar that drops out of the window if (filled == keyWindow && leaving >= 0) counts[leaving] -= 1; regimeRing[head] = regime; if (regime >= 0) counts[regime] += 1; head = (head + 1) % keyWindow; if (filled < keyWindow) filled += 1; let shiftDot = NaN; // this bar's dot on price, NaN when the leader held let best = -1; // the window's leader: the regime with the most bars, holding at least two bars in five let bestCount = 0; let counted = 0; for (let i = 0; i < REGIMES; i += 1) { counted += counts[i]; if (counts[i] > bestCount) { bestCount = counts[i]; best = i; } } if (filled < keyWindow || counted == 0 || f64(bestCount) * 5.0 < f64(counted) * 2.0) best = -1; // no leader worth the name yet if (best == candidate) candidateRun += 1; else { candidate = best; candidateRun = 1; } if (candidate >= 0 && candidateRun == HOLD_BARS && candidate != leader) { // the lead changed hands and held: mark it once if (leader >= 0) shiftDot = candidate == 0 || candidate == 2 ? low - rangeAvg * 0.6 : high + rangeAvg * 0.6; // buying pressure below the bar, selling pressure above leader = candidate; } out_new_longs(regime == 0 ? oiChange : NaN); // one bar per regime output; the others read NaN and draw nothing out_new_shorts(regime == 1 ? oiChange : NaN); out_short_covering(regime == 2 ? oiChange : NaN); out_long_closing(regime == 3 ? oiChange : NaN); out_regime(regime < 0 ? NaN : f64(regime)); const newLongs = share(0); const newShorts = share(1); const shortCovering = share(2); const longClosing = share(3); out_share_new_longs(newLongs); out_share_new_shorts(newShorts); out_share_short_covering(shortCovering); out_share_long_closing(longClosing); out_leader(leader < 0 ? NaN : f64(leader)); out_buyers_lead(leader == 0 || leader == 2 ? shiftDot : NaN); out_sellers_lead(leader == 1 || leader == 3 ? shiftDot : NaN); if (bar.isLast()) { if (oiSeen && !isNaN(newLongs)) { // the card appears once every cell has a value; history bars never write them sendShare(newLongs, str_new_longs_sb); sendShare(newShorts, str_new_shorts_sb); sendShare(shortCovering, str_short_covering_sb); sendShare(longClosing, str_long_closing_sb); sb_clear(); sb_int(keyWindow); sb_text(" bars"); str_window_sb(); } else if (!oiSeen) { // the market served no open interest at all: say so once, top right sb_clear(); sb_text("No open interest on this market"); notice.set(16, 14).text(str_text_sb); } } } ``` ## How it works **Who moved is two signs.** On a perpetual, open interest arrives in dollars, so each reading is divided by the bar's close first: a dollar figure rises with price even when no position opened, which would paint short covering as new longs. Only a perpetual serves funding, so a finite funding reading is what marks one. A prediction market's open interest is its collateral, one dollar per YES and NO pair, already a count of contracts, so it is read as served. The bar's price change and its open-interest change in contracts, each averaged over `smoothing` bars (1 is raw), sort the bar into one of four regimes; `regime` carries the index (0 new longs, 1 new shorts, 2 short covering, 3 long closing) as a data-only output. The bar's open-interest change in contracts, as a percent, is written to the one histogram output that owns the regime (`new_longs`, `new_shorts`, `short_covering`, `long_closing`) and NaN to the other three, so each colour is its own series and the legend names it. **The key card counts the window.** A ring of the last `key_window` (24, 4 to 200) regimes gives each regime's share, written to the card's text slots on the live bar (`share_*` carry the same fractions as data-only outputs). The card sits at the top right, offset inward past the price-axis tags. **A leader flip is confirmed.** `leader` is the regime with the most bars in the window. The new leader must hold at least two bars in five of the window and stay in the lead for three bars in a row; on that bar `buyers_lead` (new longs or short covering took over) carries the bar's low and `sellers_lead` the bar's high, the two dots on price. ## Where it runs Perps (Binance Futures, Hyperliquid), whose open interest is counted in coins, and prediction markets, whose open interest is the collateral as served (Polymarket serves it in sparse readings: most bars read NaN and the bars with a reading carry the whole change). The card appears once the window has readings. ## When data is missing Spot, stocks and FX have no open interest: the pane stays empty, the card stays away, and one slate line of words sits at the top right of the chart: "No open interest on this market". ## Customize it - **Smoother regimes.** Raise `smoothing` to average the two changes over more bars before sorting the bar. - **A longer key.** `key_window` up to 200 bars; the ring is sized for the largest window. - **Two colours instead of four.** Merge the two buying regimes and the two selling ones into two histogram outputs and a two-row card. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Positioning Regimes** under **Order flow**. 2. Press **Run** on a perp, such as BTCUSDT on Binance Futures: the pane fills with the four colours and the key card appears once the window has readings. 3. At the editor's Console prompt, type `last 24 regime` to read the regime index of the last 24 bars. ## Concepts used - [Data sources](../core-concepts/data-sources.md) for the `oi` source and `missing: "nan"` - [Plotting](../presentation/plotting.md) for one histogram output per colour - [Cards, frames and panels](../presentation/cards-frames-panels.md) for `draw.card` and its text slots # Absorption ![Absorption delta pane with boxed flagged candles and dots on BTC](/wrun/images/absorption.png) Bars where heavy one-sided volume failed to move price. The bar's delta (aggressive buy volume minus aggressive sell volume) sits in its own pane in muted amber and orange. A bar whose absolute delta spikes past the rolling norm while its range stays inside the ATR is lit bright in the pane, boxed on the chart with a thin border, and dotted: an orange dot above the candle when buying was absorbed (buyers hit the tape and price did not rise), an amber dot below it when selling was absorbed. Flagged bars are rare by design, a few per day on a 15m chart. The parts are side-split `trades.volume` inputs ([Data sources](../core-concepts/data-sources.md)), two histogram outputs sharing one pane (the muted delta on every bar, the bright one on flagged bars only) with a sign palette ([Styling](../presentation/styling.md)), the `Atr` and `Sma` helpers ([TA library](../functions/ta-library.md)), a box handle around each flagged candle and two `shape` outputs for the dots ([Drawing objects](../presentation/drawing-objects.md)). This is also the `absorption` template: the **Absorption** card under **Order flow** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Absorption: bars where heavy one-sided volume failed to move price. The bar's delta (buy volume minus sell volume) // sits in its own pane, muted amber and orange; a bar whose delta spikes past the rolling norm while its range stays // inside the ATR is lit bright in the pane, boxed on the chart, and dotted: above the candle when buying was absorbed // (orange), below it when selling was absorbed (amber). section("Delta"); // the dialog's first section: what makes a bar heavy param.number("delta_spike", 2.0, { min: 1.0, max: 6.0, step: 0.1, label: "Delta spike multiple", description: "A bar is heavy when its absolute delta is this many times the rolling mean of absolute delta" }); param.int("delta_window", 48, { min: 10, max: 400, label: "Delta window in bars", description: "Bars the mean of absolute delta is taken over" }); section("Range"); // the second section: what counts as going nowhere param.number("max_range", 0.8, { min: 0.2, max: 2.0, step: 0.1, label: "Max range in ATRs", description: "A bar failed to move when its range is under this many ATRs" }); param.int("atr_length", 14, { min: 2, max: 100, label: "ATR length in bars", description: "The ATR's Wilder length in bars" }); input("close", ohlcv.close); // the primary input: the chart's own candles define the grid every other input lines up on input("open", ohlcv.open); // the candle, for the box around a flagged bar input("buy", trades.volume, { side: "BUY", missing: "zero" }); // aggressive buy volume; a bar with no prints reads 0 input("sell", trades.volume, { side: "SELL", missing: "zero" }); // aggressive sell volume, the other half of the tape output("delta", histogram, lower, { colors: ["#f8c00073", "#f8680073"] }); // buy minus sell volume, muted: amber above zero, orange below output("absorbed", histogram, lower, { colors: ["#f8c000", "#f86800"] }); // the same bar, bright, on the bars the test flags; NaN elsewhere output("absorbed_buying", shape, overlay, { color: "#f86800" }); // a dot above a candle where heavy buying went nowhere output("absorbed_selling", shape, overlay, { color: "#f8c000" }); // a dot below a candle where heavy selling went nowhere string("text", { max_bytes: 40 }); // the one line of words when the market has no sided trades handles.box({ opacity: 0.08, borderWidth: 1 }); // a thin box around each flagged candle (chart time and price) handles.label({ anchor: "top_right", align: "right", color: "#94a3b8", size: 11 }); // the notice, in pane pixels from the top-right corner const MAX_BOXES = 60; // boxes ride a ring: the oldest is recycled const boxes: BoxHandle[] = []; // handle objects allocate once for (let i = 0; i < MAX_BOXES; i += 1) boxes.push(draw.box(i)); const notice = draw.label(100); // ids are one space across kinds const ORANGE = rgba(248, 104, 0, 255); const AMBER = rgba(248, 192, 0, 255); let deltaSpike = 2.0; // settings, read in onStart() let maxRange = 0.8; let meanAbsDelta = new Sma(48); // rebuilt in onStart() let atr = new Atr(14); let sidedVolume = 0.0; // every sided print seen: 0 means the market has no sided trades let prevTime = NaN; // the previous bar's open time, for the bar's width in seconds let barSeconds = NaN; let flagged = 0; // how many bars have been boxed: the ring position function onStart(): void { deltaSpike = p_delta_spike(); maxRange = p_max_range(); meanAbsDelta = new Sma(i32(p_delta_window())); atr = new Atr(i32(p_atr_length())); } // onBar() runs once per bar: the delta, the two norms, and the test: heavy delta inside a small range; then the outputs on // every bar, a box around a flagged candle, and the notice on the live bar only. function onBar(): void { const close = bar.close(); const high = bar.high(); const low = bar.low(); const barTime = bar.time(); const buy = in_buy(); const sell = in_sell(); const delta = buy - sell; sidedVolume += buy + sell; const norm = meanAbsDelta.update(Math.abs(delta)); // NaN until the window is full const range = atr.update(high, low, close); // NaN until seeded if (!isNaN(prevTime)) { const step = barTime - prevTime; if (step > 0.0 && (isNaN(barSeconds) || step < barSeconds)) barSeconds = step; // the smallest positive step is one bar } prevTime = barTime; let tone = delta >= 0.0 ? 0 : 1; // 0 buying, 1 selling, 2 absorbed buying, 3 absorbed selling let dotAbove = NaN; let dotBelow = NaN; let boxLeft = NaN; // the box around a flagged candle; NaN on every other bar let boxRight = NaN; let boxTop = NaN; let boxBottom = NaN; const heavy = !isNaN(norm) && norm > 0.0 && Math.abs(delta) >= deltaSpike * norm; const stuck = !isNaN(range) && range > 0.0 && high - low <= maxRange * range; if (heavy && stuck && !isNaN(barSeconds)) { tone = delta >= 0.0 ? 2 : 3; const pad = range * 0.12; // a little air between the box and the wicks if (delta >= 0.0) dotAbove = high + range * 0.4; // buyers hit the tape and price did not rise: buying absorbed else dotBelow = low - range * 0.4; // sellers hit the tape and price did not fall: selling absorbed boxLeft = barTime - barSeconds * 0.45; boxRight = barTime + barSeconds * 0.45; boxTop = high + pad; boxBottom = low - pad; } out_delta(delta); out_absorbed(tone >= 2 ? delta : NaN); out_absorbed_buying(dotAbove); out_absorbed_selling(dotBelow); if (!isNaN(boxLeft)) { const k = flagged % MAX_BOXES; // the ring position: the oldest box is reused flagged += 1; const ink = tone == 2 ? ORANGE : AMBER; boxes[k].set(boxLeft, boxTop, boxRight, boxBottom).color(ink).fill(ink); } if (bar.isLast() && sidedVolume <= 0.0) { // the market served no sided prints at all: say so once, top right sb_clear(); sb_text("No sided trades on this market"); notice.set(16, 14).text(str_text_sb); } } ``` ## How it works **Heavy is a ratio.** `delta` is buy volume minus sell volume; its absolute value against the rolling mean of absolute delta over `delta_window` (48) bars is the test, and a bar is heavy when the ratio passes `delta_spike` (2.0). Volume is in the market's own units, so the thresholds are ratios and the pane's scale follows the market. **Failed to move is a range test.** The bar's high-to-low range against the ATR over `atr_length` (14) bars must stay under `max_range` (0.8) ATRs. Heavy and narrow together flag the bar: `absorbed` carries the same delta, bright, on flagged bars and NaN elsewhere, so the two histogram outputs share the pane and the muted one never hides a flagged bar. Both use `colors: [amber, orange]`, a sign palette: entry 0 above zero, entry 1 below. **The side names the dot.** Heavy buying that went nowhere puts `absorbed_buying` at the bar's high (an orange dot above the candle); heavy selling that went nowhere puts `absorbed_selling` at the bar's low (an amber dot below it). A thin box handle frames the flagged candle from its open to its close in price and from the bar's open time to the next bar. ## Where it runs Markets with sided trades: crypto perps and spot, prediction markets. ## When data is missing On a market with no sided trades (FX, stocks) the delta stays at zero and one slate line of words sits at the top right of the chart: "No sided trades on this market". ## Customize it - **More flags.** Lower `delta_spike` or raise `max_range`; both are settings. - **A different norm.** Raise `delta_window` to measure the spike against a longer stretch of bars. - **No boxes.** Delete the `handles.box` line and the box calls in `onBar()` to keep the pane and the dots alone. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Absorption** under **Order flow**. 2. Press **Run** on a crypto perp or spot market, a 15m chart for example: the delta pane fills, and the flagged bars light up with their boxes and dots. 3. At the editor's Console prompt, type `last 60 absorbed` to read the flagged bars' delta over the last 60 bars (NaN where a bar is not flagged). ## Concepts used - [Data sources](../core-concepts/data-sources.md) for the `trades` source, its sides and the `missing` policy - [Styling](../presentation/styling.md) for sign palettes on histogram outputs - [Drawing objects](../presentation/drawing-objects.md) for box handles in chart time and price # Liquidation bursts ![Mirrored liquidation histogram with burst size tags on BTC](/wrun/images/liquidation-bursts.png) Long and short liquidations in USD in their own pane under the chart, one column per bar: long liquidations in orange at its foot and short liquidations in amber stacked on them, so the column is the bar's total and the legend reads each side in dollars ("Long liquidations $14.1M"). A bar whose liquidations on one side jump past the rolling norm (a z-score over the window) is tagged on price with the size in money, "$12.4M" or "$850K", in rose: a long flush is tagged under the bar's low, a short squeeze over its high, the text running right of the bar. One tag per bar, the bigger side. The parts are two `liquidations` inputs split by side ([Data sources](../core-concepts/data-sources.md)), two histogram outputs stacked into one column, the `Sma` and `Stdev` helpers behind the z-score ([TA library](../functions/ta-library.md)), and label handles in chart time and price for the tags ([Drawing objects](../presentation/drawing-objects.md)). This is also the `liquidation-bursts` template: the **Liquidation Bursts** card under **Order flow** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Liquidation Bursts: long and short liquidations in USD in their own pane, one column per bar, longs in orange at // its foot and shorts in amber stacked on them. A bar whose liquidations jump past the rolling norm (a z-score over // the window) is tagged on price with the size in money: a long flush under the bar's low, a short squeeze over its high. param.number("burst_z", 3.0, { min: 1.0, max: 8.0, step: 0.1, label: "Burst threshold in deviations", description: "A bar is a burst when its liquidations sit this many deviations above the window's mean" }); param.int("window", 96, { min: 10, max: 500, label: "Window in bars", description: "Bars the mean and deviation are taken over" }); param.int("tags_kept", 40, { min: 1, max: 60, label: "Tags kept", description: "Tags kept on the chart; the oldest is recycled" }); legend({ title: "({{window}})" }); // the words after the name in the legend: the window, read by name input("close", ohlcv.close); // the primary input: the chart's own candles define the grid every other input lines up on input("long_liq", liquidations.liquidations, { side: "SELL", missing: "zero" }); // longs are liquidated by forced sells; a bar with none reads 0 input("short_liq", liquidations.liquidations, { side: "BUY", missing: "zero" }); // shorts are liquidated by forced buys output("long_liqs", histogram, lower, { color: "#f86800", stack: "liqs", label: "Long liquidations", format: "usd" }); // at the foot of the column, in dollars output("short_liqs", histogram, lower, { color: "#f8c000", stack: "liqs", label: "Short liquidations", format: "usd" }); // stacked on the longs: the column is the bar's total output("burst", none); // data-only: -1 a long flush, +1 a short squeeze, 0 none; read it in the Console or fire an alert(...) on it string("text", { max_bytes: 40 }); // one bounded slot every label is written through handles.label({ size: 11, color: "#fb7185" }); // the tags (chart time and price) and the one line of words when the market has no liquidations const MAX_TAGS = 60; // tags ride a ring: the oldest is recycled const tags: LabelHandle[] = []; // handle objects allocate once for (let i = 0; i < MAX_TAGS; i += 1) tags.push(draw.label(i)); const notice = draw.label(100); // ids are one space across kinds const SLATE = rgba(148, 163, 184, 255); let burstZ = 3.0; // settings, read in onStart() let tagsKept = 40; let longMean = new Sma(96); // rebuilt in onStart() let longDev = new Stdev(96); let shortMean = new Sma(96); let shortDev = new Stdev(96); let liquidationsSeen = 0.0; // every liquidation seen: 0 means the market has none let rangeAvg = NaN; // a running mean of high - low: the tag's distance from the bar let bursts = 0; // how many have been tagged: the ring position function isBurst(value: f64, mean: f64, dev: f64): bool { // past the norm, with a norm to speak of return value > 0.0 && !isNaN(mean) && !isNaN(dev) && dev > 0.0 && value >= mean + burstZ * dev; } function sbMoney(usd: f64): void { // "$12.4M", "$850K", "$920" sb_text("$"); if (usd >= 1.0e9) { sb_f64(usd / 1.0e9, 2); sb_text("B"); } else if (usd >= 1.0e6) { sb_f64(usd / 1.0e6, 1); sb_text("M"); } else if (usd >= 1.0e3) { sb_f64(usd / 1.0e3, 0); sb_text("K"); } else { sb_f64(usd, 0); } } function onStart(): void { burstZ = p_burst_z(); tagsKept = i32(p_tags_kept()); const window = i32(p_window()); longMean = new Sma(window); longDev = new Stdev(window); shortMean = new Sma(window); shortDev = new Stdev(window); } // onBar() runs once per bar: both sides into their norms, the burst test per side, the tag's place; then the two stacked // histograms on every bar, a tag on a burst bar, and the notice on the live bar only. function onBar(): void { const high = bar.high(); const low = bar.low(); const barTime = bar.time(); const longLiq = in_long_liq(); const shortLiq = in_short_liq(); liquidationsSeen += longLiq + shortLiq; const range = high - low; rangeAvg = isNaN(rangeAvg) ? range : rangeAvg + (range - rangeAvg) * 0.1; const longBurst = isBurst(longLiq, longMean.update(longLiq), longDev.update(longLiq)); const shortBurst = isBurst(shortLiq, shortMean.update(shortLiq), shortDev.update(shortLiq)); let burst = 0; let tagX = NaN; let tagY = NaN; let tagUsd = NaN; if (longBurst && (!shortBurst || longLiq >= shortLiq)) { // one tag per bar: the bigger side burst = -1; tagX = barTime; tagY = low - rangeAvg * 0.6; tagUsd = longLiq; } else if (shortBurst) { burst = 1; tagX = barTime; tagY = high + rangeAvg * 0.6; tagUsd = shortLiq; } out_long_liqs(longLiq); out_short_liqs(shortLiq); out_burst(f64(burst)); if (!isNaN(tagX)) { const k = bursts % tagsKept; // the ring position: the oldest tag is reused bursts += 1; sb_clear(); sbMoney(tagUsd); tags[k].set(tagX, tagY).text(str_text_sb); tags[k].align(ALIGN_LEFT).valign(burst > 0 ? VALIGN_BOTTOM : VALIGN_TOP); // the text runs right of the bar and clear of it, away from the chart's own High and Low tags, which run left } if (bar.isLast() && liquidationsSeen <= 0.0) { // the market served no liquidations at all: say so once, top right sb_clear(); sb_text("No liquidations on this market"); notice.set(16, 14).text(str_text_sb); notice.anchor(ANCHOR_TOP_RIGHT).align(ALIGN_RIGHT).color(SLATE); } } ``` ## How it works **A liquidation's side is the forced order's side.** `input("long_liq", liquidations.liquidations, { side: "SELL", missing: "zero" })` reads longs liquidated by forced sells and its BUY twin reads shorts liquidated by forced buys, the same reading the native Liquidations indicator uses for its Longs and Shorts. A bar with none reads 0. `long_liqs` and `short_liqs` share `stack: "liqs"`: the longs fill the foot of each column in orange and the shorts stack on them in amber, both written positive and printed in dollars (`format: "usd"`), so the column's height is the bar's total and neither side reads negative. **A burst is a z-score.** Each side's liquidations against their mean and standard deviation over `window` (96) bars give a z-score; a bar is a burst when the bigger side sits `burst_z` (3.0) deviations above the mean. `burst` carries -1 for a long flush, +1 for a short squeeze, 0 otherwise: a data-only signal a declared alert can fire on. **The tag is money.** On a burst bar the size is written in money through the one bounded slot ("$12.4M", "$850K", "$920") to a rose label handle placed under the bar's low for a flush and over its high for a squeeze, x from `bar.time()`. `valign(VALIGN_TOP)` hangs a flush tag below its point and `valign(VALIGN_BOTTOM)` stands a squeeze tag above it, and `align(ALIGN_LEFT)` runs the text right of the bar, so a tag never sits on the chart's own High and Low tags, which run left from their candle. One tag per bar, the bigger side; `tags_kept` (40, 1 to 60) tags stay on the chart and the oldest is recycled. ## Where it runs Perps (Binance Futures, Hyperliquid). The pane's axis is in USD. The threshold is a ratio to the market's own norm, so a thin market whose bars mostly carry no liquidations tags small sizes ("$413" on a quiet alt) while BTC tags millions; raise `burst_z` to keep only the biggest. ## When data is missing Spot, prediction markets, stocks and FX have no liquidations: both histograms stay at zero and one slate line of words sits at the top right of the chart, "No liquidations on this market". ## Customize it - **Fewer tags.** Raise `burst_z`; the histograms keep every bar. - **A longer norm.** `window` up to 500 bars smooths the mean and the deviation. - **Both sides tagged.** Tag each side that bursts instead of the bigger one; the ring then spends two ids on such a bar. - **Alert on a burst.** Bind `burst` to a `const` and declare `alert("liquidation_burst", { when: ... })`: it fires on the bar a burst starts. Once the indicator is published and on a chart, the signal is in the chart's alert dialog ([Alerts](../functions/alerts.md)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Liquidation Bursts** under **Order flow**. 2. Press **Run** on a perp, such as BTCUSDT on Binance Futures: the stacked columns fill and the bursts are tagged on price. 3. At the editor's Console prompt, type `last 60 burst` to read the burst sign of the last 60 bars. ## Concepts used - [Data sources](../core-concepts/data-sources.md) for the `liquidations` source and its sides - [TA library](../functions/ta-library.md) for `Sma` and `Stdev` - [Drawing objects](../presentation/drawing-objects.md) for label handles in chart time and price and the text slot # Book Heat ![Resting order book painted behind the candles as a heatmap, with a bid-ask imbalance pane](/wrun/images/book-heat.png) The chart market's resting order book painted behind the candles, bar by bar: every price level the book held when the bar closed is a cell, and the deeper its shade the more size rested there, bids below the price and asks above it on one square-root scale, so a wall reads as a bright band the price has to trade through and a thin book as a faint wash. The heatmap sits under the candles, so the bars stay readable on top of it. Below the chart, the bid-ask imbalance inside a depth window around the mid price, sky while the bids outweigh the asks and orange while they do not. The parts are a celled `book.cells` input ([Order book functions](../functions/order-flow-kit.md)), a `plot.heatmap` declaration that paints that input's block without any output carrying it ([Price canvases](../presentation/price-canvases.md)), one histogram output read off the same block ([Plotting](../presentation/plotting.md)), and a label handle for the one sentence shown on a market without a book ([Drawing objects](../presentation/drawing-objects.md)). This is also the `book-heat` template: the **Book Heat** card under **Order flow** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Book Heat: the chart market's resting order book painted behind the candles as a heatmap of resting size by price, // bar by bar: the deeper the shade, the more size rested at that price when the bar closed, bids below the price and // asks above it on one scale, so a wall reads as a bright band the price has to trade through. Below the chart, the // bid-ask imbalance inside a depth window around the mid: sky while the bids outweigh the asks, orange while they do not. param.number("depth_pct", 1.0, { min: 0.1, max: 10, step: 0.1, label: "Depth window in percent", description: "Depth window for the imbalance: percent around the mid price, each side" }); input("close", ohlcv.close); // the primary input: the chart's own candles define the grid input("book", book.cells, { max_cells: 1000, block_size: 10, description: "The resting book at the bar's close, one [price, size, side] level per tuple" }); // side +1 bid, -1 ask; the chart serves its own market's book, up to 500 levels a side output("imbalance", histogram, lower, { colors: ["#38bdf8", "#f86800"], format: "%", label: "Book imbalance", description: "Bid size minus ask size inside the depth window, as a percent of both" }); // sky above zero, orange below plot.heatmap({ name: "book_heat", cells: "book", value: "size", behind_candles: true, palette: ["theme.bg", "#2563eb", "#22d3ee", "#facc15"], scale: "sqrt", auto_quantile: 0.98, opacity: 0.85, label: "Resting size", tooltip: "{{value:si}} resting at {{price}}" }); // the host paints the block the input already delivers; no output carries it string("note", { max_bytes: 40 }); // the one sentence shown when the market serves no order book handles.label({ anchor: "top_right", align: "right", color: "#94a3b8", size: 12, style: "plain", safe_area: true }); // that sentence: plain slate words at the pane's top right, clear of the chart's buttons const TUPLE = 3; // f64s per level: price, size, side const note = draw.label(0); // the one slate sentence; a handle allocates once at module load let depth = 0.01; // the setting, read in onStart() let bookSeen = false; // the market served a book on some bar function onStart(): void { depth = p_depth_pct() / 100.0; } // onBar() runs once per bar: the host paints the block itself; the module reads the same block for the imbalance. function onBar(): void { const n = in_book_cells(); if (n > 0) bookSeen = true; if (bar.isLast() && !bookSeen) { // no book on any bar (FX, stocks): one sentence in place of an empty map and pane sb_clear(); sb_text("No order book on this market"); note.set(16.0, 12.0).text(str_note_sb); } if (n < TUPLE * 2) { // an empty block (no book served for this bar) or one level alone: no reading out_imbalance(NaN); return; } const cells = in_book_view(); // this bar's levels in place: the first n values of the build's own buffer let bestBid = -Infinity; let bestAsk = Infinity; for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { if (cells[i + 2] > 0.0) { if (cells[i] > bestBid) bestBid = cells[i]; } else if (cells[i] < bestAsk) bestAsk = cells[i]; } if (!isFinite(bestBid) || !isFinite(bestAsk)) { // one side only: no mid to measure around out_imbalance(NaN); return; } const mid = (bestBid + bestAsk) * 0.5; let bids = 0.0; let asks = 0.0; for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { if (Math.abs(cells[i] - mid) > mid * depth) continue; // outside the window if (cells[i + 2] > 0.0) bids += cells[i + 1]; else asks += cells[i + 1]; } const total = bids + asks; out_imbalance(total > 0.0 ? (100.0 * (bids - asks)) / total : NaN); } ``` ## How it works **The block is the canvas.** `input("book", book.cells, { max_cells: 1000, block_size: 10 })` delivers the resting book at each bar's close as `[price, size, side]` tuples, side `+1` for a bid level and `-1` for an ask level, up to 500 levels a side. `plot.heatmap({ name: "book_heat", cells: "book", value: "size" })` tells the host to paint that block: one cell per level, one column per bar, the cell's height the book's own price grouping (`row_height` overrides it), its colour the level's size on the palette. The module never copies or forwards the block; the host reads the same rows the input already delivered. **The scale.** `palette` runs from the chart's background (`theme.bg`) through blue and cyan to yellow, low to high; `scale: "sqrt"` keeps the many small levels visible beside the walls; `auto_quantile: 0.98` sets the top of the scale at the 98th percentile of every cell in the run, so a single outsized order does not flatten the rest. `value` picks what a cell reads: `"size"` (both sides), `"bid"`, `"ask"`, or `"signed"` (bids positive, asks negative, which centres the palette on zero). `behind_candles: true` is the default; `false` paints over the candles instead. `opacity: 0.85` leaves the grid visible through the cells. **The reading beside it.** The histogram sums bid and ask size inside `depth_pct` (1%) of the mid, the mid being the best bid and the best ask averaged, and prints their difference as a percent of both: sky above zero, orange below (the two `colors` of a histogram are its sign colours). A bar whose block is empty reads NaN and draws nothing, in the pane and on the canvas alike. **The hover.** `tooltip: "{{value:si}} resting at {{price}}"` puts a readout under the cursor over any cell, `label` its title; drop `tooltip` for a heatmap with no hover. ## Where it runs Any market the chart serves an order book for: crypto spot and perpetual venues, Polymarket markets. The chart serves the book snapshots its own order book lane fetches for the chart's market; `block_size` is required by the declaration but the chart reads neither it nor `max_depth`. The heatmap paints as far back as the book history the chart holds; older bars carry an empty block. ## When data is missing A market with no book lane is refused by name before any fetch, naming the input and the way out. A market whose bars all arrive without a book (FX and stocks, where the lane answers empty) runs: the map stays empty, the histogram reads NaN, and one line of words shows at the top right of the chart, `No order book on this market`, in plain slate: the `note` label handle, written on the live bar while no bar has brought a book, pinned to the pane's top right corner, right-aligned and `safe_area: true`, so it clears the chart's own buttons. A bar with no snapshot carries an empty block: the column is blank and the histogram reads NaN. A book that lists one side only (a halted market, a one-sided prediction market) has no mid, so the histogram reads NaN while the heatmap still paints the side it has. ## Customize it - **Read one side.** Change `value: "size"` to `"bid"` or `"ask"` to paint one side of the book, or to `"signed"` for a diverging map (bids warm, asks cold) with the palette `["theme.down", "theme.bg", "theme.up"]`. - **Fix the scale.** Set `min` and `max` in size units to compare days on one scale; `auto_quantile` then goes away. - **Coarser rows.** `row_height: 50` groups the book into 50-unit price rows on a market whose levels are finer than the chart can show. - **Over the candles.** `behind_candles: false` paints the cells over the bars, with `opacity: 0.5` to keep them visible. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Book Heat** under **Order flow**. 2. Press **Run** on a liquid crypto chart (Binance Futures BTCUSDT, for example): the book paints behind the candles as the snapshots arrive and the imbalance pane fills below. 3. Hover a cell for its resting size; at the editor's Console prompt, type `imbalance` to read the live bar's reading. 4. Open the indicator's settings: on the **Style** page, the heatmap's **Sensitivity** slider recolours it live, toward Vivid to light up thinner levels, toward Subtle to keep the brightest colours for the walls ([The Style page](../settings/style-page.md#the-sensitivity-row)). ## Concepts used - [Order book functions](../functions/order-flow-kit.md) for the `book` celled class, its tuple and the depth-window scans - [Price canvases](../presentation/price-canvases.md) for `plot.heatmap`, the `value` words, the colour scale and the hover readout - [Plotting](../presentation/plotting.md) for histogram outputs and their sign colours # Liquidation Heat ![Liquidation levels painted behind the candles as a heatmap that follows price](/wrun/images/liquidation-heat.png) Where the leverage opened on recent bars would be liquidated, painted behind the candles as a heatmap. Every bar seeds liquidation prices below its close for longs and above it for shorts at four leverage tiers (10x, 25x, 50x and 100x), each weighted by a share of the bar's volume; a level the price trades through is consumed and leaves the map, the rest fade with age until `lookback` bars have passed. The map is 64 rows centred on the close, so it follows price up and down: the brighter a row, the more leverage sits there waiting to be flushed, and a dense band just above or below the price is where a sweep would run. The parts are an `out.grid` of 64 rows written cell by cell from `onBar()`, two data-only outputs that place the grid on the price axis, and a `plot.heatmap` declaration over the grid ([Price canvases](../presentation/price-canvases.md)). Every number is computed in the indicator from the chart's own candles ([Data sources](../core-concepts/data-sources.md)). This is also the `liquidation-heat` template: the **Liquidation Heat** card under **Order flow** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Liquidation Heat: where the leverage opened on recent bars would be liquidated, painted behind the candles as a // heatmap. Every bar seeds liquidation prices below its close for longs and above it for shorts at four leverage // tiers (10x, 25x, 50x, 100x), weighted by the bar's volume; a level the price trades through is consumed, the rest // fade with age. The map is 64 rows centred on the close, so it follows price: the brighter a row, the more leverage // sits there waiting to be flushed. param.int("lookback", 240, { min: 20, max: 2000, label: "Lookback in bars", description: "Bars a seeded level stays on the map before it has faded out" }); param.number("row_pct", 0.1, { min: 0.01, max: 2, step: 0.01, label: "Row height in percent", description: "Row height as a percent of the close" }); input("close", ohlcv.close); // the primary input: the chart's own candles define the grid and seed the levels output("grid_low", none, overlay, { description: "The bottom edge of the map's lowest row on this bar" }); // data-only: the heatmap reads it output("row_step", none, overlay, { description: "The map's row height in price on this bar" }); out.grid("heat", { rows: 64 }); // 64 data-only outputs heat_0..heat_63, one per row of the map, written with out_heat(row, value) plot.heatmap({ name: "liq_heat", grid: "heat", price_low: "grid_low", price_step: "row_step", palette: ["theme.bg", "#8b5cf640", "#8b5cf6", "#c026d3"], scale: "linear", min: 0, auto_quantile: 0.98, opacity: 0.8, behind_candles: true, label: "Liquidation heat", tooltip: "{{value:si}} seeded at {{price}}" }); // violet to magenta, hues no candle theme uses, faint for most cells: the candles stay readable on top const ROWS = 64; // rows in the map, the grid's declared count const TIERS = 4; // leverage tiers seeded per bar const MAX_LEVELS = 4096; // seeded levels ride a ring: the oldest is recycled const leverage = new StaticArray(TIERS); const levelPrice = new StaticArray(MAX_LEVELS); // each seeded level's price, weight and the bar it was seeded on const levelSize = new StaticArray(MAX_LEVELS); const levelBar = new StaticArray(MAX_LEVELS); const rowHeat = new StaticArray(ROWS); // this bar's map, folded from the live levels let head = 0; // the ring position let used = 0; // levels in use let barIndex = 0; // bars seen let lookback = 240; // settings, read in onStart() let rowPct = 0.001; function onStart(): void { lookback = i32(p_lookback()); rowPct = p_row_pct() / 100.0; leverage[0] = 10.0; leverage[1] = 25.0; leverage[2] = 50.0; leverage[3] = 100.0; } function seed(price: f64, size: f64): void { // one liquidation level into the ring levelPrice[head] = price; levelSize[head] = size; levelBar[head] = barIndex; head = (head + 1) % MAX_LEVELS; if (used < MAX_LEVELS) used += 1; } function clearRows(): void { // no map on this bar: every cell empty for (let r = 0; r < ROWS; r += 1) out_heat(r, NaN); } // onBar() runs once per bar: consume the levels the bar traded through, seed this bar's, then fold the live levels // onto the 64-row grid around the close and write the grid, its bottom edge and its step. function onBar(): void { const close = bar.close(); const high = bar.high(); const low = bar.low(); barIndex += 1; if (isNaN(close) || close <= 0.0) { out_grid_low(NaN); out_row_step(NaN); clearRows(); return; } for (let k = 0; k < used; k += 1) { // a level inside the bar's range was flushed: it leaves the map if (levelSize[k] > 0.0 && levelPrice[k] >= low && levelPrice[k] <= high) levelSize[k] = 0.0; } const volume = bar.volume(); const weight = isNaN(volume) || volume <= 0.0 ? 1.0 : volume; // a market with no volume still seeds, one unit a bar const share = weight / f64(TIERS); // each tier takes an equal share of the bar's volume for (let i = 0; i < TIERS; i += 1) { // longs opened here are liquidated below the close, shorts above it seed(close * (1.0 - 1.0 / leverage[i]), share); seed(close * (1.0 + 1.0 / leverage[i]), share); } const step = close * rowPct; const gridLow = close - step * f64(ROWS / 2); // the middle row holds the close for (let r = 0; r < ROWS; r += 1) rowHeat[r] = 0.0; for (let k = 0; k < used; k += 1) { if (levelSize[k] <= 0.0) continue; const age = barIndex - levelBar[k]; if (age > lookback) { // faded out levelSize[k] = 0.0; continue; } const r = i32(Math.floor((levelPrice[k] - gridLow) / step)); if (r < 0 || r >= ROWS) continue; // outside the map this bar; it comes back when price moves toward it rowHeat[r] += levelSize[k] * (1.0 - f64(age) / f64(lookback)); } out_grid_low(gridLow); out_row_step(step); for (let r = 0; r < ROWS; r += 1) out_heat(r, rowHeat[r] > 0.0 ? rowHeat[r] : NaN); // NaN = an empty cell } ``` ## How it works **A grid the module writes.** `out.grid("heat", { rows: 64 })` declares 64 data-only outputs `heat_0` to `heat_63`, one per row of the map, and the generated writer `out_heat(row, value)` fills them in `onBar()`; a row outside 0 to 63 aborts the run by name, and NaN leaves a cell empty. The two outputs beside it place the grid: `grid_low` is the bottom edge of row 0 on each bar and `row_step` every row's height in price, so the map moves with the close and keeps its shape when the chart's scale changes. `plot.heatmap({ grid: "heat", price_low: "grid_low", price_step: "row_step" })` ties the three together; a heatmap takes `cells` or `grid`, never both. **The levels.** Each bar seeds eight levels into a ring of 4,096: for every tier, a long opened at the close is liquidated at `close x (1 - 1 / leverage)` and a short at `close x (1 + 1 / leverage)`, each level weighted by a quarter of the bar's volume (one unit on a market without volume). Before seeding, the bar's range is swept: a level between the low and the high was flushed and is zeroed. Then every live level is folded onto the 64 rows around the close with a linear fade, `1 - age / lookback`; a level older than `lookback` is dropped, and one outside the map this bar is kept, since it comes back when price moves toward it. **The scale.** The palette runs from the chart's background through a faint violet (`#8b5cf640`, violet at a quarter strength, so it reads on a light chart too) and full violet to magenta: hues no candle colour uses, so the amber and orange (or green and red) bars stay readable where they cross the map. `scale: "linear"` with `min: 0` keeps most cells faint and lets only the dense rows glow, and `auto_quantile: 0.98` sets the top of the scale at the 98th percentile of every cell in the run. `behind_candles: true` keeps the bars on top. `row_pct` (0.1% of the close) sets the row height: finer rows separate the tiers, coarser rows merge them into bands. ## Where it runs Any chart: the map is built from the chart's own candles, so it draws on every market and interval. It reads best on perpetual and futures charts, where the leverage it models is traded; on spot or a prediction market the rows still draw, but there is no leverage to be flushed at them. ## When data is missing A bar whose close is NaN writes an empty map and places nothing. A market without volume seeds one unit a bar, so the map still draws, with every bar weighing the same. The map holds only what the chart has seen: the first `lookback` bars of a run build it up from nothing. ## Customize it - **A longer memory.** Raise `lookback` to keep levels longer before they fade; lower it for a map of the last session only. - **Finer or coarser rows.** `row_pct` sets the row height as a percent of the close; `rows: 64` in `out.grid` sets how many rows the map spans (2 to 128). - **Other tiers.** Change the four leverages in `onStart()`; the ring holds eight levels per bar regardless. - **A fixed scale.** Set `min` and `max` on the heatmap to compare days on one scale, or `floor` to hide the faintest cells. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Liquidation Heat** under **Order flow**. 2. Press **Run** on a perpetual chart: the map paints behind the candles, bright where recent leverage would be liquidated, and clears where price has already swept. 3. Hover a cell for the weight seeded at that price; at the editor's Console prompt, type `row_step` to read the row height in price. ## Concepts used - [Price canvases](../presentation/price-canvases.md) for `out.grid`, `plot.heatmap` over a grid, `price_low` and `price_step` - [Data sources](../core-concepts/data-sources.md) for the chart's own candles as the primary input - [Plotting](../presentation/plotting.md) for data-only outputs # OI Liquidation Heat ![Estimated liquidations painted behind the candles as a heatmap built from open interest, amber and red bands that end where price ran into them](/wrun/images/liquidation-heat-oi.png) Where the positions opened over the lookback would be liquidated, estimated from the market's open interest and painted behind the candles as a heatmap. It is the model of the [Liquidation Map](liquidation-map.md) drawn through time: every bar keeps the same ledger of opened positions and folds it onto a map of 128 rows around the close, its rows on round prices so the bands run straight, and each column shows the estimate as it stood on that bar. The brighter a row, the more money a sweep there would force out. A level that price trades through is gone from that bar on, so the bands end where price ran into them, and a band that runs on to the right edge is money still waiting. Resting the pointer on a cell reads its dollars and its price. The parts are an `oi.close` input and side-split `trades.volume` inputs read as positions ([Data sources](../core-concepts/data-sources.md)), an `out.grid` of 128 rows written cell by cell from `onBar()`, two data-only outputs that place the grid on the price axis, a `plot.heatmap` declaration over the grid ([Price canvases](../presentation/price-canvases.md)), and a label handle for the one sentence shown without open interest ([Drawing objects](../presentation/drawing-objects.md)). This is also the `liquidation-heat-oi` template: the **OI Liquidation Heat** card under **Order flow** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // OI Liquidation Heat: where the positions opened over the lookback would be liquidated, ESTIMATED from the market's // open interest and sided volume, painted behind the candles as a heatmap. It is the Liquidation Map's model drawn // through time: every bar keeps the same ledger and folds it onto a 128-row map around the close, its rows on round // prices so the bands run straight, and each column shows the estimate as it stood on that bar. The brighter a row, // the more money a sweep there would force out; a level price trades through is gone from that bar on, so the bands // end where price ran into them. // // The model, per bar: a rise in open interest opens positions at the bar's typical price, split into longs and // shorts by the bar's buy share of sided volume, and across four leverage tiers (10x, 25x, 50x, 100x) with fixed // weights; each position's liquidation price follows from its leverage and the maintenance margin. A fall in open // interest scales every live level down in proportion. A long level is swept when a later bar's low reaches it, a // short level when a later bar's high does. Levels older than the lookback drop. Nothing here is a venue's own // liquidation feed: it is a model of where the open positions sit, built from the open-interest change bar by bar. section("Positions"); param.int("lookback", 672, { min: 96, max: 2000, label: "Lookback in bars", description: "Bars of opened positions kept in the map, in the chart's own bars; the map covers no more than the chart has loaded" }); param.number("maint_margin_pct", 0.5, { min: 0, max: 2, step: 0.1, label: "Maintenance margin in percent", description: "Maintenance margin as a percent of the position: the distance a liquidation sits short of the leverage's full move" }); section("Map"); param.number("range_pct", 5, { min: 2, max: 25, step: 0.5, label: "Map range in percent", description: "Map range: percent around the close, each side; the 128 rows split it on round prices, so the reach varies a little and a narrower range draws finer rows" }); legend({ title: "estimated, up to {{lookback}} bars" }); // the words after the name in the legend: up to, since a chart holding fewer bars builds from those input("close", ohlcv.close); // the chart's own candles: the map's centre, the entry price and the sweeps input("oi", oi.close, { missing: "nan", description: "Open interest in USD; NaN on a market without it (spot, FX, prediction markets), which blanks the map" }); input("buy", trades.volume, { side: "BUY", missing: "zero", description: "Buy volume of the bar: its share of sided volume is the share of new positions read as longs" }); input("sell", trades.volume, { side: "SELL", missing: "zero", description: "Sell volume of the bar" }); output("grid_low", none, overlay, { description: "The bottom edge of the map's lowest row on this bar" }); // data-only: the heatmap reads it output("row_step", none, overlay, { description: "The map's row height in price on this bar" }); output("longs_near", none, overlay, { format: "usd", description: "Estimated long liquidations within 5% below the close, USD, every bar" }); // data-only: the Console and alerts read them output("shorts_near", none, overlay, { format: "usd", description: "Estimated short liquidations within 5% above the close, USD, every bar" }); out.grid("heat", { rows: 128 }); // 128 data-only outputs heat_0..heat_127, one per row of the map in USD, written with out_heat(row, value) string("note", { max_bytes: 80 }); // the one sentence shown when the market has no open interest handles.label({ anchor: "top_right", align: "right", color: "#94a3b8", size: 12, style: "plain", safe_area: true }); plot.heatmap({ name: "liq_heat", grid: "heat", price_low: "grid_low", price_step: "row_step", palette: ["theme.bg", "#f8c00040", "#f8c000", "#f86800", "#ef4444"], scale: "linear", min: 0, auto_quantile: 0.98, opacity: 0.8, behind_candles: true, label: "Estimated liquidations", tooltip: "{{value:usd}} at {{price}}" }); // Leverage tiers the opened positions are spread over, and the share of new open interest each tier takes: the // Liquidation Map's guess at a perpetual's crowd (most size at low leverage, a thin tail at 100x), so the two // templates estimate the same levels. const TIERS = 4; const SLOTS = TIERS * 2; // per bar: four long levels, then four short levels function tierLeverage(tier: i32): f64 { return tier == 0 ? 10.0 : tier == 1 ? 25.0 : tier == 2 ? 50.0 : 100.0; } function tierWeight(tier: i32): f64 { return tier == 0 ? 0.4 : tier == 1 ? 0.3 : tier == 2 ? 0.2 : 0.1; } const ROWS = 128; // rows in the map, the grid's declared count const MAX_LOOKBACK = 2000; // the ring is sized for the largest lookback setting const levelPrice = new StaticArray(MAX_LOOKBACK * SLOTS); // the ledger: one price and one USD size per level, SLOTS per bar const levelUsd = new StaticArray(MAX_LOOKBACK * SLOTS); // 0 once swept or dropped const rowUsd = new StaticArray(ROWS); // this bar's map: USD per row const note = draw.label(0); // the one slate sentence; a handle allocates once at module load let lookback = 672; // settings, read in onStart() let mm = 0.005; let rangeFrac = 0.05; let head = -1; // the ring: head is the newest bar, count the bars in use let count = 0; let prevOi: f64 = NaN; // the last finite open interest reading let oiSeen = false; // the market served open interest at least once let noteShown = false; let gridLow: f64 = NaN; // this bar's map: the bottom edge of row 0 and the row height let step: f64 = NaN; let longsNear = 0.0; // live long levels within 5% below the close, USD let shortsNear = 0.0; // live short levels within 5% above the close, USD function onStart(): void { lookback = i32(p_lookback()); mm = p_maint_margin_pct() / 100.0; rangeFrac = p_range_pct() / 100.0; } function niceStep(raw: f64): f64 { // the nearest of 1, 2, 2.5 and 5 times a power of ten (in ratio terms): rows on round prices that line up bar to bar const mag = Math.pow(10.0, Math.floor(Math.log(raw) / Math.LN10)); const m = raw / mag; return (m < 1.414 ? 1.0 : m < 2.236 ? 2.0 : m < 3.536 ? 2.5 : m < 7.071 ? 5.0 : 10.0) * mag; // the cut points are the geometric midpoints } function addToMap(price: f64, usd: f64): void { // a live level adds its USD to the row it falls in; off the map it waits for price to come near const pos = (price - gridLow) / step; if (pos >= 0.0 && pos < f64(ROWS)) rowUsd[i32(pos)] += usd; } // One pass over the ledger: the levels this bar traded through are gone, the rest fold onto this bar's map and, // within 5% of the close, into the near sums. function sweepAndFold(close: f64, high: f64, low: f64): void { const nearLo = close * 0.95; const nearHi = close * 1.05; longsNear = 0.0; shortsNear = 0.0; for (let r = 0; r < ROWS; r += 1) rowUsd[r] = 0.0; const used = count * SLOTS; for (let i = 0; i < used; i += 1) { const usd = levelUsd[i]; if (usd <= 0.0) continue; const price = levelPrice[i]; if (i % SLOTS < TIERS) { // a long level: swept once a bar's low reaches it if (low <= price) { levelUsd[i] = 0.0; continue; } if (price >= nearLo) longsNear += usd; } else { // a short level: swept once a bar's high reaches it if (high >= price) { levelUsd[i] = 0.0; continue; } if (price <= nearHi) shortsNear += usd; } addToMap(price, usd); } } function openPositions(base: i32, close: f64, high: f64, low: f64, dOi: f64): void { // new open interest, placed at its liquidation prices const buy = in_buy(); const sell = in_sell(); const total = buy + sell; const longShare = total > 0.0 ? buy / total : 0.5; const entry = isNaN(high) || isNaN(low) ? close : (high + low + close) / 3.0; // the bar's typical price const nearLo = close * 0.95; const nearHi = close * 1.05; for (let tier = 0; tier < TIERS; tier += 1) { const move = 1.0 / tierLeverage(tier) - mm; // the adverse move that liquidates this tier if (move <= 0.0) continue; // a margin wider than the tier's whole move: nobody holds that position const longPrice = entry * (1.0 - move); const shortPrice = entry * (1.0 + move); const longUsd = dOi * longShare * tierWeight(tier); const shortUsd = dOi * (1.0 - longShare) * tierWeight(tier); levelPrice[base + tier] = longPrice; levelUsd[base + tier] = longUsd; levelPrice[base + TIERS + tier] = shortPrice; levelUsd[base + TIERS + tier] = shortUsd; if (longUsd > 0.0) { if (longPrice >= nearLo) longsNear += longUsd; addToMap(longPrice, longUsd); } if (shortUsd > 0.0) { if (shortPrice <= nearHi) shortsNear += shortUsd; addToMap(shortPrice, shortUsd); } } } function scaleAll(factor: f64): void { // open interest fell: every live level shrinks in proportion, and the map with it const used = count * SLOTS; for (let i = 0; i < used; i += 1) if (levelUsd[i] > 0.0) levelUsd[i] *= factor; for (let r = 0; r < ROWS; r += 1) rowUsd[r] *= factor; longsNear *= factor; shortsNear *= factor; } function writeEmpty(): void { // no map on this bar: every cell empty, the grid unplaced out_grid_low(NaN); out_row_step(NaN); out_longs_near(NaN); out_shorts_near(NaN); for (let r = 0; r < ROWS; r += 1) out_heat(r, NaN); } function writeNote(): void { // on the live bar: the one sentence while the market has served no open interest, gone once it does if (!bar.isLast()) return; if (!oiSeen) { sb_clear(); sb_text("No open interest on this market, so no liquidation estimate"); note.set(16.0, 12.0).text(str_note_sb); noteShown = true; } else if (noteShown) { note.delete(); noteShown = false; } } // onBar() runs once per bar: recycle the oldest bar's slots, sweep the ledger with the bar's range while folding the // survivors onto this bar's map, fold the bar's open-interest change in (new positions at their liquidation prices, // or every level scaled down), then write the map, its bottom edge, its row height and the near sums. function onBar(): void { const close = bar.close(); const high = bar.high(); const low = bar.low(); if (isNaN(close) || close <= 0.0) { // no price on this bar: no map, and the ledger waits for the next one writeEmpty(); return; } head = (head + 1) % lookback; // this bar's slots: the oldest bar's levels are recycled once the ring is full if (count < lookback) count += 1; const base = head * SLOTS; for (let s = 0; s < SLOTS; s += 1) { levelUsd[base + s] = 0.0; levelPrice[base + s] = NaN; } step = niceStep((close * rangeFrac * 2.0) / f64(ROWS)); gridLow = (Math.floor(close / step) - f64(ROWS / 2)) * step; // a fixed lattice of round prices, the close in the middle row sweepAndFold(close, high, low); const oiNow = in_oi(); if (!isNaN(oiNow)) { // a bar without a reading changes nothing: the last reading carries forward oiSeen = true; if (!isNaN(prevOi)) { const dOi = oiNow - prevOi; if (dOi > 0.0) openPositions(base, close, high, low, dOi); else if (dOi < 0.0 && prevOi > 0.0) scaleAll(oiNow / prevOi); } prevOi = oiNow; } writeNote(); if (!oiSeen) { writeEmpty(); return; } out_grid_low(gridLow); out_row_step(step); out_longs_near(longsNear); out_shorts_near(shortsNear); for (let r = 0; r < ROWS; r += 1) out_heat(r, rowUsd[r] > 0.0 ? rowUsd[r] : NaN); // NaN = an empty cell } ``` ## How it works **Open interest is read as positions.** On every bar `in_oi()` (the `oi.close` input, in USD) is compared with the last finite reading; a bar without a reading changes nothing. A rise is new open interest opened at the bar's typical price (high, low and close averaged), split into longs and shorts by the bar's buy share of `in_buy()` and `in_sell()`, and spread over four leverage tiers, 10x, 25x, 50x and 100x, weighted 40, 30, 20 and 10 percent in `tierLeverage` and `tierWeight`. Each tier is liquidated by an adverse move of one over its leverage less `maint_margin_pct`: a 10x long opened at 84,000 with the default 0.5% margin is liquidated about 9.5% lower, near 76,000, and a 10x short about 9.5% higher, near 92,000. A fall in open interest scales every live level down in proportion (`scaleAll`), the map and the near sums with it. **The ledger is a ring.** `levelPrice` and `levelUsd` are two fixed arrays sized for the largest lookback, eight slots per bar (four long tiers, then four short tiers); once `lookback` bars are held, the oldest bar's slots are recycled, so a level older than the lookback drops. `sweepAndFold` walks the ledger once per bar: a long level is gone once the bar's low reaches it, a short level once the bar's high does, and every level left adds its dollars to the row it falls in. A level outside the map on this bar stays in the ledger and comes back when price moves toward it. Nothing fades with age: a level keeps its full size until a sweep, a fall in open interest or the lookback takes it. **The map follows the close.** The 128 rows reach about `range_pct` each side of the close (5% by default). `niceStep()` snaps the row height to a round price (1, 2, 2.5 or 5 times a power of ten), so the rows sit on the same prices bar after bar and the bands run straight: 50 on BTC near 85,000, a reach of 3,200 each side, varying a little as the snap moves. `grid_low` is the bottom edge of row 0 on each bar, a whole number of rows below the close's row, and `row_step` every row's height in price; `plot.heatmap({ grid: "heat", price_low: "grid_low", price_step: "row_step" })` reads the two to place the grid, so the map moves with the close. `out_heat(row, value)` writes a row's dollars, and NaN leaves a cell empty. A narrower range draws finer rows and leaves the far tiers off the map until price comes near them. **The scale.** The palette runs from the chart's background through a faint amber (`#f8c00040`, amber at a quarter strength, so it reads on a light chart too) and full amber and orange to red. `scale: "linear"` with `min: 0` keeps most cells dark and lets only the stacked rows glow, and `auto_quantile: 0.98` sets the top of the scale at the 98th percentile of every cell in the run, so one huge row does not wash out the rest. `behind_candles: true` keeps the bars on top, and the tooltip `{{value:usd}} at {{price}}` reads a cell as its dollars and its price. **The near sums.** `longs_near` and `shorts_near` are data-only outputs written on every bar: the estimated long liquidations within 5% below the close and the short liquidations within 5% above it, in USD. They draw nothing; read them at the editor's Console prompt or set an alert on them. **An estimate, beside two siblings.** Nothing here is a venue's own liquidation feed: read a band as "about this much, about here". [Liquidation Heat](liquidation-heat.md) builds its map from the candles alone: every bar seeds levels at its close weighted by its volume, and the levels fade with age, so it draws on any chart but knows nothing of positions opened or closed. The [Liquidation Map](liquidation-map.md) is this model, with the same tiers, margin, sweeps and lookback, docked on the price axis as a two-sided profile measured on the live bar only. This recipe keeps that ledger and draws it on every bar, behind the candles. ## Where it runs Perpetual futures with open interest: Binance Futures and Hyperliquid perpetuals, at any interval. The lookback is counted in the chart's own bars, so 672 is a week at 15m and four weeks at 1h, and the map covers no more than the chart has loaded: a 1h chart holding 297 bars builds from those 297, which is why the legend reads "up to 672 bars". Every bar draws its own column, so the history shows how the estimate moved; the forming bar replays from the chart's snapshot, so the ring advances once per bar on live ticks too ([Repainting](../core-concepts/repainting.md)). No market refuses the run: the `oi` input declares `missing: "nan"`, so a market without open interest reads NaN and gets the sentence below instead of a toast. ## When data is missing On a market without open interest (spot pairs, FX, prediction markets) the map stays empty and one line of words shows at the top right, `No open interest on this market, so no liquidation estimate`, in plain slate: the `note` label handle pinned to the pane's top right corner and right-aligned, clear of the price axis. `longs_near` and `shorts_near` read NaN there. Once the market serves open interest, the sentence goes and the map builds up from the first rise. On a market with open interest but without sided trades, every new position is split half longs and half shorts. A bar whose close is NaN writes an empty column and leaves the ledger as it was. ## Customize it - **A longer or shorter memory.** `lookback` (672, 96 to 2000) is the bars of opened positions the map keeps, in the chart's own bars: 672 is a week at 15m and four weeks at 1h. `maint_margin_pct` (0.5, 0 to 2) is the maintenance margin as a percent of the position: a larger margin moves every liquidation price closer to its entry, and a margin as wide as a tier's whole move leaves that tier out. - **A finer or wider map.** `range_pct` (5, 2 to 25) is about how far around the close the map reaches each side; the 128 rows split it on round prices, so a narrower range draws finer rows and a wider one brings the 10x tier into view. `rows: 128` in `out.grid` is the most a grid holds; a smaller grid needs `ROWS` changed to match. - **Another crowd.** The tier mix, 10x, 25x, 50x and 100x at 40, 30, 20 and 10 percent, is a guess at a perpetual's crowd, named in `tierLeverage` and `tierWeight` at the top of the file. It is the Liquidation Map's mix, so the two estimate the same levels; change both to keep them agreeing. - **A fixed scale.** Set `min` and `max` on the heatmap to compare days on one scale, or `floor` to hide the faintest cells. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **OI Liquidation Heat** under **Order flow**. 2. Press **Run** on a perpetual with open interest, such as BTCUSDT on Binance Futures at 15m: the map paints behind the candles, bright where opened positions would be liquidated, and each band ends where price swept it. The legend reads "OI Liquidation Heat estimated, up to 672 bars". 3. Rest the pointer on a cell for the dollars at that price; at the editor's Console prompt, type `last 20 longs_near` to read the estimated long liquidations within 5% below the price on the last 20 bars. ## Concepts used - [Price canvases](../presentation/price-canvases.md) for `out.grid`, `plot.heatmap` over a grid, `price_low` and `price_step` - [Data sources](../core-concepts/data-sources.md) for the `oi` and `trades` sources, `side` and the `missing` policies - [Drawing objects](../presentation/drawing-objects.md) for `handles.label`, the `draw.label(id)` handle and pane anchors - [Plotting](../presentation/plotting.md) for data-only outputs # Volume Footprint ![Footprint cells on each candle with imbalances lit and a delta pane below](/wrun/images/volume-footprint.png) Each bar's traded volume by price, split into its buy and sell halves and printed as footprint cells on the candle: the sells on the left, the buys on the right, the point of control lit and the value area shaded. An imbalance, a cell where one side out-traded the diagonal other three to one, is lit in the chart's up or down colour, and three stacked imbalances are boxed, so the trader sees where the aggression was and where it was absorbed. Below the chart, the bar's delta as a histogram, amber when buying led and orange when selling did, and under it the day's cumulative delta as a line in a pane of its own, so a large running sum never flattens the bars. The parts are a celled `volume_profile.cells` input ([Volume profile](../functions/order-flow-kit.md)), a `plot.footprint` declaration that prints that input's block cell by cell without any output carrying it ([Price canvases](../presentation/price-canvases.md)), and two outputs read off the same block, each in a pane of its own below the chart ([Plotting](../presentation/plotting.md#panes)). This is also the `volume-footprint` template: the **Volume Footprint** card under **Order flow** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Volume Footprint: each bar's traded volume by price, split into its buy and sell halves and printed as footprint // cells on the candle, the point of control and the value area lit, the imbalances lit where one side out-traded the // diagonal other three to one and three stacked imbalances boxed. Below the chart, the bar's delta as a histogram, // amber when buying led and orange when selling did, and under it the day's cumulative delta as a line in a pane of its // own, so a large running sum never flattens the bars. param.bool("reset_daily", true, { label: "Reset each UTC day", description: "Start the cumulative delta again at each UTC day" }); input("close", ohlcv.close); // the primary input: the chart's own candles define the grid input("profile", volume_profile.cells, { max_cells: 8192, description: "This bar's volume by price level" }); // [low, high, buy, sell] per level; measured on Binance Futures BTCUSDT: about 280 levels per 15m bar, 860 per 1h bar output("delta", histogram, lower, { colors: ["#f8c000", "#f86800"], format: "si", label: "Delta", description: "Buy volume minus sell volume this bar" }); // amber above zero, orange below pane("cum", { height_frac: 0.15 }); // the running sum's own pane under the delta's, on a scale of its own output("cum_delta", line, lower, { pane: "cum", color: "#38bdf8", width: 1, format: "si", label: "Cumulative delta", description: "The delta summed since the day began, or since the first bar" }); plot.footprint({ name: "footprint", cells: "profile", mode: "split_bar", labels: true, imbalance: true, imbalance_ratio: 3, stacked_imbalances: 3, imbalance_buy_color: "theme.up", imbalance_sell_color: "theme.down", poc: true, value_area: 0.7, cell_metric: "delta" }); // the host prints the block the input already delivers const TUPLE = 4; // f64s per level: low, high, buy, sell let resetDaily = true; // the setting, read in onStart() let cumDelta = 0.0; // the running sum let day: f64 = NaN; // the UTC day the sum belongs to function onStart(): void { resetDaily = pb_reset_daily(); } // onBar() runs once per bar: sum the block's buy and sell halves; the delta on every bar, the running sum started // again at each UTC day when asked. function onBar(): void { const today = Math.floor(bar.time() / 86400.0); if (resetDaily && today != day) { cumDelta = 0.0; day = today; } const n = in_profile_cells(); if (n < TUPLE) { // an empty block: the bar's delta is unknown out_delta(NaN); out_cum_delta(cumDelta); return; } const cells = in_profile_view(); // this bar's levels in place: the first n values of the build's own buffer let buy = 0.0; let sell = 0.0; for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { buy += cells[i + 2]; sell += cells[i + 3]; } const delta = buy - sell; cumDelta += delta; out_delta(delta); out_cum_delta(cumDelta); } ``` ## How it works **The block is the footprint.** `input("profile", volume_profile.cells, { max_cells: 8192 })` delivers each bar's volume by price level as `[low, high, buy, sell]` tuples. `plot.footprint({ name: "footprint", cells: "profile" })` tells the host to print that block on the candle: one column per bar, one cell per level, its height the input's bucket width times the native footprint's row table for the chart interval (`row_height` overrides it in price units). The module never copies the block; the host reads the rows the input already delivered. **The look.** `mode: "split_bar"` prints the sells left and the buys right of the candle; `"cluster"`, `"stacked_bar"`, `"ladder"` and `"imbalance_only"` are the other modes. `labels: true` prints each cell's number (`hide_zero` drops the zeros). `imbalance: true` with `imbalance_ratio: 3` lights a cell whose side out-traded the diagonal other three to one, in `imbalance_buy_color` (`theme.up`) and `imbalance_sell_color` (`theme.down`); `stacked_imbalances: 3` boxes three in a row. `poc: true` and `value_area: 0.7` light the point of control and shade the value area; `cell_metric: "delta"` grades the cells by their delta (`"volume"` and `"trades"` are the alternatives). **The reading beside it.** The histogram sums the block's buy and sell halves and prints their difference: amber above zero, orange below (the two `colors` of a histogram are its sign colours). The line sums the delta from the first bar, or from each UTC day when `reset_daily` is on, and `pane("cum", { height_frac: 0.15 })` with `pane: "cum"` on the output puts it in a second pane under the histogram's, on a scale of its own: after a trending day the running sum is many times any one bar's delta, and on one shared scale the bars would lie flat. A bar whose block is empty reads NaN in the histogram and holds the running sum. ## Where it runs Markets with a volume profile lane: crypto spot and perpetual venues and Polymarket markets, where the venue reports buy and sell volume per level. The footprint reads best from 1 minute to 1 hour, where a bar holds a few dozen to a few hundred levels (about 280 per 15m bar on Binance Futures BTCUSDT, 860 per 1h bar); `max_cells: 8192` leaves room for a 4h bar. ## When data is missing A market with no volume profile lane is refused by name before any fetch. A bar with no levels carries an empty block: the column prints nothing and the histogram reads NaN. A block larger than `max_cells` refuses the run rather than truncating; raise the cap for a coarser interval. ## Customize it - **Cluster mode.** `mode: "cluster"` prints one cell per level with the delta inside, the classic cluster chart; `"imbalance_only"` prints the lit cells alone. - **A stricter imbalance.** Raise `imbalance_ratio` to 4 or 5 to light fewer cells, or set `imbalance: false` for a plain footprint. - **Volume instead of delta.** `cell_metric: "volume"` grades the cells by total volume, with `volume_color` as the ink. - **Row height in price.** `row_height: 10` on a BTC chart prints one cell per 10 USD instead of the native table. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Volume Footprint** under **Order flow**. 2. Press **Run** on a 5m or 15m chart of a liquid crypto market: the cells print on the candles once the levels arrive, the imbalances lit, and the delta and cumulative delta panes fill below. 3. Hover a cell for its buy and sell volume; at the editor's Console prompt, type `delta` and `cum_delta` to read the live bar. ## Concepts used - [Volume profile](../functions/order-flow-kit.md) for the `volume_profile` celled class and its tuple - [Price canvases](../presentation/price-canvases.md) for `plot.footprint`, its modes, the imbalance words and the value area - [Plotting](../presentation/plotting.md) for histogram and line outputs and named panes below the chart # TPO Letters ![Market Profile letters on each day with the initial balance lines](/wrun/images/tpo-letters.png) The chart's own candles folded into a Market Profile: one letter per 30 minutes of the day, printed on every price the market traded during that half hour, the day's letters stacked into a profile with the point of control and the value area lit and the initial balance (the first hour) marked. The module adds the initial balance's high and low as dashed lines on price once the balance is set, so the trader sees the day's opening range beside the letters that built it and where the later letters broke out of it. The parts are a `plot.tpo` declaration with no `cells`, which builds the letters from the chart's own candles ([Price canvases](../presentation/price-canvases.md)), and two line outputs computed in `onBar()` from the day's first letters ([Plotting](../presentation/plotting.md)). This is also the `tpo-letters` template: the **TPO Letters** card under **Order flow** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // TPO Letters: the chart's own candles folded into a Market Profile, one letter per 30 minutes of the day printed on // every price the market traded during that half hour, the point of control and the value area lit, the initial // balance marked. The module adds the initial balance's high and low as lines on price once the balance is set, so // the trader sees the day's opening range beside the letters that built it. param.int("ib_letters", 2, { min: 1, max: 8, label: "Initial balance letters", description: "Letters in the initial balance: 2 is the first hour of 30-minute letters" }); input("close", ohlcv.close); // the primary input: the chart's own candles define the grid and feed the letters output("ib_high", line, overlay, { color: "#f8c000", width: 1, line_style: "dashed", format: "price", label: "IB high", description: "The initial balance high: the day's first letters' high; NaN until the balance is set" }); output("ib_low", line, overlay, { color: "#f8c000", width: 1, line_style: "dashed", format: "price", label: "IB low", description: "The initial balance low" }); plot.tpo({ name: "tpo", period: "day", letter_minutes: 30, display: "both", initial_balance: true, ib_color: "theme.text", poc: true, vah: true, val: true, value_area: 0.7, poc_color: "theme.text", vah_color: "theme.muted", val_color: "theme.muted", outside_va_opacity: 0.3 }); // no cells: the host builds the letters from the chart's own candles (30 minutes or finer) const LETTER_SECONDS = 1800.0; // 30-minute letters, the declaration's letter_minutes let ibLetters = 2; // the setting, read in onStart() let day: f64 = NaN; // the UTC day being built let ibHigh: f64 = NaN; // the balance so far let ibLow: f64 = NaN; function onStart(): void { ibLetters = i32(p_ib_letters()); } // onBar() runs once per bar: a new UTC day starts a new balance; the bars inside the first letters grow it; the two // lines print once the balance's last letter has closed and hold until the next day. function onBar(): void { const t = bar.time(); const today = Math.floor(t / 86400.0); if (today != day) { day = today; ibHigh = NaN; ibLow = NaN; } const letter = i32(Math.floor((t - today * 86400.0) / LETTER_SECONDS)); // this bar's letter in its day if (letter < ibLetters) { // inside the balance: grow it, print nothing yet const high = bar.high(); const low = bar.low(); if (!isNaN(high) && (isNaN(ibHigh) || high > ibHigh)) ibHigh = high; if (!isNaN(low) && (isNaN(ibLow) || low < ibLow)) ibLow = low; out_ib_high(NaN); out_ib_low(NaN); return; } out_ib_high(ibHigh); out_ib_low(ibLow); } ``` ## How it works **Letters from the candles.** `plot.tpo({ name: "tpo", period: "day", letter_minutes: 30 })` tells the host to build one letter per 30 minutes of each UTC day from the chart's own bars: a price row prints a letter for every letter interval in which it lay inside a bar's high-low range. The letter must be a whole multiple of the chart's bars, so the profile needs a chart of 30 minutes or finer; to run it on a coarser chart, declare an `intrabar` input (5-minute bars inside each chart bar) and name it in `cells`, or a `volume_profile` input, whose buckets also give each letter its volume. `display: "both"` prints blocks and letters (`"letters"` or `"blocks"` alone are the others). **The profile's marks.** `poc`, `vah` and `val` light the point of control and the value area edges (`value_area: 0.7`), the rows outside the value area faded to `outside_va_opacity`; `initial_balance: true` marks the first hour in `ib_color`. `single_prints`, `poor_extremes` and `counts` are off by default and switch on by name; `color_mode` colours the blocks by period (the default), by TPO count, or by volume or delta on `volume_profile` cells. **The lines beside it.** `ib_letters` (2) is the number of letters in the initial balance. Each UTC day starts a new balance; the bars inside its first letters grow its high and low; once the last of those letters has closed, `ib_high` and `ib_low` print as dashed amber lines and hold until the next day. Before the balance is set they read NaN and draw nothing. ## Where it runs Any chart of 30 minutes or finer: crypto, stocks, forex and CME futures alike, since the letters are built from the chart's own candles. On a coarser chart the legend chip and the Console say so by name and point to an `intrabar` input. ## When data is missing A bar with a NaN high or low grows no balance and prints no letter. A day with fewer bars than the initial balance never sets its lines. The profile holds only the days the chart has loaded. ## Customize it - **Weekly profiles.** `period: "week"` stacks a whole week's letters into one profile (`"month"` a month's); with 30-minute letters the alphabet wraps. - **A wider balance.** Raise `ib_letters` to 4 for a two-hour initial balance. - **Letters only.** `display: "letters"` drops the blocks for a classic text profile; `font_family: "mono"` lines the letters up. - **Row height.** Left out, the row is the chart's own (75 dollars on BTC at 15m, about 25 to 90 rows a day), and a `row_height` so fine that a day holds more than 512 rows is coarsened, with `tpo 'tpo': row_height 1 coarsened to 8 (512 rows per day is the chart's limit)` in the Console and on the legend chip ([Row height](../presentation/price-canvases.md#row-height)). - **With volume.** Declare `input("profile", volume_profile.cells, { max_cells: 8192 })`, add `cells: "profile"`, and switch `color_mode` to `"volume"` or set `volume_profile: true` for a volume profile beside the letters. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **TPO Letters** under **Order flow**. 2. Press **Run** on a 5m or 15m chart: the letters print day by day, the point of control and the value area lit, and the initial balance lines appear an hour into each day. 3. Hover a row for its letters and count; at the editor's Console prompt, type `ib_high` to read the live day's balance high. ## Concepts used - [Price canvases](../presentation/price-canvases.md) for `plot.tpo`, the letter rule, `period`, `display` and the profile marks - [Plotting](../presentation/plotting.md) for line outputs with a dashed style - [Data sources](../core-concepts/data-sources.md) for the `intrabar` and `volume_profile` celled classes a TPO can read instead of the candles # Session Volume Profile ![Session volume profiles inside each day's span with the session delta below](/wrun/images/session-volume-profile.png) Each day's volume by price, drawn inside the day's own time span: the profile grows from the day's first bar, buy volume green and sell volume red stacked on every row, the point of control and the value area lit, the three level prices tagged. Under the chart, the session delta: buy minus sell volume since the day opened, green while buyers carry the day and red while sellers do. The trader sees where each day's volume was accepted and who paid for it. The parts are a celled `volume_profile.cells` input read as a block in `onBar()` ([Volume profile](../functions/order-flow-kit.md)), a `plot.profile` declaration the chart draws from those same cells ([Price canvases](../presentation/price-canvases.md)) and one histogram output under the chart ([Plotting](../presentation/plotting.md)). This is also the `session-volume-profile` template: the **Session Volume Profile** card under **Order flow** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Session Volume Profile: each day's volume by price, drawn by the chart inside the day's own time span from the // bar-by-bar profile cells, with the point of control and the value area lit and the level prices tagged. The module // adds the session's running delta, buy minus sell volume since the day opened, as a histogram under the chart. The // trader sees where each day's volume was accepted and whether buyers or sellers carried it there. input("close", ohlcv.close); // the primary input: the chart's own candles define the grid input("profile", volume_profile.cells, { max_cells: 8192, description: "This bar's volume by price level" }); // [low, high, buy, sell] per level; measured on Binance Futures BTCUSDT: about 280 levels per 15m bar, 860 per 1h bar output("session_delta", histogram, lower, { color_by: "delta_tone", colors: ["#f87171", "#34d399"], label: "Session delta", format: "si", description: "Buy minus sell volume since the session opened" }); // red while sellers lead the day, green while buyers do; the legend names it output("delta_tone", none, overlay, { description: "1 while the session delta is positive, 0 while it is negative" }); // data-only: the histogram's colour index plot.profile({ name: "session_vp", cells: "profile", span: "session", session: "1d", mode: "stacked_bar", buy_color: "#34d399", sell_color: "#f87171", width_frac: 0.7, value_area: 0.7, value_area_shade: true, poc: true, vah: true, val: true, level_labels: true }); // one profile per day, drawn by the chart from the cells above: the module never touches it let sessionDay: i64 = -1; // the UTC day number of the session being summed let buyVolume = 0.0; // the session's buy and sell volume so far let sellVolume = 0.0; // onBar() runs once per bar: reset the sums where the UTC day changes, add the bar's cells, write the delta. function onBar(): void { const t = bar.time(); if (isNaN(t)) return; const day = i64(Math.floor(t / 86400.0)); if (day != sessionDay) { sessionDay = day; buyVolume = 0.0; sellVolume = 0.0; } // a new day: the sums start over const n = in_profile_cells(); // f64s in this bar's block: four per level if (n >= 4) { const cells = in_profile_view(); // this bar's cells in place: the first n values of the build's own buffer for (let i = 0; i + 3 < n; i += 4) { buyVolume += cells[i + 2]; sellVolume += cells[i + 3]; } } const delta = buyVolume - sellVolume; out_session_delta(delta); out_delta_tone(delta >= 0.0 ? 1.0 : 0.0); } ``` ## How it works **The chart draws the profile.** `plot.profile({ cells: "profile", span: "session", session: "1d" })` names the celled input and anchors one profile to each day: the chart folds that day's `[low, high, buy, sell]` cells into rows, scales the rows to the day's biggest, grows them from the day's first bar across `width_frac` (0.7) of the day's width, and lights the point of control and the `value_area` (0.7) rows; `level_labels` tags the POC, VAH and VAL prices. The module never writes a frame for it: the cells it reads for the delta are the cells the chart draws. **The session delta.** On every bar the module sums the block's buy and sell volume into the day's totals, resets them where the UTC day changes, and writes `session_delta` (buy minus sell) as a histogram under the chart, coloured by `delta_tone` (1 while buyers lead, 0 while sellers do). `label: "Session delta"` names it in the legend and on the Style page, and `format: "si"` prints it with K, M and B from 1,000 ("Session delta 1.2K"). **Words to try.** `mode: "split_bar"` draws buy and sell on opposite sides of each row and `"delta"` the net; `span: "developing"` keeps only the current day's profile, growing bar by bar; `span: "visible"` folds the bars on screen into one profile at the pane edge; `behind_candles: true` (span `"session"` only) puts the fills under the candles; `background: true` washes each day's box. Every word is listed on [Price canvases](../presentation/price-canvases.md). ## Where it runs Markets with a volume profile lane: crypto (Binance Futures, Binance spot, Hyperliquid) and Polymarket markets. Daily profiles read best on charts from 5m to 1h; on a 1d chart each day is one bar. ## When data is missing Stocks and FX have no profile lane: the run stops with the toast `Volume profile data is unavailable for '' (the volume_profile source lane answered empty or was declined), so the indicator cannot compute.`, where `<title>` is the tab's title. A bar without a profile block adds nothing to its day; a day with no cells draws no profile; the delta on a day's first bar is that bar's own. ## Customize it - **Weekly profiles.** `session: "1w"`; `"4h"` cuts four-hour profiles on an intraday chart. - **A trading region.** `session: "us"`, `"eu"` or `"asia"` cuts the profiles on that region's hours. - **A range of your own.** `span: "range"` with `start` naming an output that holds the range's first second (epoch seconds, read on the last bar) and `end` another, or absent for the newest bar. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Session Volume Profile** under **Order flow**. 2. Press **Run** on a crypto chart such as BTCUSDT on Binance Futures at 15m: a profile grows inside each day, the level prices tag at its edge, and the session delta appears under the chart. 3. At the editor's Console prompt, type `session_delta` to read the delta on the newest bar. ## Concepts used - [Volume profile](../functions/order-flow-kit.md) for the celled profile input and the block layout - [Price canvases](../presentation/price-canvases.md) for `plot.profile`, its spans and session words - [Plotting](../presentation/plotting.md) for the histogram output and its colour index <!-- source: https://openmarket.xyz/wrun/cookbook/market-hud --> # Market HUD One card at the bottom of the price pane that reads the market in one glance and follows every tick, in the glass look: a dark translucent card with round corners and rounded type, light on a light chart. The trend word leads as the headline: Bullish when the close is above the EMA and RSI is above 50, Bearish when it is below both, else Mixed, in green, red or slate after a small dot. Under it, three activity rings read RSI, the taker buy share of the last 20 bars and this bar's volume so far against its 20-bar average, each value printed beside its ring; then ATR as a percent of price, and the open-interest change over the last 20 bars, counted in contracts, as a shaded sparkline. The EMA is drawn on price, and the legend names the lengths in use. The card is one `render.hud` declaration of four tiles with `look: "glass"` ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks), [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles)): a headline `tile.pill` coloured by a ladder output, a `tile.rings` of three outputs, a `tile.value` and a `tile.spark`, each reading data-only outputs written on every bar. It reads side-split `trades.volume`, an `oi.close` input that reads NaN where the market has none and a `funding.rate_close` input that reads NaN off perpetuals ([Data sources](../core-concepts/data-sources.md)), with the `Ema`, `Rsi`, `Atr` and `Roc` helpers ([TA library](../functions/ta-library.md)). This is also the `market-hud` template: the **Market HUD** card under **HUDs** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Market HUD: one card in a corner of the price pane that reads the market in one glance, drawn by the chart in the // glass look. The trader sees the trend word first, large (Bullish when the close is above the EMA and RSI is above // its midline, Bearish when it is below both, else Mixed, in the bull, bear or neutral colour), then three activity // rings with their readings beside them (RSI, the taker buy share over the flow window, and this bar's volume so far // against its average over the same window), then ATR as a percent of price and the open-interest change over the // flow window as a filled line, counted in contracts: coins on a perpetual (the dollar reading over the bar's close, so // a price move alone never moves it), the reading as served on a prediction market (already a count of YES and NO // pairs). The EMA itself is drawn on price. A tile whose market has no data prints a dash: // open interest on spot, stocks and FX; the taker share where the venue has no sided trades. Every number on the card // is a data-only output written on every bar, so the line has history and the Console or a watch reads the same // values the card shows (the close's distance from the EMA too). // Settings: every length is a whole number with bounds; the flow window also spans the open-interest change. The // trend word's RSI midline is a setting too, and the last section holds the EMA line's colour. section("Lengths"); param.int("ema_len", 50, { min: 2, max: 400, label: "EMA length, bars", description: "EMA length in bars, the trend reference" }); param.int("rsi_len", 14, { min: 2, max: 200, label: "RSI length, bars", description: "RSI length in bars (Wilder smoothing)" }); param.int("atr_len", 14, { min: 2, max: 200, label: "ATR length, bars", description: "ATR length in bars (Wilder smoothing)" }); param.int("flow_len", 20, { min: 2, max: 200, label: "Flow window, bars", description: "Bars in the flow window and in the open-interest change" }); section("Trend word"); param.number("rsi_mid", 50, { min: 1, max: 99, step: 1, label: "RSI midline", description: "RSI midline: Bullish needs RSI above it and the close above the EMA, Bearish needs both below" }); section("Look"); param.color("ema_color", "#38bdf8", { label: "EMA line" }); // The legend reads the lengths after the name ("Market HUD 50 14 14 20"): a tile label is a fixed string, so the // card's labels name what they read in words and the settings in use ride the legend. legend({ title: "{{ema_len}} {{rsi_len}} {{atr_len}} {{flow_len}}" }); // Inputs: the chart's own candles set the grid; flow reads sided trades (a bar with no prints reads 0); open // interest reads NaN on a bar without a reading and where the market has none; funding is served on perpetuals only // (NaN elsewhere), which tells the file its open interest is dollars to turn into coins. input("close", ohlcv.close); input("buy", trades.volume, { side: "BUY", missing: "zero" }); input("sell", trades.volume, { side: "SELL", missing: "zero" }); input("oi", oi.close, { missing: "nan" }); input("funding", funding.rate_close, { missing: "nan" }); // Outputs: the EMA is drawn on price; the rest are data-only, one value per bar behind the card's tiles. The first // five keep their names and order from the earlier card; the trend rung, the EMA distance and the buy share are new. // A static colour on a data-only output is the colour its dial, sparkline or meter draws in. const emaLine = output("ema", line, overlay, { color: "@ema_color", width: 1, label: "EMA", format: "price", description: "EMA of the close over ema_len bars" }); // the colour setting paints it; the handle names it for the hover card output("rsi", none, overlay, { color: "#a78bfa", format: "0.0", description: "RSI of the close over rsi_len bars" }); output("atr_pct", none, overlay, { format: "%", description: "ATR over atr_len bars as a percent of the close" }); output("flow", none, overlay, { description: "Buy minus sell volume over flow_len bars, as a percent of their sum" }); output("oi_change", none, overlay, { color: "#2dd4bf", format: "%", description: "Open-interest change over flow_len bars in contracts (coins on a perpetual, as served on a prediction market), percent" }); output("trend", none, overlay, { description: "The trend word as a ladder rung: 0 bearish, 1 mixed, 2 bullish" }); output("ema_dist", none, overlay, { format: "%", description: "Close minus the EMA, as a percent of the EMA" }); output("buy_share", none, overlay, { color: "#94a3b8", format: "%", description: "Taker buy volume over flow_len bars, as a percent of all taker volume" }); output("volume_ratio", none, overlay, { format: "%", description: "Bar volume as a percent of its average over flow_len bars; on the live bar, its volume so far" }); hover(emaLine, [block.value("EMA", "ema", { format: "price" }), block.rows([["RSI", "rsi", "0.0"], ["ATR %", "atr_pct", "0.00"], ["Flow %", "flow", "0.0"], ["OI change in contracts %", "oi_change", "0.00"]])]); // the card the EMA line opens under the cursor: its value and the card's numbers // The trend word (or the bars the card still needs), written on the live bar into a bounded string slot the first // tile reads. string("trend_word", { max_bytes: 16 }); // The card, in the glass look (rounded type, a large headline, rings): the trend word leads as the headline across // both columns, its colour following the trend rung (bear, neutral, bull); the rings span the card under it, RSI on // the outer ring; ATR and the open-interest line pair up below. The look word takes any shipped look name. render.hud("market", { position: "bottom_center", look: "glass", title: "Market", columns: 2, tiles: [ tile.pill("Trend", "trend_word", { headline: true, color_by: "trend", colors: ["#ff003c", "#94a3b8", "#10b981"] }), tile.rings("Momentum, flow and volume", [ ["RSI", "rsi", { min: 0, max: 100, color: "#fa114f" }], ["Buy share", "buy_share", { min: 0, max: 100, color: "#a6ff00" }], ["Volume so far", "volume_ratio", { min: 0, max: 200, color: "#00f0ff" }], ]), tile.value("ATR, % of price", "atr_pct", { format: "%" }), tile.spark("OI in contracts", "oi_change", { bars: 64 }), ] }); // The flow window is a ring of the last bars' buy and sell volume, sized for the largest window at load; onStart() // reads the window in use. const MAX_WINDOW = 200; const buys = new StaticArray<f64>(MAX_WINDOW); const sells = new StaticArray<f64>(MAX_WINDOW); const vols = new StaticArray<f64>(MAX_WINDOW); let volBars = 0; let flowLen = 20; let rsiMid = 50.0; let warmBars = 50; // the bars the EMA, RSI and ATR need before the card answers let ema = new Ema(50); let rsi = new Rsi(14); let atr = new Atr(14); let rawChange = new Roc(20); // the open-interest change as served, and in coins: the funding feed picks one let coinChange = new Roc(20); let oiRaw: f64 = NaN; // the last open-interest reading and its coins; a new bar keeps them until its own reading lands let oiCoins: f64 = NaN; let perp = false; // funding seen: a perpetual, whose dollar open interest moves with price let head = 0; // onStart() runs once before the first bar: read the settings and size the helpers to them. function onStart(): void { flowLen = i32(p_flow_len()); rsiMid = p_rsi_mid(); warmBars = i32(Math.max(p_ema_len(), Math.max(p_rsi_len() + 1.0, p_atr_len()))); ema = new Ema(i32(p_ema_len())); rsi = new Rsi(i32(p_rsi_len())); atr = new Atr(i32(p_atr_len())); rawChange = new Roc(flowLen); coinChange = new Roc(flowLen); } // onBar() runs once per bar: fold the bar into every helper and the flow ring, then write the outputs; the trend // word is written on the live bar only. function onBar(): void { const close = bar.close(); const emaValue = ema.update(close); const rsiValue = rsi.update(close); const atrPct = (atr.update(bar.high(), bar.low(), close) / close) * 100.0; // The ring's sums are recounted from its slots: a running sum that adds and subtracts keeps a rounding crumb // after a quiet stretch, and a crumb over a crumb would read as a share. buys[head] = in_buy(); sells[head] = in_sell(); vols[head] = bar.volume(); head = (head + 1) % flowLen; if (volBars < flowLen) volBars += 1; let buySum = 0.0; let sellSum = 0.0; let volSum = 0.0; for (let i = 0; i < flowLen; i += 1) { buySum += buys[i]; sellSum += sells[i]; volSum += vols[i]; } const total = buySum + sellSum; const flowPct = total > 0.0 ? ((buySum - sellSum) / total) * 100.0 : NaN; // NaN where the venue has no sided trades const buyShare = total > 0.0 ? (buySum / total) * 100.0 : NaN; const oiNow = in_oi(); if (!isNaN(oiNow)) { oiRaw = oiNow; oiCoins = oiNow / close; // dollars over the close: the coins, whatever price did } if (!isNaN(in_funding())) perp = true; const rawPct = rawChange.update(oiRaw); const coinPct = coinChange.update(oiCoins); const oiPct = perp ? coinPct : rawPct; // NaN on markets without open interest, and until the window is full const volAvg = volSum / f64(volBars); if (isNaN(emaValue) || isNaN(rsiValue) || isNaN(atrPct)) { // warm-up rows write nothing; the live bar says why if (bar.isLast()) { sb_clear(); sb_text("Needs "); sb_int(warmBars); sb_text(" bars"); str_trend_word_sb(); } return; } const bullish = close > emaValue && rsiValue > rsiMid; const bearish = close < emaValue && rsiValue < rsiMid; out_ema(emaValue); out_rsi(rsiValue); out_atr_pct(atrPct); out_flow(flowPct); out_oi_change(oiPct); out_trend(bearish ? 0.0 : bullish ? 2.0 : 1.0); out_ema_dist(((close - emaValue) / emaValue) * 100.0); out_buy_share(buyShare); out_volume_ratio(volAvg > 0.0 ? (bar.volume() / volAvg) * 100.0 : NaN); // on the live bar, its volume so far if (bar.isLast()) { sb_clear(); sb_text(bullish ? "Bullish" : bearish ? "Bearish" : "Mixed"); str_trend_word_sb(); } } ``` ## How it works **One declaration is the card.** `render.hud("market", { position: "bottom_center", look: "glass", title: "Market", columns: 2, tiles: [...] })` names the anchor, the look, the title and four tiles; the chart draws the card, keeps it at its anchor while the chart pans, and opens any tile as a hover card under the pointer. `headline: true` gives the trend pill the whole first row, where the answer reads before anything else. The rings are a wide tile that spans the card under it, and the ATR value and the open-interest sparkline share the last row. **The look draws the tiles.** `look: "glass"` sets the surface (dark and slightly translucent, a 22 px corner radius, a soft shadow, light on a light chart), the type (the rounded font, the headline at 34 px, the title as written) and each tile kind's drawing: the pill as its word after a small dot in its colour, the spark as a line over a faint fill with a dot at its end. A word declared beside `look` wins over the look's own ([Looks](../presentation/hud-and-hover-cards.md#looks)). **Three rings in one tile.** `tile.rings` takes up to three `[label, output, { min, max, color }]` entries, outermost first: RSI from 0 to 100, the buy share from 0 to 100 and volume against its average from 0 to 200. Each ring fills the share of its own range its output holds, so a ring closes at RSI 100, at a window of nothing but taker buys, or on a bar with twice its average volume. Each ring takes its own `color`, and the legend beside the rings prints each value in its output's format. **Every number is an output.** `trend`, `rsi`, `buy_share`, `volume_ratio`, `atr_pct` and `oi_change` are data-only outputs (`none`), one value per bar. A tile reads the newest row and the sparkline the last 64. Each output's `format` word prints it (`%` adds a percent sign to a value already in percent units, so 0.09 reads 0.09%), and the static `color` on `oi_change` is the colour its sparkline draws in. `flow` (buy minus sell volume as a percent of their sum) rides the hover card the EMA line opens, and `ema_dist` (the close's distance from the EMA, percent) is written beside them for anything that reads it. **The trend word is a slot and a ladder.** The word is written into a bounded string slot on the live bar only. The pill's colour follows the `trend` output: 0 bearish, 1 mixed and 2 bullish pick red, slate and green from its `colors`. Until the EMA, RSI and ATR have the bars they need, the slot says so instead ("Needs 50 bars", the longest of the three lengths). **Open interest is counted in contracts.** On a perpetual the `oi` source serves open interest in dollars, which rise and fall with price even when no position opens or closes, so each reading is divided by the bar's close and the sparkline and its tile ("OI in contracts") move only when positions do: price up 2% with fewer coins open reads as a fall. The funding input tells a perpetual apart, since only a perpetual serves funding. A prediction market's open interest is its collateral, one dollar per YES and NO pair, so it is already a count of contracts and is read as served. A new bar keeps the last reading until its own lands, so the tile does not blink to a dash at each bar's open. **The windows are fixed arrays.** Buy, sell and total volume of the last `flow_len` bars sit in three arrays sized at load, each bar writing over the slot of the bar that leaves the window, and their sums are recounted from the slots on every bar, so a quiet stretch reads as no trades rather than as a rounding crumb. The buy share is buy volume over all taker volume in the window. Volume against its average is a bar's volume over the mean of the window, that bar included; on the live bar that is its volume so far, as the ring's label says ("Volume so far"), so the ring starts low at each bar's open and fills as the bar trades. Until the window fills, the mean counts only the bars seen so far. **The lengths ride the legend.** A tile's label is a fixed string, so `legend({ title: "{{ema_len}} {{rsi_len}} {{atr_len}} {{flow_len}}" })` prints the lengths in use after the name ("Market HUD 50 14 14 20") and the labels say in words what each tile reads. ## Where it runs Every market with candles: crypto perpetuals and spot, Hyperliquid, a prediction market's single outcome, US stocks and FX. Open interest needs a perpetual (counted in coins) or a prediction market (its collateral, read as served), the taker share needs sided trades and the volume ring needs volume, so the open-interest sparkline prints a dash on spot, stocks and FX, the buy share ring on stocks and FX, and the volume ring on FX, whose candles carry no volume. The card sits at the bottom centre of the price pane, away from the legend, the High and Low tags, the price axis, the newest candles at the right edge and the round button at the bottom left; on a chart whose candles dip into the bottom middle it covers them, and `position` moves it. ## When data is missing A tile whose output has no number prints a dash, and a ring without a reading stays empty with a dash beside it: open interest where the market has none, the taker share where the venue serves no sided trades, volume against its average where the candles carry no volume. The trend word, ATR and RSI need only candles, so the card always answers. The first `ema_len` bars are warm-up rows that write nothing, so the sparkline starts after them; on a chart with fewer bars than the lengths need (an `ema_len` of 400 on a short history), the headline reads "Needs 400 bars" instead of leaving the card blank. ## Customize it - **Another window.** `flow_len` (20) sets the taker window, the volume average and the open-interest change together; the legend shows the new length. - **Another corner.** Set `position` to any of the nine anchors, `top_left` to `bottom_right`; `columns: 1` stacks every tile in a narrow card. - **Another tile.** Declare a data-only output, write it in `onBar()`, and add a tile that reads it, such as `tile.value("Close vs EMA", "ema_dist", { format: "%" })` for the distance already computed. - **Change the look.** `look:` takes any of the twelve shipped looks, `default` to `classic` ([Looks](../presentation/hud-and-hover-cards.md#looks)), and a word declared beside it still wins; the Look row on the indicator's Style page switches it without code ([The Style page](../settings/style-page.md#the-look-row)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Market HUD** under **HUDs**. 2. Press **Run**: the EMA draws on price and the glass card appears at the bottom of the price pane. Rest the pointer on a tile to open it as a hover card. 3. At the editor's Console prompt, type `outputs` to read the newest value of every output the card shows. ## Concepts used - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) for `render.hud`, its anchors and its columns - [Looks](../presentation/hud-and-hover-cards.md#looks) for `look: "glass"` and what it draws for each tile kind - [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the headline pill, `tile.rings`, their options and the format words - [The Style page](../settings/style-page.md#the-look-row) for the Look row that switches the look without code - [Hover cards](../presentation/hud-and-hover-cards.md#hover-cards) for the card the EMA line and each tile open - [Legend](../presentation/legend.md) for `legend({ title })` and its `{{setting}}` placeholders - [Data sources](../core-concepts/data-sources.md) for the `trades` and `oi` sources and their `missing` policies - [TA library](../functions/ta-library.md) for `Ema`, `Rsi`, `Atr` and `Roc` <!-- source: https://openmarket.xyz/wrun/cookbook/decision-board --> # Decision board One card at the bottom of the price pane that grades the market factor by factor and says which side is ahead, so the chart keeps its full height. It is printed in the broadsheet look: cream paper, black ink, serif type and a thick and a thin rule over the title in small capitals. A headline the script writes from the six factors leads: "Buyers Ahead, 3 to 1" when the bullish factors lead the bearish ones by two or more, "Sellers Ahead" when the bearish ones do, else "Signals Split", each followed by the two counts. Under it, the net score (bullish factors minus bearish ones) of the last 64 bars runs as a stippled line, then the six readings the score comes from, each joined to its value by a dotted leader: the close against the EMA, RSI, this bar's volume so far against its 20-bar average, taker flow, the open-interest change in contracts and the longs' share of liquidations. The EMA is drawn on price, and the legend names the lengths in use. The card is one `render.hud` declaration of three tiles in one column 300 px wide, with `look: "broadsheet"` ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks), [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles)): a headline `tile.pill` over a string slot, a `tile.spark` and a `tile.rows` list, each reading data-only outputs written on every bar. It reads side-split `trades.volume`, `oi.close` reading NaN where the market has none, `funding.rate_close` reading NaN off perpetuals, and `liquidations` split by side ([Data sources](../core-concepts/data-sources.md)), with the `Ema`, `Rsi`, `Sma` and `Roc` helpers ([TA library](../functions/ta-library.md)). This is also the `decision-board` template: the **Decision Board** card under **HUDs** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Decision Board: one card in a corner of the price pane that grades the market factor by factor and says which // way it leans, drawn by the chart in the broadsheet look. The trader reads a headline first, written by the script // from the count ("Buyers Ahead, 4 to 1", "Sellers Ahead, 3 to 1" or "Signals Split, 2 to 2": the leading side's // factors against the other side's), then the net score (bullish factors minus bearish ones, -6 to +6) over the last // 64 bars as a stippled line, then the six readings behind it in dotted-leader rows: the close against the EMA, RSI, // volume against its average (the live bar's volume so far), taker flow, the open-interest change in contracts (coins // on a perpetual, as served on a prediction market) and the longs' share of liquidations. A factor whose market has no data prints a dash and leaves the count: open interest and // liquidations on spot, stocks and FX; flow and volume on FX. The EMA is drawn on price, and every number on the // card is a data-only output written on every bar, so the Console or a watch reads the same values the card shows. // Settings: the lengths, one threshold per factor rule, the margin the lean needs, and the EMA line's colour. section("Lengths"); param.int("ema_len", 50, { min: 2, max: 500, label: "EMA length, bars", description: "EMA length in bars, the trend reference" }); param.int("rsi_len", 14, { min: 2, max: 200, label: "RSI length, bars", description: "RSI length in bars" }); param.int("window", 20, { min: 2, max: 200, label: "Window, bars", description: "Bars in the volume, flow, open-interest and liquidation windows" }); section("Factor thresholds"); param.number("rsi_bull", 55, { min: 50, max: 100, step: 1, label: "RSI bullish above", description: "RSI above this level reads bullish momentum" }); param.number("rsi_bear", 45, { min: 0, max: 50, step: 1, label: "RSI bearish below", description: "RSI below this level reads bearish momentum" }); param.number("volume_spike", 1.2, { min: 1, max: 10, step: 0.1, label: "Volume spike, times average", description: "Volume at this multiple of its average or more counts: bullish on an up bar, bearish on a down bar (the live bar counts its volume so far)" }); param.number("flow_edge", 5, { min: 0, max: 100, step: 0.5, label: "Flow edge, percent", description: "Taker flow above this percent reads bullish, below minus this percent bearish" }); param.number("oi_flat", 0.05, { min: 0, max: 10, step: 0.01, label: "Minimum OI rise, percent", description: "Open interest rising more than this percent counts in the direction price moved (new positions); flat or falling open interest reads neutral (positions closing). Counted in coins on a perpetual" }); param.number("liq_heavy", 65, { min: 50, max: 100, step: 1, label: "Heavy long share, percent", description: "Longs over this percent of liquidations read bearish; longs under 100 minus it read bullish" }); param.int("lean_margin", 2, { min: 1, max: 6, label: "Lean margin, factors", description: "Bullish factors over bearish ones needed to lean Long (and the reverse to lean Short)" }); section("Look"); param.color("ema_color", "#38bdf8", { label: "EMA line" }); // The legend reads the lengths after the name ("Decision Board 50 14 20"): a tile label is a fixed string, so the // card's labels name what they read in words and the settings in use ride the legend. legend({ title: "{{ema_len}} {{rsi_len}} {{window}}" }); // Inputs: the chart's own candles set the grid; flow and liquidations read 0 on a bar without prints; open // interest reads NaN on a bar without a reading and where the market has none; funding is served on perpetuals only // (NaN elsewhere), which tells the file its open interest is dollars to turn into coins. input("close", ohlcv.close); input("buy", trades.volume, { side: "BUY", missing: "zero" }); input("sell", trades.volume, { side: "SELL", missing: "zero" }); input("oi", oi.close, { missing: "nan" }); input("funding", funding.rate_close, { missing: "nan" }); input("liq_buy", liquidations.liquidations, { side: "BUY", missing: "zero" }); // shorts liquidated: forced buying input("liq_sell", liquidations.liquidations, { side: "SELL", missing: "zero" }); // longs liquidated: forced selling // Outputs: the EMA on price; the rest are data-only, one value per bar behind the card's tiles. The first three keep // their names and order from the earlier board; the bearish count, the net score, the lean rung and the six // readings are new. A static colour on a data-only output is the colour its meter draws in. const emaLine = output("ema", line, overlay, { color: "@ema_color", width: 1, label: "EMA", format: "price", description: "EMA of the close over ema_len bars" }); // the colour setting paints it; the handle names it for the hover card output("score", none, overlay, { format: "int", description: "Bullish factors on this bar" }); output("factors", none, overlay, { format: "int", description: "Factors with data on this bar" }); output("bearish", none, overlay, { format: "int", description: "Bearish factors on this bar" }); output("net", none, overlay, { format: "int", description: "Bullish minus bearish factors, -6 to +6" }); output("lean", none, overlay, { description: "The lean as a ladder rung: 0 short, 1 neutral, 2 long" }); output("ema_dist", none, overlay, { format: "%", description: "Close minus the EMA, as a percent of the EMA" }); output("rsi", none, overlay, { format: "0.0", description: "RSI of the close over rsi_len bars" }); output("volume_ratio", none, overlay, { format: "0.00", unit: "x", description: "Bar volume over its average across the window; on the live bar, its volume so far" }); output("flow", none, overlay, { format: "%", description: "Taker buy minus sell volume over the window, as a percent of their sum" }); output("oi_change", none, overlay, { format: "%", description: "Open-interest change over the window in contracts (coins on a perpetual, as served on a prediction market), percent" }); output("long_share", none, overlay, { format: "%", description: "Longs liquidated as a percent of all liquidations in the window" }); hover(emaLine, [block.value("EMA", "ema", { format: "price" }), block.rows([["Bullish factors", "score", "int"], ["Bearish factors", "bearish", "int"], ["Factors with data", "factors", "int"]])]); // the card the EMA line opens under the cursor // The lean word, written on the live bar into a bounded string slot the first tile reads. string("lean_word", { max_bytes: 8 }); string("headline_text", { max_bytes: 48 }); // the headline sentence, written on the live bar // The card, in the broadsheet look (newsprint, serif type, small caps, dotted leaders): one column 300 px wide, the // headline sentence first, then the stippled score line and the six readings. The lean word (Long, Neutral or // Short) still rides its own slot for a hover card or a watch. render.hud("decision", { position: "bottom_center", look: "broadsheet", title: "Decision board", columns: 1, width: 300, tiles: [ tile.pill("Lean", "headline_text", { headline: true }), tile.spark("Net score, 64 bars", "net", { bars: 64 }), tile.rows([["Close vs EMA", "ema_dist", "%"], ["RSI", "rsi", "0.0"], ["Volume so far", "volume_ratio", "0.00"], ["Taker flow", "flow", "%"], ["OI change, contracts", "oi_change", "%"], ["Long share of liquidations", "long_share", "%"]]), ] }); const FACTORS = 6; const NA = 9; // a factor without data on this market const MAX_WINDOW = 200; // the rings are sized for the largest window at load; onStart() reads the one in use const buys = new StaticArray<f64>(MAX_WINDOW); const sells = new StaticArray<f64>(MAX_WINDOW); const liqBuys = new StaticArray<f64>(MAX_WINDOW); const liqSells = new StaticArray<f64>(MAX_WINDOW); const tones = new StaticArray<i32>(FACTORS); // per factor: 1 bullish, -1 bearish, 0 neutral, NA without data let window = 20; let rsiBull = 55.0; let rsiBear = 45.0; let volumeSpike = 1.2; let flowEdge = 5.0; let oiFlat = 0.05; let liqHeavy = 65.0; let leanMargin = 2; let ema = new Ema(50); let rsi = new Rsi(14); let volumeMean = new Sma(20); let rawChange = new Roc(20); // the open-interest change as served, and in coins: the funding feed picks one let coinChange = new Roc(20); let priceChange = new Roc(20); let head = 0; let tradesSeen = false; // the venue has served sided trades at least once let liqSeen = false; // the venue has served liquidations at least once let close: f64 = NaN; let prev: f64 = NaN; let emaV: f64 = NaN; let rsiV: f64 = NaN; let volumeRatio: f64 = NaN; let oiRaw: f64 = NaN; // the last open-interest reading and its coins; a new bar keeps them until its own reading lands let oiCoins: f64 = NaN; let perp = false; // funding seen: a perpetual, whose dollar open interest moves with price let flowPct: f64 = NaN; let oiPct: f64 = NaN; let pricePct: f64 = NaN; let longShare: f64 = NaN; // longs liquidated as a percent of all liquidations in the window let bullish = 0; let bearish = 0; let withData = 0; // Judge each factor from this bar's readings. A helper still warming up, or a market without the data, reads NA // and stays out of both counts. Open interest rising confirms the price move (new positions behind it); flat or // falling open interest is positions closing, which confirms nothing. function judge(): void { tones[0] = !isFinite(emaV) ? NA : close > emaV ? 1 : close < emaV ? -1 : 0; tones[1] = !isFinite(rsiV) ? NA : rsiV > rsiBull ? 1 : rsiV < rsiBear ? -1 : 0; tones[2] = !isFinite(volumeRatio) ? NA : volumeRatio >= volumeSpike && isFinite(prev) ? (close > prev ? 1 : close < prev ? -1 : 0) : 0; tones[3] = !tradesSeen ? NA : !isFinite(flowPct) ? 0 : flowPct > flowEdge ? 1 : flowPct < -flowEdge ? -1 : 0; tones[4] = !isFinite(oiPct) || !isFinite(pricePct) ? NA : oiPct <= oiFlat ? 0 : pricePct > 0.0 ? 1 : pricePct < 0.0 ? -1 : 0; tones[5] = !liqSeen ? NA : !isFinite(longShare) ? 0 : longShare > liqHeavy ? -1 : longShare < 100.0 - liqHeavy ? 1 : 0; bullish = 0; bearish = 0; withData = 0; for (let i = 0; i < FACTORS; i += 1) { if (tones[i] == NA) continue; withData += 1; if (tones[i] > 0) bullish += 1; else if (tones[i] < 0) bearish += 1; } } // onStart() runs once before the first bar: read the settings and size every helper to them. function onStart(): void { window = i32(p_window()); rsiBull = p_rsi_bull(); rsiBear = p_rsi_bear(); volumeSpike = p_volume_spike(); flowEdge = p_flow_edge(); oiFlat = p_oi_flat(); liqHeavy = p_liq_heavy(); leanMargin = i32(p_lean_margin()); ema = new Ema(i32(p_ema_len())); rsi = new Rsi(i32(p_rsi_len())); volumeMean = new Sma(window); rawChange = new Roc(window); coinChange = new Roc(window); priceChange = new Roc(window); } // onBar() runs once per bar: read the bar, update every helper and window, judge the factors, then write the // outputs; the lean word is written on the live bar only. function onBar(): void { prev = close; close = bar.close(); emaV = ema.update(close); rsiV = rsi.update(close); const volume = bar.volume(); const volumeAvg = volumeMean.update(volume); volumeRatio = volumeAvg > 0.0 ? volume / volumeAvg : NaN; // NaN on markets without volume (FX); the live bar's so far // The windows are rings of the last bars, their sums recounted from the slots: a running sum that adds and // subtracts keeps a rounding crumb after a quiet stretch, and a crumb over a crumb would read as a share. const buy = in_buy(); const sell = in_sell(); const liqBuy = in_liq_buy(); const liqSell = in_liq_sell(); buys[head] = buy; sells[head] = sell; liqBuys[head] = liqBuy; liqSells[head] = liqSell; head = (head + 1) % window; if (buy + sell > 0.0) tradesSeen = true; if (liqBuy + liqSell > 0.0) liqSeen = true; let buySum = 0.0; let sellSum = 0.0; let liqBuySum = 0.0; let liqSellSum = 0.0; for (let i = 0; i < window; i += 1) { buySum += buys[i]; sellSum += sells[i]; liqBuySum += liqBuys[i]; liqSellSum += liqSells[i]; } const flowTotal = buySum + sellSum; flowPct = flowTotal > 0.0 ? ((buySum - sellSum) / flowTotal) * 100.0 : NaN; const liqTotal = liqBuySum + liqSellSum; longShare = liqTotal > 0.0 ? (liqSellSum / liqTotal) * 100.0 : NaN; const oiNow = in_oi(); if (!isNaN(oiNow)) { oiRaw = oiNow; oiCoins = oiNow / close; // dollars over the close: the coins, whatever price did } if (!isNaN(in_funding())) perp = true; const rawPct = rawChange.update(oiRaw); const coinPct = coinChange.update(oiCoins); oiPct = perp ? coinPct : rawPct; pricePct = priceChange.update(close); judge(); if (isNaN(emaV)) return; // the first ema_len bars are warm-up rows: nothing written const net = bullish - bearish; out_ema(emaV); out_score(f64(bullish)); out_factors(f64(withData)); out_bearish(f64(bearish)); out_net(f64(net)); out_lean(net >= leanMargin ? 2.0 : net <= -leanMargin ? 0.0 : 1.0); out_ema_dist((close / emaV - 1.0) * 100.0); out_rsi(rsiV); out_volume_ratio(volumeRatio); out_flow(flowPct); out_oi_change(oiPct); out_long_share(longShare); if (bar.isLast()) { sb_clear(); sb_text(net >= leanMargin ? "Long" : net <= -leanMargin ? "Short" : "Neutral"); str_lean_word_sb(); sb_clear(); // A headline, short enough for one line: the leading side and the signal count, bullish to bearish. sb_text(net >= leanMargin ? "Buyers Ahead, " : net <= -leanMargin ? "Sellers Ahead, " : "Signals Split, "); if (net <= -leanMargin) { sb_int(i64(bearish)); sb_text(" to "); sb_int(i64(bullish)); } else { sb_int(i64(bullish)); sb_text(" to "); sb_int(i64(bearish)); } str_headline_text_sb(); } } ``` ## How it works **Each factor is a rule.** Trend is bullish with the close above the EMA (`ema_len` 50) and bearish below; RSI (`rsi_len` 14) is bullish above 55 and bearish below 45; volume is bullish at 1.2 times its `window` average on an up bar and bearish on a down bar (on the live bar its volume so far, so the factor can join the count as the bar trades); taker flow is bullish above +5% and bearish below -5%; open interest counted in contracts (on a perpetual the dollar reading over the close, so a price move alone never moves it; on a prediction market the collateral as served) that rose more than 0.05% over the window confirms the price move, bullish when price rose over the same window and bearish when it fell, while flat or falling open interest is positions closing and reads neutral; liquidations read longs over 65% of what was flushed as bearish and under 35% as bullish. Every threshold is a setting in the **Factor thresholds** section. **The score and the headline.** The score is the bullish factors minus the bearish ones; the market leans long at +2 or more and short at -2 or less (`lean_margin`). A factor the market has no data for is left out of both counts. On the live bar the script writes the headline into the bounded `headline_text` slot from the same counts: "Buyers Ahead" and the bullish count first when the score reaches the margin, "Sellers Ahead" and the bearish count first when it reaches minus the margin, else "Signals Split" with the bullish count first. **One declaration is the card.** `render.hud("decision", { position: "bottom_center", look: "broadsheet", title: "Decision board", columns: 1, width: 300, tiles: [...] })` names the anchor, the look, the title and three tiles; the chart draws the card and keeps it at its anchor while the chart pans. `headline: true` sets the pill in the look's headline type, and `width: 300` gives the one-column card room for the longest row (a one-column card is 180 px unless it says otherwise). **The look draws the tiles.** `look: "broadsheet"` prints on cream paper in black ink with serif type and square corners, sets the title in small capitals under a thick and a thin rule, draws the spark as dots (a stippled line) and joins each row's label to its value with a dotted leader. It keeps its paper on a dark and a light chart alike ([Looks](../presentation/hud-and-hover-cards.md#looks)). **Every number is an output.** The six readings (`ema_dist`, `rsi`, `volume_ratio`, `flow`, `oi_change`, `long_share`), the counts (`score` for bullish, `bearish`, `factors` with data), `net` and the `lean` rung (0 short, 1 neutral, 2 long) are data-only outputs written on every bar. The spark reads the last 64 values of `net`, and the rows print each reading with its format word (`%` adds a percent sign to a value already in percent units; `volume_ratio` adds its `unit`, so it reads `1.35x`). The lean is also written as a word, Long, Neutral or Short, into the `lean_word` slot, which no tile reads until you add one (below). **The windows are rings.** Buy, sell and liquidation volume of the last `window` bars sit in fixed rings sized at load, their sums recounted from the slots on every bar, so a quiet stretch reads as no prints rather than as a rounding crumb. Open interest is kept in contracts, and a new bar keeps the last reading until its own lands, so the factor count does not drop at each bar's open. A perpetual is told apart by its funding input, which only a perpetual serves. **The lengths ride the legend.** A tile's label is a fixed string, so `legend({ title: "{{ema_len}} {{rsi_len}} {{window}}" })` prints the lengths in use after the name ("Decision Board 50 14 20") and the row labels say in words what each reading is. ## Where it runs Every market with candles, a prediction market's single outcome included. Crypto perpetuals have all six factors, their open interest counted in coins. Spot has no open interest or liquidations, and prediction markets no liquidations, their open interest read as served (one dollar of collateral per YES and NO pair, already a count of contracts); US stocks keep the trend, RSI and volume, and FX only the trend and RSI (its candles carry no volume). The card sits at the bottom centre of the price pane, away from the legend, the High and Low tags, the price axis, the newest candles at the right edge and the round button at the bottom left; on a chart whose candles dip into the bottom middle it covers them, and `position` moves it. ## When data is missing A factor without data prints a dash in its row and leaves both counts, so the score's reach shrinks with the market: four factors on spot, two on FX, where the headline reads at most 2 to 0. Flow and liquidations count only once the venue has served one print; after that a window without prints reads neutral (the row shows a dash because there is no share to print). The first `ema_len` bars are warm-up rows that write nothing, so the stippled line starts after them. ## Customize it - **Other thresholds.** Every cut-off is a setting under **Factor thresholds**; the lengths are under **Lengths**. - **A seventh factor.** Add an input, a rule in `judge()` (raise `FACTORS`), an output and a row; the headline counts it with the others. - **The lean as a word.** `tile.pill("Lean", "lean_word", { color_by: "lean", colors: ["#ff003c", "#94a3b8", "#10b981"] })` puts Long, Neutral or Short on the card in red, slate or green, read from the slot and the rung the file already writes. - **Change the look.** `look:` takes any of the twelve shipped looks, `default` to `classic` ([Looks](../presentation/hud-and-hover-cards.md#looks)), and a word declared beside it still wins; the Look row on the indicator's Style page switches it without code ([The Style page](../settings/style-page.md#the-look-row)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Decision Board** under **HUDs**. 2. Press **Run**: the EMA draws on price and the broadsheet card appears at the bottom of the price pane. Rest the pointer on a tile to open it as a hover card. 3. At the editor's Console prompt, type `last 20 net` to read the score of the last 20 bars. ## Concepts used - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) for `render.hud`, its anchors, its columns and its width - [Looks](../presentation/hud-and-hover-cards.md#looks) for `look: "broadsheet"` and what it draws for each tile kind - [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the headline pill, the spark, the rows and the format words - [The Style page](../settings/style-page.md#the-look-row) for the Look row that switches the look without code - [Plotting](../presentation/plotting.md) for data-only outputs and the string slots the headline and the lean word are written into - [Legend](../presentation/legend.md) for `legend({ title })` and its `{{setting}}` placeholders - [Data sources](../core-concepts/data-sources.md) for the `trades`, `oi` and `liquidations` sources and their `missing` policies - [TA library](../functions/ta-library.md) for `Ema`, `Rsi`, `Sma` and `Roc` <!-- source: https://openmarket.xyz/wrun/cookbook/order-flow-hud --> # Order flow HUD A card at the bottom centre of the price pane that says who is pushing price, read from the exchange's own taker side and kept current on the live bar. It is drawn in the chart desk look: a white page with square corners, a small red tag at its top left and the title "Tug of war". It answers in a word first, as the headline: Buyers pushing, Sellers pushing or Balanced, in green, red or slate. Under the word, each of the last 24 bars draws as a red bar from the 50 mark, rising when taker buys made up more than half of that bar's taker volume and hanging below when taker sells did; the taker buy share of the last 20 bars sits on a meter around 50, the net taker delta of those bars reads in dollars with this bar's own delta in the capsule beside it, and two rows count the longs and the shorts liquidated over the same 20 bars. Nothing is drawn on price. The card is one `render.hud(...)` of five tiles in one column 300 px wide, with `look: "chart_desk"`: a headline pill, a spark, a meter, a value and rows ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks), [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles)). Its numbers are data-only outputs written on every bar and its first word a string slot written on the live bar ([Plotting](../presentation/plotting.md)). The inputs are side-split `trades.volume` and `liquidations.liquidations`, both read with `missing: "zero"` ([Data sources](../core-concepts/data-sources.md)), and the sums are the delta and cumulative delta of the [Order flow kit](../functions/order-flow-kit.md), written out by hand. This is also the `order-flow-hud` template: the **Order Flow HUD** card under **HUDs** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Order Flow HUD: who is pushing price, read from the exchange's own taker side, drawn by the chart in the chart // desk look. One card answers in a word first (Buyers pushing, Sellers pushing or Balanced, in green, red or slate), // then charts each bar's taker buy share as bars around the 50 mark (the tug of war), shows the window's buy share on // a meter around 50, the window's net taker delta in dollars with this bar's own delta beside it, and the longs and // shorts liquidated over the window. Nothing is drawn on price: the card is the whole indicator, and every number on // it is also a data-only output the Console and a watch can read (the cumulative delta too). A market without sided // trades (FX, stocks) says so in the first tile; a spot market has no liquidations, so those two rows stay empty. // Settings: the window every sum covers, and how far from even the buy share must sit before the card names a side. section("Flow window"); param.int("flow_len", 20, { min: 2, max: 200, label: "Flow window in bars", description: "Bars the net delta, the buy share and the liquidations are summed over" }); param.number("lean", 5.0, { min: 0.5, max: 25.0, step: 0.5, label: "Balanced band in points", description: "Points the taker buy share must sit above or below 50 before the card names buyers or sellers; inside the band it reads Balanced" }); legend({ title: "({{flow_len}})" }); // the window after the name in the legend // Inputs: the chart's own candles set the grid. Taker buys and sells arrive in coins and read 0 on a bar without // prints; liquidations arrive in dollars, longs as forced sells and shorts as forced buys, 0 on a quiet bar. input("close", ohlcv.close); input("buy", trades.volume, { side: "BUY", missing: "zero" }); input("sell", trades.volume, { side: "SELL", missing: "zero" }); input("long_liq", liquidations.liquidations, { side: "SELL", missing: "zero" }); input("short_liq", liquidations.liquidations, { side: "BUY", missing: "zero" }); // Outputs: all data-only, one value per bar; the card reads the newest row of each. A colour on an output paints the // mark that reads it: the meter's fill, the sparkline, the dot beside a liquidation row. output("side", none, overlay, { description: "0 sellers pushing, 1 balanced or no data, 2 buyers pushing: the first tile's colour" }); output("buy_share", none, overlay, { color: "#10b981", format: "%", description: "Taker buys as a percent of all taker volume over the window" }); output("bar_buy_share", none, overlay, { format: "%", description: "This bar's taker buys as a percent of its taker volume" }); output("net_delta", none, overlay, { format: "si", description: "Taker buys minus taker sells over the window, in dollars" }); output("bar_delta", none, overlay, { format: "si", description: "This bar's taker buys minus taker sells, in dollars" }); output("cvd", none, overlay, { color: "#38bdf8", format: "si", description: "Cumulative taker delta since the first loaded bar, in dollars" }); output("long_liqs", none, overlay, { color: "#ff003c", format: "si", description: "Longs liquidated over the window, in dollars" }); output("short_liqs", none, overlay, { color: "#10b981", format: "si", description: "Shorts liquidated over the window, in dollars" }); output("window_bars", none, overlay, { format: "int", description: "The flow window in bars, read by the card's title" }); string("who", { max_bytes: 40 }); // the first tile's words, written on the live bar // The card, in the chart desk look (a red tag, a short title, bars that grow from the meter's 50 mark): one column // 300 px wide, the deciding word first, then the tug-of-war bars, the meter, the delta and the liquidation rows. render.hud("order_flow", { position: "bottom_center", look: "chart_desk", title: "Tug of war", columns: 1, width: 300, tiles: [ tile.pill("Who is pushing", "who", { headline: true, color_by: "side", colors: ["#ff003c", "#94a3b8", "#10b981"] }), tile.spark("Each bar's buy share, %", "bar_buy_share", { bars: 24 }), tile.meter("Taker buy share over the window", "buy_share", { min: 0, max: 100, marks: [50] }), tile.value("Net taker delta, USD", "net_delta", { format: "si", delta: "bar_delta" }), tile.rows("Liquidations, USD", [["Longs", "long_liqs", "si"], ["Shorts", "short_liqs", "si"]]), ] }); const MAX_WINDOW = 200; // the flow_len ceiling: the rings are sized for it once, at load const buys = new StaticArray<f64>(MAX_WINDOW); // each bar's taker buys in dollars, one slot per bar of the window const sells = new StaticArray<f64>(MAX_WINDOW); const longs = new StaticArray<f64>(MAX_WINDOW); // each bar's liquidations in dollars const shorts = new StaticArray<f64>(MAX_WINDOW); const SELLERS = 0.0; // the first tile's ladder rungs, in the colours' order: bear, neutral, bull const BALANCED = 1.0; const BUYERS = 2.0; let flowLen = 20; // settings, read in onStart() let lean = 5.0; let head = 0; // the ring slot the next bar writes: it holds the bar that is leaving the window let filled = 0; // bars in the window so far, up to flowLen let buySum = 0.0; // the window's sums, kept by adding the new bar and taking out the one that left let sellSum = 0.0; let longSum = 0.0; let shortSum = 0.0; let cvd = 0.0; // the running delta since the first loaded bar let sidedSeen = 0.0; // every sided dollar seen: 0 means the market serves no sided trades let liquidationsSeen = 0.0; // every liquidated dollar seen: 0 means the market serves none function onStart(): void { flowLen = i32(p_flow_len()); lean = p_lean(); } // onBar() runs once per bar: price the bar's taker volume in dollars at its close, roll the window, write every // output, and on the live bar write the first tile's words. function onBar(): void { const close = bar.close(); const buyCoins = in_buy(); const sellCoins = in_sell(); const longRaw = in_long_liq(); const shortRaw = in_short_liq(); const buy = isFinite(buyCoins) && isFinite(close) ? buyCoins * close : 0.0; const sell = isFinite(sellCoins) && isFinite(close) ? sellCoins * close : 0.0; const longLiq = isFinite(longRaw) ? longRaw : 0.0; const shortLiq = isFinite(shortRaw) ? shortRaw : 0.0; sidedSeen += buy + sell; liquidationsSeen += longLiq + shortLiq; cvd += buy - sell; // Roll the window: take out the bar this slot held, put this bar in its place. buySum += buy - buys[head]; sellSum += sell - sells[head]; longSum += longLiq - longs[head]; shortSum += shortLiq - shorts[head]; buys[head] = buy; sells[head] = sell; longs[head] = longLiq; shorts[head] = shortLiq; head = (head + 1) % flowLen; if (filled < flowLen) filled += 1; // The readings: nothing until the window is full, nothing on a market that never served the feed. const hasTrades = sidedSeen > 0.0; const hasLiquidations = liquidationsSeen > 0.0; const warm = filled >= flowLen; const total = buySum + sellSum; const share = hasTrades && warm && total > 0.0 ? (buySum / total) * 100.0 : NaN; let side = BALANCED; if (share >= 50.0 + lean) side = BUYERS; else if (share <= 50.0 - lean) side = SELLERS; // a NaN share passes neither test and stays Balanced out_side(side); out_buy_share(share); out_bar_buy_share(hasTrades && buy + sell > 0.0 ? (buy / (buy + sell)) * 100.0 : NaN); out_net_delta(hasTrades && warm ? buySum - sellSum : NaN); out_bar_delta(hasTrades ? buy - sell : NaN); out_cvd(hasTrades ? cvd : NaN); out_long_liqs(hasLiquidations && warm ? Math.max(0.0, longSum) : NaN); // the floor clears rounding left by the rolling sum out_short_liqs(hasLiquidations && warm ? Math.max(0.0, shortSum) : NaN); out_window_bars(f64(flowLen)); // The first tile's words, on the live bar only: the deciding word, or why there is none. if (bar.isLast()) { sb_clear(); if (!hasTrades) sb_text("No sided trades on this market"); else if (!warm) sb_text("Not enough bars yet"); else if (isNaN(share)) sb_text("No trades in the window"); else if (side == BUYERS) sb_text("Buyers pushing"); else if (side == SELLERS) sb_text("Sellers pushing"); else sb_text("Balanced"); str_who_sb(); } } ``` ## How it works **Taker volume, in dollars.** `trades.volume` with `side: "BUY"` and `side: "SELL"` hands each bar the coins that takers bought and sold, the side that crossed the spread. Each bar's coins are priced at its close, so the deltas are in dollars like the liquidations, and read the same way on any coin. **One window, kept as a ring.** The last `flow_len` (20) bars of taker buys, taker sells and liquidations sit in rings sized once for the largest setting. Each bar adds itself and takes out the bar that left, so the sums cost the same at 20 bars or 200. The buy share is buys over buys plus sells and the net delta is buys minus sells; both, and the liquidation sums, read a dash until the window is full. **The deciding word.** A buy share at or above 50 plus `lean` (5) reads Buyers pushing, at or below 50 minus `lean` reads Sellers pushing, and anything between reads Balanced. A data-only `side` output (0, 1 or 2) picks the pill's colour from its ladder: red, slate, green. **Each bar against the 50 mark.** `bar_buy_share` is one bar's taker buys as a percent of its taker volume. The chart desk look draws a spark as bars 96 px tall that grow from the card's first meter mark, so with the meter marked at 50 the spark shows, bar by bar, which side took the larger part. `bars: 24` keeps the last 24, and the number beside the spark's label is the newest bar's share. **This bar in the capsule.** The net delta's capsule is the live bar's own buys minus sells, with an up or down arrow, so the card shows the window and the bar at once. **The cumulative delta stays an output.** `cvd` is a running sum of each bar's dollar delta, written on every bar for the Console, though no tile draws it. It counts from the first loaded bar, so its number moves when more history loads: read its slope, not its level. **Liquidations.** Longs are liquidated by forced sells and shorts by forced buys, so `side: "SELL"` reads the longs and `side: "BUY"` the shorts, already in dollars. The two rows sum the window; the red and green dots beside them are the two outputs' own colours. **The look draws the tiles.** `look: "chart_desk"` sets the white page with dark text, the red tag at the top left and the title as written, prints the headline as its word alone in its colour and draws the spark as bars from the meter's mark in the look's red. It keeps its paper on a dark and a light chart alike ([Looks](../presentation/hud-and-hover-cards.md#looks)). **Live.** Every tile reads the newest row of its output, and the chart folds live prints into the live bar as they arrive, so the card refreshes about once a second while the bar is open. ## Where it runs Every market with sided trades: crypto perpetuals and spot on the venues the chart carries (Binance, Hyperliquid and the rest) and prediction markets. The liquidation rows fill on perpetuals with a liquidations feed; a spot market has none, so they stay empty there. FX and stocks have no sided trades: the first tile reads "No sided trades on this market". ## When data is missing - A market with no sided trades at all: the first tile says so and every number reads a dash, never a fake zero. - A market with sided trades but none inside the window: the first tile reads "No trades in the window". - No liquidation seen on any loaded bar (spot, a prediction market): both rows read a dash rather than 0. A perpetual that served liquidations earlier but none inside the window reads 0. - A chart with fewer bars than `flow_len`: the first tile reads "Not enough bars yet", and the share, the net delta and the liquidation sums read a dash. The capsule and each bar's own share start on the first bar. ## Customize it - **Another window.** `flow_len` sets the window of the share, the net delta and the liquidations; the legend shows it after the name. - **A wider middle.** Raise `lean` so the card names a side only on a clear lean. - **More or fewer bars.** `bars: 24` on the spark sets how many bars stand around the 50 mark, up to 64. - **The cumulative delta on the card.** `tile.spark("Cumulative delta", "cvd", { bars: 64 })` draws the running delta the file already writes. - **Another corner.** Change `position: "bottom_center"` to any of the nine anchors, `top_left` to `bottom_right`. - **Coins instead of dollars.** Drop `* close` from the two volume lines; the liquidation rows stay in dollars. - **Change the look.** `look:` takes any of the twelve shipped looks, `default` to `classic` ([Looks](../presentation/hud-and-hover-cards.md#looks)), and a word declared beside it still wins; the Look row on the indicator's Style page switches it without code ([The Style page](../settings/style-page.md#the-look-row)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Order Flow HUD** under **HUDs**. 2. Press **Run** on a crypto chart: the chart desk card appears at the bottom centre and follows the live bar. 3. At the editor's Console prompt, type `outputs` to read the newest value of every output: the side, the buy share, this bar's share, both deltas, the cumulative delta and the two liquidation sums. ## Concepts used - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) and [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for `render.hud`, the five tiles, the headline, the pill's colour ladder and the capsule - [Looks](../presentation/hud-and-hover-cards.md#looks) for `look: "chart_desk"` and what it draws for each tile kind - [The Style page](../settings/style-page.md#the-look-row) for the Look row that switches the look without code - [Data sources](../core-concepts/data-sources.md) for side-split `trades` and `liquidations` and the `missing` policy - [Order flow kit](../functions/order-flow-kit.md) for delta and cumulative delta - [Plotting](../presentation/plotting.md) for data-only outputs and string slots <!-- source: https://openmarket.xyz/wrun/cookbook/order-book-hud --> # Order book HUD A card at the middle left of the price pane that shows what is resting near the price, read from the exchange's own order book and kept current on the live bar. It is drawn in the cockpit look: a dark see-through panel held by four amber corner brackets, with glowing cyan type and its title and labels in capitals. It names the heavier side first, as the headline: Bids heavier or Asks heavier in amber, Balanced in cyan. Under the word, the bids' share of the dollars resting within 1% of the mid lights a bar of 20 segments; three rows give the dollars bid and offered within 0.25%, 0.5% and 1% of the mid, and two rows name the biggest bid and ask within 2%: where each wall sits and how many dollars rest there. The two walls are also drawn on price as thin dotted lines, green under the price and red over it. The card is one `render.hud(...)` of four tiles with `look: "cockpit"`: a headline pill, a meter and two rows tiles whose lines are words written on the live bar ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks), [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles)). The book arrives as `book.cells`, one `[price, size, side]` row per level ([Data sources](../core-concepts/data-sources.md), [Order flow](../functions/order-flow-kit.md#what-the-chart-serves)); the numbers are data-only outputs written on every bar, the first word a string slot written on the live bar ([Plotting](../presentation/plotting.md)), and the walls two line handles ([Drawing objects](../presentation/drawing-objects.md#handles)). This is also the `order-book-hud` template: the **Order Book HUD** card under **HUDs** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Order Book HUD: what is resting near the price, read from the exchange's own order book, drawn by the chart in the // cockpit look. One card names the heavier side first (Bids heavier or Asks heavier in amber, Balanced in the look's // cyan), then the bids' share of the dollars resting within 1% of the mid on a 20-segment bar (50 marked), the // dollars bid and offered within 0.25%, 0.5% and 1% of the mid (one row per distance), and the biggest bid and ask // walls within 2%: where each sits and how many dollars rest there (one row per wall). The imbalance and its change // since the last bar stay data-only outputs for the Console and a watch. The two walls are also drawn on price as thin dotted lines, green under // the price and red over it, from line_bars bars back to the live bar and on to the price axis. A market without an // order book (FX, stocks) says so in the first tile. The book arrives as one snapshot per bar, refreshed about once a // second on the live bar, so the card follows the live book. // Settings: how far the imbalance must lean before the card names a side, and how far back the wall lines reach. // The distances the rows name (0.25%, 0.5%, 1% and the 2% wall reach) are constants below, because a tile's label is // fixed text: change a distance and its label together. section("Reading"); param.number("lean", 10.0, { min: 1.0, max: 50.0, step: 1.0, label: "Balanced band in points", description: "Imbalance points either side of zero before the card names the heavier side; inside the band it reads Balanced" }); section("Wall lines"); param.int("line_bars", 30, { min: 2, max: 300, label: "Wall line length, bars", description: "Bars the two wall lines reach back from the live bar" }); // Inputs: the chart's own candles set the grid (a file whose first feed is a block starts with them), then the book: // one [price, size, side] row per price level, side +1 a bid and -1 an ask, sizes in coins. The chart serves its own // book at its own price grouping, up to 500 levels a side, so 1000 rows hold any bar; block_size is required by the // declaration, and the chart does not read it. input("close", ohlcv.close); input("book", book.cells, { max_cells: 1000, block_size: 10 }); // Outputs: all data-only, one value per bar, so a watch or an alert can read them too; the card shows the words the // slots below carry, written from these numbers on the live bar. output("side", none, overlay, { description: "0 asks heavier, 1 balanced or no book, 2 bids heavier: the first tile's colour" }); output("imbalance", none, overlay, { format: "%", description: "Bids minus asks over their sum within 1% of the mid, percent" }); output("imbalance_change", none, overlay, { format: "%", description: "The imbalance's change since the previous bar's snapshot, points" }); output("bid_share", none, overlay, { color: "#10b981", format: "%", description: "Bids as a percent of all dollars resting within 1% of the mid" }); output("bids_near", none, overlay, { color: "#10b981", format: "si", description: "Dollars bid within 0.25% of the mid" }); output("asks_near", none, overlay, { color: "#ff003c", format: "si", description: "Dollars offered within 0.25% of the mid" }); output("bids_mid", none, overlay, { color: "#10b981", format: "si", description: "Dollars bid within 0.5% of the mid" }); output("asks_mid", none, overlay, { color: "#ff003c", format: "si", description: "Dollars offered within 0.5% of the mid" }); output("bids_far", none, overlay, { color: "#10b981", format: "si", description: "Dollars bid within 1% of the mid" }); output("asks_far", none, overlay, { color: "#ff003c", format: "si", description: "Dollars offered within 1% of the mid" }); output("bid_wall", none, overlay, { color: "#10b981", format: "price", description: "The price of the biggest bid within 2% of the mid" }); output("bid_wall_size", none, overlay, { format: "si", description: "Dollars resting at the bid wall" }); output("ask_wall", none, overlay, { color: "#ff003c", format: "price", description: "The price of the biggest ask within 2% of the mid" }); output("ask_wall_size", none, overlay, { format: "si", description: "Dollars resting at the ask wall" }); string("heavier", { max_bytes: 40 }); // the first tile's words, written on the live bar string("depth_near", { max_bytes: 24 }); // "48.9M / 61.7M": dollars bid / offered within 0.25%, written on the live bar string("depth_mid", { max_bytes: 24 }); string("depth_far", { max_bytes: 24 }); string("bid_wall_text", { max_bytes: 32 }); // "84000 · 262.0M": the wall's price and the dollars resting there string("ask_wall_text", { max_bytes: 32 }); handles.line({ width: 1, lineStyle: "dotted" }); // the two wall lines, in chart time and price // The card, in the cockpit look (corner brackets, monospace type, a faint glow, segment bars): the deciding word leads // as the headline, the bid-share bar spans the card under it, then one row per distance (dollars bid / offered) and // one row per wall (price and dollars). Two columns give the rows room for both numbers. render.hud("order_book", { position: "middle_left", look: "cockpit", title: "Order book", columns: 2, tiles: [ tile.pill("Heavier side, within 1%", "heavier", { headline: true, color_by: "side", colors: ["#FFB000", "#7DF9FF", "#FFB000"] }), tile.meter("Bid share within 1%", "bid_share", { min: 0, max: 100, marks: [50], color: "#7DF9FF" }), tile.rows("Bids / asks, USD", [["Within 0.25%", "depth_near"], ["Within 0.5%", "depth_mid"], ["Within 1%", "depth_far"]]), tile.rows("Walls within 2%, price and USD", [["Bid wall", "bid_wall_text"], ["Ask wall", "ask_wall_text"]]), ] }); const NEAR_PCT = 0.25; // the three depth bands and the wall reach, percent from the mid (the rows' labels name them) const MID_PCT = 0.5; const FAR_PCT = 1.0; // also the band the imbalance reads const WALL_PCT = 2.0; const BID_GREEN = rgb(16, 185, 129); // #10b981, the bid wall line const ASK_RED = rgb(255, 0, 60); // #ff003c, the ask wall line const bidLine = draw.line(0); // handle objects allocate once; ids are one space across kinds const askLine = draw.line(1); const ASKS_HEAVIER = 0.0; // the first tile's ladder rungs, in the colours' order: bear, neutral, bull const BALANCED = 1.0; const BIDS_HEAVIER = 2.0; let lean = 10.0; // settings, read in onStart() let lineBars = 30; let bookSeen = false; // a snapshot with both sides has arrived on some bar: the market has a book let prevImbalance: f64 = NaN; // the previous bar's imbalance, for the capsule let prevTime: f64 = NaN; // the previous bar's open time, for the bar's width in seconds let barSeconds: f64 = NaN; // This bar's readings, set by readBook(): NaN when the bar has no usable snapshot. let bidsNear: f64 = NaN; let asksNear: f64 = NaN; let bidsMid: f64 = NaN; let asksMid: f64 = NaN; let bidsFar: f64 = NaN; let asksFar: f64 = NaN; let bidWall: f64 = NaN; let bidWallSize: f64 = NaN; let askWall: f64 = NaN; let askWallSize: f64 = NaN; function clearReadings(): void { bidsNear = NaN; asksNear = NaN; bidsMid = NaN; asksMid = NaN; bidsFar = NaN; asksFar = NaN; bidWall = NaN; bidWallSize = NaN; askWall = NaN; askWallSize = NaN; } // readBook() walks this bar's snapshot twice: first for the touch (the highest bid and the lowest ask: the chart hands // the asks over from the highest price down, so position says nothing), then for the dollars in each band and the // biggest level on each side within the wall reach. A level's dollars are its price times its size in coins. It // answers false when the bar has no usable snapshot: none delivered, a side missing, or a crossed book. function readBook(): bool { clearReadings(); const n = in_book_cells(); // numbers delivered this bar: 0 when the block is empty, -1 when the bar has none if (n < 3) return false; const cells = in_book_view(); let bestBid = -Infinity; let bestAsk = Infinity; for (let i = 0; i + 2 < n; i += 3) { const price = cells[i]; if (!(price > 0.0) || !(cells[i + 1] > 0.0)) continue; // an empty level is served but rests nothing if (cells[i + 2] > 0.0) bestBid = Math.max(bestBid, price); else if (cells[i + 2] < 0.0) bestAsk = Math.min(bestAsk, price); } if (!(bestBid > 0.0) || !(bestAsk < Infinity) || !(bestAsk > bestBid)) return false; // one side missing, or a crossed book bookSeen = true; const mid = (bestBid + bestAsk) * 0.5; bidsNear = 0.0; asksNear = 0.0; bidsMid = 0.0; asksMid = 0.0; bidsFar = 0.0; asksFar = 0.0; bidWallSize = 0.0; askWallSize = 0.0; for (let i = 0; i + 2 < n; i += 3) { const price = cells[i]; const size = cells[i + 1]; if (!(price > 0.0) || !(size > 0.0)) continue; const dollars = price * size; if (cells[i + 2] > 0.0) { const away = ((mid - price) / mid) * 100.0; // percent below the mid if (away <= NEAR_PCT) bidsNear += dollars; if (away <= MID_PCT) bidsMid += dollars; if (away <= FAR_PCT) bidsFar += dollars; if (away <= WALL_PCT && dollars > bidWallSize) { bidWallSize = dollars; bidWall = price; } } else if (cells[i + 2] < 0.0) { const away = ((price - mid) / mid) * 100.0; // percent above the mid if (away <= NEAR_PCT) asksNear += dollars; if (away <= MID_PCT) asksMid += dollars; if (away <= FAR_PCT) asksFar += dollars; if (away <= WALL_PCT && dollars > askWallSize) { askWallSize = dollars; askWall = price; } } } if (isNaN(bidWall)) bidWallSize = NaN; // no bid within the reach: no wall to name if (isNaN(askWall)) askWallSize = NaN; return true; } // One wall line: from lineBars bars back to the live bar at the wall's price, extended to the price axis; deleted when // there is no wall (a delete on a line that was never drawn does nothing). function drawWall(line: LineHandle, price: f64, t: f64, color: i32): void { if (isNaN(price) || isNaN(barSeconds)) { line.delete(); return; } line.set(t - f64(lineBars) * barSeconds, price, t, price).color(color).style(LineStyle.Dotted).extend(Extend.Right); } // Money as text: 48.9M, 262.0M, 950.0K, one decimal, in the row's slot. function sbUsd(x: f64): void { if (x >= 1.0e9) { sb_f64(x / 1.0e9, 1); sb_text("B"); } else if (x >= 1.0e6) { sb_f64(x / 1.0e6, 1); sb_text("M"); } else { sb_f64(x / 1.0e3, 1); sb_text("K"); } } // One depth row: "<bid dollars> / <ask dollars>", or a dash while the bar has no snapshot. function sbDepth(bids: f64, asks: f64): void { sb_clear(); if (isNaN(bids) || isNaN(asks)) sb_text("-"); else { sbUsd(bids); sb_text(" / "); sbUsd(asks); } } // One wall row: "<price> · <dollars>", or a dash when no level sits within the reach. function sbWall(price: f64, dollars: f64): void { sb_clear(); if (isNaN(price) || isNaN(dollars)) sb_text("-"); else { sb_f64(price, 2); sb_text(" · "); sbUsd(dollars); } } function onStart(): void { lean = p_lean(); lineBars = i32(p_line_bars()); } // onBar() runs once per bar: read the snapshot, write every output, and on the live bar write the first tile's words // and draw the two wall lines. function onBar(): void { const t = bar.time(); if (!isNaN(prevTime) && t > prevTime && (isNaN(barSeconds) || t - prevTime < barSeconds)) barSeconds = t - prevTime; // the smallest step is one bar prevTime = t; const hasSnapshot = readBook(); const depth = bidsFar + asksFar; const imbalance = depth > 0.0 ? ((bidsFar - asksFar) / depth) * 100.0 : NaN; let side = BALANCED; if (imbalance >= lean) side = BIDS_HEAVIER; else if (imbalance <= -lean) side = ASKS_HEAVIER; // a NaN imbalance passes neither test and stays Balanced out_side(side); out_imbalance(imbalance); out_imbalance_change(imbalance - prevImbalance); // NaN until two bars in a row have a book out_bid_share(depth > 0.0 ? (bidsFar / depth) * 100.0 : NaN); out_bids_near(bidsNear); out_asks_near(asksNear); out_bids_mid(bidsMid); out_asks_mid(asksMid); out_bids_far(bidsFar); out_asks_far(asksFar); out_bid_wall(bidWall); out_bid_wall_size(bidWallSize); out_ask_wall(askWall); out_ask_wall_size(askWallSize); prevImbalance = imbalance; if (bar.isLast()) { sb_clear(); if (!bookSeen) sb_text("No order book on this market"); else if (!hasSnapshot) sb_text("Waiting for the book"); else if (isNaN(imbalance)) sb_text("Nothing resting within 1%"); else if (side == BIDS_HEAVIER) sb_text("Bids heavier"); else if (side == ASKS_HEAVIER) sb_text("Asks heavier"); else sb_text("Balanced"); str_heavier_sb(); sbDepth(bidsNear, asksNear); str_depth_near_sb(); sbDepth(bidsMid, asksMid); str_depth_mid_sb(); sbDepth(bidsFar, asksFar); str_depth_far_sb(); sbWall(bidWall, bidWallSize); str_bid_wall_text_sb(); sbWall(askWall, askWallSize); str_ask_wall_text_sb(); drawWall(bidLine, bidWall, t, BID_GREEN); drawWall(askLine, askWall, t, ASK_RED); } } ``` ## How it works **One snapshot per bar.** `input("book", book.cells, { max_cells: 1000, block_size: 10 })` hands each bar the book the chart loads for its own market, at its own price grouping, up to 500 levels a side. History bars carry the snapshot of their bar; the live bar's book is refreshed about once a second. `block_size` is required by the declaration and the chart does not read it. **The touch first.** The chart hands the bids over best first and the asks from the highest price down, so the first pass looks for the highest bid and the lowest ask by price, never by position, and skips empty levels. The mid is halfway between them; a book missing a side, or crossed, reads as no snapshot. **Dollars by distance.** The second pass prices each level (its price times its size in coins) and adds it to every band it sits inside: 0.25%, 0.5% and 1% from the mid. The 1% band also gives the imbalance, bids minus asks over their sum in percent, and the bid share, bids over that sum. The biggest level on each side within 2% is that side's wall. **The deciding word.** An imbalance at or above `lean` (10 points) reads Bids heavier, at or below minus `lean` reads Asks heavier, and anything between reads Balanced. A data-only `side` output (0, 1 or 2) picks the pill's colour from its ladder: amber for either heavier side and cyan for Balanced, so the colour says whether the book leans and the word says which way. The imbalance and its change since the previous bar's book, in points, are written on every bar as `imbalance` and `imbalance_change` for the Console. **The bid share in segments.** The meter reads `bid_share` from 0 to 100 in the cyan it declares; the cockpit look draws a meter as 20 segments, so each segment stands for 5 points of the share and ten lit segments is an even book. The number beside the label is the share itself. **The look draws the tiles.** `look: "cockpit"` sets the dark see-through panel with square corners, the four corner brackets in its amber accent, monospaced cyan type with a soft glow, the title and the labels in capitals, and each tile kind's drawing: the pill as its word alone in its colour, the meter as segments. It keeps its colours on a dark and a light chart alike ([Looks](../presentation/hud-and-hover-cards.md#looks)). **The walls on price.** Two line handles, made once at load, are set on the live bar at the two wall prices, reaching back `line_bars` (30) bars and extended to the price axis, dotted, green for the bid wall and red for the ask wall. A side with no level inside 2% deletes its line. ## Where it runs Every market whose order book the chart serves to an indicator: crypto perpetuals and spot such as Binance Futures BTCUSDT and Binance ETHUSDT. FX and stocks have no order book, and some thin perpetuals have none on the chart either: there the first tile reads "No order book on this market" and nothing is drawn. On a prediction market the chart serves a book in coarse price steps, wider than 1% of a mid in the tens of cents, so the card reads "Nothing resting within 1%" and names no wall. ## When data is missing - A market with no order book on the chart: the first tile says so, every number reads a dash and no line is drawn. - A bar whose snapshot has not arrived yet, or arrived with one side empty or crossed: that bar writes no numbers, and on the live bar the first tile reads "Waiting for the book" until it does. - A book with no level inside 1% of the mid: the first tile reads "Nothing resting within 1%", every band reads 0 (the bands nest) and the bid share reads a dash with no segment lit; a level within 2% is still named as a wall. - A band with no level inside it reads 0; a side with no level within 2% names no wall and draws no line. ## Customize it - **A wider middle.** Raise `lean` so the card names a side only on a clear lean. - **Longer or shorter wall lines.** `line_bars` sets how far back the two lines reach from the live bar. - **Other distances.** The bands and the wall reach are the constants `NEAR_PCT`, `MID_PCT`, `FAR_PCT` and `WALL_PCT`; change one together with the row labels that name it, since a tile label is fixed text. - **The imbalance as a number.** `tile.value("Imbalance within 1%", "imbalance", { format: "%", delta: "imbalance_change" })` puts the imbalance on the card with its change since the last bar's book in the capsule. - **Another corner.** Change `position: "middle_left"` to any of the nine anchors, `top_left` to `bottom_right`. - **Change the look.** `look:` takes any of the twelve shipped looks, `default` to `classic` ([Looks](../presentation/hud-and-hover-cards.md#looks)), and a word declared beside it still wins; the Look row on the indicator's Style page switches it without code ([The Style page](../settings/style-page.md#the-look-row)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Order Book HUD** under **HUDs**. 2. Press **Run** on a crypto chart: the cockpit card appears at the middle left and the two walls draw on price. 3. At the editor's Console prompt, type `outputs` to read the newest value of every output: the imbalance and its change, the bid share, the six depth sums and the two walls with their sizes. ## Concepts used - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) and [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for `render.hud`, the four tiles, the headline and the pill's colour ladder - [Looks](../presentation/hud-and-hover-cards.md#looks) for `look: "cockpit"` and what it draws for each tile kind - [The Style page](../settings/style-page.md#the-look-row) for the Look row that switches the look without code - [Data sources](../core-concepts/data-sources.md) and [Order flow](../functions/order-flow-kit.md#what-the-chart-serves) for `book.cells`, its row shape and its order - [Drawing objects](../presentation/drawing-objects.md#handles) for line handles made once and set on the live bar - [Plotting](../presentation/plotting.md) for data-only outputs and string slots <!-- source: https://openmarket.xyz/wrun/cookbook/session-hud --> # Session HUD Where the day stands, in one card at the bottom of the price pane, drawn in the stage look: a green gradient card with the chart's base asset (BTC on a BTC chart) on a cover square beside the title, and a bright green for the headline and every fill. It names the session trading now as the headline (Asia, London, New York or Closed), how much of that session has passed on a track bar, and the session up next with a countdown ("London in 2 h 5 min"); then how much of an average day's range today has already used, on a split bar that runs to one and a half average days; where the close sits inside today's range, on a track from the low (0) to the high (100); how far the close is from the day's VWAP; and how far it is from the prior day's high and low, in percent. On price, the day's VWAP draws in sky, starting again each day, and the prior day's high and low run as dotted slate lines from today's first bar to the right edge. The parts are three `param.session` windows read with `inSession` and a `Clock` per zone ([Sessions and units](../settings/sessions-and-units.md), [Clock and sessions kit](../functions/time-and-sessions-kit.md)), the bar's `time.trade_date` for the day ([Data sources](../core-concepts/data-sources.md)), a `candles` stream of daily candles kept by a `CandleList` for the average daily range ([Multi-timeframe](../core-concepts/multi-timeframe.md), [Higher-timeframe kit](../functions/higher-timeframe-kit.md)), a line output for the VWAP ([Plotting](../presentation/plotting.md)), two line handles for the prior day ([Drawing objects](../presentation/drawing-objects.md#handles)), and a two-column `render.hud` card with `look: "stage"` of two pills, two gauges, a meter, a value and a rows tile ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks), [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles)). This is also the `session-hud` template: the **Session HUD** card under **HUDs** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Session HUD: where the day stands, in one card at the bottom of the price pane, drawn by the chart in the stage // look. The trader sees the session trading now (Asia, London, New York, or Closed) as the headline beside the // chart's coin as a cover, how much of that session has passed as a track bar, which session opens next and when, // how much of an average day's range today has already used, where the close sits inside today's range, and how far // the close is from the day's VWAP and from the prior day's high and low (the minutes left ride an output). On price: the day's VWAP in sky, and the prior // day's high and low as dotted slate lines across today. Every market with candles: crypto, stocks and forex. // Settings: the three session windows, each in its own zone (daylight saving follows the zone, bar by bar), and the // number of days in the average daily range. section("Sessions"); param.session("asia", "00:00-08:00", { tz: "UTC", label: "Asia" }); param.session("london", "07:00-16:00", { tz: "UTC", label: "London" }); param.session("new_york", "13:30-20:00", { tz: "UTC", label: "New York" }); section("Range"); param.int("adr_days", 14, { min: 2, max: 30, label: "Days in average range", description:"Days in the average daily range that today's range is measured against" }); chart.interval_sec(); // the chart's bar interval in seconds, written by the chart: a 4h or coarser chart gets words instead input("close", ohlcv.close); // the chart's own candles: the grid input("trade_date", time.trade_date); // the bar's trade date, one number per day: the UTC date on crypto and forex, the New York date on US stocks input("days", candles.cells, { interval: "1d", bars: 30, description: "The newest 30 closed daily candles: the average daily range and the prior day" }); // On price: the day's VWAP, starting again with each trade date. output("vwap", line, overlay, { color: "#38bdf8", width: 1, label: "Day VWAP", format: "price", description: "The day's volume-weighted average price; on a market without volume every bar weighs the same" }); // The readings, data-only and written on every bar: the card's tiles read the newest one, the Console reads them all. output("session_now", none, overlay, { description: "The session trading now: 0 closed, 1 Asia, 2 London, 3 New York (the later one where two overlap)" }); output("session_done", none, overlay, { format: "0", description: "Percent of the session trading now that has passed; NaN while no session trades" }); output("minutes_left", none, overlay, { description: "Minutes from the bar's open to the session's close; NaN while no session trades" }); output("range_used", none, overlay, { format: "0", description: "Today's high-low range as a percent of the average daily range" }); output("range_position", none, overlay, { description: "Where the close sits in today's range: 0 at the low, 100 at the high" }); output("vs_vwap", none, overlay, { color: "#38bdf8", description: "Close against the day's VWAP, percent" }); // a row's dot takes its output's colour: sky like the line output("vs_prior_high", none, overlay, { color: "#94a3b8", description: "Close against the prior day's high, percent" }); output("vs_prior_low", none, overlay, { color: "#94a3b8", description: "Close against the prior day's low, percent" }); string("up_next", { max_bytes: 32 }); // the next session to open and when, written on the live bar string("session", { max_bytes: 32 }); // the card's first tile: the session's name, or why there is none handles.line({ color: "#94a3b8", width: 1, lineStyle: "dotted", extend: "right" }); // the prior day's high and low, from today's first bar to the right edge // The card, in the stage look (the chart's coin as a cover, a heavy headline, track bars, one green accent): two // columns at the bottom centre of the pane, clear of the legend at the top left, the price axis and its tags on the // right, and the floating button at the bottom left. The session's name leads as the headline; every tile reads // the newest row. render.hud("session", { position: "bottom_center", look: "stage", title: "Session", columns: 2, tiles: [ tile.pill("Trading now", "session", { headline: true }), tile.gauge("Session passed, %", "session_done", { min: 0, max: 100, format: "0" }), tile.pill("Up next", "up_next"), tile.meter("Day range vs average, %", "range_used", { min: 0, max: 150, marks: [100] }), tile.gauge("Close in today's range", "range_position", { min: 0, max: 100, format: "0" }), tile.value("Close vs day VWAP", "vs_vwap", { format: "%" }), tile.rows("Close vs prior day", [ ["High", "vs_prior_high", "%"], ["Low", "vs_prior_low", "%"], ]), ], }); const SESSIONS = 3; // Asia, London, New York, in the order a later one wins an overlap const NAMES = ["Asia", "London", "New York"]; const MAX_DAYS = 30; // the daily stream's depth: adr_days is at most this const starts = new StaticArray<f64>(SESSIONS); // each window's start and end in minutes from midnight, and its zone, read in onStart() const ends = new StaticArray<f64>(SESSIONS); const zones = new StaticArray<i32>(SESSIONS); let clocks: Clock[] = []; // one clock per session, in the zone its window is written in, built in onStart() const days = new CandleList(MAX_DAYS); // the newest closed daily candles, kept from the stream const priorHighLine: LineHandle = draw.line(0); // handle objects allocate once, at module load const priorLowLine: LineHandle = draw.line(1); let adrDays = 14; // settings, read in onStart() let coarse = false; let dayKey: f64 = NaN; // today's trade date, and the open of its first bar let dayStart: f64 = NaN; let dayWhole = false; // today began inside the loaded history: the first day may have started before it let dayHigh: f64 = NaN; // today so far let dayLow: f64 = NaN; let pv = 0.0; // today's VWAP sums: price times volume, volume, and the plain sum and count for a market without volume let vol = 0.0; let typicalSum = 0.0; let typicalCount = 0; let chartPriorHigh: f64 = NaN; // the prior day from the chart's own bars, where the daily stream is not served let chartPriorLow: f64 = NaN; // onStart() runs once before the first bar: the settings, and a clock per session zone. function onStart(): void { starts[0] = p_asia_start(); ends[0] = p_asia_end(); zones[0] = i32(p_asia_tz()); starts[1] = p_london_start(); ends[1] = p_london_end(); zones[1] = i32(p_london_tz()); starts[2] = p_new_york_start(); ends[2] = p_new_york_end(); zones[2] = i32(p_new_york_tz()); clocks = [new Clock(SESSION_TZ_IDS[zones[0]]), new Clock(SESSION_TZ_IDS[zones[1]]), new Clock(SESSION_TZ_IDS[zones[2]])]; adrDays = i32(p_adr_days()); if (adrDays > MAX_DAYS) adrDays = MAX_DAYS; coarse = p_chart_interval_sec() >= 14400.0; // 4h and coarser: a session is a bar or two, its minutes would say nothing } // Minutes from a bar's open to its session's close, on the session's own clock; a window may wrap midnight. function minutesLeft(s: i32, t: f64): f64 { const clock = clocks[s]; clock.update(t); const minute = f64(clock.hour() * 60 + clock.minute()); const start = unchecked(starts[s]); const end = unchecked(ends[s]); if (end > start || minute < end) return end - minute; return 1440.0 - minute + end; // a window that wraps midnight, before midnight: it closes tomorrow } // The share of a session that has passed at a bar's open, percent. function sessionDone(s: i32, t: f64): f64 { const start = unchecked(starts[s]); const end = unchecked(ends[s]); const length = end > start ? end - start : 1440.0 - start + end; return Math.max(0.0, Math.min(100.0, (1.0 - minutesLeft(s, t) / length) * 100.0)); } // Minutes from a bar's open to a session's next start, on the session's own clock. function minutesToStart(s: i32, t: f64): f64 { const clock = clocks[s]; clock.update(t); const minute = f64(clock.hour() * 60 + clock.minute()); const start = unchecked(starts[s]); return start > minute ? start - minute : 1440.0 - minute + start; } // The prior day's level across today, or nothing while it is unknown. function drawPrior(line: LineHandle, price: f64, t: f64): void { if (isNaN(price) || isNaN(dayStart)) { line.delete(); // a no-op when never drawn return; } line.set(dayStart, price, t, price); } // onBar() runs once per bar: feed the daily stream, place the bar in its session and its day, then write the // readings; the session's name and the two prior-day lines on the live bar only. function onBar(): void { const t = bar.time(); days.load(in_days_view(), in_days_cells(), t); // every bar feeds its block, an empty one too const close = bar.close(); if (isNaN(close)) return; if (coarse) { if (bar.isLast()) { sb_clear(); sb_text("Needs a chart under 4h"); str_session_sb(); } return; } // The session: the later window wins where two overlap (London over Asia, New York over London). let now = -1; for (let s = 0; s < SESSIONS; s += 1) if (inSession(t, unchecked(starts[s]), unchecked(ends[s]), unchecked(zones[s]))) now = s; out_session_now(f64(now + 1)); out_minutes_left(now >= 0 ? minutesLeft(now, t) : NaN); out_session_done(now >= 0 ? sessionDone(now, t) : NaN); // The day: a new trade date starts a new one, and the day that ends becomes the prior day if it was seen whole. const high = bar.high(); const low = bar.low(); const key = in_trade_date(); const fresh = key != dayKey; if (fresh) { if (dayWhole) { chartPriorHigh = dayHigh; chartPriorLow = dayLow; } dayWhole = !isNaN(dayKey); dayKey = key; dayStart = t; dayHigh = high; dayLow = low; pv = 0.0; vol = 0.0; typicalSum = 0.0; typicalCount = 0; } else { if (high > dayHigh) dayHigh = high; if (low < dayLow) dayLow = low; } const typical = (high + low + close) / 3.0; const volume = bar.volume(); if (volume > 0.0) { pv += typical * volume; vol += volume; } typicalSum += typical; typicalCount += 1; const vwap = !dayWhole ? NaN : vol > 0.0 ? pv / vol : typicalSum / f64(typicalCount); out_vwap(fresh ? NaN : vwap); // the day's first bar draws nothing, so each day's line starts on its own // The average daily range of the newest closed days, and the prior day: the stream's newest closed day where it // is served, else the chart's own bars. The day's last bar is handed the daily candle that closes at its end, the // day's own: a held candle that opened on today's trade date or later is skipped, never read as a prior day. let held = days.count(); while (held > 0 && days.openSec(held - 1) >= key) held -= 1; let adr: f64 = NaN; if (held >= adrDays) { let sum = 0.0; for (let i = held - adrDays; i < held; i += 1) sum += days.high(i) - days.low(i); adr = sum / f64(adrDays); } const priorHigh = held > 0 ? days.high(held - 1) : chartPriorHigh; const priorLow = held > 0 ? days.low(held - 1) : chartPriorLow; const range = dayHigh - dayLow; out_range_used(dayWhole && adr > 0.0 ? (100.0 * range) / adr : NaN); out_range_position(dayWhole && range > 0.0 ? (100.0 * (close - dayLow)) / range : NaN); out_vs_vwap(vwap > 0.0 ? 100.0 * (close / vwap - 1.0) : NaN); out_vs_prior_high(priorHigh > 0.0 ? 100.0 * (close / priorHigh - 1.0) : NaN); out_vs_prior_low(priorLow > 0.0 ? 100.0 * (close / priorLow - 1.0) : NaN); if (bar.isLast()) { sb_clear(); sb_text(now >= 0 ? NAMES[now] : "Closed"); str_session_sb(); let next = -1; let wait = 1441.0; for (let s = 0; s < SESSIONS; s += 1) { if (s == now) continue; const m = minutesToStart(s, t); if (m < wait) { wait = m; next = s; } } sb_clear(); if (next >= 0) { const h = i64(Math.floor(wait / 60.0)); sb_text(NAMES[next]); sb_text(" in "); if (h > 0) { sb_int(h); sb_text(" h "); } sb_int(i64(wait) - h * 60); sb_text(" min"); } str_up_next_sb(); drawPrior(priorHighLine, priorHigh, t); drawPrior(priorLowLine, priorLow, t); } } ``` ## How it works **The session is cut from the clock.** Each window is a `param.session` (Asia 00:00 to 08:00, London 07:00 to 16:00, New York 13:30 to 20:00, all UTC by default), read in `onStart()` as a start, an end and a zone. Per bar, `inSession(...)` says whether the bar's open falls inside each window, daylight saving included; where two overlap, the later one in the list wins (London over Asia from 07:00, New York over London from 13:30). `session_now` carries the answer as a number, 0 closed to 3 New York. **Time from the bar's open.** No wall clock reaches an indicator: the newest bar's open is its clock. `minutes_left` counts from the live bar's open to the session's close on the session's own clock (a `Clock` built in the window's zone), and `session_done` turns it into the share of the session that has passed, 0 at its open and 100 at its close. Both step once per bar: on a 15m chart the track bar can sit up to 15 minutes behind, on a 1h chart up to an hour. **Up next.** On the live bar the script measures, on each other session's own clock, the minutes from the bar's open to that session's next start, and writes the nearest one into the bounded `up_next` slot as words: "London in 2 h 5 min", or "New York in 45 min" under an hour. It steps once per bar too, so it can read up to one bar long. **The day is the trade date.** `time.trade_date` gives each bar its trading day: the UTC date on crypto and forex, the New York date on US stocks. A new trade date starts a new day: today's high, low and VWAP sums start again, and the day that ended becomes the prior day when the chart saw it from its first bar. The first day of the loaded history may have begun before it, so its readings stay empty until the next day starts. **The VWAP.** Each bar adds its typical price (high plus low plus close, over 3) weighted by its volume, and the VWAP is that sum over the day's volume. On a market that reports no volume every bar weighs the same, so the line is the day's average typical price. The day's first bar draws nothing, so each day's line starts on its own instead of joining the day before. **The average day.** `input("days", candles.cells, { interval: "1d", bars: 30 })` streams the closed daily candles and a `CandleList` keeps the newest 30. The average daily range is the mean of high minus low over the newest `adr_days` (14) of them, and `range_used` is today's high minus low as a percent of it: 100 is an average day's whole range, and the meter runs to 150. The prior day's high and low come from the newest closed daily candle where the stream is served, else from the chart's own bars. A day's candle closes at the end of the day's last bar, so that bar (23:00 on a 1h crypto chart) is handed today's own candle: every held candle that opened on today's trade date or later (`days.openSec(i)` at or past `in_trade_date()`) is skipped before either reading, so the 23:00 bar still measures against yesterday's high and low and an average of the days before today. **The card reads the outputs.** `render.hud("session", { position: "bottom_center", look: "stage", columns: 2, tiles })` pins a 300 px card of two columns: the headline pill over the `session` slot on the first row; a gauge of `session_done` (0 to 100) beside a pill over the `up_next` slot; a meter of `range_used` (0 to 150) across the card; a gauge of `range_position` (0 to 100) beside a value of `vs_vwap` in `%`; and a rows tile of the distances from the prior day's high and low in `%`. The distances are already percents: the card prints two decimals, a minus sign below the level and no sign above it. A row's dot takes its output's colour, slate for the prior day like its lines. Every tile reads the newest row; the session's name, the up-next words and the two dotted lines are written on the live bar only, the numbers on every bar. **The look draws the tiles.** `look: "stage"` sets the green gradient card with its cover square (the chart's base asset over the accent), the headline bold at 30 px, and its green accent on the headline and on every gauge and meter that declares no colour of its own. A gauge draws as a track with a knob, a meter as a split bar (the part used in the accent, the rest of the way to 150 in the chart's down colour, the two numbers at its ends) and a pill as its words alone. It keeps its colours on a dark and a light chart alike ([Looks](../presentation/hud-and-hover-cards.md#looks)). **Where the card sits.** At the bottom centre: the legend holds the top left, the action bar and the High and Low tags the top right, the VWAP tag and the newest candles the right edge, and the app's floating button the bottom left. ## Where it runs Every market with candles, on charts finer than 4h: crypto perpetuals and spot (Binance Futures BTCUSDT, Binance spot ETHUSDT, Hyperliquid PURR), US stocks and forex (gold). The windows are clock windows in their zones, so on crypto they run every day, weekends included; a forex chart has no bars over the weekend, so on a Saturday it shows Friday's last bar, after New York's close: "Closed". The average daily range needs the chart's daily candle stream, which crypto charts serve and forex and US stock charts do not (below). ## When data is missing - **Forex and US stocks: no daily stream.** The chart does not serve a `candles` stream on those markets, so `days` reads no candles: the range meter prints "-", the prior day's high and low come from the chart's own bars, and the Console and the legend show the chart's warning, which names the input: `Input 'days' pins interval '1d' on an index-mode chart (FX_OTC): pinned legs are served on timestamp-mode markets only, so the input reads its missing fill (NaN) on every bar.` Everything else on the card reads as usual. - **No volume.** Forex reports no volume, so the VWAP weighs every bar the same there: the line is the day's average typical price. - **Between sessions.** Outside the three windows the headline reads "Closed", the session-passed bar prints "-", and Up next counts down to the next session to open. - **Coarse charts.** On a 4h or coarser chart a session is a bar or two, so the headline reads "Needs a chart under 4h", the other tiles print "-", and nothing is drawn on price. - **The first loaded day.** The chart's history may start inside a day; that day's VWAP, range and position stay empty, and the next day starts clean. On a short chart (a few hours of 1m bars) today itself can be that first day: the session word, the session-passed bar and Up next still read. - **A flat day.** A day whose high equals its low leaves the close-in-range track reading "-". ## Customize it - **Other sessions.** The three windows are settings, each with its own zone; a window that crosses midnight (`22:00-04:00`) wraps on its own, and the track bar and the countdown count across midnight. - **A longer or shorter average.** `adr_days` goes from 2 to 30; the stream holds 30 days, so past that raise `bars` on the `days` input and `MAX_DAYS` with it. - **The minutes as a number.** `tile.value("Minutes to close", "minutes_left", { format: "int" })` puts the minutes left in the session on the card beside the track bar's share. - **Another place for the card.** Change `position` to another of the nine anchors (`middle_left` works too); keep the right edge free for the VWAP tag and the newest candles. - **Change the look.** `look:` takes any of the twelve shipped looks, `default` to `classic` ([Looks](../presentation/hud-and-hover-cards.md#looks)), and a word declared beside it still wins; the Look row on the indicator's Style page switches it without code ([The Style page](../settings/style-page.md#the-look-row)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Session HUD** under **HUDs**. 2. Press **Run** on a chart finer than 4h, such as 15m: the VWAP draws on price, the prior day's high and low run across today, and the stage card appears at the bottom centre. 3. At the editor's Console prompt, type `session_done` and `range_used` to read the newest values, or `last 60 session_now` for the session on each of the last 60 bars. ## Concepts used - [Sessions and units](../settings/sessions-and-units.md) for `param.session`, its zones and `inSession` - [Clock and sessions kit](../functions/time-and-sessions-kit.md) for `Clock` - [Higher-timeframe kit](../functions/higher-timeframe-kit.md) for `CandleList` - [Data sources](../core-concepts/data-sources.md) for `time.trade_date` - [Multi-timeframe](../core-concepts/multi-timeframe.md) for the `candles` stream and where the chart serves it - [Drawing objects](../presentation/drawing-objects.md#handles) for line handles from today's first bar to the right edge - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) and [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the card, its tiles and the headline - [Looks](../presentation/hud-and-hover-cards.md#looks) for `look: "stage"`, its cover and what it draws for each tile kind - [The Style page](../settings/style-page.md#the-look-row) for the Look row that switches the look without code <!-- source: https://openmarket.xyz/wrun/cookbook/gamma-map --> # Gamma map The chart coin's live options chain turned into gamma exposure by strike, docked on the price axis as a profile. Each row is one strike's net GEX, calls minus puts, in USD per 1% move of spot: sky where calls carry the strike, violet where puts do. Three lines run across the chart with a price tag at the axis: the call wall (sky), the put wall (violet) and the gamma flip (amber, dashed). A card at the bottom of the pane, in the terminal look (black, monospaced, its title on an amber bar), answers one question first, as the headline: are dealers long or short gamma where spot trades? Long gamma, in green, means spot is at or above the gamma flip, where the dealers' hedging leans against moves; short gamma, in red, means spot is under the flip, where their hedging chases them. Under the answer the card reads the chain's net GEX, how far spot sits from the flip, and four numbered rows of prices: 1) the flip, 2) the call wall, 3) the put wall and 4) max pain; its title bar counts the expiries. The parts are a celled `options_chain.cells` input read on the live bar ([Data sources](../core-concepts/data-sources.md)), a frame feeding `plot.levels` docked on the price axis ([Cards, frames and panels](../presentation/cards-frames-panels.md#frames-panels-and-compact-widgets)), line and label handles for the walls, the flip and their tags ([Drawing objects](../presentation/drawing-objects.md#handles)), and a `render.hud` card with `look: "terminal"` of a headline pill, two values and a rows tile over data-only outputs and two text slots ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks), [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles)). This is also the `gamma-map` template: the **Gamma Map** card under **HUDs** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Gamma Map: the chart coin's live options chain as gamma exposure by strike, docked on the price axis. Each row is // one strike's net GEX (calls minus puts, in USD per 1% move of spot): sky where calls carry the strike, violet where // puts do. The call wall (the strike holding the most call open interest), the put wall (most put open interest) and // the gamma flip (where cumulative net GEX crosses zero, the crossing nearest spot when the far wings' few dollars flip // the sum back and forth) run across the chart as lines with price tags at the axis. // A card at the bottom of the pane, in the terminal look, answers one question first: are dealers long or short // gamma where spot trades? // Long gamma at or above the flip (their hedging leans against moves), short gamma under it (their hedging chases // moves). Under the answer, the chain's net GEX (every strike summed), how far spot sits from the flip, and the four // levels. The chain is live only: history bars draw nothing and cost nothing; everything is measured on the last bar. // The frame channel, declared here so the profile is written from a static byte buffer: the generated writeFrame // takes a string, and a string built per bar allocates, which the sandbox refuses once the bars start. @external("wrun", "wrun_output_frame") declare function wrun_output_frame(slot: i32, ptr: i32, len: i32): void; section("Strikes and expiries"); param.number("window_pct", 6, { min: 1, max: 50, label: "Strike window, percent", description: "Strikes in the docked profile and the walls: percent around spot, each side" }); param.int("nearest_expiries", 0, { min: 0, max: 24, label: "Expiries counted", description: "Expiries counted: 0 = every listed expiry, N = the N nearest" }); input("close", ohlcv.close); // the chart's own close: the grid, and spot for the strike window input("chain", options_chain.cells, { max_cells: 4000, venue: "auto", description: "The live options chain of the chart's coin (venue auto: the chart's own market when it lists options, else the coin's Deribit chain)" }); // [strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega] per contract; a BTC chain is about 1,550 contracts // The readings, data-only: the card's tiles read them and the Console can too. Each is NaN on history bars. output("net_gex", none, overlay, { description: "Net gamma exposure of the counted chain, USD per 1% move of spot; live bar only" }); output("gamma_flip", none, overlay, { description: "Gamma flip: the strike where cumulative net GEX crosses zero, the crossing nearest spot when it crosses more than once" }); output("call_wall", none, overlay, { color: "#38bdf8", description: "Call wall: the strike at or above spot, inside the window, holding the most call open interest" }); // the colour is the dot beside its row on the card, the line's own sky output("put_wall", none, overlay, { color: "#a78bfa", description: "Put wall: the strike at or below spot, inside the window, holding the most put open interest" }); output("max_pain", none, overlay, { description: "Max pain: the settlement price on the strike grid that minimises the chain's intrinsic payout" }); output("gex_tone", none, overlay, { description: "1 while the chain's net GEX is positive, 0 while negative" }); output("gamma_regime", none, overlay, { description: "Dealer gamma at spot, the first tile's colour: 0 short gamma (spot under the flip), 2 long gamma (spot at or above it), 1 no reading (no flip in range, no gamma, no chain); live bar only" }); output("spot_vs_flip", none, overlay, { description: "Spot against the gamma flip, percent: above 0 while spot trades above the flip, below 0 under it" }); string("tag", { max_bytes: 40 }); // one bounded slot the three price tags are written through, one at a time string("dealer_gamma", { max_bytes: 32 }); // the card's first tile: Long gamma, Short gamma, or why there is no reading string("expiries", { max_bytes: 32 }); // the card's title: which expiries were counted handles.line({ width: 1, extend: "both" }); // the walls and the flip, across the whole chart handles.label({ anchor: "right", align: "right", size: 11, color: "#e2e8f0" }); // price tags: x in pixels from the right edge, y in price const gex_rows = frame("gex_rows", { max_bytes: 16384 }); // the docked profile: one row per grid price plot.levels({ name: "gex_by_strike", frame: gex_rows, dock: "right", width_frac: 0.12, labels: false, color: "#38bdf8", thickness_px: 12 }); // bars at most 12 px tall, so a zoomed-in chart reads as a ladder, never as slabs // The card, in the terminal look (an amber title bar, monospace type, numbered rows): a HUD pinned at the bottom // centre of the pane, clear of the docked profile and the tags on the right, the legend at the top left and the // floating button at the bottom left. Every tile reads the newest row, the live bar's. The regime leads as the // headline, coloured by the regime at spot (bear red for short gamma, slate for no reading, bull green for long // gamma); net GEX prints in dollars and the levels follow as numbered rows, prices with thousands separators; the title // carries the expiries counted ({{expiries}} reads the slot of that name). render.hud("gamma", { position: "bottom_center", look: "terminal", title: "Gamma, {{expiries}}", columns: 2, tiles: [ tile.pill("Dealer gamma", "dealer_gamma", { headline: true, color_by: "gamma_regime", colors: ["#ff003c", "#94a3b8", "#10b981"] }), tile.value("Net GEX per 1% move", "net_gex", { format: "usd" }), tile.value("Spot vs the flip", "spot_vs_flip", { format: "%" }), tile.rows([ ["1) Gamma flip", "gamma_flip", "auto"], ["2) Call wall", "call_wall", "auto"], ["3) Put wall", "put_wall", "auto"], ["4) Max pain", "max_pain", "auto"], ]), ], }); const MAX_STRIKES = 512; // distinct strikes the chain may hold across the counted expiries const MAX_EXPIRIES = 64; // distinct live expiries const MAX_ROWS = 200; // grid rows in the docked profile const TUPLE = 10; // f64s per contract: strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega const strikePrice = new StaticArray<f64>(MAX_STRIKES); // the strike table, sorted ascending, one row per strike const callOi = new StaticArray<f64>(MAX_STRIKES); const putOi = new StaticArray<f64>(MAX_STRIKES); const callGex = new StaticArray<f64>(MAX_STRIKES); // USD per 1% move, the dealer-naive sign convention: calls positive, puts negative const putGex = new StaticArray<f64>(MAX_STRIKES); const expiryMs = new StaticArray<f64>(MAX_EXPIRIES); // the live expiries, sorted ascending const rowGex = new StaticArray<f64>(MAX_ROWS); // the profile grid: signed net GEX per row const jsonBytes = new StaticArray<u8>(16384); // the frame JSON, appended as UTF-8 bytes let jsonUsed = 0; const SKY = rgba(56, 189, 248, 255); const VIOLET = rgba(167, 139, 250, 255); const AMBER = rgba(248, 192, 0, 255); const PILL = rgba(15, 23, 42, 235); // the tags' backdrop const callWallLine: LineHandle = draw.line(0); // handle objects allocate once; ids are one space across kinds const putWallLine: LineHandle = draw.line(1); const flipLine: LineHandle = draw.line(2); const tags: LabelHandle[] = [draw.label(3), draw.label(4), draw.label(5)]; // call wall, put wall, flip let windowPct = 0.1; // settings, read in onStart() let nearestExpiries = 0; let close: f64 = NaN; // this bar let t: f64 = NaN; let firstT: f64 = NaN; // the first bar's open time: where the lines start let strikeCount = 0; // the strike table in use let expiryCount = 0; let netGex: f64 = NaN; // the chain's readings, measured on the live bar let flip: f64 = NaN; let callWall: f64 = NaN; let putWall: f64 = NaN; let maxPain: f64 = NaN; // ── The strike table: sorted insertion, one row per strike ── function strikeSlot(strike: f64): i32 { // the row of this strike, inserted when new; -1 when the table is full let lo = 0; let hi = strikeCount; while (lo < hi) { const mid = (lo + hi) >> 1; if (strikePrice[mid] < strike) lo = mid + 1; else hi = mid; } if (lo < strikeCount && strikePrice[lo] == strike) return lo; if (strikeCount >= MAX_STRIKES) return -1; for (let i = strikeCount; i > lo; i -= 1) { strikePrice[i] = strikePrice[i - 1]; callOi[i] = callOi[i - 1]; putOi[i] = putOi[i - 1]; callGex[i] = callGex[i - 1]; putGex[i] = putGex[i - 1]; } strikePrice[lo] = strike; callOi[lo] = 0.0; putOi[lo] = 0.0; callGex[lo] = 0.0; putGex[lo] = 0.0; strikeCount += 1; return lo; } function noteExpiry(ms: f64): void { // sorted insertion of a distinct live expiry let lo = 0; let hi = expiryCount; while (lo < hi) { const mid = (lo + hi) >> 1; if (expiryMs[mid] < ms) lo = mid + 1; else hi = mid; } if (lo < expiryCount && expiryMs[lo] == ms) return; if (expiryCount >= MAX_EXPIRIES) return; for (let i = expiryCount; i > lo; i -= 1) expiryMs[i] = expiryMs[i - 1]; expiryMs[lo] = ms; expiryCount += 1; } // ── The chain, measured on the live bar ── function measureChain(n: i32): void { // n = f64 cells in the live block const cells = in_chain_view(); // the live chain in place: the first n values of the build's own buffer const nowMs = t * 1000.0; strikeCount = 0; expiryCount = 0; for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) if (cells[i + 1] > nowMs) noteExpiry(cells[i + 1]); // the live expiries, for the nearest-N filter if (expiryCount == 0) return; const cutoff = nearestExpiries > 0 && nearestExpiries < expiryCount ? expiryMs[nearestExpiries - 1] : expiryMs[expiryCount - 1]; for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { const strike = cells[i]; const expiry = cells[i + 1]; const oi = cells[i + 3]; if (!(strike > 0.0) || !(oi > 0.0) || expiry <= nowMs || expiry > cutoff) continue; const slot = strikeSlot(strike); if (slot < 0) continue; const multiplier = cells[i + 8] > 0.0 ? cells[i + 8] : 1.0; // Deribit open interest is in coin units (multiplier 1); CME open interest is contracts times the point value const spot = cells[i + 7] > 0.0 ? cells[i + 7] : close; const gexUsd = cells[i + 4] * oi * multiplier * spot * spot * 0.01; // the USD delta change of the open interest for a 1% move of spot if (cells[i + 2] > 0.0) { callOi[slot] += oi; callGex[slot] += gexUsd; } else { putOi[slot] += oi; putGex[slot] += gexUsd; } } // Net GEX, max pain and the flip over every counted strike; the walls among the strikes inside the window, the // ones the profile shows: the call wall is the heaviest call strike at or above spot, the put wall the heaviest // put strike at or below it (a wall three windows away is not what a chart of this range is trading against). netGex = 0.0; let bestCall = 0.0; let bestPut = 0.0; callWall = NaN; putWall = NaN; const lo = close * (1.0 - windowPct); const hi = close * (1.0 + windowPct); for (let k = 0; k < strikeCount; k += 1) { netGex += callGex[k] - putGex[k]; const strike = strikePrice[k]; if (strike < lo || strike > hi) continue; if (strike >= close && callOi[k] > bestCall) { bestCall = callOi[k]; callWall = strike; } if (strike <= close && putOi[k] > bestPut) { bestPut = putOi[k]; putWall = strike; } } maxPain = NaN; let leastPain = Infinity; for (let s = 0; s < strikeCount; s += 1) { // the settlement price on the strike grid that pays the least intrinsic value const settle = strikePrice[s]; let pain = 0.0; for (let k = 0; k < strikeCount; k += 1) { if (settle > strikePrice[k]) pain += (settle - strikePrice[k]) * callOi[k]; else if (settle < strikePrice[k]) pain += (strikePrice[k] - settle) * putOi[k]; } if (pain < leastPain) { leastPain = pain; maxPain = settle; } } // The flip: the interpolated zero crossing of cumulative net GEX, walking strikes upward. The far wings carry a few // dollars, and a chain narrowed to its nearest expiries can flip their running sum back and forth there; the // crossing that matters is the one where spot trades, so the flip is the crossing nearest spot. A sum that lands // exactly on zero crosses at that strike and starts a new segment, so no later crossing is interpolated across it. flip = NaN; let cum = 0.0; let prevCum = 0.0; let prevStrike: f64 = NaN; for (let k = 0; k < strikeCount; k += 1) { cum += callGex[k] - putGex[k]; let cross: f64 = NaN; if (!isNaN(prevStrike) && prevCum != 0.0) { if (cum == 0.0) cross = strikePrice[k]; else if ((cum > 0.0) != (prevCum > 0.0)) { const share = Math.abs(prevCum) / (Math.abs(prevCum) + Math.abs(cum)); cross = prevStrike + (strikePrice[k] - prevStrike) * share; } } if (!isNaN(cross) && (isNaN(flip) || Math.abs(cross - close) < Math.abs(flip - close))) flip = cross; if (cum == 0.0) prevStrike = NaN; // a zero ends the segment: the next one starts at the next nonzero sum else { prevCum = cum; prevStrike = strikePrice[k]; } } } // ── JSON bytes: append text and numbers without allocating ── function jbByte(b: u32): void { if (jsonUsed < jsonBytes.length) { jsonBytes[jsonUsed] = <u8>b; jsonUsed += 1; } } function jbText(s: String): void { for (let i = 0; i < s.length; i += 1) jbByte(<u32>s.charCodeAt(i)); // ASCII only here } function jbUint(v: u64): void { if (v >= 10) jbUint(v / 10); jbByte(0x30 + <u32>(v % 10)); } function jbNum(v: f64, decimals: i32): void { // a fixed-point number, or 0 when not finite if (!isFinite(v)) { jbByte(0x30); return; } let x = v; if (x < 0.0) { jbByte(0x2d); x = -x; } let scale = 1.0; for (let i = 0; i < decimals; i += 1) scale *= 10.0; const scaled = Math.round(x * scale); const whole = Math.floor(scaled / scale); jbUint(<u64>whole); if (decimals > 0) { jbByte(0x2e); const frac = <u64>(scaled - whole * scale); let digits = 1; let probe = frac; while (probe >= 10) { probe /= 10; digits += 1; } for (let i = digits; i < decimals; i += 1) jbByte(0x30); jbUint(frac); } } function jbFlush(slot: i32): void { wrun_output_frame(slot, i32(changetype<usize>(jsonBytes)), jsonUsed); jsonUsed = 0; } function priceDecimals(step: f64): i32 { // enough decimals that the grid's row prices stay evenly spaced when parsed (the docked grid is contiguous only within 0.1% of its step) const d = i32(Math.ceil(Math.log(2000.0 / step) / Math.LN10)); return d < 2 ? 2 : d > 9 ? 9 : d; } // ── The docked profile: strikes inside the window on a half-step grid, so each bar fills half its band ── function commonGap(lo: f64, hi: f64): f64 { // the gap most neighbouring strikes inside the window sit apart (ties: the smaller); NaN with fewer than two strikes let best: f64 = NaN; let bestCount = 0; let prev: f64 = NaN; for (let k = 0; k < strikeCount; k += 1) { const s = strikePrice[k]; if (s < lo || s > hi) continue; if (!isNaN(prev)) { const gap = s - prev; let count = 0; let q: f64 = NaN; for (let j = 0; j < strikeCount; j += 1) { // how many windowed neighbours sit this far apart const u = strikePrice[j]; if (u < lo || u > hi) continue; if (!isNaN(q) && Math.abs(u - q - gap) <= gap * 1e-6) count += 1; q = u; } if (count > bestCount || (count == bestCount && gap < best)) { bestCount = count; best = gap; } } prev = s; } return best; } function writeProfile(): void { const lo = close * (1.0 - windowPct); const hi = close * (1.0 + windowPct); let step = commonGap(lo, hi); // the ladder's usual gap: the odd finer strike near the money joins its nearest row instead of shrinking every bar if (isNaN(step) || step <= 0.0) { // fewer than two strikes in the window: the chain's own smallest gap, or a percent of spot step = Infinity; for (let k = 1; k < strikeCount; k += 1) if (strikePrice[k] - strikePrice[k - 1] < step) step = strikePrice[k] - strikePrice[k - 1]; if (!isFinite(step) || step <= 0.0) step = close * 0.01; } let half = step * 0.5; let gridLo = Math.floor(lo / half) * half; let rows = i32(Math.ceil((hi - gridLo) / half)) + 1; while (rows > MAX_ROWS) { // a very wide window on a fine ladder: coarsen the grid until it fits half *= 2.0; gridLo = Math.floor(lo / half) * half; rows = i32(Math.ceil((hi - gridLo) / half)) + 1; } if (rows < 3) return; // the engine needs three prices for a grid for (let r = 0; r < rows; r += 1) rowGex[r] = 0.0; for (let k = 0; k < strikeCount; k += 1) { const s = strikePrice[k]; if (s < lo || s > hi) continue; const r = i32(Math.round((s - gridLo) / half)); if (r >= 0 && r < rows) rowGex[r] += callGex[k] - putGex[k]; } const decimals = priceDecimals(half); jbText("{\"prices\":["); for (let r = 0; r < rows; r += 1) { if (r > 0) jbByte(0x2c); jbNum(gridLo + f64(r) * half, decimals); } jbText("],\"values\":["); for (let r = 0; r < rows; r += 1) { if (r > 0) jbByte(0x2c); jbNum(Math.abs(rowGex[r]), 0); // bar length: the size of the strike's net exposure } jbText("],\"colors\":["); for (let r = 0; r < rows; r += 1) { if (r > 0) jbByte(0x2c); jbText(rowGex[r] > 0.0 ? "\"#38bdf8\"" : rowGex[r] < 0.0 ? "\"#a78bfa\"" : "\"#94a3b8\""); // calls carry the strike: sky; puts: violet; an empty row draws nothing (opaque: the docked renderer ignores an alpha in a row colour and falls back to the plot colour) } jbText("]}"); jbFlush(gex_rows); } // ── Text helpers ── function sbGrouped(n: i64): void { // 84210 -> 84,210 if (n >= 1000) { sbGrouped(n / 1000); sb_text(","); const r = n % 1000; if (r < 100) sb_text("0"); if (r < 10) sb_text("0"); sb_int(r); } else sb_int(n); } function sbPrice(v: f64): void { // decimals follow the price's size: 84,210 / 2,431.55 / 12.345 / 0.13797 if (v >= 1000.0) sbGrouped(i64(Math.round(v))); else if (v >= 100.0) sb_f64(v, 2); else if (v >= 1.0) sb_f64(v, 3); else sb_f64(v, 5); } function drawLevel(which: i32, name: String, price: f64, ink: i32, dashed: bool, nudge: bool): void { // a line across the chart at the strike and its price tag at the axis const lineHandle = which == 0 ? callWallLine : which == 1 ? putWallLine : flipLine; if (isNaN(price)) { lineHandle.delete(); // no-ops when never drawn tags[which].delete(); return; } lineHandle.set(firstT, price, t, price).color(ink).width(dashed ? 1.0 : 1.5).style(dashed ? LineStyle.Dashed : LineStyle.Solid).extend(Extend.Both); sb_clear(); sb_text(name); sb_text(" "); sbPrice(price); tags[which].set(nudge ? 168.0 : 56.0, price).text(str_tag_sb).color(ink).fill(PILL); // 56 px in from the right edge clears the axis tags (High, Low, last price reach about 46 px into the pane); a wall sitting on the flip steps further in so the two tags never overlap } function near(a: f64, b: f64): bool { // two levels within 0.3% of each other share a tag row return !isNaN(a) && !isNaN(b) && Math.abs(a - b) <= Math.abs(b) * 0.003; } function onStart(): void { windowPct = p_window_pct() / 100.0; nearestExpiries = i32(p_nearest_expiries()); } // onBar() runs once per bar: history rows carry an empty block and cost one read; the live bar measures the chain. Then the // readings as numbers (NaN on history rows); the profile, the lines, the tags and the card's words on the live bar only. function onBar(): void { close = bar.close(); t = bar.time(); if (isNaN(firstT) && !isNaN(t)) firstT = t; let chainRead = false; // the live bar carried a chain and it was measured let chainCells = -1; // f64 values the live bar carried; -1 on history bars if (bar.isLast()) { chainCells = in_chain_cells(); if (chainCells >= TUPLE) { measureChain(chainCells); chainRead = strikeCount > 0; } } if (isNaN(close)) return; out_net_gex(chainRead ? netGex : NaN); out_gamma_flip(chainRead ? flip : NaN); out_call_wall(chainRead ? callWall : NaN); out_put_wall(chainRead ? putWall : NaN); out_max_pain(chainRead ? maxPain : NaN); out_gex_tone(chainRead ? (netGex >= 0.0 ? 1.0 : 0.0) : NaN); // The regime where spot trades: net GEX sums every strike, but the dealers' hedging turns at the flip, so the word // asks which side of it spot is on. No flip (the running sum never changes sign) or no gamma leaves no word to name. const regimeKnown = chainRead && !isNaN(netGex) && !isNaN(flip); const longGamma = regimeKnown && close >= flip; out_gamma_regime(regimeKnown ? (longGamma ? 2.0 : 0.0) : bar.isLast() ? 1.0 : NaN); out_spot_vs_flip(chainRead && flip > 0.0 ? (close / flip - 1.0) * 100.0 : NaN); // NaN while the flip is: a sum that never changes sign has none if (chainRead) { writeProfile(); drawLevel(2, "Flip", flip, AMBER, true, false); drawLevel(0, "Call wall", callWall, SKY, false, near(callWall, flip)); drawLevel(1, "Put wall", putWall, VIOLET, false, near(putWall, flip) || near(putWall, callWall)); // The card's words: the regime at spot leads, then the expiries counted for the title. sb_clear(); if (isNaN(netGex)) sb_text("No gamma in the chain"); else if (isNaN(flip)) sb_text("No flip in range"); else sb_text(longGamma ? "Long gamma" : "Short gamma"); str_dealer_gamma_sb(); sb_clear(); if (nearestExpiries > 0 && nearestExpiries < expiryCount) { sb_text("nearest "); sb_int(nearestExpiries); sb_text(" of "); sb_int(expiryCount); } else { sb_text("all "); sb_int(expiryCount); } sb_text(" expiries"); str_expiries_sb(); } else if (bar.isLast()) { // The live bar carried no chain to measure: the first tile says why, so the card is never a row of blanks. sb_clear(); sb_text(chainCells >= TUPLE ? "No live expiry in the chain" : "Waiting for the options chain"); str_dealer_gamma_sb(); sb_clear(); sb_text("no chain read"); str_expiries_sb(); } } ``` ## How it works **The chain is live only.** `input("chain", options_chain.cells, { max_cells: 4000, venue: "auto" })` delivers the chart coin's listed contracts as `[strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega]` tuples on the live row (a BTC chain is about 1,550); history bars carry an empty block, so the map is measured on the last bar and refreshed as the chain moves. `venue: "auto"` reads the chart's own market when it lists options, else the coin's Deribit chain. **GEX per contract.** Gamma times open interest times the multiplier times spot squared times 0.01 (Deribit open interest is in coin units with multiplier 1; CME open interest is contracts times the point value); net is calls minus puts per strike, the flip the interpolated zero crossing of cumulative net GEX walking strikes upward. The far wings carry a few dollars each, and a chain narrowed to its nearest expiries can flip their running sum back and forth there, so when the sum crosses zero more than once the flip is the crossing nearest spot: with `nearest_expiries` at 2 the BTC chain's sum dithers around zero near 74,000, and the flip still reads about 86,100, where spot trades. A sum that lands exactly on zero crosses at that strike and the walk starts afresh after it, so no crossing is interpolated across the zero. `nearest_expiries` (0) counts every listed expiry or the N nearest. `net_gex`, `gamma_flip`, `call_wall`, `put_wall`, `max_pain`, `gex_tone`, `gamma_regime` and `spot_vs_flip` are data-only outputs, live bar only. **The window is the view.** `window_pct` (6) sets the strikes shown in the docked profile and searched for the walls, percent around spot each side; the flip, max pain and net GEX always count the whole chain. `frame("gex_rows")` carries one row per grid price and feeds `plot.levels`, whose `thickness_px: 12` keeps every bar at most 12 px tall, so a 1m chart reads the profile as a ladder instead of slabs over the axis; its text is appended to a static byte buffer and sent through the frame channel the file declares itself, so the live bar allocates nothing. The walls and the flip are line handles extended across the chart, each with a price tag written through the one bounded `tag` slot. **The word follows spot against the flip.** The first tile names the regime where spot trades: "Long gamma" while the close is at or above the flip, "Short gamma" while it is under it, "No flip in range" when the running sum never changes sign. Net GEX is a different number: it sums every strike of the chain, so the card can read "Short gamma" beside a positive net GEX. Both are true at once: the chain as a whole is net long gamma, and spot trades under the strike where that running sum turns, the side where the dealers' hedging chases moves. `gamma_regime` carries the word as a number (0 short, 1 no reading, 2 long); `gex_tone` stays the sign of net GEX. **The card reads the outputs.** `render.hud("gamma", { position: "bottom_center", look: "terminal", columns: 2, tiles })` pins the card. The first tile is a headline `tile.pill` over the `dealer_gamma` slot, coloured by the `gamma_regime` ladder: bear red at 0 (short gamma), slate at 1 (no reading), bull green at 2 (long gamma). Two `tile.value` tiles read `net_gex` in `usd` (`$291.2M`) and `spot_vs_flip` in `%`: the value is already a percent, printed with two decimals, a minus sign while spot sits under the flip and no sign above it. A `tile.rows` reads the flip, the walls and max pain in `auto` (thousands separators, a whole strike without decimals: `90,000`), numbered 1) to 4) in their labels; a wall's row carries a dot in its line's colour, because a row's dot is the colour declared on its output. The title, `"Gamma, {{expiries}}"`, reads the `expiries` slot, so it says which expiries were counted. Every tile reads the newest row, the live bar's, and the two slots are written there only. **The look draws the tiles.** `look: "terminal"` sets a black card with square corners and monospaced type, the title and the labels in capitals, the labels in amber over white numbers, and the title on a full-width amber bar in black ink; the headline prints as its word alone in its ladder colour. It keeps its colours on a dark and a light chart alike ([Looks](../presentation/hud-and-hover-cards.md#looks)). **Where the card sits.** At the bottom centre: the legend holds the top left, the action bar and the High and Low tags the top right, the docked profile and the level tags the right edge, and the app's floating button the bottom left. The card covers older volume bars, never the newest candles. ## Where it runs Charts of a coin Deribit lists options for (BTC, ETH, SOL and the rest of its list), on any venue: Binance Futures BTCUSDT, Binance spot ETHUSDT and so on. The chart serves the newest chain on the live bar only, every history bar an empty block, and refreshes it with a snapshot about every 30 seconds. A CME futures chart has its own chain, but the editor's Run is paused on CME markets and a community indicator cannot read CME data; OpenMarket's official wrun indicators can. ## When data is missing Other markets are refused by name before any fetch, so the card does not draw there: the run stops, the legend marks the indicator, and the Console names the input and the way out: `Input 'chain' reads options_chain cells, but the chart market HYPERLIQUID_FUTURES/PURR has no option chain (wrun_options_chain_unavailable): coin 'PURR' is not listed on Deribit and the market is not an options venue; open a CME futures chart or a chart of a coin Deribit lists (BTC, ETH, SOL, ...)`. Gold refuses the same way. On a chain it cannot use, the first tile says why instead of the card going blank: "No live expiry in the chain" when every listed expiry has passed, "No gamma in the chain" when the venue serves no gamma, "Waiting for the options chain" while the live bar carries none. A flip that never crosses zero turns the first tile into "No flip in range" in slate, leaves "Spot vs the flip" and the flip's row reading "-", and draws no line; a side with no strike inside the window leaves its wall's row reading "-" and draws no wall. The levels can also sit outside the price range on screen: the lines are there, the chart's scale does not stretch to them, and the card's rows still read them. ## Customize it - **A narrower map.** Lower `window_pct`; the walls are searched inside the same window. - **Front expiries only.** Set `nearest_expiries` to 1 or 2 for the gamma that moves this week; the title bar then reads "Gamma, nearest 2 of 13 expiries", in capitals. - **Pin the venue.** Change `venue: "auto"` to `"deribit"` and Run again to read the coin's Deribit chain even where the chart's market lists options of its own. `"cme"` reads a CME futures chart's chain and is refused on any other chart; `"binance"`, `"okx"`, `"bybit"`, `"bullish"` and `"derive"` read that venue's chain and are refused on a coin the venue does not list. - **Another place for the card.** Change `position` to another of the nine anchors (`middle_left` works too); keep the right edge free for the profile and the tags. - **Change the look.** `look:` takes any of the twelve shipped looks, `default` to `classic` ([Looks](../presentation/hud-and-hover-cards.md#looks)), and a word declared beside it still wins; the Look row on the indicator's Style page switches it without code ([The Style page](../settings/style-page.md#the-look-row)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Gamma Map** under **HUDs**. 2. Press **Run** on a BTC or ETH chart: the profile docks on the price axis once the chain arrives, the walls and the flip draw across the chart, and the terminal card appears at the bottom centre. 3. At the editor's Console prompt, type `net_gex` and `spot_vs_flip` to read the chain's net GEX and spot's distance from the flip on the live bar. ## Concepts used - [Data sources](../core-concepts/data-sources.md) for the `options_chain` celled class, its tuple and the `venue` word - [Cards, frames and panels](../presentation/cards-frames-panels.md#frames-panels-and-compact-widgets) for frames and `plot.levels` - [Drawing objects](../presentation/drawing-objects.md#handles) for line handles extended across the chart and right-anchored tags - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) and [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the card, its tiles, the headline, the ladder colour and the slot in the title - [Looks](../presentation/hud-and-hover-cards.md#looks) for `look: "terminal"`, its title bar and what it draws for each tile kind - [The Style page](../settings/style-page.md#the-look-row) for the Look row that switches the look without code <!-- source: https://openmarket.xyz/wrun/cookbook/volume-profile-value-area --> # Volume profile and value area ![Volume profile docked on the price axis with POC lines and heatmap](/wrun/images/volume-profile-value-area.png) The last 96 bars' volume profile docked on the price axis, the point of control row amber, the value area rows sky, the rest slate. The rows never go finer than the market's own price levels, each level's volume is spread over the rows it covers, and a level the candle never touched is ignored, so the profile reads as a profile everywhere. POC, VAH and VAL are drawn as lines across the window they were measured on, through to the axis (POC solid amber, VAH and VAL dashed sky), each with a price tag at the axis, and the legend reads all three ("POC 84,798 VAH 85,987 VAL 83,288"), so a level the window measured below or above the candles in view still has a readout. Below the chart, a heatmap of volume by UTC hour for the last seven days, newest day on top, each cell the hour's percentile rank among the grid's hours, so the heavy hours jump out and the quiet ones fade. The parts are a celled `volume_profile.cells` input read as a block in `onBar()` ([Volume profile](../functions/order-flow-kit.md#volume-profile)), a frame feeding `plot.levels` docked on the price axis and a second frame feeding `panel.heatmap` below the chart ([Cards, frames and panels](../presentation/cards-frames-panels.md)), line and label handles for the three levels and their tags ([Drawing objects](../presentation/drawing-objects.md)), and a legend entry that reads them ([Legend](../presentation/legend.md)). This is also the `volume-profile-value-area` template: the **Volume Profile and Value Area** card under **Beyond the time axis** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Volume Profile and Value Area: the last `window` bars' volume profile docked on the price axis with the point of // control and the value area lit, POC / VAH / VAL as lines on price with price tags at the axis, and an hour-by-day // volume heatmap below the chart. The trader sees where volume was accepted and where price sits against it. section("Profile"); // the dialog's first section: the docked profile's settings param.int("window", 96, { min: 10, max: 500, label: "Window in bars", description: "Bars in the rolling profile" }); param.number("value_area", 70, { min: 50, max: 95, label: "Value area in percent", description: "Value area as a percent of the window's volume" }); param.int("rows", 64, { min: 16, max: 128, label: "Profile rows", description: "Price rows in the docked profile" }); section("Heatmap"); // the second section: the panel under the chart param.int("heat_days", 7, { min: 2, max: 14, label: "Heatmap days", description: "Days shown in the volume heatmap" }); input("close", ohlcv.close); // the primary input: the chart's own candles define the grid input("profile", volume_profile.cells, { max_cells: 8192, description: "This bar's volume by price level" }); // [low, high, buy, sell] per level; measured on Binance Futures BTCUSDT: about 280 levels per 15m bar, 860 per 1h bar output("poc", none, overlay, { description: "Point of control of the rolling profile" }); // data-only: the live level is drawn as a line across its window output("vah", none, overlay, { description: "Value area high" }); output("val", none, overlay, { description: "Value area low" }); string("tag", { max_bytes: 40 }); // one bounded slot the three price tags are written through, one at a time string("readout", { max_bytes: 64 }); // the three levels in one line of words, for the legend handles.line({ color: "#38bdf8", width: 1, lineStyle: "dashed", extend: "right" }); // POC, VAH and VAL across the window they were measured on, through to the axis handles.label({ anchor: "right", align: "right", size: 11, color: "#e2e8f0" }); // price tags: x in pixels from the right edge, y in price const profile_rows = frame("profile_rows", { max_bytes: 8192 }); // the docked profile: one row per price bin const heat_rows = frame("heat_rows", { max_bytes: 16384 }); // the heatmap: one cell per hour and day plot.levels({ name: "profile", frame: profile_rows, dock: "right", width_frac: 0.2, labels: false, color: "#38bdf8" }); panel.heatmap({ name: "volume_heat", title: "Volume by hour, percentile rank", x: "category", place: "below", frame: heat_rows }); render.legend("level_readout", { text: "readout", color: "#38bdf8" }); // POC, VAH and VAL in the legend: readable when a level sits off the price pane const MAX_WINDOW = 500; const BINS = 48; const MAX_ROWS = 128; const MAX_DAYS = 14; const HOURS = 24; // ring sizes for the largest settings const barLo = new StaticArray<f64>(MAX_WINDOW); const barHi = new StaticArray<f64>(MAX_WINDOW); const barT = new StaticArray<f64>(MAX_WINDOW); // each ring bar's price span and open time const barBins = new StaticArray<f64>(MAX_WINDOW * BINS); // each ring bar's volume in BINS equal bins across its span const rowVol = new StaticArray<f64>(MAX_ROWS); // the window profile: volume per price row const heat = new StaticArray<f64>(MAX_DAYS * HOURS); const heatBars = new StaticArray<i32>(MAX_DAYS * HOURS); // volume and bar count per day and hour const jsonBytes = new StaticArray<u8>(16384); let jsonUsed = 0; // the frame JSON, appended as UTF-8 bytes const tags: LabelHandle[] = [draw.label(0), draw.label(1), draw.label(2)]; const levelLines: LineHandle[] = [draw.line(3), draw.line(4), draw.line(5)]; // POC, VAH, VAL price tags and the same three levels as lines; handle objects allocate once, ids one space across kinds let window = 96; let valueArea = 0.7; let rows = 64; let heatDays = 7; // the params, read in onStart() let head = -1; let count = 0; // the ring: head is the newest bar, count the bars in use let dayBase: i64 = -1; // the day number (days since the epoch) of the heatmap's oldest row let t: f64 = NaN; let poc: f64 = NaN; let vah: f64 = NaN; let val: f64 = NaN; // this bar's open time and the window's three levels let levelHeight: f64 = Infinity; // the market's price level height, the smallest [low, high] span seen: the row grid never goes finer let rowCount = 64; // rows in use this bar: the setting, or fewer on a market whose levels are coarser than the setting would ask let probLike = true; // every close so far sits in 0..1 on a 0.001 grid: a prediction market, whose prices read as percent let winLo: f64 = NaN; let rowHeight: f64 = NaN; let pocRow = 0; let lowRow = 0; let highRow = 0; // the window profile's geometry // ── JSON bytes: append text and numbers without allocating; one string is made per frame write ── function jbByte(b: u32): void { if (jsonUsed < jsonBytes.length) { jsonBytes[jsonUsed] = <u8>b; jsonUsed += 1; } } function jbText(s: String): void { const n = String.UTF8.byteLength(s); if (jsonUsed + n <= jsonBytes.length) jsonUsed += i32(String.UTF8.encodeUnsafe(changetype<usize>(s), s.length, changetype<usize>(jsonBytes) + jsonUsed)); } function jbUint(v: u64): void { if (v >= 10) jbUint(v / 10); jbByte(0x30 + <u32>(v % 10)); } function jbNum(v: f64, decimals: i32): void { // a fixed-point number, or null when not finite if (!isFinite(v)) { jbText("null"); return; } let x = v; if (x < 0.0) { jbByte(0x2d); x = -x; } let scale = 1.0; for (let i = 0; i < decimals; i += 1) scale *= 10.0; const scaled = Math.round(x * scale); const whole = Math.floor(scaled / scale); const frac = scaled - whole * scale; jbUint(<u64>whole); if (decimals > 0) { jbByte(0x2e); let f = <u64>frac; let digits = 1; let probe = f; while (probe >= 10) { probe /= 10; digits += 1; } for (let i = digits; i < decimals; i += 1) jbByte(0x30); jbUint(f); } } function jbFlush(slot: i32): void { writeFrame(slot, String.UTF8.decodeUnsafe(changetype<usize>(jsonBytes), jsonUsed)); jsonUsed = 0; } // ── Text helpers for the price tags ── function sbGrouped(n: i64): void { // 84210 -> 84,210 if (n >= 1000) { sbGrouped(n / 1000); sb_text(","); const r = n % 1000; if (r < 100) sb_text("0"); if (r < 10) sb_text("0"); sb_int(r); } else sb_int(n); } function sbPrice(v: f64): void { // decimals follow the price's size: 84,210 / 2,431.55 / 12.345 / 0.13797; a prediction market's price reads as percent if (probLike) { sb_f64(v * 100.0, 1); sb_text("%"); } else if (v >= 1000.0) sbGrouped(i64(Math.round(v))); else if (v >= 100.0) sb_f64(v, 2); else if (v >= 1.0) sb_f64(v, 3); else sb_f64(v, 5); } function priceDecimals(step: f64): i32 { // enough decimals that the rounded row prices stay evenly spaced (the docked grid is contiguous only within 0.1% of its step) const d = i32(Math.ceil(Math.log(2000.0 / step) / Math.LN10)); return d < 2 ? 2 : d > 9 ? 9 : d; } // ── The heatmap: volume per UTC hour and day, the last heat_days days ── function shiftHeat(by: i32): void { // a new day arrived: drop the oldest rows if (by >= heatDays) { for (let i = 0; i < MAX_DAYS * HOURS; i += 1) { heat[i] = 0.0; heatBars[i] = 0; } return; } for (let r = 0; r < heatDays; r += 1) for (let h = 0; h < HOURS; h += 1) { const from = r + by; heat[r * HOURS + h] = from < heatDays ? heat[from * HOURS + h] : 0.0; heatBars[r * HOURS + h] = from < heatDays ? heatBars[from * HOURS + h] : 0; } } function foldHeat(t: f64, volume: f64): void { if (isNaN(t) || isNaN(volume)) return; const day = i64(Math.floor(t / 86400.0)); const hour = i32(Math.floor((t - f64(day) * 86400.0) / 3600.0)); if (dayBase < 0) dayBase = day - i64(heatDays - 1); // the first bar lands in the newest row if (day < dayBase) return; const newest = dayBase + i64(heatDays - 1); if (day > newest) { const by = i32(day - newest); shiftHeat(by); dayBase += i64(by); } const at = i32(day - dayBase) * HOURS + hour; heat[at] += volume; heatBars[at] += 1; } let civilMonth = 0; let civilDay = 0; function civilFromDays(days: i64): void { // days since 1970-01-01 -> month and day of month (proleptic Gregorian) const z = days + 719468; const era = (z >= 0 ? z : z - 146096) / 146097; const doe = z - era * 146097; const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; const doy = doe - (365 * yoe + yoe / 4 - yoe / 100); const mp = (5 * doy + 2) / 153; civilDay = i32(doy - (153 * mp + 2) / 5 + 1); civilMonth = i32(mp < 10 ? mp + 3 : mp - 9); } function jbTwoDigits(n: i32): void { if (n < 10) jbByte(0x30); jbUint(<u64>n); } function percentile(at: i32, filled: i32): f64 { // the share of filled hours at or below this hour's volume, 0 to 100 let below = 0; const v = heat[at]; for (let i = 0; i < heatDays * HOURS; i += 1) if (heatBars[i] > 0 && heat[i] <= v) below += 1; return Math.round((100.0 * f64(below)) / f64(filled)); } function writeHeat(): void { // [hour, day, percentile] triplets: hour on x, day on y (newest day first); the engine colours from the lowest to the highest value let filled = 0; for (let i = 0; i < heatDays * HOURS; i += 1) if (heatBars[i] > 0) filled += 1; if (filled == 0) return; let first = true; jbText("{\"rows\":["); for (let r = heatDays - 1; r >= 0; r -= 1) { civilFromDays(dayBase + i64(r)); for (let h = 0; h < HOURS; h += 1) { const at = r * HOURS + h; if (heatBars[at] == 0) continue; if (!first) jbText(","); first = false; jbText("[\""); jbTwoDigits(h); jbText("\",\""); jbTwoDigits(civilMonth); jbText("-"); jbTwoDigits(civilDay); jbText("\","); jbNum(percentile(at, filled), 0); jbText("]"); } } jbText("]}"); jbFlush(FRAME_HEAT_ROWS); } // ── The rolling profile ── function spread(a: f64, b: f64, volume: f64, gridLo: f64, width: f64, base: i32, cells: i32, into: StaticArray<f64>): void { // [a, b]'s volume over the grid cells it overlaps, in proportion if (volume <= 0.0 || !(b >= a)) return; let first = i32(Math.floor((a - gridLo) / width)); let last = i32(Math.floor((b - gridLo) / width)); if (first < 0) first = 0; if (last > cells - 1) last = cells - 1; if (first > cells - 1) first = cells - 1; if (last < 0) last = 0; if (first >= last || b - a <= 0.0) { into[base + first] += volume; return; } // one cell, or a level thinner than the grid for (let k = first; k <= last; k += 1) { const cellLo = gridLo + f64(k) * width; const cellHi = cellLo + width; const overlap = Math.min(b, cellHi) - Math.max(a, cellLo); if (overlap > 0.0) into[base + k] += (volume * overlap) / (b - a); } } function foldBar(candleLow: f64, candleHigh: f64): void { // this bar's cells into BINS bins across the bar's own price span, appended to the ring head = (head + 1) % window; if (count < window) count += 1; barT[head] = t; const n = in_profile_cells(); const base = head * BINS; for (let b = 0; b < BINS; b += 1) barBins[base + b] = 0.0; barLo[head] = NaN; barHi[head] = NaN; if (n < 4) return; // an empty block: the bar counts, its volume is unknown const cells = in_profile_view(); // this bar's cells in place: the first n values of the build's own buffer const typical = cells[(n / 8) * 4 + 1] - cells[(n / 8) * 4]; // the middle level's span: the market's level width (one stray level never sets it) if (typical > 0.0 && typical < levelHeight) levelHeight = typical; const tolerance = isFinite(levelHeight) ? levelHeight : 0.0; // a level may straddle the candle's edge by one step let lo = candleLow; let hi = candleHigh; // the candle's own span; the levels are read against it if (!(hi >= lo)) { lo = Infinity; hi = -Infinity; for (let i = 0; i + 3 < n; i += 4) { if (cells[i] < lo) lo = cells[i]; if (cells[i + 1] > hi) hi = cells[i + 1]; } } // no candle span: the cells' own if (!(hi >= lo)) return; if (hi == lo) hi = lo + Math.abs(lo) * 1e-9 + 1e-9; const binWidth = (hi - lo) / f64(BINS); for (let i = 0; i + 3 < n; i += 4) { const centre = (cells[i] + cells[i + 1]) * 0.5; if (centre < lo - tolerance || centre > hi + tolerance) continue; // a level the candle never touched: a stray print, ignored spread(cells[i], cells[i + 1], cells[i + 2] + cells[i + 3], lo, binWidth, base, BINS, barBins); // the level's volume over the bins it covers } barLo[head] = lo; barHi[head] = hi; } function buildProfile(): bool { // the ring's bars folded into `rows` price rows across the window's span; POC and value area let lo = Infinity; let hi = -Infinity; for (let k = 0; k < count; k += 1) { const i = (head + window - k) % window; if (isNaN(barLo[i])) continue; if (barLo[i] < lo) lo = barLo[i]; if (barHi[i] > hi) hi = barHi[i]; } if (!(hi > lo)) return false; rowHeight = (hi - lo) / f64(rows); // the setting's row height, or the market's level height when that is coarser (a thin-tick market) if (isFinite(levelHeight) && levelHeight > rowHeight) rowHeight = levelHeight; winLo = Math.floor(lo / rowHeight) * rowHeight; // rows sit on the level grid rowCount = i32(Math.ceil((hi - winLo) / rowHeight)); if (rowCount < 2) return false; if (rowCount > rows) rowCount = rows; lo = winLo; for (let r = 0; r < rowCount; r += 1) rowVol[r] = 0.0; let total = 0.0; for (let k = 0; k < count; k += 1) { const i = (head + window - k) % window; if (isNaN(barLo[i])) continue; const binWidth = (barHi[i] - barLo[i]) / f64(BINS); const base = i * BINS; for (let b = 0; b < BINS; b += 1) { const v = barBins[base + b]; if (v <= 0.0) continue; spread(barLo[i] + f64(b) * binWidth, barLo[i] + f64(b + 1) * binWidth, v, lo, rowHeight, 0, rowCount, rowVol); total += v; // the bin's volume over the rows it covers } } if (total <= 0.0) return false; pocRow = 0; for (let r = 1; r < rowCount; r += 1) if (rowVol[r] > rowVol[pocRow]) pocRow = r; lowRow = pocRow; highRow = pocRow; let inside = rowVol[pocRow]; // the value area grows from the POC toward the heavier neighbour while (inside < valueArea * total && (lowRow > 0 || highRow < rowCount - 1)) { const up = highRow < rowCount - 1 ? rowVol[highRow + 1] : -1.0; const down = lowRow > 0 ? rowVol[lowRow - 1] : -1.0; if (up >= down) { highRow += 1; inside += up; } else { lowRow -= 1; inside += down; } } poc = lo + (f64(pocRow) + 0.5) * rowHeight; vah = lo + f64(highRow + 1) * rowHeight; val = lo + f64(lowRow) * rowHeight; return true; } function writeProfile(): void { // prices strictly increasing (row centres), a value per row (0 where empty, so the grid stays whole), a colour per row const decimals = priceDecimals(rowHeight); jbText("{\"prices\":["); for (let r = 0; r < rowCount; r += 1) { if (r > 0) jbText(","); jbNum(winLo + (f64(r) + 0.5) * rowHeight, decimals); } jbText("],\"values\":["); for (let r = 0; r < rowCount; r += 1) { if (r > 0) jbText(","); jbNum(rowVol[r] > 0.0 ? rowVol[r] : 0.0, 2); } jbText("],\"colors\":["); for (let r = 0; r < rowCount; r += 1) { if (r > 0) jbText(","); jbText(r == pocRow ? "\"#f8c000\"" : r >= lowRow && r <= highRow ? "\"#38bdf8\"" : "\"#94a3b8\""); } jbText("]}"); jbFlush(FRAME_PROFILE_ROWS); } function drawLevel(which: i32, name: String, price: f64, ink: i32, from: f64): void { // the level as a line from the window's first bar to the live bar (extended to the axis) and its price tag levelLines[which].set(from, price, t, price).color(ink).width(which == 0 ? 2.0 : 1.0).style(which == 0 ? LineStyle.Solid : LineStyle.Dashed).extend(Extend.Right); sb_clear(); sb_text(name); sb_text(" "); sbPrice(price); tags[which].set(which == 0 ? 6.0 : 96.0, price).text(str_tag_sb).color(ink).fill(rgba(15, 23, 42, 235)); // the POC tag at the axis, VAH and VAL a step inward so close levels never overlap } // onStart() runs once before the first bar: read the params. function onStart(): void { window = i32(p_window()); valueArea = p_value_area() / 100.0; rows = i32(p_rows()); heatDays = i32(p_heat_days()); memory.grow(1); } // headroom for the frame strings: the sandbox forbids growth after onStart() // onBar() runs once per bar: fold the bar into the heatmap and the ring, rebuild the window profile; then the three levels as // numbers on every bar, and the docked profile, the heatmap, the level lines and the tags on the live bar only. function onBar(): void { t = bar.time(); const close = bar.close(); foldHeat(t, bar.volume()); foldBar(bar.low(), bar.high()); // the bar's open time, volume and span, from the chart's own candle: a level outside the span is ignored, so one bad level never stretches the profile if (!isNaN(close) && (close < 0.0 || close > 1.0 || Math.abs(close * 1000.0 - Math.round(close * 1000.0)) > 1e-6)) probLike = false; if (count < 2 || !buildProfile()) return; // no profile yet: nothing is written on this bar out_poc(poc); out_vah(vah); out_val(val); if (bar.isLast()) { writeProfile(); writeHeat(); sb_clear(); sb_text("POC "); sbPrice(poc); sb_text(" VAH "); sbPrice(vah); sb_text(" VAL "); sbPrice(val); str_readout_sb(); // the legend's readout const from = barT[(head + window - (count - 1)) % window]; // the oldest bar in the window drawLevel(0, "POC", poc, rgba(248, 192, 0, 255), from); drawLevel(1, "VAH", vah, rgba(56, 189, 248, 255), from); drawLevel(2, "VAL", val, rgba(56, 189, 248, 255), from); } } ``` ## How it works **One block per bar.** `input("profile", volume_profile.cells, { max_cells: 8192 })` delivers each bar's volume by price level as `[low, high, buy, sell]` tuples (about 280 levels per 15m bar and 860 per 1h bar on Binance Futures BTCUSDT). The rolling profile folds the last `window` (96) bars' blocks into `rows` (64) price bins between the window's low and high; a level outside the bar's own high-to-low span is ignored, so a stray print never stretches the profile, and each level's volume is spread over the bins it covers. **POC and the value area.** The bin with the most volume is the point of control; the value area grows from it, bin by bin toward the heavier neighbour, until it holds `value_area` (70) percent of the window's volume. `poc`, `vah` and `val` are data-only outputs, one value per bar; on the live bar each is drawn as a line handle across the window it was measured on, extended right to the axis, with a price tag written through the one bounded slot to a right-anchored label. The price axis scales to the candles in view, not to the window the levels are measured on, so after a run-up VAL can sit below the pane with its line and tag; the same three prices are written into a second slot, `readout`, on the live bar, and `render.legend("level_readout", { text: "readout" })` prints that line of words in the legend, in sky. On a prediction market the tags and the readout read as percent, like the axis. **Two frames, two views.** `frame("profile_rows")` carries one row per bin and feeds `plot.levels`, the docked profile (the POC row amber, value-area rows sky, the rest slate); `frame("heat_rows")` carries one cell per hour and day for the last `heat_days` (7) days and feeds `panel.heatmap`, each cell the hour's percentile rank among the grid's hours (100 is the busiest). ## Where it runs Markets with a volume profile lane: crypto (Binance Futures, Binance spot, Hyperliquid) and Polymarket markets, where the profile shows the odds the crowd traded most. The heatmap reads best on charts finer than 1h; on 1h each cell is one bar. ## When data is missing Stocks and FX have no profile lane: the run stops with the toast `Volume profile data is unavailable for '<title>' (the volume_profile source lane answered empty or was declined), so the indicator cannot compute.`, where `<title>` is the tab's title. A bar without a profile block counts toward the window with no volume; the first bar draws nothing (the profile needs two bars); hours with no bars stay empty in the heatmap. ## Customize it - **A session profile.** Reset the fold at a UTC boundary (the [Session map](session-map.md) window test) instead of rolling the last `window` bars. - **Finer rows.** `rows` up to 128; the rows never go finer than the market's own price levels. - **A wider value area.** `value_area` up to 95 percent. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Volume Profile and Value Area** under **Beyond the time axis**. 2. Press **Run** on a crypto chart finer than 1h, such as BTCUSDT on Binance Futures at 15m: the profile docks on the price axis, the three levels draw with their tags, and the heatmap appears below the chart. 3. At the editor's Console prompt, type `poc`, `vah` and `val` to read the three levels on the newest bar. ## Concepts used - [Volume profile](../functions/order-flow-kit.md#volume-profile) for the celled profile input and the block layout - [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames, `plot.levels` and `panel.heatmap` - [Drawing objects](../presentation/drawing-objects.md) for line and label handles extended to the axis <!-- source: https://openmarket.xyz/wrun/cookbook/vol-term-structure --> # Volatility term structure ![Implied volatility term structure pane, regime card and tenor curve](/wrun/images/vol-term-structure.png) Implied volatility at one week (sky), one month (violet) and three months (teal) in their own pane. The gap between the one-week and three-month lines is tinted slate while the curve is normal and rose while it is inverted (one-week IV above three-month IV: options pricing near-term stress). On the price pane, a dot marks the bar where the curve flipped: rose when it inverted, slate when it turned normal again. Below the chart, a small curve of the three tenors now against a week ago. A card at the top right of the price pane names the regime (low, normal or high vol by the one-month IV's percentile rank over the lookback), the one-month IV and its rank, the curve's state and gap, the 25-delta one-month skew, a 48-bar sparkline of the one-month IV, and what the grey curve stands for. The parts are three `implied_volatility` inputs at different tenors and a `skew` input ([Data sources](../core-concepts/data-sources.md)), a `range` between two outputs tinted by a data-only ladder ([Styling](../presentation/styling.md)), two `shape` outputs for the flip dots, a frame feeding a `panel.line` on a category axis, a `draw.card` with a sparkline ([Cards, frames and panels](../presentation/cards-frames-panels.md)) and a `render.label` for the one sentence on a coin without the series ([Plotting](../presentation/plotting.md)). This is also the `vol-term-structure` template: the **Volatility Term Structure** card under **Beyond the time axis** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Volatility Term Structure: implied volatility at one week, one month and three months in a pane, the gap between // 1W and 3M shaded rose while the curve is inverted (near-term stress), a small curve chart of now against a week // ago below the chart, and a card naming the regime, the IV rank and the 25-delta skew. The trader sees whether // options price near-term stress or calm, and how that changed over the week. A coin without the series reads one // sentence instead of an empty pane. param.int("lookback", 180, { min: 20, max: 2000, label: "Rank lookback, bars", description: "Bars the IV rank is measured over" }); legend({ title: "({{lookback}})" }); // the words after the name in the legend: the lookback, read by name input("close", ohlcv.close); // the primary input: the chart's own candles define the grid the volatility series ride input("one_week", implied_volatility.implied_volatility, { tenor: "ONE_W", description: "Implied volatility, one week" }); // carried between observations input("one_month", implied_volatility.implied_volatility, { tenor: "ONE_M", description: "Implied volatility, one month" }); input("three_month", implied_volatility.implied_volatility, { tenor: "THREE_M", description: "Implied volatility, three months" }); input("skew_one_month", skew.skew, { tenor: "ONE_M", description: "25-delta skew, one month" }); const oneWeek = output("iv_1w", line, lower, { color: "#38bdf8", width: 2, label: "IV 1W", format: "0.0", description: "Implied volatility, one week" }); // the handle names it for the hover card output("iv_1m", line, lower, { color: "#a78bfa", width: 2, label: "IV 1M", format: "0.0", description: "Implied volatility, one month" }); output("iv_3m", line, lower, { color: "#2dd4bf", width: 2, label: "IV 3M", format: "0.0", description: "Implied volatility, three months" }); output("inverted", none, lower, { description: "1 while one-week IV sits above three-month IV" }); // data-only: the shading's ladder and the card's tone output("iv_rank", none, lower, { description: "Percentile rank of one-month IV over the lookback, 0 to 100" }); output("regime_tone", none, lower, { description: "0 low vol, 1 normal, 2 high vol" }); output("skew_25d", none, lower, { description: "25-delta skew, one month" }); hover(oneWeek, [block.value("IV 1W", "iv_1w", { format: "0.0" }), block.rows([["IV 1M", "iv_1m", "0.0"], ["IV 3M", "iv_3m", "0.0"], ["IV rank", "iv_rank", "int"], ["25d skew", "skew_25d", "0.00"]])]); // the card the one-week line opens under the cursor output("stress_on", shape, overlay, { color: "#fb7185", width: 6, description: "The close of the bar where one-week IV crossed above three-month IV" }); // an event dot on price output("stress_off", shape, overlay, { color: "#94a3b8", width: 6, description: "The close of the bar where the curve turned normal again" }); range("iv_1w", "iv_3m", { color_by: "inverted", colors: ["#94a3b81f", "#fb718538"] }); // the gap between 1W and 3M: a slate tint while normal, rose while inverted string("regime_word", { max_bytes: 24 }); // "High vol" string("curve_word", { max_bytes: 32 }); // "1W over 3M by 4.2" string("compare_word", { max_bytes: 24 }); // what the grey curve is: a week ago, or the oldest bar loaded string("notice", { max_bytes: 48 }); // the one sentence on a coin the volatility series do not cover, written on the live bar render.label("notice_tag", { position: "top_right", text: "notice", color: "#94a3b8", style: "plain", offset: [12, 40] }); // under the pane's corner buttons const curve_rows = frame("curve_rows", { max_bytes: 1024 }); // three tenor rows: [tenor, now, a week ago] panel.line({ name: "term_curve", title: "Term structure: now against a week ago", x: "category", place: "below", frame: curve_rows, series: [{ name: "Now", color: "#38bdf8" }, { name: "A week ago", color: "#94a3b8" }] }); draw.card("vol_regime", { title: "Vol regime", anchor: "top_right", offset: [0, 40], // below the chart's own High tag, which always sits in the pane's top-right corner rows: [ { label: "Regime", value: { text: "regime_word" }, color: { color_by: "regime_tone", colors: ["#94a3b8", "#38bdf8", "#fb7185"] } }, { label: "1M IV", value: { output: "iv_1m" } }, { label: "IV rank, 0 to 100", value: { output: "iv_rank", format: "int" } }, { label: "Curve", value: { text: "curve_word" }, color: { color_by: "inverted", colors: ["#94a3b8", "#fb7185"] } }, { label: "25d skew 1M", value: { output: "skew_25d" } }, { label: "1M IV, 48 bars", spark: { output: "iv_1m", window: 48 } }, { label: "Grey curve", value: { text: "compare_word" } }, ], }); const RING = 2048; const WEEK_SECONDS = 604800.0; // the rings hold the largest lookback and a week of 5m bars const ringW = new StaticArray<f64>(RING); const ringM = new StaticArray<f64>(RING); const ringQ = new StaticArray<f64>(RING); // 1W, 1M, 3M per bar let lookback = 180; let head = -1; let count = 0; // the ring: head is the newest bar let t: f64 = NaN; let prevT: f64 = NaN; // this bar's open time and the previous one: together they give the bar width, so a week is counted in bars let prevInverted: f64 = NaN; // the flip detector: the curve's state on the previous bar let readingSeen = false; // some bar carried all three tenors: the market has the series function round1(v: f64): f64 { return Math.round(v * 10.0) / 10.0; } function onStart(): void { lookback = i32(p_lookback()); memory.grow(1); } // headroom for the live bar's frame string: the sandbox forbids growth after onStart() // onBar() runs once per bar: read the three tenors and the skew, append to the rings, rank the one-month reading over the lookback; // then the lines and the card numbers on every bar, the curve frame on the live bar only. function onBar(): void { prevT = t; t = bar.time(); const close = bar.close(); const ivW = in_one_week(); const ivM = in_one_month(); const ivQ = in_three_month(); const skew25 = in_skew_one_month(); // this bar's readings head = (head + 1) % RING; if (count < RING) count += 1; ringW[head] = ivW; ringM[head] = ivM; ringQ[head] = ivQ; if (isNaN(ivW) || isNaN(ivM) || isNaN(ivQ)) { // no reading on this bar: nothing is written; a coin never served one gets the sentence if (bar.isLast() && !readingSeen) { sb_clear(); sb_text("No implied volatility for this coin"); str_notice_sb(); } return; } readingSeen = true; let below = 0; let seen = 0; const span = lookback < count ? lookback : count; for (let k = 0; k < span; k += 1) { const v = ringM[(head + RING - k) % RING]; if (isNaN(v)) continue; seen += 1; if (v <= ivM) below += 1; } const rank = seen > 0 ? (100.0 * f64(below)) / f64(seen) : NaN; // the card's numbers: the rank, its tone and the curve's state const tone = isNaN(rank) ? 1.0 : rank < 20.0 ? 0.0 : rank > 80.0 ? 2.0 : 1.0; const inverted = ivW > ivQ ? 1.0 : 0.0; const stressOn = prevInverted == 0.0 && inverted == 1.0 ? close : NaN; // the event dots: the close on the bar the curve inverted on, NaN elsewhere const stressOff = prevInverted == 1.0 && inverted == 0.0 ? close : NaN; // the bar it turned normal on prevInverted = inverted; out_iv_1w(round1(ivW)); out_iv_1m(round1(ivM)); out_iv_3m(round1(ivQ)); out_inverted(inverted); out_iv_rank(rank); out_regime_tone(tone); out_skew_25d(isNaN(skew25) ? NaN : round1(skew25)); out_stress_on(stressOn); out_stress_off(stressOff); const width = isNaN(prevT) ? NaN : t - prevT; // the bar's width in seconds const weekBars = isNaN(width) || width <= 0.0 ? 0 : i32(Math.round(WEEK_SECONDS / width)); // bars in a week on this chart const back = weekBars > 0 && weekBars < count ? weekBars : count - 1; // a week back, or the oldest bar loaded const ago = (head + RING - back) % RING; sb_clear(); sb_text(tone == 0.0 ? "Low vol" : tone == 2.0 ? "High vol" : "Normal vol"); str_regime_word_sb(); sb_clear(); sb_text(inverted == 1.0 ? "1W over 3M by " : "1W under 3M by "); sb_f64(Math.abs(ivW - ivQ), 1); str_curve_word_sb(); sb_clear(); if (back == weekBars) sb_text("a week ago"); else { sb_text("oldest, "); sb_int(back); sb_text(" bars back"); } str_compare_word_sb(); if (bar.isLast()) { writeFrame(FRAME_CURVE_ROWS, "{\"rows\":[[\"1W\"," + round1(ivW).toString() + "," + (isNaN(ringW[ago]) ? "null" : round1(ringW[ago]).toString()) + "],[\"1M\"," + round1(ivM).toString() + "," + (isNaN(ringM[ago]) ? "null" : round1(ringM[ago]).toString()) + "],[\"3M\"," + round1(ivQ).toString() + "," + (isNaN(ringQ[ago]) ? "null" : round1(ringQ[ago]).toString()) + "]]}"); } } ``` ## How it works **Tenors are inputs.** `input("one_week", implied_volatility.implied_volatility, { tenor: "ONE_W" })` and its ONE_M and THREE_M twins ride the chart's grid, carried between observations; `skew.skew` at ONE_M is the 25-delta skew. `iv_1w`, `iv_1m` and `iv_3m` plot in the lower pane; `skew_25d` is data-only. **Inversion is a ladder.** `inverted` is 1 while one-week IV sits above three-month IV; `range("iv_1w", "iv_3m", { color_by: "inverted", colors: [slate, rose] })` tints the gap from it. `stress_on` carries the close on the bar the curve inverted (a rose dot on price) and `stress_off` the close where it turned normal again; both are NaN elsewhere. **Rank is the regime.** `iv_rank` is the percentile rank of one-month IV over `lookback` (180) bars, 0 to 100; `regime_tone` reads 0 low vol, 1 normal, 2 high vol from it, and the card's title takes the tone. The card's value cells are text slots written on the live bar; the one-month IV feeds its 48-bar sparkline. **A week ago is counted in bars.** The bar width from consecutive `bar.time()` readings turns a week into a bar count; `frame("curve_rows")` carries the three tenors now and a week ago and feeds `panel.line` on a category axis below the chart. ## Where it runs BTC and ETH charts on any venue (the volatility series follow the chart's coin). On a Polymarket market the lanes answer with BTC's series (the chart has no coin, so the lane falls back to BTC): the pane then shows BTC's curve, not the market's. ## When data is missing Other coins, FX and stocks have no volatility series: the run stops with the toast `Implied volatility data is unavailable for '<title>' (the deribit_implied_volatility source lane answered empty or was declined), so the indicator cannot compute.`, where `<title>` is the tab's title. If a chart of such a coin does run, no bar carries a reading and nothing is drawn: the live bar writes the one sentence `No implied volatility for this coin` at the top right of the pane, 40 px down so it clears the pane's corner buttons (`render.label("notice_tag", { position: "top_right", text: "notice", offset: [12, 40] })`), instead of leaving an empty pane. Bars before the first volatility observation draw nothing; when the loaded history is shorter than a week the grey curve is the oldest bar loaded and the card's "Grey curve" row says "oldest, N bars back". ## Customize it - **Other tenors.** The chart serves five tenors, `ONE_W`, `ONE_M`, `TWO_M`, `THREE_M` and `SIX_M`; this example reads three of them, and `ONE_D`, `THREE_D` and `ONE_Y` are refused by name. For a two-tenor view, drop one input together with its line and its labels. - **Another wing.** The card's skew is the one-month skew at 25 delta, the default; `delta: 5`, `15` or `35` on the `skew` input reads another. - **A longer rank.** `lookback` up to 2000 bars. - **A month ago.** Change the bar count behind the grey curve from a week to a month. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Volatility Term Structure** under **Beyond the time axis**. 2. Press **Run** on a BTC or ETH chart: the three tenors draw in their pane, the curve below the chart and the card at the top right. 3. At the editor's Console prompt, type `last 20 iv_rank` to read the one-month IV's rank over the last 20 bars. ## Concepts used - [Data sources](../core-concepts/data-sources.md) for the `implied_volatility` and `skew` sources and the `tenor` word - [Styling](../presentation/styling.md) for `range` bands and colour ladders - [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames, `panel.line`, `draw.card` and sparklines - [Plotting](../presentation/plotting.md) for `render.label` pinned by `position`, the one sentence <!-- source: https://openmarket.xyz/wrun/cookbook/options-dashboard --> # Options Dashboard ![The options desk's right cell top to bottom: the BTCUSDT 1h candles, the BTC Options tiles panel with its eight tiles, the Deribit GEX curve panel with the dashed Spot marker, the gamma flip, peak and trough callouts and the LONG GAMMA badge chip, and the rolling CVD histogram in its own pane next to the time axis](/wrun/images/style-anything/panes.png) Eight tiles below the chart read the chart coin's live options chain at a glance: live net GEX at spot, the gamma flip and max pain with their distance from spot (the flip is the curve's own zero crossing nearest spot, so the tile, the legend and the curve name one level, and the tile reads `No flip in range` while the curve keeps one sign across its grid), the dealer regime in words (Negative Gamma, dips sold and rallies bought; Positive Gamma, dips bought and rallies sold), the put/call ratio, the 25-delta skew of the front expiry, net vega exposure and dealer delta in coins. Under them, the net gamma exposure curve over a grid of hypothetical spots: a smooth line in the chart's text ink, filled in its up colour above zero and its down colour below, a dashed Spot marker, callouts pinned on the curve at the gamma flip, the peak and the trough, and a badge chip naming the regime (SHORT GAMMA, AMPLIFIED in the down colour, LONG GAMMA, DAMPENED in the up colour). At the bottom, a rolling cumulative volume delta histogram in a pane of its own, in the up colour while positive and the down colour while negative. The legend entry names the regime and the flip, in the up colour under positive gamma and the down colour under negative. Every colour is a theme word, so the desk follows the chart's theme and the user's own up and down colours. The chain is live only: the tiles and the curve are rewritten on the last bar, and the CVD runs over every bar. The parts are a celled `options_chain.cells` input read as a block on the live bar and two sided `trades.volume` inputs ([Data sources](../core-concepts/data-sources.md)), two frames written from the generated frame buffer feeding a `panel.tiles` and a `panel.line` placed below the chart ([Frames, panels and compact widgets](../presentation/cards-frames-panels.md#frames-panels-and-compact-widgets)), the `OptionsChain` kit for the totals, max pain and the ratio ([Options kit](../functions/options-kit.md)), a named pane for the histogram ([Panes](../presentation/plotting.md#panes)) and a legend entry coloured by a ladder ([Styling](../presentation/styling.md)). It is the right cell of the options desk that [Style anything](../presentation/style-anything.md) walks through part by part; the [Strike Matrix](strike-matrix.md) recipe is its left cell. This is also the `options-dashboard` template: the **Options Dashboard** card under **Beyond the time axis** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Options Dashboard: eight stat tiles over the chart coin's live options chain (net GEX, the gamma flip, max pain, // the dealer regime, the put/call ratio, the 25-delta skew, net vega exposure, dealer delta), the net gamma exposure // curve over a grid of hypothetical spots with a signed fill and marked spot, flip, peak and trough, and a rolling // cumulative volume delta histogram in a pane of its own. The gamma flip is the curve's own zero crossing (the one // nearest spot), so the tile, the legend and the curve name one level. The chain is live only: the tiles and the // curve are rewritten on the last bar; the CVD runs over every bar. param.int("cvd_bars", 96, { min: 2, max: 500, label: "CVD window, bars", description: "Bars in the rolling CVD window" }); param.number("grid_down", 10, { min: 1, max: 50, label: "Grid below spot, percent", description: "The spot grid's reach below spot, percent" }); param.number("grid_up", 21, { min: 1, max: 50, label: "Grid above spot, percent", description: "The spot grid's reach above spot, percent" }); input("close", ohlcv.close); // spot: the grid's centre and the "spot" marker input("buy", trades.volume, { side: "BUY", missing: "zero", description: "Aggressive buy volume" }); input("sell", trades.volume, { side: "SELL", missing: "zero", description: "Aggressive sell volume" }); input("chain", options_chain.cells, { max_cells: 4000, venue: "auto", description: "The live options chain of the chart's coin (venue auto: the chart's own market when it lists options, else the coin's Deribit chain)" }); // [strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega] per contract output("net_gex", none, overlay, { description: "Net gamma exposure at spot, USD per 1% move; live bar only" }); output("gamma_flip", none, overlay, { description: "The spot where the net GEX curve crosses zero, the crossing nearest spot; NaN while the curve keeps one sign across the grid; live bar only" }); output("max_pain", none, overlay, { description: "The strike that pays option holders the least; live bar only" }); output("regime_sign", none, overlay, { description: "0 under negative net gamma, 1 under positive: the legend entry's colour index" }); output("cvd_sign", none, lower, { description: "0 while the rolling CVD is negative, 1 while positive: the histogram's palette index" }); output("cvd", histogram, lower, { pane: "cvd", color_by: "cvd_sign", colors: ["theme.down", "theme.up"], width: 0.7, label: "Agg. Rolling CVD", format: "si", description: "Rolling cumulative volume delta: aggressive buys minus sells over the window" }); pane("cvd", { height_frac: 0.12, format: "si" }); // the Indicator's home pane: it carries the Indicator's name, so no title here string("regime_text", { max_bytes: 48 }); // the legend entry: "Neg Gamma | flip $85,722" render.legend("regime_entry", { text: "regime_text", color_by: "regime_sign", colors: ["theme.down", "theme.up"] }); const tileRows = frame("tile_rows", { max_bytes: 4096 }); const gexCurve = frame("curve_rows", { max_bytes: 16384 }); // Eight tiles in two rows of four: the panel prints signed dollars, a tile's own format word or text wins, and the // accent tints a numeric tile by its sign (the chart's up colour above zero, its down colour below); a tile with its own // colour keeps it. The tiles, the curve and the CVD pane take 0.55 of the chart, so the candles keep the rest. panel.tiles({ name: "tiles", title: "Options, Deribit", x: "category", place: "below", frame: tileRows, columns: 4, accent: "auto", positive_color: "theme.up", negative_color: "theme.down", format: "usd", signed: true, hover_card: true, height_frac: 0.18 }); // The gamma curve: a smooth line in the chart's text ink over hypothetical spot, filled in the up colour above zero // and the down colour below, both axes in dollars; the spot marker, the point callouts (the flip, the peak, the trough) // and the regime badge chip are written per run into the frame. The one series needs no legend chip: the title names // it and the hover card reads it. panel.line({ name: "gex_curve", title: "Deribit GEX (Hypothetical Spot)", x: "number", place: "below", frame: gexCurve, series: [{ name: "Net GEX", color: "theme.text", width: 2 }], chrome: "grid", smooth: true, fill_mode: "signed", fill_positive_color: "theme.up", fill_negative_color: "theme.down", fill_fade: true, x_format: "usd", x_decimals: 1, format: "usd", signed: true, decimals: 0, x_title: "Spot", y_zero: true, legend_style: "none", hover_card: true, height_frac: 0.25, maximize: true, stats_row: true }); const TUPLE = 10; // f64s per contract: strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega const POINTS = 125; // grid spots const MAX_EXPIRIES = 64; // distinct live expiries the chain may list const MAX_CVD = 500; // the ring the rolling CVD reads const YEAR_MS = 31536000000.0; const SQRT_TWO_PI = 2.5066282746310002; const gridSpot = new StaticArray<f64>(POINTS); const gridGex = new StaticArray<f64>(POINTS); const expiryMs = new StaticArray<f64>(MAX_EXPIRIES); // the live expiries, sorted ascending const deltas = new StaticArray<f64>(MAX_CVD); // buy minus sell per bar, the rolling window let chain = new OptionsChain(1); // the whole chain measured by strike: the totals, max pain, the ratio let cvdBars = 96; // settings, read in onStart() let gridDown = 0.1; let gridUp = 0.21; let close: f64 = NaN; // this bar let t: f64 = NaN; let nowMs: f64 = 0.0; // the chain's clock, read on the live bar let cvd: f64 = 0.0; // the rolling CVD and its ring let cvdHead = 0; let cvdCount = 0; let haveChain = false; // the live bar measured a chain let expiryCount = 0; let netGex: f64 = NaN; // the readings, measured on the live bar let pain: f64 = NaN; let pcr: f64 = NaN; let skewPp: f64 = NaN; let vex: f64 = NaN; let dealerDelta: f64 = NaN; let curveFlip: f64 = NaN; // the gamma flip: the curve's zero crossing nearest spot, interpolated on the grid let peakIdx = 0; let troughIdx = 0; function onStart(): void { chain = new OptionsChain(512); cvdBars = i32(p_cvd_bars()); if (cvdBars > MAX_CVD) cvdBars = MAX_CVD; gridDown = p_grid_down() / 100.0; gridUp = p_grid_up() / 100.0; } // ── The expiry list: sorted insertion of a distinct live expiry ── function noteExpiry(ms: f64): void { let lo = 0; let hi = expiryCount; while (lo < hi) { const mid = (lo + hi) >> 1; if (expiryMs[mid] < ms) lo = mid + 1; else hi = mid; } if (lo < expiryCount && expiryMs[lo] == ms) return; if (expiryCount >= MAX_EXPIRIES) return; for (let i = expiryCount; i > lo; i -= 1) expiryMs[i] = expiryMs[i - 1]; expiryMs[lo] = ms; expiryCount += 1; } // ── The chain's clock: when its gammas were priced, not the bar's open ── // The chart prices each contract's gamma by Black-Scholes from its mark IV and underlying at the moment it reads the // chain, so the contract nearest the money (|delta| nearest 0.5), solved for the time to expiry its gamma implies, // names that moment. A venue that serves its own greeks gives no such answer: the bar's open stands in. function chainClockMs(cells: StaticArray<f64>, n: i32, barOpenMs: f64): f64 { let best = -1; let bestGap = 0.25; // |delta| within 0.25 of 0.5 let nearest = Infinity; // the nearest listed expiry: the chain lists none that has passed for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { if (cells[i + 1] < nearest) nearest = cells[i + 1]; const gap = Math.abs(Math.abs(cells[i + 5]) - 0.5); if (cells[i] > 0.0 && cells[i + 4] > 0.0 && cells[i + 6] > 0.0 && cells[i + 7] > 0.0 && gap < bestGap) { bestGap = gap; best = i; } } if (best < 0) return barOpenMs; const sigma = cells[best + 6] > 3.0 ? cells[best + 6] / 100.0 : cells[best + 6]; // percent a year, or a fraction const m = Math.log(cells[best + 7] / cells[best]); const q = cells[best + 4] * cells[best + 7]; // gamma times spot = pdf(d1) / u, u = sigma * sqrt(T), d1 = m / u + u / 2 let lo = Math.sqrt(2.0 * (Math.sqrt(1.0 + m * m) - 1.0)); // where pdf(d1) / u peaks: past it the gamma falls as T grows let hi = 10.0; for (let k = 0; k < 80; k += 1) { // bisection on the falling side const u = 0.5 * (lo + hi); const d1 = m / u + 0.5 * u; if (Math.exp(-0.5 * d1 * d1) / (2.5066282746310002 * u) > q) lo = u; else hi = u; } const u = 0.5 * (lo + hi); const clock = cells[best + 1] - ((u * u) / (sigma * sigma)) * 31536000000.0; return clock > barOpenMs - 86400000.0 && clock < nearest ? clock : barOpenMs; // anything else is no reading } function bsGamma(spot: f64, strike: f64, sigma: f64, years: f64): f64 { // Black-Scholes gamma, r = 0 if (!(spot > 0.0) || !(strike > 0.0) || !(sigma > 0.0) || !(years > 0.0)) return 0.0; const v = sigma * Math.sqrt(years); const d1 = (Math.log(spot / strike) + 0.5 * sigma * sigma * years) / v; const pdf = Math.exp(-0.5 * d1 * d1) / SQRT_TWO_PI; return pdf / (spot * v); } // ── The chain, measured on the live bar: the curve, the skew, the vega and delta exposure, then the kit's totals ── // Each contract sits on the curve at its own underlying moved with spot (a dated future moves with the coin), priced // at the chain's clock, so the curve at spot reads the net GEX tile. function measureChain(n: i32): void { // n = f64 cells in the live block const cells = in_chain_view(); // the live chain in place: the first n values of the build's own buffer expiryCount = 0; for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) if (cells[i + 1] > nowMs) noteExpiry(cells[i + 1]); for (let p = 0; p < POINTS; p += 1) { gridSpot[p] = close * (1.0 - gridDown + ((gridDown + gridUp) * f64(p)) / f64(POINTS - 1)); gridGex[p] = 0.0; } const front = expiryCount > 0 ? expiryMs[0] : NaN; let callIv: f64 = NaN; // the 25-delta call and put of the front expiry let callGap = Infinity; let putIv: f64 = NaN; let putGap = Infinity; vex = 0.0; dealerDelta = 0.0; let contracts = 0; for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { const strike = cells[i]; const expiry = cells[i + 1]; const side = cells[i + 2]; const oi = cells[i + 3]; const delta = cells[i + 5]; let iv = cells[i + 6]; const multiplier = cells[i + 8] > 0.0 ? cells[i + 8] : 1.0; const vega = cells[i + 9]; if (!(strike > 0.0) || !(oi > 0.0) || !(expiry > nowMs)) continue; if (iv > 3.0) iv = iv / 100.0; // a percent iv (55.3) read as a fraction const sign = side > 0.0 ? 1.0 : -1.0; if (isFinite(vega)) vex += sign * vega * oi * multiplier; // USD per vol point, the dealer-naive sign if (isFinite(delta)) dealerDelta -= delta * oi * multiplier; // dealers hold the other side of the open interest if (expiry == front && iv > 0.0 && isFinite(delta)) { if (side > 0.0) { const gap = Math.abs(delta - 0.25); if (gap < callGap) { callGap = gap; callIv = iv; } } else { const gap = Math.abs(delta + 0.25); if (gap < putGap) { putGap = gap; putIv = iv; } } } if (!(iv > 0.0)) continue; const years = (expiry - nowMs) / YEAR_MS; const basis = cells[i + 7] > 0.0 ? cells[i + 7] / close : 1.0; // the contract's underlying over the chart's spot for (let p = 0; p < POINTS; p += 1) { const s = gridSpot[p] * basis; gridGex[p] += sign * bsGamma(s, strike, iv, years) * oi * multiplier * s * s * 0.01; } contracts += 1; } haveChain = contracts > 0; skewPp = isNaN(callIv) || isNaN(putIv) ? NaN : (putIv - callIv) * 100.0; curveFlip = NaN; peakIdx = 0; troughIdx = 0; for (let p = 1; p < POINTS; p += 1) { if (gridGex[p] > gridGex[peakIdx]) peakIdx = p; if (gridGex[p] < gridGex[troughIdx]) troughIdx = p; const a = gridGex[p - 1]; const b = gridGex[p]; if ((a < 0.0 && b > 0.0) || (a > 0.0 && b < 0.0)) { // a zero crossing; the one nearest spot is the flip const x = gridSpot[p - 1] + (gridSpot[p] - gridSpot[p - 1]) * (a / (a - b)); if (isNaN(curveFlip) || Math.abs(x - close) < Math.abs(curveFlip - close)) curveFlip = x; } } chain.load(in_chain_view(), n, nowMs, close, 0, 0.0); // every live expiry, every strike netGex = chain.totalNetGex(); pain = chain.maxPain(); pcr = chain.putCallRatio(); } // ── Text and frames, built without allocating ── function fbUsd(v: f64): void { // "+$34.7M", "-$42.0M", "+$1.23B" if (!isFinite(v)) { fb_text("n/a"); return; } fb_text(v < 0.0 ? "-$" : "+$"); const a = Math.abs(v); if (a >= 1.0e9) { fb_f64(a / 1.0e9, 2); fb_text("B"); } else if (a >= 1.0e6) { fb_f64(a / 1.0e6, 1); fb_text("M"); } else if (a >= 1.0e3) { fb_f64(a / 1.0e3, 1); fb_text("K"); } else fb_f64(a, 0); } function fbPctFromSpot(price: f64): void { // "+1.4% from spot" const pct = (price / close - 1.0) * 100.0; if (pct >= 0.0) fb_text("+"); fb_f64(pct, 1); fb_text("% from spot"); } function fbTone(v: f64): void { // the colour word of a signed reading fb_text(v < 0.0 ? "\"theme.down\"" : "\"theme.up\""); } function priceDecimals(v: f64): i32 { // decimals follow the price's size, so the tiles read on any coin: $87,526, $150.25, $0.4213 return v >= 1000.0 ? 0 : v >= 1.0 ? 2 : 4; } function fbGrouped(n: i64): void { // 85979 -> 85,979 if (n >= 1000) { fbGrouped(n / 1000); fb_text(","); const r = n % 1000; if (r < 100) fb_text("0"); if (r < 10) fb_text("0"); fb_int(r); } else fb_int(n); } function fbPrice(v: f64): void { // "$85,979", "$150.25", "$0.4213" fb_text("$"); if (v >= 1000.0) fbGrouped(i64(Math.round(v))); else fb_f64(v, priceDecimals(v)); } function fbPct(v: f64): void { // 10 -> "10", 12.5 -> "12.5" fb_f64(v, v == Math.floor(v) ? 0 : 1); } function writeTiles(): void { // [label, value, caption, color, spark, format], null to skip an element fb_clear(); fb_text("{\"rows\":["); fb_text("[\"Live net GEX\","); fb_f64(netGex, 0); fb_text(",\"@ $"); if (close >= 1000.0) { fb_f64(close / 1000.0, 1); fb_text("K"); } else fb_f64(close, priceDecimals(close)); fb_text(" | chart\",null,null,\"usd\"]"); if (isNaN(curveFlip)) { // the curve keeps one sign across the grid: no flip to name fb_text(gridGex[0] < 0.0 ? ",[\"Gamma flip\",\"No flip in range\",\"Negative from -" : ",[\"Gamma flip\",\"No flip in range\",\"Positive from -"); fbPct(gridDown * 100.0); fb_text("% to +"); fbPct(gridUp * 100.0); fb_text("%\",\"theme.text\"]"); } else { fb_text(",[\"Gamma flip\",\""); fbPrice(curveFlip); fb_text("\",\""); fbPctFromSpot(curveFlip); fb_text("\",\"theme.text\"]"); } fb_text(",[\"Max pain\",\""); fbPrice(pain); fb_text("\",\""); fbPctFromSpot(pain); fb_text("\",\"theme.text\"]"); fb_text(netGex < 0.0 ? ",[\"Dealer regime\",\"Negative Gamma\",\"Dips sold, rallies bought\",\"theme.down\"]" : ",[\"Dealer regime\",\"Positive Gamma\",\"Dips bought, rallies sold\",\"theme.up\"]"); fb_text(",[\"P/C ratio\",\""); // a ratio, so text: two decimals and no sign if (isFinite(pcr)) fb_f64(pcr, 2); else fb_text("n/a"); fb_text(pcr > 1.0 ? "\",\"Puts favored\",\"theme.down\"]" : "\",\"Calls favored\",\"theme.up\"]"); fb_text(",[\"25D skew\",\""); if (isFinite(skewPp)) { if (skewPp >= 0.0) fb_text("+"); fb_f64(skewPp, 1); fb_text("pp"); } else fb_text("n/a"); fb_text(skewPp > 0.0 ? "\",\"Puts bid (downside)\"," : "\",\"Calls bid (upside)\","); fbTone(skewPp > 0.0 ? -1.0 : 1.0); fb_text("]"); fb_text(",[\"Net VEX\","); fb_f64(vex, 0); fb_text(vex >= 0.0 ? ",\"Dealers long vega\",null,null,\"usd\"]" : ",\"Dealers short vega\",null,null,\"usd\"]"); fb_text(",[\"Dealer delta\",\""); // in the chart's coin (the chain's open interest is in coin units); the module has no name for the coin, so the caption says "in coins" if (isFinite(dealerDelta)) { const a = Math.abs(dealerDelta); if (dealerDelta < 0.0) fb_text("-"); if (a >= 1000.0) { fb_f64(a / 1000.0, 1); fb_text("K"); } else fb_f64(a, 0); } else fb_text("n/a"); fb_text(dealerDelta < 0.0 ? "\",\"Net short delta, in coins\"," : "\",\"Net long delta, in coins\","); fbTone(dealerDelta); fb_text("]"); fb_text("]}"); writeFrameBuffer(tileRows); } function fbMarker(idx: i32, color: string): void { // a point callout pinned on the curve: "<usd> @ $<spot>" with a leader and a dot fb_text(",{\"x\":"); fb_f64(gridSpot[idx], 1); fb_text(",\"valign\":\"point\",\"series\":\"Net GEX\",\"label\":\""); fbUsd(gridGex[idx]); fb_text(" @ $"); fb_f64(gridSpot[idx], priceDecimals(gridSpot[idx])); fb_text("\",\"color\":\""); fb_text(color); fb_text("\",\"badge\":true}"); } function writeCurve(): void { fb_clear(); fb_text("{\"rows\":["); for (let p = 0; p < POINTS; p += 1) { if (p > 0) fb_text(","); fb_text("["); fb_f64(gridSpot[p], 1); fb_text(","); fb_f64(gridGex[p], 0); fb_text("]"); } fb_text("],\"markers\":[{\"x\":\"spot\",\"label\":\"Spot\",\"badge\":true,\"wash\":true}"); if (!isNaN(curveFlip)) { // the flip as a point callout on the curve (its value is zero there), the x reading in the pill fb_text(",{\"x\":"); fb_f64(curveFlip, 1); fb_text(",\"valign\":\"point\",\"series\":\"Net GEX\",\"label\":\"Gamma flip\",\"color\":\"theme.text\",\"badge\":true,\"show_value\":true}"); } fbMarker(peakIdx, "theme.up"); fbMarker(troughIdx, "theme.down"); fb_text("],\"caption\":\"live chain\",\"badge\":"); fb_text(netGex < 0.0 ? "{\"text\":\"SHORT GAMMA - AMPLIFIED\",\"color\":\"theme.down\"}" : "{\"text\":\"LONG GAMMA - DAMPENED\",\"color\":\"theme.up\"}"); // short gamma in the chart's down colour, long gamma in its up colour fb_text("}"); writeFrameBuffer(gexCurve); } function sbGrouped(n: i64): void { // 85979 -> 85,979 if (n >= 1000) { sbGrouped(n / 1000); sb_text(","); const r = n % 1000; if (r < 100) sb_text("0"); if (r < 10) sb_text("0"); sb_int(r); } else sb_int(n); } function writeLegendText(): void { sb_clear(); sb_text(netGex < 0.0 ? "Neg Gamma | " : "Pos Gamma | "); if (isNaN(curveFlip)) sb_text("no flip in range"); else { sb_text("flip $"); if (curveFlip >= 1000.0) sbGrouped(i64(Math.round(curveFlip))); else sb_f64(curveFlip, priceDecimals(curveFlip)); } str_regime_text_sb(); } // onBar() runs once per bar: the rolling CVD on every bar; the live bar measures the chain and writes the tiles, // the curve and the legend entry. function onBar(): void { t = bar.time(); close = bar.close(); // The rolling CVD: this bar's delta joins the ring, the bar that falls out of the window leaves it. const d = in_buy() - in_sell(); if (cvdCount == cvdBars) cvd -= deltas[cvdHead]; else cvdCount += 1; deltas[cvdHead] = d; cvdHead = (cvdHead + 1) % cvdBars; cvd += d; out_cvd(cvd); out_cvd_sign(cvd >= 0.0 ? 1.0 : 0.0); haveChain = false; if (bar.isLast() && !isNaN(close) && !isNaN(t)) { const n = in_chain_cells(); if (n >= TUPLE) { nowMs = chainClockMs(in_chain_view(), n, t * 1000.0); measureChain(n); } } out_net_gex(haveChain ? netGex : NaN); out_gamma_flip(haveChain ? curveFlip : NaN); out_max_pain(haveChain ? pain : NaN); out_regime_sign(haveChain ? (netGex < 0.0 ? 0.0 : 1.0) : NaN); if (haveChain) { writeLegendText(); writeTiles(); writeCurve(); } } ``` ## How it works **The chain is live only.** `input("chain", options_chain.cells, { max_cells: 4000, venue: "auto" })` delivers the chart coin's listed contracts as `[strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega]` tuples on the live row (a BTC chain is about 1,550); history bars carry an empty block and cost one read, so the tiles and the curve are measured on the last bar and rewritten as the chain moves. `venue: "auto"` reads the chart's own market when it lists options, else the coin's Deribit chain. **The curve.** A grid of 125 hypothetical spots runs from `grid_down` (10) percent below spot to `grid_up` (21) percent above it. Each live contract with an implied volatility sits on the curve at its own underlying moved with spot (a dated future moves with the coin: the underlying times the grid spot over the chart's spot), and Black-Scholes gamma there (rate zero, the time to expiry in years) times open interest, the multiplier and that underlying squared times 0.01 is added to the grid spot's reading, calls positive and puts negative; an implied volatility above 3 is read as a percent. The time to expiry runs from the chain's clock, the moment the chart priced the chain, never the bar's open: `chainClockMs()` solves the gamma of the contract whose delta sits nearest 0.5 for the time to expiry it implies, and a venue that serves its own greeks gives no answer, so the bar's open stands in there. At spot the curve therefore reads the live net GEX tile. The pass also finds the peak and the trough of the curve and interpolates its zero crossings; the one nearest spot is the gamma flip, the level the tile, the legend entry, the `gamma_flip` output and the flip callout all read. Then the whole chain goes through the `OptionsChain` kit: `totalNetGex()` for the live net GEX tile, `maxPain()` for max pain and `putCallRatio()` for the ratio. **The other tiles.** The 25-delta skew is the front expiry's put implied volatility nearest delta minus 0.25, less its call implied volatility nearest 0.25, in volatility points; net VEX sums vega times open interest times the multiplier with the dealer-naive sign; dealer delta sums minus delta times open interest times the multiplier, since dealers hold the other side of the open interest; it reads in the chart's coin (the chain's open interest is in coin units), and because the module has no name for the coin its caption says `in coins` instead of naming one. The tiles frame is `{ rows: [[label, value, caption, color, spark, format], ...] }`, `null` skipping an element: a number prints through the panel's `format: "usd"` with `signed: true` unless the row carries its own format word, a text value prints as written (`$85,722`, `0.56` for the ratio, `+1.4pp`; prices carry thousands separators and their decimals follow the coin's price size; with no zero crossing the gamma flip tile reads `No flip in range` over the grid's reach, `Positive from -10% to +21%`), and `accent: "auto"` tints a numeric tile by its sign, `positive_color: "theme.up"` or `negative_color: "theme.down"`, while a row with its own colour keeps it. **The curve frame.** `{ rows: [[spot, gex], ...], markers, caption, badge }`: the rows are the grid, `markers` carry the Spot marker (`x: "spot"`, with a `wash`), the flip as a point callout (`valign: "point"` and `series: "Net GEX"` pin it on the line, `show_value` prints the spot in its pill) and the peak and trough callouts with their readings, and `badge` is the regime chip on the title row. `panel.line` draws it with `x: "number"` over the spot grid (`x_format: "usd"`), `smooth` on the line (its `series` colour `theme.text`, the chart's text ink), `fill_mode: "signed"` with `fill_positive_color: "theme.up"`, `fill_negative_color: "theme.down"` and `fill_fade`, `y_zero` so the zero line always shows, `legend_style: "none"` (the one series needs no chip: the title names it and the hover card reads it), a `stats_row`, `hover_card` and `maximize`; `height_frac: 0.25` gives it the larger share of the space below the chart, the tiles `0.18`, so with the CVD pane's `0.12` the three take 0.55 of the chart and the candles keep the rest. **The CVD pane.** Each bar's aggressive buy volume minus its sell volume joins a ring of `cvd_bars` (96) deltas and the bar that falls out of the window leaves it; the sum is the `cvd` histogram. `output("cvd", histogram, lower, { pane: "cvd", color_by: "cvd_sign", colors })` colours each bar from the `cvd_sign` ladder, `theme.down` while the sum is negative and `theme.up` while positive, and `pane("cvd", { height_frac: 0.12, format: "si" })` sizes the pane and formats its axis; the pane carries the indicator's name, so it declares no title. **The legend entry.** `render.legend("regime_entry", { text: "regime_text", color_by: "regime_sign", colors })` reads the string slot written on the live bar (`Neg Gamma | flip $85,722`, or `Pos Gamma | no flip in range`) and colours it from the regime ladder. ## Where it runs Charts of a coin Deribit lists options for (BTC, ETH, SOL and the rest of its list), on any venue: Binance Futures BTCUSDT, Binance spot ETHUSDT and so on. The chart serves the newest chain on the live bar only, every history bar an empty block, and refreshes it with a snapshot about every 30 seconds; the sided trades behind the CVD are the chart market's own. Run it beside the [Strike Matrix](strike-matrix.md) on a second chart cell for the whole desk, and on a short timeframe drag that cell's price axis to zoom it out and see more strikes. A CME futures chart has its own chain, but the editor's Run is paused on CME markets and a community indicator cannot read CME data; OpenMarket's official wrun indicators can. ## When data is missing Other markets are refused by name before any fetch, naming the input and the way out: `Input 'chain' reads options_chain cells, but the chart market HYPERLIQUID_FUTURES/PURR has no option chain (wrun_options_chain_unavailable): coin 'PURR' is not listed on Deribit and the market is not an options venue; open a CME futures chart or a chart of a coin Deribit lists (BTC, ETH, SOL, ...)`. While the chain has not arrived yet the tiles and the curve are empty and the CVD pane runs on its own; a bar without sided trades reads zero into the CVD (`missing: "zero"` on both volume inputs); a reading the chain cannot give prints `n/a` in its tile, and a curve with no zero crossing gets no flip callout, its tile reads `No flip in range` and `gamma_flip` reads NaN. ## Customize it - **A wider or narrower grid.** `grid_down` and `grid_up` take 1 to 50 percent each side of spot; the curve's zero crossing has to fall inside the grid for the flip to show, so widen `grid_down` when the tile reads `No flip in range`. - **A longer CVD window.** `cvd_bars` up to 500. - **Fewer tiles.** Delete a row in `writeTiles()` and set `columns` on `panel.tiles` to the new row length; the panel lays the rest out. - **Pin the venue.** Change `venue: "auto"` to `"deribit"` and Run again to read the coin's Deribit chain even where the chart's market lists options of its own; `"cme"` reads a CME futures chart's chain. - **The look.** The badge's words and colours are written into the frame in `writeCurve()`, and every colour on the desk is a theme word (`theme.up`, `theme.down`, `theme.text`) in the two `panel.*` declarations, the ladders and the frames, so it follows the chart's theme; a hex word in any of those places fixes a colour instead ([Style anything](../presentation/style-anything.md)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Options Dashboard** under **Beyond the time axis**. 2. Press **Run** on a BTC or ETH chart: the tiles and the curve appear below the chart once the chain arrives, the CVD pane at the bottom from the first bar. 3. Hover a tile or the curve for its card; at the editor's Console prompt, type `net_gex` to read live net GEX, or `last 20 cvd` for the rolling delta over the last 20 bars. ## Concepts used - [Data sources](../core-concepts/data-sources.md) for the `options_chain` celled class, its tuple, the `venue` word and sided `trades.volume` - [Frames, panels and compact widgets](../presentation/cards-frames-panels.md#frames-panels-and-compact-widgets) for `panel.tiles`, `panel.line`, the panel frames, markers and the badge - [Panes](../presentation/plotting.md#panes) for a named pane, its height and its format - [Styling](../presentation/styling.md) for `color_by` ladders on an output and a legend entry - [Options kit](../functions/options-kit.md) for `OptionsChain`, the totals, max pain and the ratio - [Style anything](../presentation/style-anything.md) for the whole desk, part by part <!-- source: https://openmarket.xyz/wrun/cookbook/odds-vs-price --> # Odds vs price ![Polymarket odds vs BTC: correlation pane, divergence bars, verdict card](/wrun/images/odds-vs-price.png) On a Polymarket chart, the market's YES odds set against a pinned asset, Binance Futures BTCUSDT by default. A pane below price holds the rolling correlation of odds changes and BTC returns (sky line, -1 to 1) and a divergence histogram: the odds' z-score minus BTC's over the z-score window, slate while inside the threshold and lit amber (odds stretched above BTC's move) or orange (below) once past it. On the odds candles, a dot marks the bar where the divergence first crossed the threshold. A window below the chart shows BTC's own close over the last two correlation windows, so the move the odds are measured against is on screen. A card at the top right reads the market's odds, BTC's price and its move over the window, the correlation, the divergence and a plain-words verdict. The parts are one candle leg pinned to another market ([Multi-source](../core-concepts/multi-source.md)), the `Correlation` and `Zscore` helpers ([TA library](../functions/ta-library.md)), a histogram with a four-colour ladder ([Styling](../presentation/styling.md)), a frame feeding a `panel.line` on a time axis and a `draw.card` with text slots ([Cards, frames and panels](../presentation/cards-frames-panels.md)). This is also the `odds-vs-price` template: the **Odds vs Price** card under **Prediction markets** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Odds vs Price: on a Polymarket chart, the market's YES odds against a pinned asset (Binance Futures BTCUSDT). // A pane with the rolling correlation of odds changes and asset returns, a divergence histogram (the odds' z-score // minus the asset's, lit past the threshold), the asset's own close in a window below the chart, and a card with the // live reading. The trader sees whether the odds are moving with the asset or have stretched away from it. param.int("window", 48, { min: 10, max: 500, label: "Correlation window in bars", description: "Bars in the rolling correlation" }); param.int("zscore_window", 96, { min: 10, max: 500, label: "Z-score window in bars", description: "Bars the divergence z-scores are measured over" }); param.number("threshold", 2, { min: 0.5, max: 5, step: 0.1, label: "Stretch threshold", description: "Divergence z-score that counts as stretched" }); input("close", ohlcv.close, { description: "This chart's close: the YES odds on a Polymarket market" }); // the primary input: the chart's own candles input("asset_close", ohlcv.close, { exchange: "BINANCE_FUTURES", symbol: "BTCUSDT", missing: "carry", description: "The asset leg: BTCUSDT on Binance Futures" }); // pinned: change the pair here const correlationLine = output("correlation", line, lower, { color: "#38bdf8", width: 2, label: "Correlation", format: "0.00", description: "Rolling correlation of odds changes and asset returns, -1 to 1" }); // the handle names it for the hover card output("divergence", histogram, lower, { color_by: "div_tone", colors: ["#475569", "#475569", "#f8c000", "#f86800"], label: "Divergence", format: "0.00", description: "Odds z-score minus asset z-score" }); // slate inside the threshold, amber or orange once stretched; two decimals in the legend hover(correlationLine, [block.value("Correlation", "correlation", { format: "0.00" }), block.rows([["Divergence", "divergence", "0.00"]])]); // the card the correlation line opens under the cursor output("div_tone", none, lower, { description: "0 mild up, 1 mild down, 2 stretched up, 3 stretched down" }); // data-only: the histogram's ladder output("stretch_up", shape, overlay, { color: "#f8c000", width: 6, description: "The close of the bar where the divergence first crossed above the threshold" }); // an event dot on the odds candle output("stretch_down", shape, overlay, { color: "#f86800", width: 6, description: "The close of the bar where the divergence first crossed below minus the threshold" }); string("market_line", { max_bytes: 24 }); // "Odds 62.0%" or "Price 0.1423" string("asset_line", { max_bytes: 24 }); // "84,210" string("move_text", { max_bytes: 24 }); // "+0.4%" over the correlation window string("corr_text", { max_bytes: 16 }); // "0.29" string("div_text", { max_bytes: 16 }); // "+3.97" string("reading", { max_bytes: 24 }); // the plain-words verdict, at most 24 characters (the card clips longer values) const asset_rows = frame("asset_rows", { max_bytes: 16384 }); // the asset window: [bar open, asset close] for the last 2 x window bars panel.line({ name: "asset_window", title: "Asset leg: BTCUSDT on Binance Futures", x: "time", place: "below", frame: asset_rows, legend_style: "none", series: [{ name: "BTC close", color: "#f8c000" }] }); // no series chip beside the title: the one series is named once, by its value row draw.card("odds_vs_asset", { title: "Odds vs BTC", anchor: "top_right", offset: [0, 40], // below the chart's own High tag, which always sits in the pane's top-right corner background_opacity: 1, // an opaque surface: the candles under the card never show through its rows rows: [ { label: "This market", value: { text: "market_line" } }, { label: "BTC", value: { text: "asset_line" } }, { label: "BTC move, window", value: { text: "move_text" } }, { label: "Correlation", value: { text: "corr_text" } }, { label: "Divergence", value: { text: "div_text" }, color: { color_by: "div_tone", colors: ["#e2e8f0", "#e2e8f0", "#f8c000", "#f86800"] } }, { label: "Read", value: { text: "reading" } }, ], }); const RING = 1024; // the asset window ring const ringT = new StaticArray<f64>(RING); const ringAsset = new StaticArray<f64>(RING); let head = -1; let count = 0; const jsonBytes = new StaticArray<u8>(16384); let jsonUsed = 0; // the asset window JSON, appended as UTF-8 bytes (the sandbox forbids memory growth after onStart()) let window = 48; let zWindow = 96; let threshold = 2.0; // the params let corr = new Correlation(48); let zOdds = new Zscore(96); let zAsset = new Zscore(96); // rebuilt in onStart() let close: f64 = NaN; let prevClose: f64 = NaN; let asset: f64 = NaN; let prevAsset: f64 = NaN; // the two legs on this bar and the previous one let prevTone: f64 = NaN; // the divergence's tone on the previous bar: the crossing detector let unitScale = true; let centScale = true; let sameMarket = true; let bars = 0; // what this chart looks like: odds 0..1 on a 0.001 grid, odds 0..100 on a 0.1 grid, or the asset itself function round2(v: f64): f64 { return Math.round(v * 100.0) / 100.0; } function offGrid(v: f64, step: f64): bool { const k = v / step; return Math.abs(k - Math.round(k)) > 1e-6; } // ── JSON bytes: append text and numbers without allocating; one string is made per frame write ── function jbByte(b: u32): void { if (jsonUsed < jsonBytes.length) { jsonBytes[jsonUsed] = <u8>b; jsonUsed += 1; } } function jbText(text: String): void { const n = String.UTF8.byteLength(text); if (jsonUsed + n <= jsonBytes.length) jsonUsed += i32(String.UTF8.encodeUnsafe(changetype<usize>(text), text.length, changetype<usize>(jsonBytes) + jsonUsed)); } function jbUint(v: u64): void { if (v >= 10) jbUint(v / 10); jbByte(0x30 + <u32>(v % 10)); } function jbNum(v: f64, decimals: i32): void { // a fixed-point number, or null when not finite if (!isFinite(v)) { jbText("null"); return; } let x = v; if (x < 0.0) { jbByte(0x2d); x = -x; } let scale = 1.0; for (let i = 0; i < decimals; i += 1) scale *= 10.0; const scaled = Math.round(x * scale); const whole = Math.floor(scaled / scale); const frac = scaled - whole * scale; jbUint(<u64>whole); if (decimals > 0) { jbByte(0x2e); const f = <u64>frac; let digits = 1; let probe = f; while (probe >= 10) { probe /= 10; digits += 1; } for (let i = digits; i < decimals; i += 1) jbByte(0x30); jbUint(f); } } function jbFlush(slot: i32): void { writeFrame(slot, String.UTF8.decodeUnsafe(changetype<usize>(jsonBytes), jsonUsed)); jsonUsed = 0; } function sbGrouped(n: i64): void { if (n >= 1000) { sbGrouped(n / 1000); sb_text(","); const r = n % 1000; if (r < 100) sb_text("0"); if (r < 10) sb_text("0"); sb_int(r); } else sb_int(n); } function sbPrice(v: f64): void { if (v >= 1000.0) sbGrouped(i64(Math.round(v))); else if (v >= 100.0) sb_f64(v, 2); else if (v >= 1.0) sb_f64(v, 3); else sb_f64(v, 4); } function onStart(): void { window = i32(p_window()); zWindow = i32(p_zscore_window()); threshold = p_threshold(); corr = new Correlation(window); zOdds = new Zscore(zWindow); zAsset = new Zscore(zWindow); memory.grow(2); // headroom for the one frame string per write: the sandbox forbids growth after onStart() } // onBar() runs once per bar: the bar's odds change against the asset's log return into the correlation, both levels into their // z-scores; then the pane's two series and the card's words on every ready bar, the asset window on the live bar only. function onBar(): void { prevClose = close; prevAsset = asset; close = bar.close(); asset = in_asset_close(); const t = bar.time(); if (isNaN(close) || isNaN(asset)) return; bars += 1; if (close < 0.0 || close > 1.0 || offGrid(close, 0.001)) unitScale = false; // odds as 0..1 tick in thousandths if (close < 0.0 || close > 100.0 || offGrid(close, 0.1)) centScale = false; // odds as 0..100 tick in tenths of a cent if (Math.abs(asset - close) > 1e-9 * Math.max(1.0, Math.abs(close))) sameMarket = false; head = (head + 1) % RING; if (count < RING) count += 1; ringT[head] = t; ringAsset[head] = asset; const oddsChange = isNaN(prevClose) ? NaN : close - prevClose; const assetReturn = isNaN(prevAsset) || prevAsset <= 0.0 ? NaN : Math.log(asset / prevAsset); const correlation = isNaN(oddsChange) || isNaN(assetReturn) ? NaN : corr.update(oddsChange, assetReturn); const zo = zOdds.update(close); const za = zAsset.update(Math.log(asset)); const divergence = isNaN(zo) || isNaN(za) ? NaN : zo - za; const tone = isNaN(divergence) ? 0.0 : Math.abs(divergence) >= threshold ? (divergence > 0.0 ? 2.0 : 3.0) : divergence > 0.0 ? 0.0 : 1.0; const stretchUp = tone == 2.0 && prevTone != 2.0 && !isNaN(prevTone) ? close : NaN; // the event dots: the close on the bar the divergence crossed the threshold, NaN elsewhere const stretchDown = tone == 3.0 && prevTone != 3.0 && !isNaN(prevTone) ? close : NaN; prevTone = isNaN(divergence) ? NaN : tone; if (isNaN(correlation) || isNaN(divergence)) return; // still warming up: nothing is written on this bar out_correlation(round2(correlation)); out_divergence(Math.abs(divergence) < 0.005 ? NaN : round2(divergence)); out_div_tone(tone); out_stretch_up(stretchUp); out_stretch_down(stretchDown); // a zero-height bar is skipped sb_clear(); if (unitScale) { sb_text("Odds "); sb_f64(close * 100.0, 1); sb_text("%"); } else if (centScale) { sb_text("Odds "); sb_f64(close, 1); sb_text("%"); } else { sb_text("Price "); sbPrice(close); } str_market_line_sb(); const back = window < count ? window : count - 1; const agoAsset = ringAsset[(head + RING - back) % RING]; sb_clear(); sbPrice(asset); str_asset_line_sb(); sb_clear(); if (agoAsset > 0.0) { const move = ((asset - agoAsset) / agoAsset) * 100.0; if (move >= 0.0) sb_text("+"); sb_f64(move, 1); sb_text("% over "); sb_int(back); sb_text(" bars"); } else sb_text("n/a"); str_move_text_sb(); sb_clear(); sb_f64(correlation, 2); str_corr_text_sb(); sb_clear(); if (divergence > 0.0) sb_text("+"); sb_f64(divergence, 2); str_div_text_sb(); sb_clear(); const odds = unitScale || centScale; // the words follow the chart: odds on a prediction market, the market itself elsewhere if (sameMarket && bars > 4) sb_text("Chart is the asset leg"); else if (divergence >= threshold) sb_text(odds ? "Odds stretched above BTC" : "Stretched above BTC"); else if (divergence <= -threshold) sb_text(odds ? "Odds stretched below BTC" : "Stretched below BTC"); else if (correlation > 0.3) sb_text(odds ? "Odds moving with BTC" : "Moving with BTC"); else if (correlation < -0.3) sb_text(odds ? "Odds moving against BTC" : "Moving against BTC"); else sb_text(odds ? "Odds detached from BTC" : "Detached from BTC"); str_reading_sb(); if (bar.isLast()) { // the asset window: [bar open in seconds, asset close], oldest first const shown = count < 2 * window ? count : 2 * window; const decimals = asset >= 100.0 ? 2 : 6; jbText("{\"rows\":["); for (let k = shown - 1; k >= 0; k -= 1) { const i = (head + RING - k) % RING; if (k != shown - 1) jbText(","); jbText("["); jbNum(ringT[i], 0); jbText(","); jbNum(ringAsset[i], decimals); jbText("]"); } jbText("]}"); jbFlush(FRAME_ASSET_ROWS); } } ``` ## How it works **One pinned leg.** `input("asset_close", ohlcv.close, { exchange: "BINANCE_FUTURES", symbol: "BTCUSDT", missing: "carry" })` reads the asset's close on the chart's own grid; to compare against another asset, change the exchange and symbol in that one line. The chart's close is the primary input: the YES odds on a Polymarket market. **Correlation and divergence.** `correlation` is the rolling correlation of odds changes and asset returns over `window` (48) bars. `divergence` is the odds' z-score minus the asset's over `zscore_window` (96) bars; `div_tone` reads 0 mild up, 1 mild down, 2 stretched up, 3 stretched down from it against `threshold` (2), and the histogram's `color_by: "div_tone"` ladder paints slate, slate, amber, orange; `format: "0.00"` prints it in the legend with two decimals ("-0.43"), never a rounded "-0". `stretch_up` and `stretch_down` carry the close on the bar the divergence first crossed the threshold, the dots on the odds candles. **The asset is on screen.** `frame("asset_rows")` carries `[bar open, asset close]` for the last two correlation windows and feeds `panel.line` on a time axis below the chart; `legend_style: "none"` leaves the one series out of the chips beside the panel's title, so its name reads once, on its value row. The card's cells (odds, the asset's price and move, the correlation, the divergence, a verdict of at most 24 characters) are text slots written on the live bar, on an opaque surface (`background_opacity: 1`) so the candles under the card never show through its rows. ## Where it runs Polymarket markets on charts of one minute and coarser (the card reads odds on the 0..1 and the 0..100 scale). The chart serves the pinned leg as another market's candles at the chart's interval, joined bar by bar and live from that market's candle feed. On any other crypto chart it still runs: the first leg is then the chart's own price, so the pane reads as relative strength against BTC, the card labels the row "Price" and the verdict drops the word odds; on the BTCUSDT perp itself the two legs are one series and the card says "Chart is the asset leg". An alert on this indicator reads the pinned BTC leg as well, since alerts serve another market's candles on a secondary `ohlcv` input ([Alerts](../functions/alerts.md)); the file reads the odds as the chart's own `ohlcv` candles, never the `odds` source alerts cannot evaluate. The BTC pin is another market at the chart's interval, not a coarser timeframe, so the alert keeps 500 bars, the largest maximum among its settings, which holds the default 48 and 96-bar windows. ## When data is missing The pane needs `zscore_window` bars of both legs before it draws; a bar without an asset observation carries the last one; a divergence under 0.005 draws no bar. ## Customize it - **Another asset.** Change the `exchange` and `symbol` words on the pinned input (declaration literals, not settings) and the panel's title. - **A stricter stretch.** Raise `threshold`; the dots and the lit bars thin out. - **A longer memory.** `window` and `zscore_window` go up to 500 bars each. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Odds vs Price** under **Prediction markets**. 2. Press **Run** on a Polymarket market's chart: the pane, the asset window below the chart and the card draw once both legs have `zscore_window` bars. 3. At the editor's Console prompt, type `last 20 divergence` to read the divergence of the last 20 bars. ## Concepts used - [Multi-source](../core-concepts/multi-source.md) for pinned symbol and exchange legs - [TA library](../functions/ta-library.md) for `Correlation` and `Zscore` - [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames, `panel.line`, `draw.card` and text slots <!-- source: https://openmarket.xyz/wrun/cookbook/etf-flows --> # ETF flows ![Daily ETF flow bars, cumulative strip and flow card on BTC](/wrun/images/etf-flows.png) The daily net flow of the chart coin's spot ETFs as one wide bar per day in a pane under the chart, amber for an inflow day and orange for an outflow day, in dollars (the legend and the axis print `$29.0M`), with a zero line between them that stays out of the legend; the flow summed since the start of the loaded window as a line in a strip along the bottom of the price pane (its own scale, so the day bars keep theirs); and a faint wash behind each day's candles in the day's colour, so the flow reads against price. A day whose flow is a multiple of the window's mean day is tagged with its size ("+$1.20B"). A card at the top right reads the latest day (value and date), the window's sum over its days, the current streak ("4 days of inflow") and the biggest day of the window; a date names its month ("Oct 2") and carries its year when it is not this year ("Oct 6, 2025"). The parts are an `etf_flow.flow_usd` input landing once per day with `missing: "nan"` ([Data sources](../core-concepts/data-sources.md)), lower-pane box handles for the day bars and label handles for the tags ([Drawing objects](../presentation/drawing-objects.md)), an `out.inset` strip along the bottom of the price pane, a `render.bgcolor` wash ([Styling](../presentation/styling.md)) and a `draw.card` with text slots ([Cards, frames and panels](../presentation/cards-frames-panels.md)). This is also the `etf-flows` template: the **ETF Flows** card under **Beyond the time axis** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // ETF Flows: the daily net flow of the chart coin's spot ETFs, in millions of USD, as one wide bar per day in a pane // under the chart (amber inflow, orange outflow), the flow summed since the start of the loaded window as a line in a // strip along the bottom of the price pane, and a faint tint behind each day's candles in the day's colour, so the // flow reads against price. A day whose flow // dwarfs the window's norm is tagged with its size. A card at the top right reads the latest day, the window's sum, // the current streak and the biggest day. The flow is daily: on an intraday chart it lands on the first bar of its // day, so the pane draws the day once (a box across the day) instead of a bar per 15m; on daily and coarser charts // each bar is its own day and the histogram carries it. Weekends and holidays have no flow and draw nothing. param.number("big_day", 2.0, { min: 1.0, max: 10.0, step: 0.1, label: "Big day multiple", description: "A day is tagged with its size when its flow is this many times the window's mean day" }); input("close", ohlcv.close); // the chart's own close: the grid input("flow", etf_flow.flow_usd, { fund: "all", missing: "nan", description: "Daily net spot-ETF flow in USD, summed over every fund listed for the chart's coin; change fund to one ticker (IBIT, ETHA, ...) to follow it alone" }); // lands on the first bar of its day, NaN elsewhere output("flow_sign", none, overlay, { description: "+1 on an inflow day, -1 on an outflow day, NaN on a day without a flow; the tint's gate" }); // first output on price: the package homes on the chart output("flow_tone", none, overlay, { description: "1 on an inflow day, 0 on an outflow day: the tint's colour index" }); output("latest_tone", none, overlay, { description: "1 while the latest day is an inflow, 0 while an outflow: the card's colour index" }); output("day_flow", none, overlay, { description: "The day's net flow in millions of USD, carried through the day" }); output("inflow", histogram, lower, { color: "#f8c000", label: "Inflow", format: "usd", description: "Inflow days, USD, on the bar the day lands on" }); // usd prints the legend and the axis as $29.0M output("outflow", histogram, lower, { color: "#f86800", label: "Outflow", format: "usd", description: "Outflow days, USD, on the bar the day lands on" }); output("zero", line, lower, { color: "#334155", width: 1, legend: false, description: "The pane's zero line: inflow above it, outflow below; it keeps zero inside the pane's scale" }); // a scale helper: kept out of the legend out.inset("cumulative", { dock: "bottom", height_px: 44, shape: "line" }); // net flow summed since the start of the loaded window, millions of USD: a strip along the bottom of the price pane with its own scale, so the day bars keep theirs string("latest", { max_bytes: 24 }); // the card's cells, one slot each, written on the live bar string("window", { max_bytes: 24 }); string("streak", { max_bytes: 24 }); string("biggest", { max_bytes: 24 }); string("text", { max_bytes: 24 }); // the big-day tags string("note", { max_bytes: 80 }); // the one sentence while no flow day is in view render.bgcolor("flow_days", { where: "flow_sign", color_by: "flow_tone", colors: ["#f8680014", "#f8c00014"] }); // a faint wash behind each day's candles, alpha 0.08 handles.box({ panel: "lower", opacity: 0.55, borderWidth: 1 }); // one box per day in the pane: x in seconds, y in USD handles.label({ panel: "lower", size: 11, color: "#e2e8f0", align: "center" }); // the big-day tags, centred over the day draw.card("flow_card", { title: "Spot ETF flow", anchor: "top_right", offset: [56, 0], // inward past the price-axis tags rows: [ { label: "Latest day", value: { text: "latest" }, color: { color_by: "latest_tone", colors: ["#f86800", "#f8c000"] } }, { label: "This window", value: { text: "window" } }, { label: "Streak", value: { text: "streak" } }, { label: "Biggest day", value: { text: "biggest" } }, ], }); render.label("note_label", { position: "top_center", text: "note", color: "#94a3b8", style: "knockout", offset: [0, 8] }); // the sentence: top centre, clear of the card const DAY = 86400.0; const MAX_BOXES = 60; // day boxes ride a ring: the oldest is recycled const MAX_TAGS = 20; const AMBER = rgba(248, 192, 0, 255); const ORANGE = rgba(248, 104, 0, 255); const boxes: BoxHandle[] = []; for (let i = 0; i < MAX_BOXES; i += 1) boxes.push(draw.box(i)); const tags: LabelHandle[] = []; // ids are one space across kinds for (let i = 0; i < MAX_TAGS; i += 1) tags.push(draw.label(100 + i)); let bigDay = 2.0; // setting, read in onStart() let prevT: f64 = NaN; // the previous bar's open time let barSec: f64 = NaN; // the grid spacing, the smallest gap seen between bars let dayIndex: i64 = -1; // the current day (days since the epoch) and what it carries let dayFlowM: f64 = NaN; // the day's flow in millions, NaN until it lands let dayStart: f64 = NaN; let pendingStart: f64 = NaN; // a day that landed before the grid spacing was known: its box is drawn one bar later let pendingFlowM: f64 = NaN; let cumulativeM = 0.0; // since the first landed day of the window let days = 0; // landed days in the window let sumAbsM = 0.0; // for the big-day norm let boxesDrawn = 0; let tagsDrawn = 0; let streak = 0; // consecutive landed days with the latest day's sign let latestM: f64 = NaN; let latestDay: i64 = -1; let biggestM: f64 = NaN; let biggestDay: i64 = -1; const MONTHS: StaticArray<string> = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; let civilYear: i64 = 0; let civilMonth = 0; let civilDay = 0; function civilFromDays(daysSinceEpoch: i64): void { // days since 1970-01-01 -> year, month and day of month (proleptic Gregorian) const z = daysSinceEpoch + 719468; const era = (z >= 0 ? z : z - 146096) / 146097; const doe = z - era * 146097; const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; const doy = doe - (365 * yoe + yoe / 4 - yoe / 100); const mp = (5 * doy + 2) / 153; civilDay = i32(doy - (153 * mp + 2) / 5 + 1); civilMonth = i32(mp < 10 ? mp + 3 : mp - 9); civilYear = yoe + era * 400 + (civilMonth <= 2 ? 1 : 0); } function sbDate(day: i64, today: i64): void { // "Sep 29", with the year when it is not this bar's year: "Oct 6, 2025" civilFromDays(today); const thisYear = civilYear; civilFromDays(day); sb_text(MONTHS[civilMonth - 1]); sb_text(" "); sb_int(civilDay); if (civilYear != thisYear) { sb_text(", "); sb_int(civilYear); } } function sbMoneyM(m: f64): void { // millions in: "+$351M", "-$1.20B", "+$5.1K" sb_text(m < 0.0 ? "-$" : "+$"); const v = Math.abs(m); if (v >= 1000.0) { sb_f64(v / 1000.0, 2); sb_text("B"); } else if (v >= 1.0) { sb_f64(v, 0); sb_text("M"); } else { sb_f64(v * 1000.0, 0); sb_text("K"); } } // A day's box in the pane: from the day's first second to its last (a 5% gap keeps neighbouring days apart), 0 to the flow. function drawDayBox(start: f64, flowM: f64): void { const k = boxesDrawn % MAX_BOXES; boxesDrawn += 1; const ink = flowM >= 0.0 ? AMBER : ORANGE; boxes[k].set(start, flowM * 1.0e6, start + DAY * 0.95, 0.0).fill(ink).color(ink).opacity(0.55).border(1.0); } // A tag over (or under) the day's bar when the day dwarfs the window's mean day. function tagBigDay(start: f64, flowM: f64): void { const k = tagsDrawn % MAX_TAGS; tagsDrawn += 1; sb_clear(); sbMoneyM(flowM); tags[k].set(start + DAY * 0.475, flowM * 1.0e6).text(str_text_sb).color(flowM >= 0.0 ? AMBER : ORANGE); } function onStart(): void { bigDay = p_big_day(); } // onBar() runs once per bar: the grid spacing, the day the bar belongs to, and the day's flow when it lands; then the day's // numbers on every bar, the histogram on the landing bar, the box when the chart is finer than a day, the tag on a big day, // the card on the live bar only. function onBar(): void { const close = bar.close(); const t = bar.time(); if (!isNaN(prevT) && t > prevT && (isNaN(barSec) || t - prevT < barSec)) barSec = t - prevT; prevT = t; const day = i64(Math.floor(t / DAY)); if (day != dayIndex) { dayIndex = day; dayStart = f64(day) * DAY; dayFlowM = NaN; } let landedNow = false; // this bar carried the day's value const flow = in_flow(); if (!isNaN(flow) && flow != 0.0 && isNaN(dayFlowM)) { // the day's value, once; a reported zero (a day the funds did not trade) counts as no flow dayFlowM = flow / 1.0e6; landedNow = true; cumulativeM += dayFlowM; days += 1; sumAbsM += Math.abs(dayFlowM); if (!isNaN(latestM) && (latestM >= 0.0) == (dayFlowM >= 0.0)) streak += 1; else streak = 1; latestM = dayFlowM; latestDay = day; if (isNaN(biggestM) || Math.abs(dayFlowM) > Math.abs(biggestM)) { biggestM = dayFlowM; biggestDay = day; } } if (isNaN(close)) return; const hasDay = !isNaN(dayFlowM); out_flow_sign(hasDay ? (dayFlowM >= 0.0 ? 1.0 : -1.0) : NaN); out_flow_tone(hasDay ? (dayFlowM >= 0.0 ? 1.0 : 0.0) : NaN); out_latest_tone(isNaN(latestM) ? NaN : latestM >= 0.0 ? 1.0 : 0.0); out_day_flow(dayFlowM); out_inflow(landedNow && dayFlowM >= 0.0 ? dayFlowM * 1.0e6 : NaN); // the pane is in USD: the histogram, the boxes and the tags out_outflow(landedNow && dayFlowM < 0.0 ? dayFlowM * 1.0e6 : NaN); out_zero(0.0); out_cumulative(days > 0 ? cumulativeM : NaN); const intraday = !isNaN(barSec) && barSec < DAY; if (!isNaN(pendingStart) && !isNaN(barSec)) { // the first bar's day, drawn now that the spacing is known if (intraday) drawDayBox(pendingStart, pendingFlowM); pendingStart = NaN; } if (landedNow) { if (isNaN(barSec)) { pendingStart = dayStart; pendingFlowM = dayFlowM; } else if (intraday) drawDayBox(dayStart, dayFlowM); if (days >= 3 && Math.abs(dayFlowM) >= bigDay * (sumAbsM / f64(days))) tagBigDay(dayStart, dayFlowM); // a norm needs a few days } if (bar.isLast()) str_note(days > 0 ? "" : "No ETF flow day in view: open the 1h chart or pan back a few days"); // an empty text draws no label if (bar.isLast() && days > 0) { sb_clear(); sbMoneyM(latestM); sb_text(" on "); sbDate(latestDay, day); str_latest_sb(); sb_clear(); sbMoneyM(cumulativeM); sb_text(" over "); sb_int(days); sb_text(days == 1 ? " day" : " days"); str_window_sb(); sb_clear(); sb_int(streak); sb_text(streak == 1 ? " day of " : " days of "); sb_text(latestM >= 0.0 ? "inflow" : "outflow"); str_streak_sb(); sb_clear(); sbMoneyM(biggestM); sb_text(" on "); sbDate(biggestDay, day); str_biggest_sb(); } } ``` ## How it works **A daily series on an intraday chart.** `input("flow", etf_flow.flow_usd, { fund: "all", missing: "nan" })` sums every fund listed for the chart's coin (11 for BTC, 10 for ETH, 7 for SOL); change `fund` to one ticker (IBIT, FBTC, ETHA, ...) to follow that fund alone, and the lane names the coin's tickers when one does not match. The day's value arrives on the first bar of its day and nothing else that day, so the pane draws the day once: a box handle from the day's first second to its last, with a thin histogram bar (`inflow` or `outflow`) on the landing bar that keeps the pane's scale honest, and the `zero` line between them. The pane is in dollars: `format: "usd"` on the two histograms prints the legend and the axis as `$29.0M` and `-$149.0M`, the boxes and tags sit at the same dollar heights, and `legend: false` keeps the zero line, a scale helper, out of the legend. On daily and coarser charts each bar is its own day and the histogram carries it (a weekly bar sums its days). **The flow reads against price.** `flow_sign` (+1, -1 or NaN) gates and `flow_tone` (1 inflow, 0 outflow) colours `render.bgcolor("flow_days", ...)`, the faint wash behind each day's candles; `out.inset("cumulative", { dock: "bottom", height_px: 44, shape: "line" })` draws the flow summed since the start of the window in a strip along the bottom of the price pane with its own scale. `day_flow` carries the day's flow in millions through the day as a data-only output. **Tags and the card.** A day whose flow is `big_day` (2.0) times the window's mean day (after three landed days) is tagged with its size through the text slot, centred over the day. The card's cells (the latest day and its date, the window's sum, the streak, the biggest day) are text slots written on the live bar, amber while the latest day is an inflow and orange while an outflow. `sbDate()` writes the month's short name and the day, and the year after a comma when the day falls in another year than the live bar's, so a biggest day a year back never reads as a date to come. Boxes ride a ring of 60 days; tags a ring of 20. ## Where it runs Charts of BTC, ETH and SOL on any venue (Binance Futures BTCUSDT, Binance spot ETHUSDT, ...). The flows arrive once a day, after the US session, and the chart polls for them while it is open. OpenMarket's alerts engine does not read ETF flows yet, so an alert on this indicator is refused when you save it ([Alerts](../functions/alerts.md)). ## When data is missing Other coins are refused by name before any fetch: "Input 'flow' (etf_flow) needs a chart of a coin with listed spot ETFs (BTC, ETH, SOL): this chart's coin is PURR". Gold refuses the same way. Weekends and holidays have no flow (the lane reports zero, which counts as no flow) and draw nothing; a window whose first day has no midnight bar starts on the next landed day; a day the lane has not reported yet (today, before the close) draws nothing and the card keeps the latest reported day. An intraday chart whose loaded window holds no flow day (a 1m chart on a Monday morning) reads one sentence at the top centre of the price pane, "No ETF flow day in view: open the 1h chart or pan back a few days", through a `render.label` over the `note` slot, written on the live bar and empty once a day lands. ## Customize it - **One fund.** Change `fund: "all"` to a ticker (a declaration literal, not a setting) and Run again. A ticker the coin does not list is refused by name, and the message names the coin's funds. - **More tags.** Lower `big_day`; a day is tagged once its flow passes the multiple of the window's mean day. - **No wash.** Delete the `render.bgcolor` line to keep the pane, the strip and the card without the tint. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **ETF Flows** under **Beyond the time axis**. 2. Press **Run** on a BTC, ETH or SOL chart, such as BTCUSDT on Binance Futures at 1h: the day bars fill the pane, the strip runs along the bottom of the price pane, and the card appears at the top right. 3. At the editor's Console prompt, type `last 5 day_flow` to read the day's flow, in millions of USD, on the last five bars (`inflow` and `outflow` carry it in dollars). ## Concepts used - [Data sources](../core-concepts/data-sources.md) for the `etf_flow` source, the `fund` word and `missing: "nan"` - [Drawing objects](../presentation/drawing-objects.md) for lower-pane box and label handles - [Styling](../presentation/styling.md) for `render.bgcolor` and [Cards, frames and panels](../presentation/cards-frames-panels.md) for `out.inset`, `draw.card` and text slots <!-- source: https://openmarket.xyz/wrun/cookbook/liquidation-map --> # Liquidation map ![A two-sided liquidation profile docked against the price axis, amber rows above the price and orange rows below, two tagged dotted cluster lines and the cockpit card at the top left](/wrun/images/liquidation-map.png) An estimate of where the market's open positions would be liquidated, drawn on the price axis as a two-sided profile. The profile docks on the right of the pane, against the price axis, split at a centre line. Amber rows above the price are where shorts would be liquidated (a liquidated short is a forced buy, so these rows are fuel for a move up); they grow to one side of the centre. Orange rows below the price are where longs would be liquidated (forced sells); they grow to the other side. A longer and brighter row means more money sits at that price, and every bar glows toward its tip. Rows that price has already traded through are gone, so the profile is thin where price has recently been and thick just beyond the recent highs and lows. Resting the pointer on a row reads its price and dollars. The largest rows on each side (three by default) run across the chart as thin dotted lines in the side's colour, each with a knockout tag that ends just left of the profile: the price, then the money at it ("63.4K $84M"). A card at the top left, under the chart legend, reads "Estimated liquidations" in the cockpit look (corner brackets, monospace type, a faint glow): the largest row in range as the big line ("$84M at 63.4K", in the colour of its side), then the money within 5% below the price (longs, orange) and within 5% above it (shorts, amber). Resting the pointer on a number explains it. The legend reads "Liquidation Map estimated, 672 bars". The parts are one `plot.levels` profile docked on the price axis with `baseline: "center"`, fed by one frame ([Docked profiles](../presentation/cards-frames-panels.md#docked-profiles)), an `oi.close` input and side-split `trades.volume` inputs ([Data sources](../core-concepts/data-sources.md)), line and label handles for the cluster lines, their tags and the one sentence shown without open interest ([Drawing objects](../presentation/drawing-objects.md)), and a `render.hud` of three tiles in the cockpit look ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks)). This is also the `liquidation-map` template: the **Liquidation Map** card under **Beyond the time axis** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Liquidation Map: where the positions opened over the lookback would be liquidated, ESTIMATED from the market's // open interest and sided volume, docked on the price axis as a two-sided profile. Rows above the price are the // shorts' liquidation prices (a liquidated short is a forced buy), drawn in amber to one side of the dock's centre // line; rows below the price are the longs' (a forced sell), in orange to the other side. The hotter a row, the // brighter it paints, and every bar glows toward its tip. A level that price already traded through is gone, so the // profile reads thin where price has been and thick where it has not. The largest rows on each side run across the // chart as thin dotted lines with a knockout tag ("63.4K $84M") on the price's side of its line, so a line at the // pane's edge keeps its tag inside the pane, and a cockpit card at the top left reads the // largest row in range as its headline and the money within 5% of the price on each side. Nothing here is a venue's // own liquidation feed: it is a model of where the open positions sit, built from the open-interest change bar by bar. // // The model, per bar: a rise in open interest opens positions at the bar's typical price, split into longs and // shorts by the bar's buy share of sided volume, and across four leverage tiers (10x, 25x, 50x, 100x) with fixed // weights; each position's liquidation price follows from its leverage and the maintenance margin. A fall in open // interest scales every live level down in proportion. A long level is swept when a later bar's low reaches it, a // short level when a later bar's high does. Levels older than the lookback drop. The profile, lines and card are // measured on the live bar only; history bars only keep the ledger. section("Positions"); param.int("lookback", 672, { min: 96, max: 2000, label: "Lookback, bars", description: "Bars of opened positions kept in the map: 672 is a week on a 15m chart, four weeks on 1h" }); param.number("maint_margin_pct", 0.5, { min: 0, max: 2, step: 0.1, label: "Maintenance margin, percent", description: "Maintenance margin as a percent of the position: the distance a liquidation sits short of the leverage's full move" }); section("Profile"); param.number("range_pct", 12, { min: 3, max: 40, label: "Profile range, percent", description: "Profile range: percent around the close, each side" }); param.int("rows", 160, { min: 40, max: 160, label: "Profile rows", description: "Price rows in the docked profile (the step snaps to a round price, so the count varies a little)" }); section("Cluster lines"); param.int("clusters", 3, { min: 0, max: 3, label: "Cluster lines per side", description: "Largest rows per side drawn as lines across the chart with a tag; 0 = none" }); param.number("cluster_gap_pct", 0.5, { min: 0, max: 5, step: 0.1, label: "Cluster gap, percent", description: "Two cluster lines on the same side sit at least this far apart, percent of price, so their tags never stack" }); param.int("tag_inset_px", 352, { min: 0, max: 1200, label: "Tag inset, pixels", description: "Pixels in from the price axis where the cluster tags end: past the docked profile, so a tag never sits on a bar" }); legend({ title: "estimated, {{lookback}} bars" }); // the words after the name in the legend input("close", ohlcv.close); // the chart's own candles: the grid, the entry price, and the sweeps input("oi", oi.close, { missing: "nan", description: "Open interest in USD; NaN on a market without it (spot, FX, prediction markets), which blanks the map" }); input("buy", trades.volume, { side: "BUY", missing: "zero", description: "Buy volume of the bar: its share of sided volume is the share of new positions read as longs" }); input("sell", trades.volume, { side: "SELL", missing: "zero", description: "Sell volume of the bar" }); output("longs_near", none, overlay, { format: "usd", description: "Estimated long liquidations within 5% below the close, USD, every bar" }); // data-only: the card and the Console read them output("shorts_near", none, overlay, { format: "usd", description: "Estimated short liquidations within 5% above the close, USD, every bar" }); output("largest_usd", none, overlay, { format: "usd", description: "The largest profile row within range, USD; live bar only" }); output("largest_price", none, overlay, { format: "price", description: "The price of the largest profile row; live bar only" }); output("largest_side", none, overlay, { description: "0 when the largest row is below the close (longs), 1 when above (shorts), 2 when the map is empty: the headline's colour index" }); string("tag", { max_bytes: 40 }); // one bounded slot the cluster tags are written through, one at a time string("largest_text", { max_bytes: 32 }); // the card's headline, "$84M at 63.4K", written on the live bar string("note", { max_bytes: 80 }); // the one sentence shown when the market has no open interest, through a label handle at the top right const liq_rows = frame("liq_rows", { max_bytes: 32768 }); // the docked profile: one row per grid price, a colour and a hint per row // The docked profile: zero mid-dock (baseline center), the shorts' rows growing one way and the longs' the other, each // row in its own colour from the frame (amber above the price, orange below, brighter the more money sits there), every // bar fading from a third strength at its root to full at its tip, bars at most 12 px tall so a zoomed-in chart reads // as a ladder instead of slabs, a hover card per row in dollars. plot.levels({ name: "liquidations", frame: liq_rows, dock: "right", width_frac: 0.22, baseline: "center", labels: false, color: "#f8c000", opacity: 1, thickness_px: 12, gradient: ["#f8c00055", "#f8c000"], format: "usd", hover: true }); // The cluster lines and their tags are handles (the module picks the rows each run): width-1 dotted lines across the // whole chart, and knockout tags whose right edge sits a fixed inset left of the price axis, clear of the profile, // each hanging on the price's side of its line (a short cluster's tag under its line, a long cluster's over it). handles.line({ width: 1, line_style: "dotted", extend: "both" }); handles.label({ anchor: "right", align: "right", size: 11, style: "knockout", font_weight: "medium", padding: 4, color: "#f8c000" }); // The card, in the cockpit look (corner brackets, monospace type, a faint glow), under the chart legend at the top // left: the largest row in range leads as the headline in the side's colour, then the money within 5% of the price on // each side, one tile per side. Every tile reads the newest row, the live bar's. render.hud("liq_card", { position: "top_left", look: "cockpit", title: "Estimated liquidations", columns: 2, safe_area: true, tiles: [ tile.pill("Largest row in range", "largest_text", { headline: true, draw: "text", color_by: "largest_side", colors: ["#f86800", "#f8c000", "#94a3b8"] }), tile.value("Longs within 5%", "longs_near", { format: "usd", color: "#f86800", hint: "Estimated long liquidations within 5% below the price: forced sells if price falls there" }), tile.value("Shorts within 5%", "shorts_near", { format: "usd", color: "#f8c000", hint: "Estimated short liquidations within 5% above the price: forced buys if price rises there" }), ], }); // Leverage tiers the opened positions are spread over, and the share of new open interest each tier takes: a // guess at the mix of a perpetual's crowd (most size at low leverage, a thin tail at 100x). Named here so the // profile's shape can be argued about in one place. const TIERS = 4; const SLOTS = TIERS * 2; // per bar: four long levels, then four short levels function tierLeverage(tier: i32): f64 { return tier == 0 ? 10.0 : tier == 1 ? 25.0 : tier == 2 ? 50.0 : 100.0; } function tierWeight(tier: i32): f64 { return tier == 0 ? 0.4 : tier == 1 ? 0.3 : tier == 2 ? 0.2 : 0.1; } const MAX_LOOKBACK = 2000; // the ring is sized for the largest lookback setting const MAX_ROWS = 240; // grid rows in the docked profile: the rows setting, up to 1.41x more when the step rounds down to the nearest round price const MAX_CLUSTERS = 3; const HEAT_STEPS = 12; // how many brightness steps a row's colour takes, from a third strength to full const levelPrice = new StaticArray<f64>(MAX_LOOKBACK * SLOTS); // the ledger: one price and one USD size per level, SLOTS per bar const levelUsd = new StaticArray<f64>(MAX_LOOKBACK * SLOTS); // 0 once swept or closed const rowUsd = new StaticArray<f64>(MAX_ROWS); // the profile grid: USD per row const rowBlocked = new StaticArray<bool>(MAX_ROWS); // rows already taken by a cluster line, and the rows within the gap around it const shortHex = new StaticArray<string>(HEAT_STEPS); // the frame's row colours, quoted, built once in onStart(): amber at HEAT_STEPS alphas const longHex = new StaticArray<string>(HEAT_STEPS); // orange at the same alphas const HEX_DIGITS = "0123456789abcdef"; const AMBER = rgba(248, 192, 0, 255); // shorts' liquidations, above price: the lines' and tags' ink const ORANGE = rgba(248, 104, 0, 255); // longs' liquidations, below price const SLATE = rgba(148, 163, 184, 255); // the one sentence on a market without open interest const lines: LineHandle[] = []; // handle objects allocate once; ids are one space across kinds const tags: LabelHandle[] = []; for (let i = 0; i < MAX_CLUSTERS * 2; i += 1) { lines.push(draw.line(i)); // 0..2 the short clusters, 3..5 the long clusters tags.push(draw.label(MAX_CLUSTERS * 2 + i)); } const note = draw.label(MAX_CLUSTERS * 4); // the one line of words when the market has no open interest: a label handle pinned to the pane's top right corner, under the pane's corner buttons (a declared draw.label sits at a price, a render.label at top_right right-aligns under the price axis) let lookback = 672; // settings, read in onStart() let rangeFrac = 0.12; let rows = 160; let mm = 0.005; let clusters = 3; let gapFrac = 0.005; let tagInset = 352.0; let head = -1; // the ring: head is the newest bar, count the bars in use let count = 0; let prevOi: f64 = NaN; // the last finite open interest reading let oiSeen = false; // the market served open interest at least once let close: f64 = NaN; let t: f64 = NaN; let firstT: f64 = NaN; // the first bar's open time: where the cluster lines start let longsNear = 0.0; // live long levels within 5% below the close, USD let shortsNear = 0.0; // live short levels within 5% above the close, USD let gridLo: f64 = NaN; // the profile grid, built on the live bar let step: f64 = NaN; let rowCount = 0; let rowMax = 0.0; // the largest row in the grid: the brightness scale // ── The ledger, kept on every bar ── function sweepAndSum(high: f64, low: f64): void { // levels this bar traded through are gone; what remains near the close is summed const nearLo = close * 0.95; const nearHi = close * 1.05; longsNear = 0.0; shortsNear = 0.0; const used = count * SLOTS; for (let i = 0; i < used; i += 1) { const usd = levelUsd[i]; if (usd <= 0.0) continue; const price = levelPrice[i]; if (i % SLOTS < TIERS) { // a long level: swept once a bar's low reaches it if (low <= price) { levelUsd[i] = 0.0; continue; } if (price >= nearLo) longsNear += usd; } else { // a short level: swept once a bar's high reaches it if (high >= price) { levelUsd[i] = 0.0; continue; } if (price <= nearHi) shortsNear += usd; } } } function scaleAll(factor: f64): void { // open interest fell: every live level shrinks in proportion const used = count * SLOTS; for (let i = 0; i < used; i += 1) if (levelUsd[i] > 0.0) levelUsd[i] *= factor; longsNear *= factor; shortsNear *= factor; } function openPositions(base: i32, dOi: f64, high: f64, low: f64): void { // new open interest, placed at its liquidation prices const buy = in_buy(); const sell = in_sell(); const total = buy + sell; const longShare = total > 0.0 ? buy / total : 0.5; const entry = (high + low + close) / 3.0; const nearLo = close * 0.95; const nearHi = close * 1.05; for (let tier = 0; tier < TIERS; tier += 1) { const move = 1.0 / tierLeverage(tier) - mm; // the adverse move that liquidates this tier if (move <= 0.0) continue; // a margin wider than the tier's whole move: nobody holds that position const longPrice = entry * (1.0 - move); const shortPrice = entry * (1.0 + move); const longUsd = dOi * longShare * tierWeight(tier); const shortUsd = dOi * (1.0 - longShare) * tierWeight(tier); levelPrice[base + tier] = longPrice; levelUsd[base + tier] = longUsd; levelPrice[base + TIERS + tier] = shortPrice; levelUsd[base + TIERS + tier] = shortUsd; if (longPrice >= nearLo) longsNear += longUsd; if (shortPrice <= nearHi) shortsNear += shortUsd; } } // ── The docked profile: an evenly spaced grid of rounded prices around the close ── function niceStep(raw: f64): f64 { // the nearest of 1, 2, 2.5, 5 times a power of ten (in ratio terms), so the rows read as round prices and stay near the rows setting const mag = Math.pow(10.0, Math.floor(Math.log(raw) / Math.LN10)); const m = raw / mag; return (m < 1.414 ? 1.0 : m < 2.236 ? 2.0 : m < 3.536 ? 2.5 : m < 7.071 ? 5.0 : 10.0) * mag; // the cut points are the geometric midpoints } function priceDecimals(width: f64): i32 { // enough decimals that the rounded row prices stay evenly spaced when parsed (the docked grid is contiguous only within 0.1% of its step) const d = i32(Math.ceil(Math.log(2000.0 / width) / Math.LN10)); return d < 2 ? 2 : d > 9 ? 9 : d; } function buildGrid(): bool { const lo = close * (1.0 - rangeFrac); const hi = close * (1.0 + rangeFrac); if (!(hi > lo) || !(lo > 0.0)) return false; step = niceStep((hi - lo) / f64(rows)); gridLo = Math.floor(lo / step) * step; rowCount = i32(Math.floor((hi - gridLo) / step)) + 1; while (rowCount > MAX_ROWS) { // cannot happen with the settings' bounds; kept so the buffer is never overrun step *= 2.0; gridLo = Math.floor(lo / step) * step; rowCount = i32(Math.floor((hi - gridLo) / step)) + 1; } if (rowCount < 3) return false; // the engine needs three prices for a grid for (let r = 0; r < rowCount; r += 1) rowUsd[r] = 0.0; const used = count * SLOTS; for (let i = 0; i < used; i += 1) { // each live level adds its USD to the row it falls in const usd = levelUsd[i]; if (usd <= 0.0) continue; const r = i32(Math.round((levelPrice[i] - gridLo) / step)); if (r >= 0 && r < rowCount) rowUsd[r] += usd; } rowMax = 0.0; for (let r = 0; r < rowCount; r += 1) if (rowUsd[r] > rowMax) rowMax = rowUsd[r]; return true; } function rowPrice(r: i32): f64 { return gridLo + f64(r) * step; } function heatStep(usd: f64): i32 { // 0 (empty or faint) .. HEAT_STEPS - 1 (the largest row), on a square-root ramp so mid-sized rows still read if (!(rowMax > 0.0) || usd <= 0.0) return 0; const k = i32(Math.round(Math.sqrt(usd / rowMax) * f64(HEAT_STEPS - 1))); return k < 0 ? 0 : k >= HEAT_STEPS ? HEAT_STEPS - 1 : k; } function writeProfile(): void { // prices ascending; a signed value per row (shorts positive above the close, longs negative below, 0 where empty so the grid stays whole); a colour per row by heat; a hint per filled row const decimals = priceDecimals(step); fb_clear(); fb_text("{\"prices\":["); for (let r = 0; r < rowCount; r += 1) { if (r > 0) fb_text(","); fb_f64(rowPrice(r), decimals); } fb_text("],\"values\":["); for (let r = 0; r < rowCount; r += 1) { if (r > 0) fb_text(","); const usd = rowUsd[r]; fb_f64(usd <= 0.0 ? 0.0 : rowPrice(r) > close ? usd : -usd, 0); } fb_text("],\"colors\":["); for (let r = 0; r < rowCount; r += 1) { if (r > 0) fb_text(","); const k = heatStep(rowUsd[r]); fb_text(rowPrice(r) > close ? shortHex[k] : longHex[k]); } fb_text("],\"tooltips\":["); for (let r = 0; r < rowCount; r += 1) { if (r > 0) fb_text(","); if (rowUsd[r] <= 0.0) fb_text("null"); else fb_text(rowPrice(r) > close ? "\"Short liquidations here: forced buys\"" : "\"Long liquidations here: forced sells\""); } fb_text("]}"); writeFrameBuffer(liq_rows); } // ── Text helpers ── function sbCompactPrice(v: f64): void { // 84461.8 -> "84.5K", 2431.6 -> "2431.6", 0.13797 -> "0.13797" if (v >= 10000.0) sb_compact(v, 1); else sb_auto(v); } function sbMoney(usd: f64): void { // "$1.23B", "$84M", "$8.4M", "$640K", "$12.5K" sb_text("$"); const v = Math.abs(usd); if (v >= 1.0e9) { sb_f64(v / 1.0e9, 2); sb_text("B"); } else if (v >= 1.0e6) { sb_f64(v / 1.0e6, v >= 1.0e7 ? 0 : 1); sb_text("M"); } else if (v >= 1.0e3) { sb_f64(v / 1.0e3, v >= 1.0e5 ? 0 : 1); sb_text("K"); } else sb_f64(v, 0); } function hex2(n: i32): string { // two hex digits of a byte; onStart() only (it allocates) return HEX_DIGITS.charAt((n >> 4) & 15) + HEX_DIGITS.charAt(n & 15); } // ── The cluster lines: the largest rows on each side, at least cluster_gap_pct apart so the tags stay readable ── function drawClusters(): void { for (let r = 0; r < rowCount; r += 1) rowBlocked[r] = false; const gapRows = i32(Math.ceil((close * gapFrac) / step)); // rows either side of a pick that the next pick skips for (let side = 0; side < 2; side += 1) { // 0: shorts above the close (amber); 1: longs below (orange) const ink = side == 0 ? AMBER : ORANGE; let drawn = 0; for (let j = 0; j < clusters; j += 1) { let best = -1; for (let r = 0; r < rowCount; r += 1) { if (rowBlocked[r] || rowUsd[r] <= 0.0) continue; const above = rowPrice(r) > close; if ((side == 0) != above) continue; if (best < 0 || rowUsd[r] > rowUsd[best]) best = r; } if (best < 0) break; for (let r = best - gapRows; r <= best + gapRows; r += 1) if (r >= 0 && r < rowCount) rowBlocked[r] = true; // this row and the gap around it are taken const price = rowPrice(best); const k = side * MAX_CLUSTERS + drawn; lines[k].set(firstT, price, t, price).color(ink); sb_clear(); sbCompactPrice(price); sb_text(" "); sbMoney(rowUsd[best]); tags[k].set(tagInset, price).text(str_tag_sb).color(ink).valign(side == 0 ? VALIGN_TOP : VALIGN_BOTTOM); // toward the price: a line at the pane's edge keeps its tag inside drawn += 1; } for (let j = drawn; j < MAX_CLUSTERS; j += 1) { // fewer clusters than last time: the spare handles go lines[side * MAX_CLUSTERS + j].delete(); tags[side * MAX_CLUSTERS + j].delete(); } } } function clearDrawings(): void { for (let k = 0; k < MAX_CLUSTERS * 2; k += 1) { lines[k].delete(); // no-ops when never drawn tags[k].delete(); } } function onStart(): void { lookback = i32(p_lookback()); mm = p_maint_margin_pct() / 100.0; rangeFrac = p_range_pct() / 100.0; rows = i32(p_rows()); clusters = i32(p_clusters()); gapFrac = p_cluster_gap_pct() / 100.0; tagInset = f64(p_tag_inset_px()); for (let k = 0; k < HEAT_STEPS; k += 1) { // the row colours: the app's flow pair at a third strength for an empty row, full for the largest const a = i32(Math.round((0.3 + 0.7 * f64(k) / f64(HEAT_STEPS - 1)) * 255.0)); shortHex[k] = "\"#f8c000" + hex2(a) + "\""; longHex[k] = "\"#f86800" + hex2(a) + "\""; } } // onBar() runs once per bar: sweep the ledger with the bar's range, then fold the bar's open-interest change into it // (new positions at their liquidation prices, or every level scaled down); the near sums as numbers on every bar; // the profile, the cluster lines, the tags and the card on the live bar only. function onBar(): void { close = bar.close(); t = bar.time(); const high = bar.high(); const low = bar.low(); if (isNaN(firstT) && !isNaN(t)) firstT = t; if (isNaN(close)) return; sweepAndSum(high, low); head = (head + 1) % lookback; // this bar's slots: the oldest bar's levels are recycled once the ring is full if (count < lookback) count += 1; const base = head * SLOTS; for (let s = 0; s < SLOTS; s += 1) { levelUsd[base + s] = 0.0; levelPrice[base + s] = NaN; } const oiNow = in_oi(); if (!isNaN(oiNow)) { // a bar without a reading changes nothing: the last reading carries forward oiSeen = true; if (!isNaN(prevOi)) { const dOi = oiNow - prevOi; if (dOi > 0.0) openPositions(base, dOi, high, low); else if (dOi < 0.0 && prevOi > 0.0) scaleAll(oiNow / prevOi); } prevOi = oiNow; } out_longs_near(oiSeen ? longsNear : NaN); out_shorts_near(oiSeen ? shortsNear : NaN); if (!bar.isLast()) return; if (!oiSeen) { // the market served no open interest at all: the one sentence, top right, and nothing else clearDrawings(); sb_clear(); sb_text("No open interest on this market, so no liquidation estimate"); note.set(16.0, 40.0).text(str_note_sb).anchor(ANCHOR_TOP_RIGHT).align(ALIGN_RIGHT).style(LabelStyle.Plain).fontWeight(FontWeight.Normal).color(SLATE); sb_clear(); sb_text("No open interest"); str_largest_text_sb(); out_largest_side(2.0); return; } note.delete(); // no-op when never drawn if (!buildGrid()) { clearDrawings(); return; } writeProfile(); drawClusters(); let best = -1; // the largest row within range, either side for (let r = 0; r < rowCount; r += 1) if (rowUsd[r] > 0.0 && (best < 0 || rowUsd[r] > rowUsd[best])) best = r; const largestUsd = best < 0 ? 0.0 : rowUsd[best]; const largestPrice = best < 0 ? NaN : rowPrice(best); out_largest_usd(largestUsd); out_largest_price(largestPrice); out_largest_side(best < 0 ? 2.0 : largestPrice > close ? 1.0 : 0.0); sb_clear(); if (best < 0) sb_text("No positions yet"); else { sbMoney(largestUsd); sb_text(" at "); sbCompactPrice(largestPrice); } str_largest_text_sb(); } ``` ## How it works **Open interest is read as positions.** On every bar `in_oi()` (the `oi.close` input, in USD) is compared with the last finite reading; a bar without a reading changes nothing. A rise is new open interest opened at the bar's typical price (high, low and close averaged), split into longs and shorts by the bar's buy share of `in_buy()` and `in_sell()` (half and half where the venue serves no sided trades), and spread over four leverage tiers, 10x, 25x, 50x and 100x, weighted 40, 30, 20 and 10 percent in `tierLeverage` and `tierWeight`. Each tier's liquidation price follows from its leverage and `maint_margin_pct`: a 10x long opened at 84,000 with the default 0.5% margin sits about 9.5% lower. A fall in open interest scales every live level down in proportion (`scaleAll`). Nothing here is a venue's own liquidation feed: read the rows as "about this much, about here". **The ledger is a ring.** `levelPrice` and `levelUsd` are two fixed arrays sized for the largest lookback, eight slots per bar (four long tiers, then four short tiers), indexed by bar; once `lookback` bars are held the oldest bar's slots are recycled, so levels older than the lookback drop. `sweepAndSum` walks the ledger first on every bar: a long level is gone once the bar's low reaches it, a short level once the bar's high does, and what remains within 5% of the close is summed into `longs_near` and `shorts_near`, data-only outputs written on every bar. The forming bar replays from the chart's snapshot, so the ring advances once per bar on live ticks too ([Repainting](../core-concepts/repainting.md)). **One frame is the profile.** On the live bar only, `buildGrid` lays an evenly spaced grid of rounded prices across `range_pct` each side of the close (`niceStep` snaps the step to 1, 2, 2.5 or 5 times a power of ten, so the row count varies a little from `rows`; on BTC near 85,000 the step is 100), and every live level adds its dollars to the row it falls in. `writeProfile` builds the `liq_rows` frame in the frame buffer with `fb_clear`, `fb_text` and `fb_f64` and sends it with `writeFrameBuffer`: `prices` ascending, a signed value per row (the shorts' rows above the close positive, the longs' below negative, 0 where empty so the grid stays whole), a colour per row and a `tooltips` hint per filled row naming its side. `baseline: "center"` puts zero mid-dock, so the two sides are the sign of one value and grow away from each other; `dock: "right"` and `width_frac: 0.22` set the profile against the price axis; `thickness_px: 12` caps a bar's height so a zoomed-in chart reads as a ladder, not slabs; `hover: true` opens the chart's hover card on a row with its price, its dollars and the hint. **Heat is the row's colour.** `heatStep` maps a row's dollars against the largest row (`rowMax`) onto twelve steps on a square-root ramp, so mid-sized rows still read, and `onStart` builds the twelve colours once: amber `#f8c000` and orange `#f86800` at alphas from 30% for an empty row to 100% for the largest. `opacity: 1` paints those alphas exactly, and `gradient: ["#f8c00055", "#f8c000"]` fades every bar from a third strength at its root to full at its tip while each row keeps its own colour. **The cluster lines are handles.** `handles.line({ width: 1, line_style: "dotted", extend: "both" })` and `handles.label({ anchor: "right", align: "right", size: 11, style: "knockout", font_weight: "medium", padding: 4 })` declare the kinds; six `draw.line(id)` and six `draw.label(id)` handles are made once at the top, three per side. `drawClusters` picks the largest row on each side largest-first, blocks the rows within `cluster_gap_pct` of each pick so two tags never stack, then sets each line from the first bar's time to the live bar's time at the row's price and its tag `tag_inset_px` pixels in from the price axis at that price, the text built in the `tag` slot by `sbCompactPrice` and `sbMoney` ("63.4K $84M"). Each tag hangs on the price's side of its line, `valign(VALIGN_TOP)` under a short cluster's line and `valign(VALIGN_BOTTOM)` over a long cluster's, so a cluster whose line sits on the pane's top or bottom edge keeps its tag inside the pane. Spare handles are deleted when a side has fewer clusters than last time. **The card reads slots and outputs.** `render.hud("liq_card", { position: "top_left", look: "cockpit", title: "Estimated liquidations", columns: 2, safe_area: true, tiles: [...] })` sits under the legend. The headline is a `tile.pill` with `draw: "text"` reading the `largest_text` slot ("$84M at 63.4K", written on the live bar), coloured by `largest_side`: 0 orange when the largest row is below the close (longs), 1 amber when above (shorts), 2 slate when the map is empty. Two `tile.value` tiles read `longs_near` and `shorts_near` in `format: "usd"`, each with a `hint` sentence on its hover card. `largest_usd` and `largest_price` are data-only outputs written on the live bar for anything that reads them, and `legend({ title: "estimated, {{lookback}} bars" })` prints the lookback after the name. ## Where it runs Perpetual futures with open interest: Binance Futures and Hyperliquid perpetuals, at any interval (BTCUSDT on Binance Futures and the PURR perpetual on Hyperliquid at 15m and 1h in the builds). The lookback is counted in bars, so 672 is a week at 15m and four weeks at 1h. The profile, the lines and the card are measured on the live bar, so they follow the forming bar; history bars only keep the ledger. The lines live in chart time and the tags in pane pixels, so on a coin whose 1h view spans a wide range the rows are thin and a tag can sit over candles; `tag_inset_px` moves the tags. No market refuses the run: the `oi` input declares `missing: "nan"`, so a market without open interest reads NaN and gets the sentence below instead of a toast. ## When data is missing On a market without open interest (spot pairs, FX, prediction markets) the indicator shows one line of words at the top right, `No open interest on this market, so no liquidation estimate`, in plain slate: the `note` label handle pinned 40 px under the pane's top right corner and right-aligned, clear of the price axis and of the pane's corner buttons. The card's headline reads "No open interest" in slate, so the card is not an empty frame, the two value tiles print a dash (`longs_near` and `shorts_near` read NaN until the market has served open interest at least once), and it draws nothing else: no profile, no lines. On a market with open interest but without sided trades, every new position is split half longs and half shorts. While no live level falls inside the profile's range the headline reads "No positions yet" and there is no row to tag. ## Customize it - **A longer or shorter memory.** `lookback` (672, 96 to 2000) is the bars of opened positions the map keeps, counted in the chart's bars: 672 is a week on a 15m chart and four weeks on 1h. `maint_margin_pct` (0.5, 0 to 2) is the maintenance margin as a percent of the position: a larger margin moves every liquidation price closer to its entry. - **A wider or finer profile.** `range_pct` (12, 3 to 40) is the percent around the price the profile covers each side; `rows` (160, 40 to 160) the price rows in it, the step snapping to a round price so the count varies a little. - **More or fewer lines.** `clusters` (3, 0 to 3) is the largest rows per side drawn across the chart, 0 for none; `cluster_gap_pct` (0.5, 0 to 5) is how far apart two lines on one side must sit, percent of price, so their tags never stack; `tag_inset_px` (352, 0 to 1200) is where the tags end, in from the price axis: raise it on a wide pane where the tags land on the longest rows. - **Another crowd.** The tier mix, 10x, 25x, 50x and 100x at 40, 30, 20 and 10 percent, is a guess at a perpetual's crowd, named in `tierLeverage` and `tierWeight` at the top of the file so the profile's shape can be argued about in one place; a venue's own leverage distribution would replace it. - **Change the look.** The card is `look: "cockpit"`, a home look that keeps its dark paper on a light chart; the Look row on the indicator's Style page switches it without code ([The Style page](../settings/style-page.md#the-look-row)), and glass is the other look this example was shot in. The row switches the card only: the profile, the lines and the tags keep their amber and orange inks. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Liquidation Map** under **Beyond the time axis**. 2. Press **Run** on a perpetual with open interest, such as BTCUSDT on Binance Futures at 15m: the two-sided profile docks against the price axis, the cluster lines cross the chart with their tags, and the cockpit card appears at the top left under the legend. Rest the pointer on a row to read its price and dollars. 3. At the editor's Console prompt, type `last 20 longs_near` to read the estimated long liquidations within 5% below the price on the last 20 bars. ## Concepts used - [Docked profiles](../presentation/cards-frames-panels.md#docked-profiles) for `plot.levels`, `baseline: "center"`, `thickness_px`, `gradient`, `hover`, the frame's `prices`, `values`, `colors` and `tooltips`, and the `fb_*` frame buffer on the same page - [Data sources](../core-concepts/data-sources.md) for the `oi` and `trades` sources, `side` and the `missing` policies - [Drawing objects](../presentation/drawing-objects.md) for `handles.line`, `handles.label`, the `draw.line(id)` and `draw.label(id)` handles, pane anchors and the knockout style - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) for `render.hud`, its anchors and `safe_area` - [Looks](../presentation/hud-and-hover-cards.md#looks) for `look: "cockpit"` and what it draws - [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the headline `tile.pill` with `draw: "text"` and `tile.value` with `hint` - [The Style page](../settings/style-page.md#the-look-row) for the Look row that switches the look without code - [Text formatting](../functions/text-formatting.md) for `sb_compact`, `sb_auto` and `sb_f64` behind the tags and the headline <!-- source: https://openmarket.xyz/wrun/cookbook/options-odds-cloud --> # Options odds cloud ![Options odds cloud docked on the price axis, the 68% band lines with their tags and the glass odds card on BTC](/wrun/images/options-odds-cloud.png) Where the options market expects price to settle at the chosen expiry, drawn on the price axis as one smooth profile. A cloud docked on the right of the price pane, one bar per price row, its length the chain's implied odds that price settles in that row: the core is sky, the tails fade to slate, every bar fades from the axis to its tip, and a teal point-of-control line marks the most likely row, with a teal tag beside the price axis naming its price at the row's own resolution ("Most likely 85.26K"). The rows follow the price scale on the chart: they span the loaded bars' low to high plus a margin, never wider than three standard deviations either side of spot, at least `rows` of them, so a 1m chart gets rows a few dollars apart and a 1h chart the whole bell; on a fine chart the visible slice of the bell is nearly flat, which is the odds, not a fault. Two solid sky lines run across the chart at the 16% and 84% prices, tagged "+1σ 87.0K" and "-1σ 83.7K": the middle 68% of the outcomes. Two dotted slate lines sit at the 2.5% and 97.5% prices, tagged "+2σ" and "-2σ" with their prices: the middle 95%. A card at the top left, under the legend and clear of the newest candles, in the glass look (a translucent card with round corners, dark on a dark chart and light on a light one) set in the app's own type, carries the odds and the expiry read as its title ("68% likely, Tue 06 Oct 08:00 UTC"), the band alone as its headline ("83.7K to 87.0K", labelled "Range at expiry"), then the odds of a 5% move either way ("Up 5% or more: 2%", "Down 5% or more: 2%"). Hovering a cloud row opens its own odds, to two decimals. The parts are a celled `options_chain.cells` input read on the live bar ([Options kit](../functions/options-kit.md)), a frame feeding `plot.levels` docked on the price axis with per-row colours, a `gradient` alpha profile and a `poc` line at the row the frame names, its docked tag off ([Docked profiles](../presentation/cards-frames-panels.md#docked-profiles)), four declared `draw.line` lines and five `draw.label` tags in the knockout style, placed from the newest bar ([Run-level drawings](../presentation/drawing-objects.md#run-level-drawings)), and a `render.hud` card with `look: "glass"` and `font_family: "ui"` of a text-drawn headline pill and a rows tile over string slots ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks), [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles)). This is also the `options-odds-cloud` template: the **Options Odds Cloud** card under **Beyond the time axis** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Options Odds Cloud: where the options market expects price to settle at the chosen expiry, docked on the price // axis as one smooth profile. Each row is the chain's implied odds that price settles there: a ramp from slate tails // to a sky core, every bar fading from its root to its tip so it reads as a cloud, the most likely row marked by a // teal point-of-control line with a "Most likely 85.18K" knockout tag beside the price axis. The rows follow the // price scale the trader sees: the loaded bars' high..low widened by a margin, never wider than spot times // exp(+-3 sigma), at least 'rows' rows across it, so the cloud is as fine on a 1m chart as on a 1h chart. The 16% and // 84% prices (the middle 68%) run across the chart as solid sky lines with knockout tags "+1σ 87.9K" / "-1σ 81.2K"; // the 2.5% and 97.5% prices are dotted slate lines tagged "+2σ" / "-2σ" (every line shows whenever the pane covers // it). A card at the top left, under the legend, titled "68% likely" with the expiry read, carries the 68% band as // its headline ("83.7K to 87.0K") and the odds of a 5% move either way. The chain is live only: history bars only // widen the loaded price range; everything else is measured on the last bar, from the chain's own clock (the moment // its greeks were priced, never the bar's open), so the odds do not depend on the chart's interval. section("Expiry and grid"); param.int("target_days", 2, { min: 1, max: 90, label: "Days to expiry", description: "Expiry read: the listed expiry at least 12 h away whose distance in days is closest to this; a higher timeframe wants a longer horizon" }); param.int("rows", 90, { min: 60, max: 160, label: "Cloud rows", description: "Rows in the docked cloud, at least: spread over the loaded bars' price range plus the margin, within spot times exp(+-3 sigma)" }); param.int("margin", 25, { min: 0, max: 100, label: "Margin, percent", description: "How far the cloud reaches past the loaded bars' high and low, percent of that range" }); section("Look"); param.color("core_ink", "#38bdf8", { label: "Cloud core" }); param.color("tail_ink", "#94a3b8", { label: "Cloud tails" }); param.color("peak_ink", "#2dd4bf", { label: "Most likely row" }); input("close", ohlcv.close); // the chart's own close: the bar grid, and spot when the chain carries no underlying input("chain", options_chain.cells, { max_cells: 4000, venue: "deribit", description: "The coin's Deribit options chain, one tuple per listed contract, live bar only" }); // [strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega]; side +1 call, -1 put; mark_iv in percent a year // The readings, data-only: the card and the Console read them. Each is NaN on history bars. output("p_up_move", none, overlay, { format: "%", description: "Odds of settling 5% or more above spot at the expiry read, percent; live bar only" }); output("p_down_move", none, overlay, { format: "%", description: "Odds of settling 5% or more below spot at the expiry read, percent" }); output("band_low", none, overlay, { format: "price", description: "The 16% price: the lower edge of the 68% band" }); output("band_high", none, overlay, { format: "price", description: "The 84% price: the upper edge of the 68% band" }); output("tail_low", none, overlay, { format: "price", description: "The 2.5% price: the lower edge of the 95% band" }); output("tail_high", none, overlay, { format: "price", description: "The 97.5% price: the upper edge of the 95% band" }); output("most_likely", none, overlay, { format: "price", description: "The grid row holding the most odds, the cloud's peak" }); output("iv_atm", none, overlay, { format: "%", description: "At-the-money implied volatility of the expiry read, percent a year" }); output("days_to_expiry", none, overlay, { format: "0.0", description: "Days from the chain's read time to the expiry read" }); output("t_first", none, overlay, { description: "The first loaded bar's open time, epoch seconds: where the lines start" }); output("t_last", none, overlay, { description: "The live bar's open time, epoch seconds: where the lines and tags end" }); string("title_text", { max_bytes: 48 }); // the card's title: the odds and the expiry read, "68% likely, Fri 10 Oct 08:00 UTC" string("band_text", { max_bytes: 32 }); // the card's headline: the 68% band, "81.2K to 87.9K" string("up_text", { max_bytes: 16 }); // the card's rows: "12%" string("down_text", { max_bytes: 16 }); string("tag_band_high", { max_bytes: 24 }); // the four price tags string("tag_band_low", { max_bytes: 24 }); string("tag_tail_high", { max_bytes: 24 }); string("tag_tail_low", { max_bytes: 24 }); string("tag_most_likely", { max_bytes: 32 }); // the cloud's peak: "Most likely 85.18K", "Most likely 85176" on rows under 10 dollars string("notice", { max_bytes: 48 }); // the one sentence on a market without a chain handles.label({ anchor: "top_right", align: "right", size: 11, color: "#94a3b8", style: "knockout", padding: 6, safe_area: true }); // the notice, in pane pixels from the top-right corner const cloud_rows = frame("cloud_rows", { max_bytes: 16384 }); // the docked cloud: one row per grid price // The cloud: one bar per grid price docked right, a tenth of the pane wide so the newest candles stay readable, each // row in its own colour from the frame (the slate-to-sky ramp), every bar fading from its root at the axis to its tip // (the gradient's alpha profile), the most likely row marked by the point-of-control line in teal (its tag is the // knockout label below, beside the axis), a hover card per row printing the row's odds. plot.levels({ name: "odds_by_price", frame: cloud_rows, dock: "right", width_frac: 0.1, labels: false, color: "@core_ink", opacity: 0.85, gradient: ["#38bdf8ff", "#38bdf859"], format: "%", decimals: 2, hover: true, poc: true, poc_color: "@peak_ink", poc_width: 2, poc_extend: "dock", poc_label: false }); // The lines across the whole pane, placed from the newest bar: the 68% band's edges solid sky, the 95% band's // dotted slate; each shows whenever the pane covers it (an overlay never widens the price pane). draw.line("band_high_line", { x1: "t_first", y1: "band_high", x2: "t_last", y2: "band_high", color: "#38bdf8", width: 1, line_style: "solid", extend: "both" }); draw.line("band_low_line", { x1: "t_first", y1: "band_low", x2: "t_last", y2: "band_low", color: "#38bdf8", width: 1, line_style: "solid", extend: "both" }); draw.line("tail_high_line", { x1: "t_first", y1: "tail_high", x2: "t_last", y2: "tail_high", color: "#94a3b8", width: 1, line_style: "dotted", extend: "both" }); draw.line("tail_low_line", { x1: "t_first", y1: "tail_low", x2: "t_last", y2: "tail_low", color: "#94a3b8", width: 1, line_style: "dotted", extend: "both" }); // The tags: knockout labels (a box in the chart's background colour, so a line never runs through its own text), // right-aligned and riding beside the price axis as the chart scrolls. draw.label("tag_band_high", { x: "t_last", y: "band_high", text: "tag_band_high", color: "#38bdf8", style: "knockout", align: "right", valign: "middle", font_weight: "medium", sticky_right: true }); draw.label("tag_band_low", { x: "t_last", y: "band_low", text: "tag_band_low", color: "#38bdf8", style: "knockout", align: "right", valign: "middle", font_weight: "medium", sticky_right: true }); draw.label("tag_tail_high", { x: "t_last", y: "tail_high", text: "tag_tail_high", color: "#94a3b8", style: "knockout", align: "right", valign: "middle", font_weight: "medium", sticky_right: true }); draw.label("tag_tail_low", { x: "t_last", y: "tail_low", text: "tag_tail_low", color: "#94a3b8", style: "knockout", align: "right", valign: "middle", font_weight: "medium", sticky_right: true }); // The most likely row's tag rides in the same column beside the price axis, never on it: a knockout in teal. draw.label("tag_most_likely", { x: "t_last", y: "most_likely", text: "tag_most_likely", color: "#2dd4bf", style: "knockout", align: "right", valign: "middle", font_weight: "medium", sticky_right: true }); // The card, in the glass look with the app's own type (the look's rounded stack is not on every system): pinned at // the top left under the legend, clear of the newest candles and the cloud on the right, the title carrying the odds // and the expiry read ({{title_text}} reads the slot of that name), the 68% band alone as the headline, then the odds // of a 5% move either way as two rows; 250 px fits the longest band ("112.3K to 118.9K") with the look's padding. The // Style page's Look row switches it (broadsheet is the second look this card was shot in). render.hud("odds", { position: "top_left", look: "glass", font_family: "ui", title: "{{title_text}}", columns: 1, width: 250, safe_area: true, tiles: [ tile.pill("Range at expiry", "band_text", { headline: true, color: "#38bdf8", font_size: 24, draw: "text" }), tile.rows([ ["Up 5% or more", "up_text"], ["Down 5% or more", "down_text"], ]), ], }); const TUPLE = 10; // f64s per contract const MAX_STRIKES = 512; // distinct strikes one expiry may list const MAX_EXPIRIES = 64; // distinct live expiries const MAX_ROWS = 512; // the levels frame's cap; the rows setting stays far under it const HALF_DAY_MS = 43200000.0; const DAY_MS = 86400000.0; const YEAR_MS = 31536000000.0; const MOVE = 0.05; // the move the card prices: 5% either way const strikePrice = new StaticArray<f64>(MAX_STRIKES); // the strike table of the expiry read, sorted ascending const strikeIv = new StaticArray<f64>(MAX_STRIKES); // the out-of-the-money implied volatility at each strike, a fraction a year const expiryMs = new StaticArray<f64>(MAX_EXPIRIES); // the live expiries, sorted ascending const rowPrice = new StaticArray<f64>(MAX_ROWS); // the cloud grid: row centres const rowProb = new StaticArray<f64>(MAX_ROWS); // the probability of settling inside each row const edgeAbove = new StaticArray<f64>(MAX_ROWS + 1); // P(settle above the row's lower edge), one more than the rows const notice = draw.label(0); // the one sentence on a market without a chain; handle objects allocate once let targetDays = 2.0; // settings, read in onStart() let rowsWanted = 90; let marginShare = 0.25; // the margin setting as a share of the loaded range const coreRgb = new StaticArray<i32>(3); // the ramp's inks as r, g, b: read from the colour settings in onStart() const tailRgb = new StaticArray<i32>(3); let close: f64 = NaN; // this bar let t: f64 = NaN; let firstT: f64 = NaN; // the first bar's open time: where the lines start let loadedHigh: f64 = -Infinity; // the loaded bars' price range: the cloud's rows follow it let loadedLow: f64 = Infinity; let strikeCount = 0; let expiryCount = 0; let expiry: f64 = NaN; // the expiry read, epoch ms let spot: f64 = NaN; // the chain's underlying let years: f64 = NaN; // time to expiry in years, from the chain's clock let ivAtm: f64 = NaN; // a fraction a year let sigmaT: f64 = NaN; // iv_atm times sqrt(T): one standard deviation of the log price let gridLo: f64 = NaN; let gridStep: f64 = NaN; let gridRows = 0; let peakRow = -1; let q025: f64 = NaN; // the quantile prices let q16: f64 = NaN; let q84: f64 = NaN; let q975: f64 = NaN; let pUpMove: f64 = NaN; // odds of a 5% move, percent let pDownMove: f64 = NaN; // ── The strike table: sorted insertion, one row per strike ── function strikeSlot(strike: f64): i32 { // the row of this strike, inserted when new; -1 when the table is full let lo = 0; let hi = strikeCount; while (lo < hi) { const mid = (lo + hi) >> 1; if (strikePrice[mid] < strike) lo = mid + 1; else hi = mid; } if (lo < strikeCount && strikePrice[lo] == strike) return lo; if (strikeCount >= MAX_STRIKES) return -1; for (let i = strikeCount; i > lo; i -= 1) { strikePrice[i] = strikePrice[i - 1]; strikeIv[i] = strikeIv[i - 1]; } strikePrice[lo] = strike; strikeIv[lo] = NaN; strikeCount += 1; return lo; } function noteExpiry(ms: f64): void { // sorted insertion of a distinct live expiry let lo = 0; let hi = expiryCount; while (lo < hi) { const mid = (lo + hi) >> 1; if (expiryMs[mid] < ms) lo = mid + 1; else hi = mid; } if (lo < expiryCount && expiryMs[lo] == ms) return; if (expiryCount >= MAX_EXPIRIES) return; for (let i = expiryCount; i > lo; i -= 1) expiryMs[i] = expiryMs[i - 1]; expiryMs[lo] = ms; expiryCount += 1; } // ── The math: the smile, the normal, the odds ── function ivAt(price: f64): f64 { // implied volatility at any price: linear between neighbouring strikes, flat beyond the ends if (strikeCount == 0) return NaN; if (price <= strikePrice[0]) return strikeIv[0]; if (price >= strikePrice[strikeCount - 1]) return strikeIv[strikeCount - 1]; let lo = 0; let hi = strikeCount - 1; while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (strikePrice[mid] <= price) lo = mid; else hi = mid; } const share = (price - strikePrice[lo]) / (strikePrice[hi] - strikePrice[lo]); return strikeIv[lo] + (strikeIv[hi] - strikeIv[lo]) * share; } function normalCdf(x: f64): f64 { // the standard normal CDF through Abramowitz-Stegun 7.1.26 (error under 2e-7) const z = Math.abs(x) / Math.SQRT2; const u = 1.0 / (1.0 + 0.3275911 * z); const poly = u * (0.254829592 + u * (-0.284496736 + u * (1.421413741 + u * (-1.453152027 + u * 1.061405429)))); const erf = 1.0 - poly * Math.exp(-z * z); return x >= 0.0 ? 0.5 * (1.0 + erf) : 0.5 * (1.0 - erf); } function pAbove(price: f64): f64 { // the chain's odds that price settles above this price: N(d2) with the smile's volatility at the price if (!(price > 0.0)) return 1.0; const iv = ivAt(price); if (!(iv > 0.0)) return NaN; const d2 = (Math.log(spot / price) - 0.5 * iv * iv * years) / (iv * Math.sqrt(years)); return normalCdf(d2); } function priceWhereAbove(target: f64): f64 { // the price with P(above) = target, by bisection over eight standard deviations let lo = spot * Math.exp(-8.0 * sigmaT); let hi = spot * Math.exp(8.0 * sigmaT); for (let i = 0; i < 64; i += 1) { const mid = 0.5 * (lo + hi); if (pAbove(mid) > target) lo = mid; else hi = mid; } return 0.5 * (lo + hi); } function niceStepAtMost(raw: f64): f64 { // the largest round step at or under raw: 1, 1.5, 2, 2.5, 3, 4, 5, 6, 8 times a power of ten let mag = Math.pow(10.0, Math.floor(Math.log(raw) / Math.LN10)); if (mag > raw) mag /= 10.0; // the logarithm rounded up across a power of ten const m = raw / mag; if (m >= 8.0) return 8.0 * mag; if (m >= 6.0) return 6.0 * mag; if (m >= 5.0) return 5.0 * mag; if (m >= 4.0) return 4.0 * mag; if (m >= 3.0) return 3.0 * mag; if (m >= 2.5) return 2.5 * mag; if (m >= 2.0) return 2.0 * mag; if (m >= 1.5) return 1.5 * mag; return mag; } function priceDecimals(step: f64): i32 { // enough decimals that the grid's row prices stay evenly spaced when parsed (the docked grid is contiguous only within 0.1% of its step) const d = i32(Math.ceil(Math.log(2000.0 / step) / Math.LN10)); return d < 2 ? 2 : d > 9 ? 9 : d; } // ── The chain's clock: when its gammas were priced, not the bar's open ── // The chart prices each contract's gamma by Black-Scholes from its mark IV and underlying at the moment it reads the // chain, so the contract nearest the money (|delta| nearest 0.5), solved for the time to expiry its gamma implies, // names that moment. A venue that serves its own greeks gives no such answer: the bar's open stands in. function chainClockMs(cells: StaticArray<f64>, n: i32, barOpenMs: f64): f64 { let best = -1; let bestGap = 0.25; // |delta| within 0.25 of 0.5 let nearest = Infinity; // the nearest listed expiry: the chain lists none that has passed for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { if (cells[i + 1] < nearest) nearest = cells[i + 1]; const gap = Math.abs(Math.abs(cells[i + 5]) - 0.5); if (cells[i] > 0.0 && cells[i + 4] > 0.0 && cells[i + 6] > 0.0 && cells[i + 7] > 0.0 && gap < bestGap) { bestGap = gap; best = i; } } if (best < 0) return barOpenMs; const sigma = cells[best + 6] > 3.0 ? cells[best + 6] / 100.0 : cells[best + 6]; // percent a year, or a fraction const m = Math.log(cells[best + 7] / cells[best]); const q = cells[best + 4] * cells[best + 7]; // gamma times spot = pdf(d1) / u, u = sigma * sqrt(T), d1 = m / u + u / 2 let lo = Math.sqrt(2.0 * (Math.sqrt(1.0 + m * m) - 1.0)); // where pdf(d1) / u peaks: past it the gamma falls as T grows let hi = 10.0; for (let k = 0; k < 80; k += 1) { // bisection on the falling side const u = 0.5 * (lo + hi); const d1 = m / u + 0.5 * u; if (Math.exp(-0.5 * d1 * d1) / (2.5066282746310002 * u) > q) lo = u; else hi = u; } const u = 0.5 * (lo + hi); const clock = cells[best + 1] - ((u * u) / (sigma * sigma)) * 31536000000.0; return clock > barOpenMs - 86400000.0 && clock < nearest ? clock : barOpenMs; // anything else is no reading } // ── The chain, measured on the live bar ── function measureChain(n: i32): bool { // n = f64 cells in the live block; false when nothing usable is listed const cells = in_chain_view(); // the live chain in place: the first n values of the build's own buffer const nowMs = chainClockMs(cells, n, t * 1000.0); // the chain's clock: a coarse bar's open would stretch the time to expiry expiryCount = 0; for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) if (cells[i + 1] >= nowMs + HALF_DAY_MS) noteExpiry(cells[i + 1]); // the expiries at least 12 h away if (expiryCount == 0) return false; let best = 0; let bestDistance = Infinity; for (let k = 0; k < expiryCount; k += 1) { // the expiry whose distance in days is closest to the setting; ties keep the earlier const distance = Math.abs((expiryMs[k] - nowMs) / DAY_MS - targetDays); if (distance < bestDistance) { bestDistance = distance; best = k; } } expiry = expiryMs[best]; spot = NaN; for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { // spot: the chain's own underlying at that expiry if (cells[i + 1] == expiry && cells[i + 7] > 0.0) { spot = cells[i + 7]; break; } } if (!(spot > 0.0)) spot = close; if (!(spot > 0.0)) return false; strikeCount = 0; for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { // one volatility per strike, from the out-of-the-money contract: puts below spot, calls at or above if (cells[i + 1] != expiry) continue; const strike = cells[i]; if (!(strike > 0.0)) continue; const isCall = cells[i + 2] > 0.0; if (isCall != (strike >= spot)) continue; let iv = cells[i + 6]; if (!(iv > 0.0)) continue; if (iv > 3.0) iv /= 100.0; // the venue serves percent a year (45.2); a fraction (0.452) passes through const slot = strikeSlot(strike); if (slot < 0) continue; strikeIv[slot] = iv; } if (strikeCount == 0) return false; years = (expiry - nowMs) / YEAR_MS; if (!(years > 0.0)) return false; ivAtm = ivAt(spot); if (!(ivAtm > 0.0)) return false; sigmaT = ivAtm * Math.sqrt(years); q025 = priceWhereAbove(0.975); q16 = priceWhereAbove(0.84); q84 = priceWhereAbove(0.16); q975 = priceWhereAbove(0.025); pUpMove = pAbove(spot * (1.0 + MOVE)) * 100.0; pDownMove = (1.0 - pAbove(spot * (1.0 - MOVE))) * 100.0; // The grid follows the price scale the trader sees: the loaded bars' low..high widened by the margin on each side // (never thinner than a tenth of a standard deviation, for a flat or one-bar window), clamped to spot times // exp(+-3 sigma), at the largest round step that puts at least 'rows' rows across it. A 1m chart gets rows a few // dollars apart over its few hundred dollars of range, a 1h chart the whole bell; the odds are the same. const bandLo = spot * Math.exp(-3.0 * sigmaT); const bandHi = spot * Math.exp(3.0 * sigmaT); let lo = bandLo; let hi = bandHi; const range = loadedHigh - loadedLow; if (range >= 0.0) { // false when no bar carried a finite high and low: the grid keeps the whole band let seenLo = loadedLow - range * marginShare; let seenHi = loadedHigh + range * marginShare; const thinnest = 0.1 * spot * sigmaT; if (seenHi - seenLo < thinnest) { const mid = 0.5 * (seenLo + seenHi); seenLo = mid - 0.5 * thinnest; seenHi = mid + 0.5 * thinnest; } if (seenLo > lo) lo = seenLo; if (seenHi < hi) hi = seenHi; if (!(hi > lo)) { // the loaded range sits outside the band entirely (a far basis): keep the band lo = bandLo; hi = bandHi; } } // The row probability is the drop in P(above) across the row, after P(above) is forced non-increasing up the grid. gridStep = niceStepAtMost((hi - lo) / f64(rowsWanted - 1)); gridLo = Math.floor(lo / gridStep) * gridStep; gridRows = i32(Math.ceil((hi - gridLo) / gridStep)) + 1; while (gridRows > MAX_ROWS) { // never with the rows setting's range; kept so the frame can never overflow gridStep *= 2.0; gridLo = Math.floor(lo / gridStep) * gridStep; gridRows = i32(Math.ceil((hi - gridLo) / gridStep)) + 1; } if (gridRows < 3) return false; // the engine needs three prices for a grid for (let r = 0; r <= gridRows; r += 1) edgeAbove[r] = pAbove(gridLo + (f64(r) - 0.5) * gridStep); for (let r = 1; r <= gridRows; r += 1) if (!(edgeAbove[r] <= edgeAbove[r - 1])) edgeAbove[r] = edgeAbove[r - 1]; peakRow = 0; for (let r = 0; r < gridRows; r += 1) { rowPrice[r] = gridLo + f64(r) * gridStep; const p = edgeAbove[r] - edgeAbove[r + 1]; rowProb[r] = p > 0.0 ? p : 0.0; if (rowProb[r] > rowProb[peakRow]) peakRow = r; } return true; } // ── The docked cloud ── const HEX: String[] = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"]; // allocated at load, so a row colour never allocates per bar function fbHexByte(v: i32): void { // two lowercase hex digits fb_text(HEX[(v >> 4) & 0xf]); fb_text(HEX[v & 0xf]); } function fbRamp(w: f64): void { // "#rrggbb": the tail ink moved toward the core ink by w (0..1), opaque const k = w < 0.0 ? 0.0 : w > 1.0 ? 1.0 : w; fb_text("\"#"); for (let c = 0; c < 3; c += 1) fbHexByte(i32(Math.round(f64(tailRgb[c]) + (f64(coreRgb[c]) - f64(tailRgb[c])) * k))); fb_text("\""); } function readInk(packed: f64, rgb: StaticArray<i32>): void { // a colour setting arrives packed as ((r * 256 + g) * 256 + b) * 1024 + alpha thousandths const v = Math.floor(packed / 1024.0); const n = v >= 0.0 && v <= 16777215.0 ? i32(v) : 0; rgb[0] = (n >> 16) & 0xff; rgb[1] = (n >> 8) & 0xff; rgb[2] = n & 0xff; } function writeCloud(): void { const decimals = priceDecimals(gridStep); const peak = rowProb[peakRow] > 0.0 ? rowProb[peakRow] : 1.0; fb_clear(); fb_text("{\"prices\":["); for (let r = 0; r < gridRows; r += 1) { if (r > 0) fb_text(","); fb_f64(rowPrice[r], decimals); } fb_text("],\"values\":["); for (let r = 0; r < gridRows; r += 1) { if (r > 0) fb_text(","); fb_f64(rowProb[r] * 100.0, 4); // bar length: the row's odds in percent } fb_text("],\"colors\":["); for (let r = 0; r < gridRows; r += 1) { if (r > 0) fb_text(","); fbRamp(rowProb[r] / peak); // the ramp by the row's share of the peak: slate tails, a sky core } // The point of control marks the row the tag names: on a nearly flat slice (a 1m window) several rows tie at the // written precision and the engine's own pick could land a few rows away. No poc_label_text: the docked tag stays // off, the knockout tag beside the axis names the row. fb_text("],\"poc_price\":"); fb_f64(rowPrice[peakRow], decimals); fb_text("}"); writeFrameBuffer(cloud_rows); } // ── Text helpers ── function sbK(v: f64): void { // prices as the market reads them, thousands as K: 87.9K / 2.43K / 152.33 / 0.15234 if (v >= 10000.0) { sb_f64(v / 1000.0, 1); sb_text("K"); } else if (v >= 1000.0) { sb_f64(v / 1000.0, 2); sb_text("K"); } else sb_auto(v); } function tag(name: String, price: f64): void { // "+1σ 87.9K" into the line buffer sb_clear(); sb_text(name); sb_text(" "); sbK(price); } function sbRowPrice(v: f64, step: f64): void { // a grid row's price, fine enough to tell its row from the next: 85.2K on 100-dollar rows, 85.18K on 10s, the axis's own digits below that (85176) if (v >= 1000.0 && step >= 100.0) sb_compact(v, 1); else if (v >= 1000.0 && step >= 10.0) sb_compact(v, 2); else sb_price(v, step); } function onStart(): void { targetDays = f64(p_target_days()); rowsWanted = i32(p_rows()); marginShare = f64(p_margin()) / 100.0; readInk(p_core_ink(), coreRgb); readInk(p_tail_ink(), tailRgb); } // onBar() runs once per bar: history rows carry an empty block and cost three reads (the first bar's time is kept // for the lines, every bar's high and low widen the loaded price range the grid follows); the live bar measures the // chain, then writes the readings as numbers, the cloud, the lines, the tags and the card's words. A live bar // without a usable chain writes the one slate sentence instead. function onBar(): void { close = bar.close(); t = bar.time(); if (isNaN(firstT) && !isNaN(t)) firstT = t; const high = bar.high(); const low = bar.low(); if (high > loadedHigh) loadedHigh = high; // NaN never widens the range if (low < loadedLow) loadedLow = low; if (!bar.isLast()) return; const n = in_chain_cells(); const chainRead = n >= TUPLE && measureChain(n); out_t_first(firstT); out_t_last(t); if (!chainRead) { sb_clear(); sb_text("No option chain for this coin"); notice.set(16, 14).text(str_notice_sb); sb_clear(); sb_text("Options odds"); str_title_text_sb(); sb_clear(); sb_text("No live expiry"); str_band_text_sb(); sb_clear(); sb_text("-"); str_up_text_sb(); sb_clear(); sb_text("-"); str_down_text_sb(); return; } notice.delete(); // a no-op when never drawn out_p_up_move(pUpMove); out_p_down_move(pDownMove); out_band_low(q16); out_band_high(q84); out_tail_low(q025); out_tail_high(q975); out_most_likely(rowPrice[peakRow]); out_iv_atm(ivAtm * 100.0); out_days_to_expiry(years * 365.0); writeCloud(); tag("+1σ", q84); str_tag_band_high_sb(); tag("-1σ", q16); str_tag_band_low_sb(); tag("+2σ", q975); str_tag_tail_high_sb(); tag("-2σ", q025); str_tag_tail_low_sb(); sb_clear(); sb_text("Most likely "); sbRowPrice(rowPrice[peakRow], gridStep); str_tag_most_likely_sb(); // The card's words: the odds and the expiry read for the title, the 68% band alone as the headline, the odds of // a 5% move as rows. sb_clear(); sb_text("68% likely, "); sb_time(expiry / 1000.0, "EEE dd MMM HH:mm", 0); sb_text(" UTC"); str_title_text_sb(); sb_clear(); sbK(q16); sb_text(" to "); sbK(q84); str_band_text_sb(); sb_clear(); sb_f64(pUpMove, 0); sb_text("%"); str_up_text_sb(); sb_clear(); sb_f64(pDownMove, 0); sb_text("%"); str_down_text_sb(); } ``` ## How it works **The chain is live only.** `input("chain", options_chain.cells, { max_cells: 4000, venue: "deribit" })` serves the coin's Deribit chain on the newest bar as one ten-value tuple per listed contract (strike, expiry, side, open interest, gamma, delta, the mark implied volatility, the underlying, the multiplier and vega), and every history bar carries an empty block. `onBar()` keeps the first bar's open time for the lines and widens the loaded price range with every bar's `bar.high()` and `bar.low()`, history included, then returns on every bar but the last, so history draws nothing; on the live bar `in_chain_cells()` counts the values and `in_chain_view()` reads the block in place. The chart's own `close` is the bar grid, and spot when the chain carries no underlying. **The chain's clock, not the bar's.** The odds run from the moment the chart priced the chain, never from the live bar's open, which on a 1w chart can sit days before now. The chart prices each contract's gamma by Black-Scholes from its mark IV and underlying when it reads the chain, so `chainClockMs()` takes the contract whose delta sits nearest 0.5 and solves its gamma for the time to expiry it implies: the expiry minus that time is the read time. A venue that serves its own greeks gives no such answer, and then the bar's open stands in. A 1w chart and a 1h chart over the same chain read the same expiry, days to expiry and band. **One expiry, one smile.** `measureChain()` lists the expiries at least 12 hours past the chain's clock and keeps the one whose distance in days is closest to `target_days` (ties keep the earlier). Spot is the chain's own underlying at that expiry, else the close. One implied volatility per strike comes from the out-of-the-money contract (puts below spot, calls at or above), the venue's percent a year turned into a fraction, and `ivAt()` reads the smile at any price: linear between neighbouring strikes, flat beyond the ends. **The odds are one curve.** `pAbove()` is the chain's odds that price settles above a price, N(d2) with the smile's volatility at that price through `normalCdf()`. `priceWhereAbove()` bisects over eight standard deviations for the prices with 97.5%, 84%, 16% and 2.5% odds of settling above them (`tail_low`, `band_low`, `band_high`, `tail_high`), and `p_up_move` and `p_down_move` are the odds of settling 5% or more above or below spot (`MOVE`). `iv_atm`, `days_to_expiry` (from the chain's clock to the expiry read) and `most_likely` ride beside them, all data-only outputs that read NaN on history bars. **The rows follow the loaded range.** On the live bar `measureChain()` takes the loaded bars' low to high, widens it by `margin` percent of itself on each side, keeps it at least a tenth of a standard deviation wide (for a flat or one-bar window), and clamps it to spot times exp(plus or minus three standard deviations of the log price). `niceStepAtMost()` then takes the largest round step (1, 1.5, 2, 2.5, 3, 4, 5, 6 or 8 times a power of ten) at or under the span over `rows - 1`, so `rows` is a floor: the grid holds at least that many rows. A 1m chart gets rows a few dollars apart over its few hundred dollars of range, a 1h chart the whole bell, and the odds are the same; on a fine chart the visible slice of the bell is nearly flat, which is the odds, not a fault. A row's odds are the drop in P(above) across the row, after the curve is forced non-increasing up the grid. **The grid is the cloud.** `writeCloud()` builds the frame through `fb_*` with `prices` (written to `priceDecimals()` of the step, so the rows stay evenly spaced when parsed), `values` (the odds in percent), `colors` (the tail ink moved toward the core ink by the row's share of the peak, so the tails read slate and the core sky) and `poc_price` (the most likely row), and sends it with `writeFrameBuffer(cloud_rows)`. `plot.levels` docks it right at 0.1 of the pane, narrow enough that the newest candles over it stay readable: `color: "@core_ink"` is the level's own colour, the fallback for a row without one; `gradient` is the alpha profile from each bar's root to its tip (a row with its own colour keeps its hue and takes the stops' alpha); `poc: true` draws a 2 px point-of-control line in `@peak_ink` across the dock at the price the frame names, so on a nearly flat slice, where several rows tie at the written precision, the line still marks the row the tag names; `hover: true` opens a row's odds (`format: "%"`, `decimals: 2`: a 10-dollar row holds about 0.24%). `poc_label: false` turns the engine's docked tag off, and the frame writes no `poc_label_text`, since that field alone would turn it back on. `core_ink` and `tail_ink` arrive in `onStart()` packed as one number each, and `readInk()` unpacks them to red, green and blue for the ramp. **The lines and tags are declarations.** `draw.line("band_high_line", { x1: "t_first", y1: "band_high", x2: "t_last", y2: "band_high", ... extend: "both" })` reads its coordinates from outputs by name on the newest bar; the 68% band's edges are solid sky, the 95% band's dotted slate, and each shows whenever the pane covers it. The tags are `draw.label` declarations reading string slots (`tag_band_high` holds "+1σ 87.0K", built by `sbK()` with thousands as K), with `style: "knockout"` (a box in the chart's background colour, so a line never runs through its own text), `align: "right"`, `valign: "middle"` and `sticky_right: true`, so they ride beside the price axis as the chart scrolls. **The most likely row has its own tag.** `draw.label("tag_most_likely", { x: "t_last", y: "most_likely", text: "tag_most_likely", color: "#2dd4bf", ... sticky_right: true })` is a teal knockout, right-aligned beside the price axis in the same column as the sigma tags, never on the axis. `sbRowPrice()` writes the row's price at the grid's resolution: a price of 1000 or more prints compact through `sb_compact` (thousands as K), with one decimal on rows of 100 or more ("Most likely 85.2K") and two on rows of 10 or more ("Most likely 85.26K"); anything else prints through `sb_price` with the step as the tick, the axis's own digits ("Most likely 85272"). The frame's `poc_price` names the same row, so the POC line and the tag mark one price. The tag's colour is the literal teal because a `draw.label` colour cannot name a setting ([The Style page](../settings/style-page.md#gotchas)): a changed peak ink moves the POC line and leaves the tag teal. **The card reads slots.** `render.hud("odds", { position: "top_left", look: "glass", font_family: "ui", title: "{{title_text}}", columns: 1, width: 250, safe_area: true, tiles: [...] })`: the title is the one slot `title_text`, written on the live bar as "68% likely, " then `sb_time(expiry / 1000.0, "EEE dd MMM HH:mm", 0)` and " UTC"; `tile.pill("Range at expiry", "band_text", { headline: true, color: "#38bdf8", font_size: 24, draw: "text" })` is the headline, the band alone in sky at 24 px, where `draw: "text"` prints the words alone in their colour instead of the glass look's dot before them; `tile.rows` prints `up_text` and `down_text`, the odds rounded to whole percent. `font_family: "ui"` beside the look wins over the look's `rounded` type, so the card reads in the app's own type (the rounded stack is not on every system). The 250 px width fits the longest band ("112.3K to 118.9K") at 24 px with the look's 16 px padding each side, and `safe_area` starts the card under the legend, so it covers the oldest candles on screen, never the newest ones or the cloud. ## Where it runs Charts of a coin Deribit lists options for (BTC, ETH, SOL and the rest of its list), on any venue and any interval: Binance Futures BTCUSDT at 1m, 15m and 1h, and Binance spot ETHUSDT at 15m and 1h. The odds do not depend on the chart's interval; only the rows' span does: at 1m the cloud is a fine, nearly flat wall of rows a few dollars apart over the whole pane, at 15m a hump, at 1h the whole bell. The pane's own range decides what is in view: on a 15m chart the "+2σ" and "-2σ" lines, and sometimes one edge of the 68% band, sit outside the pane while a 1h pane shows the whole bell, and a longer horizon on a fine chart needs the price axis zoomed out, since an overlay never widens the price pane. The cloud mounts as its own overlay, so the legend counts two indicators for one file and names the cloud's row "Odds by price": the level's name, `odds_by_price`, in words, since a name without a `label` reads as sentence-case words. A coin without a Deribit chain refuses the run by name before any bar, the legend shows the error chip and nothing draws: `Input 'chain' reads options_chain cells with venue "deribit", but Deribit lists no options for coin 'PURR' (wrun_options_chain_venue_unavailable): open a chart of a coin Deribit lists (BTC, ETH, SOL, ...), or declare venue "auto"`. ## When data is missing A live bar whose chain cannot be read (an empty block, no expiry at least 12 hours away, or no strike carrying a volatility) prints one slate sentence at the top right of the pane, `No option chain for this coin`, through the `handles.label` notice; the card's title then reads "Options odds", its headline "No live expiry" and both rows a dash, no cloud frame is written, and the lines and tags draw nothing (their price outputs are NaN on the newest bar). History bars draw nothing: every reading is NaN there, and their highs and lows only set the range the rows follow. The lines, placed from the newest bar with `extend: "both"`, cross the whole pane whatever the loaded history, while the cloud's rows follow it: a window where no bar carried a finite high and low, or one that sits wholly outside the band (a far basis), keeps the whole band of spot times exp(plus or minus three standard deviations), and a flat or one-bar window still spans a tenth of a standard deviation. ## Customize it - **A longer horizon.** `target_days` (2, from 1 to 90) picks the expiry read: the listed expiry at least 12 hours away whose distance in days is closest to it. A higher timeframe wants a longer horizon, and a long horizon on a fine chart puts the band outside the pane until the price axis is zoomed out. - **A finer cloud.** `rows` (90, from 60 to 160) is the fewest rows the grid puts across the loaded range and its margin: the step is the largest round price at or under the span over `rows - 1`, so the cloud holds at least that many rows and a higher setting gives a finer step. - **A wider reach.** `margin` (25, from 0 to 100) is how far the cloud reaches past the loaded bars' high and low, percent of that range on each side: 0 keeps the rows to the loaded range, and no margin takes them past spot times exp(plus or minus three standard deviations). - **Other inks.** `core_ink` (sky), `tail_ink` (slate) and `peak_ink` (teal) under the Look section: the ramp runs from the tail ink to the core ink by each row's share of the peak, and the point-of-control line takes the peak ink. The Most likely tag keeps its literal teal (`#2dd4bf`) and the band lines and tags their literal sky and slate, since a `draw.*` colour takes a literal, not a setting: change them in the source to match new inks. - **Another move.** `MOVE` (0.05) is the move the card prices; the rows' labels are literals, so change "Up 5% or more" and "Down 5% or more" with it (`p_up_move` and `p_down_move` carry the same odds for the Console and watches). - **Change the look.** The card is made in `look: "glass"` with `font_family: "ui"` beside it, and a word beside `look` wins over the look, so the card keeps the app's type whatever look it wears. The Look row on the indicator's Style page switches the look without code ([The Style page](../settings/style-page.md#the-look-row)); broadsheet (a warm off-white surface, rules over a small-caps title and rows with dot leaders) is the second look this example was shot in. The source writes the look as a literal; binding `look` to a `param.choice` over looks as `"@name"` makes it a setting, whose row then stands in for the Look row ([Looks](../presentation/hud-and-hover-cards.md#looks)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Options Odds Cloud** under **Beyond the time axis**. 2. Press **Run** on a chart of a coin Deribit lists, such as BTCUSDT on Binance Futures at 15m: the cloud docks on the right of the price pane with its Most likely tag beside the price axis, the band lines cross the chart with their tags in the same column, and the glass card appears at the top left, under the legend. Rest the pointer on a cloud row to read its odds. Switch to 1m for rows a few dollars apart over the whole pane, or to 1h for the whole bell. 3. At the editor's Console prompt, type `last 20 p_up_move` to read the odds of a 5% rise on the last 20 bars: a number on the newest bar only, since the chain is live only. ## Concepts used - [Options kit](../functions/options-kit.md) for `options_chain.cells`, the ten-value tuple, `venue` and reading the chain under `bar.isLast()` - [Docked profiles](../presentation/cards-frames-panels.md#docked-profiles) for `plot.levels`, the frame's `colors` and `poc_price`, `gradient`, `poc` with `poc_label: false`, `decimals` and `hover` - [Frames, panels and compact widgets](../presentation/cards-frames-panels.md#frames-panels-and-compact-widgets) for `frame()`, the `fb_*` builders and `writeFrameBuffer` - [Run-level drawings](../presentation/drawing-objects.md#run-level-drawings) for declared `draw.line` and `draw.label`, their output-name coordinates, `extend`, `sticky_right` and the `style` word - [Handles](../presentation/drawing-objects.md#handles) for the `handles.label` notice - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) for `render.hud`, `width`, `offset` and `safe_area` - [Looks](../presentation/hud-and-hover-cards.md#looks) for `look: "glass"`, a word beside `look` winning over it, the broadsheet row and `look` bound to a `param.choice` - [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the headline `tile.pill` with `draw: "text"` and `tile.rows` - [The Style page](../settings/style-page.md) for the Look row, `param.color` bound by `"@name"` and the literal a `draw.*` colour keeps - [Strings and text](../functions/text-formatting.md) for `sb_time`, `sb_compact`, `sb_price`, `sb_auto` and `sb_f64` <!-- source: https://openmarket.xyz/wrun/cookbook/whos-trading --> # Who is trading ![Whale buy share card on BTC in the stage look, a donut of volume share by trade size and bought against sold columns per size under the chart](/wrun/images/whos-trading.png) Who did the volume over the last `window` bars, by the size of each trade. Under the chart, a donut shares the window's dollar volume out over seven trade-size buckets, from under 1K to 10M and up, in a fixed ramp: slate for the three small sizes, then sky, violet and teal up to the whales. Every traded slice carries its name and its share ("10K-100K 51%"), the hole prints the window's total dollars, and a size that did not trade in the window has no slice. Under the donut, one pair of columns per size: bought dollars in amber, sold dollars in orange, each column printed in dollars (K, M, B) with the axis in dollars too, and a chip on the title row with the window's verdict: "Whales buying", "Whales selling", "Whales balanced" or "No whale prints". On the price pane, a card in the stage look (the chart's coin as a cover, a heavy headline, one green accent) at the top left under the legend: "Who is trading, last 96 bars" as the title (the window in bars), the whales' buy share as the headline ("Whales 62% buy": of every dollar whales traded, the part they bought; amber from the `lean` setting up, orange from its mirror down, slate between or when no whale traded), then two rows, "10M+ prints" (trades of 10M and up in the window, buys and sells together) and "Retail share" (the part of the window's dollars traded in trades under 10K, the sum of the donut's two smallest slices as printed). The legend reads the window after the name ("Who Is Trading 96 bars"). Everything is measured on the live bar over a rolling window; history bars only feed the window. The parts are a `trade_volume_by_size.cells` input with `max_cells: 7` ([Data sources](../core-concepts/data-sources.md)), two frames feeding a `panel.pie` donut and a `panel.bars` of vertical columns with a badge chip ([Cards, frames and panels](../presentation/cards-frames-panels.md)), a `render.hud` card of a headline `tile.pill` and a `tile.rows` in the stage look ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks)), and a `handles.label` handle for the one sentence a market with no size buckets gets ([Drawing objects](../presentation/drawing-objects.md)). This is also the `whos-trading` template: the **Who Is Trading** card under **Beyond the time axis** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Who Is Trading: who did the volume over the last 'window' bars, by the size of each trade. The trader sees a donut // under the chart (the window's dollar volume shared out over seven trade-size buckets, from under 1K to 10M and up, // a slate to teal ramp from small to whale, each slice labelled with its name and share), bars under it (bought in // amber against sold in orange, one pair per bucket, a badge chip with the window's verdict), and a card // on the price pane in the stage look: the whales' buy share as the headline ("Whales 62% buy", amber when they // bought more than they sold, orange when they sold more), then the count of 10M+ prints and the retail share of the // volume. Every bar feeds the rolling window; the panels and the card's numbers are measured on the live bar only. // A trade here is one fill, bucketed by its own dollar value: an order that fills in pieces lands in smaller buckets, so // the whale share and the 10M+ count are floors. // Settings: the window in bars, and the buy share above which whales count as buying (below its mirror, selling). param.int("window", 96, { min: 12, max: 1000, label: "Window in bars", description: "Bars in the rolling window (96 bars of 15m is one day)" }); param.number("lean", 55, { min: 50, max: 90, step: 1, label: "Whale lean in percent", description: "Whale buy share, percent, from which whales count as buying; below 100 minus it they count as selling" }); legend({ title: "{{window}} bars" }); // the words after the name in the legend: the window, read by name // Inputs: the chart's own candles set the grid; the size buckets ride a celled block, one row per bucket that traded. input("close", ohlcv.close); input("sizes", trade_volume_by_size.cells, { max_cells: 7, description: "This bar's traded USD by trade-size bucket, each fill bucketed by its own value" }); // [bucket, buy_usd, sell_usd, buy_count, sell_count] per bucket that traded; seven buckets hold a full bar // Outputs: data only, written on every bar, so the card's tiles, the Console and a watch read the same numbers. output("whale_buy_share", none, overlay, { format: "0", description: "Whales' bought USD as a percent of what whales traded over the window (trades of 100K and up)" }); output("whale_share", none, overlay, { format: "0", description: "Whales' share of the window's USD volume, percent" }); output("retail_share", none, overlay, { format: "0", description: "Share of the window's USD volume in trades under 10K, percent" }); output("big_prints", none, overlay, { format: "int", description: "Prints of 10M and up over the window, buys and sells together" }); output("whale_tone", none, overlay, { description: "The verdict as a ladder rung: 0 whales selling, 1 balanced or no whale prints, 2 whales buying" }); // The card's words, written on the live bar. string("headline", { max_bytes: 24 }); // "Whales 62% buy" string("span_text", { max_bytes: 16 }); // "96 bars": the title's when string("retail_text", { max_bytes: 8 }); // "18%": the sum of the two retail slices as the donut prints them string("notice", { max_bytes: 40 }); // the one sentence when the market has no size buckets handles.label({ anchor: "top_right", align: "right", color: "#94a3b8", size: 11 }); // the notice, in pane pixels from the top-right corner // The panels under the chart: two frames, each one JSON snapshot rewritten on the live bar. const share_rows = frame("share_rows", { max_bytes: 2048 }); // the donut: [bucket, usd, color] per bucket const flow_rows = frame("flow_rows", { max_bytes: 2048 }); // the bars: [bucket, bought, sold] per bucket, plus the verdict badge // The donut: the window's dollars by trade size, labels on every traded slice, the total in the hole, a hairline gap // between slices in the chart's own background. panel.pie({ name: "size_share", title: "Share of volume by trade size", x: "category", place: "below", frame: share_rows, hole: 0.55, labels: true, chrome: "none", legend_style: "title", hole_total: true, hole_caption: "Volume", slice_gap: 2, border_color: "theme.bg", border_width: 1, format: "usd", height_frac: 0.17 }); // The bars: bought against sold per bucket, one amber and one orange column under each bucket name, dollars on the // axis and on every column, the window's verdict as the chip on the title row (written per run into the frame). panel.bars({ name: "size_flow", title: "Bought vs sold by trade size", x: "category", place: "below", frame: flow_rows, orientation: "vertical", chrome: "grid", labels: true, series: [{ name: "Bought", color: "#f8c000" }, { name: "Sold", color: "#f86800" }], format: "usd", decimals: 0, hover_card: true, legend_style: "title", height_frac: 0.18 }); // The card, in the stage look (the chart's coin as a cover, a heavy headline, one green accent): one column at the // top left under the legend. The whales' buy share leads as the headline, its colour following the verdict rung // (selling, balanced, buying); two rows under it. The viewer switches the look on the Style page's Look row. render.hud("who", { position: "top_left", look: "stage", title: "Who is trading, last {{span_text}}", columns: 1, width: 300, safe_area: true, tiles: [ tile.pill("Whale buy share", "headline", { headline: true, font_size: 30, color_by: "whale_tone", colors: ["#f86800", "#94a3b8", "#f8c000"] }), tile.rows("Over the window", [["10M+ prints", "big_prints", "int"], ["Retail share", "retail_text"]], { leader: "dots" }), ] }); // The seven buckets, by the USD value of one trade: 1 under 1K, 2 1K to 10K, 3 10K to 100K, 4 100K to 500K, // 5 500K to 1M, 6 1M to 10M, 7 10M and up. Whales are buckets 4 to 7; retail is buckets 1 and 2. const BUCKETS = 7; const FIRST_WHALE = 3; // bucket 4, as a 0-based slot const LAST_RETAIL = 1; // bucket 2, as a 0-based slot const BIGGEST = 6; // bucket 7, as a 0-based slot const TUPLE = 5; // f64s per block row: bucket, buy_usd, sell_usd, buy_count, sell_count const BUCKET_NAMES: String[] = ["<1K", "1K-10K", "10K-100K", "100K-500K", "500K-1M", "1M-10M", "10M+"]; const SLICE_COLORS: String[] = ["#475569", "#64748b", "#94a3b8", "#38bdf8", "#a78bfa", "#2dd4bfb3", "#2dd4bf"]; // slate ramp for the small trades, then sky, violet and teal up to the whales const AMBER = "#f8c000"; // the flow pair: bought amber, sold orange; slate for no lean const ORANGE = "#f86800"; const SLATE = "#94a3b8"; const SELLING = 0.0; // the verdict rungs, in the ladder's order const BALANCED = 1.0; const BUYING = 2.0; // The rolling window rides rings sized for the largest window; onStart() reads the one in use. const MAX_WINDOW = 1000; const ringBuy = new StaticArray<f64>(MAX_WINDOW * BUCKETS); // each ring bar's bought USD per bucket const ringSell = new StaticArray<f64>(MAX_WINDOW * BUCKETS); // and its sold USD const ringBig = new StaticArray<f64>(MAX_WINDOW); // and its count of 10M+ prints const winBuy = new StaticArray<f64>(BUCKETS); // the window totals per bucket: the new bar added, the bar leaving subtracted const winSell = new StaticArray<f64>(BUCKETS); let winBig = 0.0; let window = 96; let lean = 55.0; let head = 0; // the ring slot the next bar takes let total = 0.0; // the window's USD volume over every bucket let whaleBuy = 0.0; // the whales' bought and sold USD let whaleSell = 0.0; let whaleBuyShare: f64 = NaN; let tone = BALANCED; const notice = draw.label(0); // handle objects allocate once, at module load // Fold this bar's block into the rings and the window totals. function foldBar(): void { const base = head * BUCKETS; for (let b = 0; b < BUCKETS; b += 1) { winBuy[b] -= ringBuy[base + b]; // the bar leaving the window winSell[b] -= ringSell[base + b]; ringBuy[base + b] = 0.0; ringSell[base + b] = 0.0; } winBig -= ringBig[head]; ringBig[head] = 0.0; const n = in_sizes_cells(); // -1 absent, 0 empty: either way this bar adds nothing if (n > 0) { const cells = in_sizes_view(); // this bar's rows in place for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { const b = i32(cells[i]) - 1; if (b < 0 || b >= BUCKETS) continue; const buy = cells[i + 1] > 0.0 ? cells[i + 1] : 0.0; const sell = cells[i + 2] > 0.0 ? cells[i + 2] : 0.0; ringBuy[base + b] += buy; ringSell[base + b] += sell; winBuy[b] += buy; winSell[b] += sell; if (b == BIGGEST) { const prints = (cells[i + 3] > 0.0 ? cells[i + 3] : 0.0) + (cells[i + 4] > 0.0 ? cells[i + 4] : 0.0); ringBig[head] += prints; winBig += prints; } } } head = (head + 1) % window; } // The window's readings. Running sums can drift a hair below zero once a bucket empties, so a negative reads as zero. function measure(): void { total = 0.0; whaleBuy = 0.0; whaleSell = 0.0; for (let b = 0; b < BUCKETS; b += 1) { if (winBuy[b] < 0.0) winBuy[b] = 0.0; if (winSell[b] < 0.0) winSell[b] = 0.0; total += winBuy[b] + winSell[b]; if (b >= FIRST_WHALE) { whaleBuy += winBuy[b]; whaleSell += winSell[b]; } } if (winBig < 0.5) winBig = 0.0; const whaleUsd = whaleBuy + whaleSell; whaleBuyShare = whaleUsd > 0.0 ? (100.0 * whaleBuy) / whaleUsd : NaN; tone = isNaN(whaleBuyShare) ? BALANCED : whaleBuyShare >= lean ? BUYING : whaleBuyShare <= 100.0 - lean ? SELLING : BALANCED; } // A bucket's share of the window's volume as the donut prints it: a whole percent. function printedShare(b: i32): i32 { return i32(Math.round((100.0 * (winBuy[b] + winSell[b])) / total)); } // The two frames, built in the kit's frame buffer without allocating: the donut's slices and the bars' rows with // the verdict chip. function writeFrames(): void { fb_clear(); fb_text("{\"rows\":["); for (let b = 0; b < BUCKETS; b += 1) { if (b > 0) fb_text(","); fb_text("["); fb_str(BUCKET_NAMES[b]); fb_text(","); fb_f64(winBuy[b] + winSell[b], 0); fb_text(","); fb_str(SLICE_COLORS[b]); fb_text("]"); } fb_text("]}"); writeFrameBuffer(share_rows); fb_clear(); fb_text("{\"badge\":{\"text\":"); fb_str(isNaN(whaleBuyShare) ? "No whale prints" : tone == BUYING ? "Whales buying" : tone == SELLING ? "Whales selling" : "Whales balanced"); fb_text(",\"color\":"); fb_str(tone == BUYING ? AMBER : tone == SELLING ? ORANGE : SLATE); fb_text("},\"rows\":["); for (let b = 0; b < BUCKETS; b += 1) { if (b > 0) fb_text(","); fb_text("["); fb_str(BUCKET_NAMES[b]); fb_text(","); fb_f64(winBuy[b], 0); fb_text(","); fb_f64(winSell[b], 0); fb_text("]"); } fb_text("]}"); writeFrameBuffer(flow_rows); } // The card's words: the headline, the title's when, and the retail share as the sum of the two retail slices' // printed shares, so the card and the donut never differ by a rounding point. function writeWords(): void { sb_clear(); if (isNaN(whaleBuyShare)) sb_text("No whale prints"); else { sb_text("Whales "); sb_int(i64(Math.round(whaleBuyShare))); sb_text("% buy"); } str_headline_sb(); sb_clear(); sb_int(window); sb_text(" bars"); str_span_text_sb(); sb_clear(); sb_int(printedShare(0) + printedShare(LAST_RETAIL)); sb_text("%"); str_retail_text_sb(); } // onStart() runs once before the first bar: the settings. function onStart(): void { window = i32(p_window()); lean = p_lean(); } // onBar() runs once per bar: fold the bar into the window, write the readings as numbers; the frames and the card's // words on the live bar only. A window with no size buckets at all writes the one sentence instead. function onBar(): void { foldBar(); measure(); out_whale_buy_share(whaleBuyShare); out_whale_share(total > 0.0 ? (100.0 * (whaleBuy + whaleSell)) / total : NaN); out_retail_share(total > 0.0 ? (100.0 * (winBuy[0] + winSell[0] + winBuy[LAST_RETAIL] + winSell[LAST_RETAIL])) / total : NaN); out_big_prints(total > 0.0 ? winBig : NaN); out_whale_tone(tone); if (!bar.isLast()) return; if (total <= 0.0) { sb_clear(); sb_text("No trade sizes on this market"); notice.set(16, 44).text(str_notice_sb); // under the chart's own High tag, which takes the corner when the high is at the top edge return; } notice.delete(); // a no-op when the notice was never drawn writeFrames(); writeWords(); } ``` ## How it works **Seven buckets are the input.** `input("sizes", trade_volume_by_size.cells, { max_cells: 7 })` serves this bar's traded dollars by the size of each trade, one `[bucket, buy_usd, sell_usd, buy_count, sell_count]` row per bucket that traded, bucket 1 (under 1K) to bucket 7 (10M and up); a bucket that did not trade has no row, and a bar with no trades is an empty block. `in_sizes_cells()` counts this bar's values (`-1` absent, `0` empty) and `in_sizes_view()` holds them in place; `foldBar()` walks them five at a time. Whales are buckets 4 to 7 (`FIRST_WHALE`, 100K and up) and retail is buckets 1 and 2 (`LAST_RETAIL`, under 10K); bucket 3 counts toward the donut, the bars and the total, but toward neither share on the card. **A bucket counts fills, not orders.** The lane buckets every fill, one matched trade at one price, by its own dollar value, so an order that takes liquidity from many resting orders arrives as many smaller fills: a 2M market buy that fills in forty pieces of 50K counts in the 10K-100K bucket, not the 1M-10M one. Read the whales' share and the 10M+ count as floors for what large traders did, and the retail share as a ceiling. **The window is a ring of running sums.** `foldBar()` subtracts the bar leaving the window from the per-bucket totals `winBuy` and `winSell`, writes this bar's bought and sold dollars into its slot of `ringBuy` and `ringSell` (sized at load for `MAX_WINDOW`, 1000 bars by seven buckets) and adds them to the totals; `ringBig` and `winBig` do the same for the 10M+ prints (`buy_count` plus `sell_count` of bucket 7). `measure()` then sums the window: `total` over every bucket, `whaleBuy` and `whaleSell` over buckets 4 to 7; a running sum that drifts a hair below zero once a bucket empties reads as zero. `onStart()` reads `window` (96) and `lean` (55), and `legend({ title: "{{window}} bars" })` reads the window by name, so the legend row prints "Who Is Trading 96 bars". **The verdict is a ladder.** `whaleBuyShare` is the whales' bought dollars over what whales traded, NaN when no whale traded. The rung `tone` is `BUYING` (2) from `lean` up, `SELLING` (0) from 100 minus `lean` down, `BALANCED` (1) between or when no whale traded; `whale_tone` carries it as a data-only output, and the pill's `colors` ladder reads orange, slate and amber from it. `whale_buy_share`, `whale_share` and `retail_share` (percent) and `big_prints` (a count) are the other data-only outputs, written on every bar so the Console and a watch read the numbers the card shows. **Two frames are the panels.** On the live bar, `writeFrames()` builds `share_rows` in the frame buffer with the `fb_*` writers, one `[name, usd, color]` row per bucket from `BUCKET_NAMES` and `SLICE_COLORS`, and `panel.pie` draws it as the donut: `hole: 0.55` with `hole_total` and `hole_caption: "Volume"` print the window's dollars in the hole, `labels: true` names every traded slice with its share, `legend_style: "title"` puts a chip per bucket on the title row (an untraded bucket still names itself), `slice_gap: 2` with `border_color: "theme.bg"` cuts a hairline gap in the chart's own background, and `chrome: "none"` drops the box. `flow_rows` carries one `[name, bought, sold]` row per bucket plus a `badge` with the verdict's words and colour, and `panel.bars` draws it as vertical columns, `Bought` in amber and `Sold` in orange, `format: "usd"` with `decimals: 0` on the axis and on every column (`labels: true`), `hover_card: true` for a readout under the pointer. A badge written into the frame replaces the declared chip for that run, so the verdict rides the title row with no chip in the declaration. **The card reads slots and outputs.** `render.hud("who", { position: "top_left", look: "stage", title: "Who is trading, last {{span_text}}", columns: 1, width: 300, safe_area: true, ... })` is one column of 300 px that starts under the legend, its title reading the `span_text` slot ("96 bars"). `tile.pill("Whale buy share", "headline", { headline: true, font_size: 30, color_by: "whale_tone", colors: [...] })` is the headline, its words from the `headline` slot ("Whales 62% buy", or "No whale prints"), its colour from the verdict rung; `font_size: 30` is the stage look's own headline size, declared so a look with a larger one keeps the headline inside the card. `tile.rows("Over the window", [["10M+ prints", "big_prints", "int"], ["Retail share", "retail_text"]], { leader: "dots" })` prints the count from the output and the retail share from a slot, with dotted leaders. `writeWords()` fills the three slots with the `sb_*` builders on the live bar; the retail text is the sum of the two retail slices' printed shares (`printedShare`, a whole percent each), so the card never disagrees with the donut by a rounding point, while the exact ratio stays the `retail_share` output. **The one sentence is a handle.** `handles.label({ anchor: "top_right", align: "right", color: "#94a3b8", size: 11 })` declares label handles placed in pane pixels from the top-right corner, and `draw.label(0)` makes the one handle at module load. On a live bar whose window holds no dollars at all, `notice.set(16, 44).text(str_notice_sb)` draws "No trade sizes on this market" under the chart's own High tag and the frames and the words are not written; on any other live bar `notice.delete()` removes it (a no-op when it was never drawn). ## Where it runs Crypto markets whose venue serves volume by trade size: Binance Futures perpetuals, Binance spot and Hyperliquid perpetuals (BTCUSDT on Binance Futures, ETHUSDT on Binance spot and PURR on Hyperliquid, at 15m and 1h). `trade_volume_by_size` is a required lane: on a market where the feed is declined (FX, CME, stocks) the run is refused by name before any bar, with a toast that begins "Trade volume by size data is unavailable" and ends "so the indicator cannot compute". The card sits at the top left of the price pane under the legend (`safe_area`), and the two panels take 0.17 and 0.18 of the chart's height under it. ## When data is missing When every bar in the window is an empty block (the feed answers but nothing traded by size), the panels are not drawn and one slate line at the top right of the price pane reads "No trade sizes on this market"; the card's tiles read a dash. Before the window is full the numbers cover the bars loaded so far. The window totals are running sums kept per bucket, so a size that stops trading leaves the donut as its bars leave the window, and a bucket that never traded keeps its legend chip with no slice; on a window with no prints of 10M and up the 10M+ columns print $0 and the card's row reads 0. ## Customize it - **A longer window.** `window` sets the bars in the rolling window (96, 12 to 1000); 96 bars of 15m is one day, and the card's title and the legend say the window in bars. - **A stricter lean.** `lean` is the whale buy share, in percent, from which whales count as buying (55, 50 to 90); below 100 minus it they count as selling, between they are balanced. The headline's colour and the badge follow it. - **Whales from 500K.** Set `FIRST_WHALE` to 4 (bucket 5 as a 0-based slot) to count whales from 500K and up, and reword the `whale_buy_share` and `whale_share` descriptions to match. - **Bars on their side.** `orientation: "horizontal"` on `panel.bars` lays the seven buckets down the left edge; at 0.18 of the chart the pane prints only every other bucket name, so give it about 0.24 with `height_frac` and take the difference from the donut. - **Change the look.** The Look row on the indicator's Style page switches the card between the twelve looks without code ([The Style page](../settings/style-page.md#the-look-row)); this example was made in `stage` and is also shown in `signal`, a black card with a hairline border and the headline as a capsule in the verdict's colour ([Looks](../presentation/hud-and-hover-cards.md#looks)). `look` on the declaration takes a look's name as a literal, never a setting, and a word declared beside it still wins. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Who Is Trading** under **Beyond the time axis**. 2. Press **Run** on a crypto chart whose venue serves volume by trade size, such as BTCUSDT on Binance Futures at 15m: the donut and the bought-against-sold columns appear under the chart, the stage card at the top left of the price pane under the legend, and the legend row reads the window. 3. At the editor's Console prompt, type `last 20 whale_buy_share` to read the whales' buy share of the window on the last 20 bars. ## Concepts used - [Data sources](../core-concepts/data-sources.md) for `trade_volume_by_size.cells`, its seven buckets and the `in_sizes_cells()` and `in_sizes_view()` readers - [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames, the `fb_*` writers, `panel.pie`, `panel.bars`, their words and the frame `badge` - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) for `render.hud`, its anchors, `width` and `safe_area` - [Looks](../presentation/hud-and-hover-cards.md#looks) for `look: "stage"`, `signal` and the other ten - [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the headline `tile.pill`, `tile.rows` and `leader: "dots"` - [The Style page](../settings/style-page.md#the-look-row) for the Look row that switches the look without code - [Drawing objects](../presentation/drawing-objects.md) for `handles.label`, `draw.label(id)` and the `set`, `text` and `delete` calls - [Strings and text](../functions/text-formatting.md) for the `sb_*` builders and `str_<slot>_sb()` <!-- source: https://openmarket.xyz/wrun/cookbook/rotation-map --> # Rotation map ![Rotation map pane under a BTC chart with eight labelled coin dots and their fading trails in four tinted quadrants, a chip naming the leader and the terminal card at the top left](/wrun/images/rotation-map.png) Eight large coins on one map under the chart, placed by how they are doing against BTC. Left to right is strength: how far each coin's price in BTC sits above or below its own longer average. Bottom to top is momentum: whether that strength is still rising or already fading. Two dashed guides through 100 cut the map into the four quadrants a rotation moves through, clockwise: improving (top left, sky), leading (top right, teal), weakening (bottom right, amber), lagging (bottom left, orange), each quietly tinted and named in its corner. One labelled dot per coin, all one size and in its quadrant's colour, and behind each a short tail of its earlier positions on a thin line, the tail's dots smaller and fainter toward the far end. Every position is read from closed hourly candles with the same averages on every chart, so a coin sits at the same place on a 1m, a 15m and a 1h chart and the map moves once an hour; only the tail's reach follows the chart: the last day on charts up to 5m, three days up to 30m, a week on 1h and coarser. The dot farthest top right is the leader: its name carries its move against BTC over the tail ("SOL +4.1%"), and a chip on the panel's title row names it with its quadrant ("SOL leading"). A card at the top left of the price pane, in the terminal look (an amber title bar, monospace type, labels in capitals), answers first: the leader and its move against BTC as the headline, in its quadrant's colour, then how many coins are improving and how many weakening. The card's title says what the tail covers ("Rotation vs BTC, last 24h", "last 3d" or "last 7d"). The parts are nine `candles.cells` streams of closed hourly candles on Binance Futures, BTC as the reference plus eight coins, beside the chart's own close ([Multi-timeframe](../core-concepts/multi-timeframe.md#history-the-candles-stream), [Data sources](../core-concepts/data-sources.md#celled-sources)), the chart's bar interval from `chart.interval_sec()` for the tail step ([Sessions and units](../settings/sessions-and-units.md#the-interval-and-the-colours)), a frame feeding a `panel.scatter` with `guides`, `quadrants`, `trails` and dot `labels` ([Cards, frames and panels](../presentation/cards-frames-panels.md)), four `param.color` quadrant inks read in `onStart()` and written into the frame as hex ([The Style page](../settings/style-page.md), [Colors kit](../functions/colors-kit.md)), a `render.hud` card in the terminal look ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks)), and a label handle for the one sentence ([Drawing objects](../presentation/drawing-objects.md)). This is also the `rotation-map` template: the **Rotation Map** card under **Beyond the time axis** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Rotation Map: eight large coins on a scatter below the chart, each placed by its strength against BTC (x) and the // momentum of that strength (y), both read from closed hourly candles with the same averages on every chart, so a // coin sits at the same place on a 1m, a 15m and a 1h chart. Per coin and per hourly candle: the ratio to BTC is // smoothed by an EMA (14 days); the strength is 100 plus how far that smoothed ratio sits from its own longer average // (18 days), in units of its usual distance from it; the momentum is 100 plus how far the strength sits from its own // short average (9 days), in the same units. Two dashed guides cut the pane into four quadrants at 100/100, faintly // tinted: leading (strong and still rising, teal), improving (weak but rising, sky), weakening (strong but fading, // amber), lagging (weak and falling, orange). Every coin is a labelled dot in its quadrant's colour with a short tail // of six earlier positions, one per tail step, small and faint at the far end. The tail step follows the chart, so a // tail covers the last day on a 1m chart, three days on a 15m chart and a week on a 1h chart. The dot farthest // top-right is the leader: its label carries its move against BTC over the tail, the chip on the title row names it, // and a terminal card at the top left answers first: who leads, by how much, and how many coins are improving and // weakening. The map and the words are written on the live bar only. section("Map"); param.int("tail_points", 6, { min: 0, max: 12, label: "Tail points", description: "Earlier positions drawn behind each coin, one per tail step (0 = heads only)" }); param.int("smooth", 14, { min: 1, max: 21, label: "Smoothing in days", description: "Days the ratio to BTC is smoothed over (an EMA)" }); param.int("trend", 18, { min: 3, max: 28, label: "Trend average in days", description: "Days of the longer average the strength is measured from" }); param.int("momentum", 9, { min: 1, max: 14, label: "Momentum average in days", description: "Days of the short average the momentum is measured from" }); section("Quadrant colours"); param.color("leading_ink", "#2dd4bf", { label: "Leading", description: "Strong against BTC and still rising" }); param.color("improving_ink", "#38bdf8", { label: "Improving", description: "Weak against BTC but rising" }); param.color("weakening_ink", "#f8c000", { label: "Weakening", description: "Strong against BTC but fading" }); param.color("lagging_ink", "#f86800", { label: "Lagging", description: "Weak against BTC and falling" }); // The chart's bar interval in seconds, written by the chart before the first bar: it sets the tail step, 4 hours on // charts up to 5m, 12 hours up to 30m, 28 hours on 1h and coarser (0, unknown, counts as a 15m chart). chart.interval_sec(); // Inputs: the chart's own candles set the bar grid; then BTC and the eight coins as streams of closed hourly candles // on Binance Futures, the newest 1500 of them on the first bar and each later one on the bar it closes with, whatever // the chart's interval. To swap a coin: change the symbol in its input line and the name at the same position in // COIN_NAMES below (keep eight). Ten sources is the chart's cap per indicator, so nothing else may read a source. input("close", ohlcv.close); input("btc", candles.cells, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500, description: "BTC hourly candles, the reference every coin is measured against" }); input("eth", candles.cells, { symbol: "ETHUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 }); input("sol", candles.cells, { symbol: "SOLUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 }); input("xrp", candles.cells, { symbol: "XRPUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 }); input("bnb", candles.cells, { symbol: "BNBUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 }); input("doge", candles.cells, { symbol: "DOGEUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 }); input("ada", candles.cells, { symbol: "ADAUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 }); input("avax", candles.cells, { symbol: "AVAXUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 }); input("link", candles.cells, { symbol: "LINKUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 }); // Readings, data only: the card's rows read the counts, the Console and a watch can read them on every bar. output("leading", none, overlay, { description: "Coins in the leading quadrant: strength above 100 and momentum above 100" }); output("improving", none, overlay, { description: "Coins in the improving quadrant: strength below 100, momentum above 100" }); output("weakening", none, overlay, { description: "Coins in the weakening quadrant: strength above 100, momentum below 100" }); output("lagging", none, overlay, { description: "Coins in the lagging quadrant: strength and momentum both below 100" }); output("leader_quadrant", none, overlay, { description: "The quadrant of the dot farthest top-right, the headline's colour: 0 leading, 1 improving, 2 weakening, 3 lagging" }); string("leader", { max_bytes: 32 }); // the headline: the leader and its move against BTC over the tail string("window_text", { max_bytes: 16 }); // the card's title reads it: "last 24h" string("notice", { max_bytes: 64 }); // the one slate line while the hourly candles load // The one slate line: a label handle pinned at the top right of the price pane, clear of the chart's own chrome. handles.label({ anchor: "top_right", align: "right", color: "#94a3b8", size: 12, style: "plain", safe_area: true }); // The map: one scatter frame rewritten on the live bar, rows [coin, x, y, size, colour, label]. Rows that share a coin // key form its tail (oldest first, the head last), a thin line fading toward the far end; the dashed guides cut the // four quadrants, each faintly tinted and named in its corner; the chip and the tints are rewritten per run. const rotationRows = frame("rotation_rows", { max_bytes: 16384 }); panel.scatter({ name: "rotation", title: "Rotation vs BTC", x: "number", place: "below", frame: rotationRows, height_frac: 0.45, chrome: "grid", labels: true, label_overlap: "leader", legend_style: "none", hover_card: true, format: "0.0", x_title: "Strength vs BTC", y_title: "Momentum", guides: [ { axis: "x", value: 100, color: "theme.muted", line_style: "dashed" }, { axis: "y", value: 100, color: "theme.muted", line_style: "dashed" }, ], quadrants: { x: 100, y: 100, colors: ["#2dd4bf0c", "#38bdf80c", "#f868000c", "#f8c0000c"], labels: ["Leading", "Improving", "Lagging", "Weakening"] }, trails: true, trail_width: 1, trail_fade: true, }); // The card, in the terminal look (an amber title bar, monospace type): the leader and its move against BTC as the // headline, coloured by its quadrant, then the two counts the rotation is made of. {{window_text}} reads the slot. render.hud("rotation_card", { position: "top_left", look: "terminal", title: "Rotation vs BTC, {{window_text}}", columns: 1, width: 210, safe_area: true, tiles: [ tile.pill("Leader", "leader", { headline: true, color_by: "leader_quadrant", colors: ["#2dd4bf", "#38bdf8", "#f8c000", "#f86800"] }), tile.rows([ ["Improving", "improving", "int"], ["Weakening", "weakening", "int"], ]), ], }); // The coins, in the order of the inputs above. const COINS = 8; const COIN_NAMES: StaticArray<string> = ["ETH", "SOL", "XRP", "BNB", "DOGE", "ADA", "AVAX", "LINK"]; const QUADRANT_WORDS: StaticArray<string> = [" leading", " improving", " weakening", " lagging"]; // the chip's word by quadrant const HEX1: StaticArray<string> = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"]; // hex digits, written one at a time so a colour never allocates const TUPLE = 6; // one candle in a stream block: [offset_ms, open, high, low, close, volume] // Every coin's (strength, momentum, log ratio) per hourly candle, in a ring as long as the longest tail can reach: // 12 points at the 28-hour step, plus the head. const MAX_STEP = 28; const MAX_TAIL = 12; const HIST = MAX_TAIL * MAX_STEP + 1; const histX = new StaticArray<f64>(COINS * HIST); const histY = new StaticArray<f64>(COINS * HIST); const histR = new StaticArray<f64>(COINS * HIST); // Per coin: the last close (a missing hour carries it), the smoothed log ratio, its longer average and the average // squared distance from it (with the share of weight the average has seen so far, so an early reading is not // inflated), the short average of the strength and the average squared momentum (with its weight), and the counts. const lastClose = new StaticArray<f64>(COINS); const smoothed = new StaticArray<f64>(COINS); const trendMean = new StaticArray<f64>(COINS); const trendVar = new StaticArray<f64>(COINS); const trendWeight = new StaticArray<f64>(COINS); const momMean = new StaticArray<f64>(COINS); const momVar = new StaticArray<f64>(COINS); const momWeight = new StaticArray<f64>(COINS); const seen = new StaticArray<i32>(COINS); // hourly candles folded with a ratio const zSeen = new StaticArray<i32>(COINS); // of those, candles with a strength const cursor = new StaticArray<i32>(COINS); // where this bar's merge stands in the coin's block // The live bar's map, per coin. const strength = new StaticArray<f64>(COINS); // the head's x, NaN without values const momentum = new StaticArray<f64>(COINS); // the head's y const move = new StaticArray<f64>(COINS); // the coin's move against BTC over the tail, percent const quadrant = new StaticArray<i32>(COINS); // 0 leading, 1 improving, 2 weakening, 3 lagging, -1 without values const order = new StaticArray<i32>(COINS); // coin indices by distance top-right, the leader first const tailDrawn = new StaticArray<i32>(COINS); // tail points the live bar draws behind each head const inkR = new StaticArray<i32>(4); // the four quadrant inks from the settings, one channel each const inkG = new StaticArray<i32>(4); const inkB = new StaticArray<i32>(4); const HEAD_SIZE = 5.0; // the head's radius, px, the same for every coin const TAIL_MIN = 1.5; // tail dot radii, the far end to the newest const TAIL_MAX = 2.5; const TAIL_ALPHA_MIN = 0x26; // tail dot alpha, the far end to the newest (the line between them fades on its own) const TAIL_ALPHA_MAX = 0x8c; const TINT_ALPHA = 0x0c; // the quadrant tints, quiet const REACH_MIN = 0.5; // each axis reaches at least half a usual distance past 100 on both sides: a calm hour still shows a cross const SIDE_MIN = 0.3; // the axes fit the dots, and the side of a guide with no dots still keeps 30% of the axis const PAD_X = 0.08; // room past the farthest dot, as a share of the axis, so no head sits on the pane's edge; more const PAD_Y = 0.15; // above and below, where the quadrant words sit in the corners const notice = draw.label(0); // the one slate line; a handle allocates once at module load let step = 12; // hours between two tail points, from the chart interval let tailPoints = 6; // settings, read in onStart() let smoothLen = 336; // the three averages, in hourly candles let trendLen = 432; let momLen = 216; let aSmooth = 0.0; let aTrend = 0.0; let aMom = 0.0; let histHead = 0; // the ring slot of the newest hourly candle let hours = 0; // hourly candles folded so far let rows = 0; // scatter rows written so far on this live bar let noticeShown = false; let axisLo = 0.0; // fitAxis() writes its two ends here let axisHi = 0.0; // The coin's stream by position, in the order of the inputs. function coinCells(c: i32): i32 { switch (c) { case 0: return in_eth_cells(); case 1: return in_sol_cells(); case 2: return in_xrp_cells(); case 3: return in_bnb_cells(); case 4: return in_doge_cells(); case 5: return in_ada_cells(); case 6: return in_avax_cells(); default: return in_link_cells(); } } function coinView(c: i32): StaticArray<f64> { switch (c) { case 0: return in_eth_view(); case 1: return in_sol_view(); case 2: return in_xrp_view(); case 3: return in_bnb_view(); case 4: return in_doge_view(); case 5: return in_ada_view(); case 6: return in_avax_view(); default: return in_link_view(); } } // The coin's close for the hour that opened at 'offset' (ms from this bar's open), NaN when its block has no candle // for that hour. Both blocks run oldest first, so one cursor per coin walks its block once per bar. function coinCloseAt(c: i32, offset: f64): f64 { const n = coinCells(c); if (n <= 0) return NaN; const view = coinView(c); let j = unchecked(cursor[c]); while (j + TUPLE <= n && unchecked(view[j]) < offset) j += TUPLE; unchecked((cursor[c] = j)); return j + TUPLE <= n && unchecked(view[j]) == offset ? unchecked(view[j + 4]) : NaN; } // One closed hour: every coin's ratio to BTC, smoothed; its strength and momentum; kept in the ring. function foldHour(offset: f64, btc: f64): void { histHead = (histHead + 1) % HIST; hours += 1; for (let c = 0; c < COINS; c += 1) { let px = coinCloseAt(c, offset); if (isNaN(px) || px <= 0.0) px = unchecked(lastClose[c]); else unchecked((lastClose[c] = px)); const at = c * HIST + histHead; let x: f64 = NaN; let y: f64 = NaN; let r: f64 = NaN; if (btc > 0.0 && px > 0.0) { r = Math.log(px / btc); const n = unchecked(seen[c]); if (n == 0) { unchecked((smoothed[c] = r)); unchecked((trendMean[c] = r)); } else { const s = unchecked(smoothed[c]) + aSmooth * (r - unchecked(smoothed[c])); unchecked((smoothed[c] = s)); const dev = s - unchecked(trendMean[c]); unchecked((trendMean[c] = trendMean[c] + aTrend * dev)); unchecked((trendVar[c] = (1.0 - aTrend) * (trendVar[c] + aTrend * dev * dev))); unchecked((trendWeight[c] = (1.0 - aTrend) * trendWeight[c] + aTrend)); } unchecked((seen[c] = n + 1)); const v = unchecked(trendWeight[c]) > 0.0 ? unchecked(trendVar[c]) / unchecked(trendWeight[c]) : 0.0; if (n + 1 >= trendLen && v > 0.0) { const z = (unchecked(smoothed[c]) - unchecked(trendMean[c])) / Math.sqrt(v); x = 100.0 + z; const k = unchecked(zSeen[c]); if (k == 0) { unchecked((momMean[c] = z)); } else { unchecked((momMean[c] = momMean[c] + aMom * (z - momMean[c]))); const d = z - unchecked(momMean[c]); unchecked((momVar[c] = (1.0 - aTrend) * momVar[c] + aTrend * d * d)); unchecked((momWeight[c] = (1.0 - aTrend) * momWeight[c] + aTrend)); const mv = unchecked(momVar[c]) / unchecked(momWeight[c]); if (k + 1 >= momLen && mv > 0.0) y = 100.0 + d / Math.sqrt(mv); } unchecked((zSeen[c] = k + 1)); } } unchecked((histX[at] = x)); unchecked((histY[at] = y)); unchecked((histR[at] = r)); } } // The ring slot of the candle 'back' hours before the newest, -1 when the ring does not reach it. function slotBack(back: i32): i32 { return back < hours && back < HIST ? (histHead - back + HIST) % HIST : -1; } // The quadrant of a position: strong means x above 100, rising means y above 100. function quadrantOf(x: f64, y: f64): i32 { const strong = x > 100.0; const rising = y > 100.0; return strong ? (rising ? 0 : 2) : rising ? 1 : 3; } // How far top-right a dot sits: its distance along the diagonal from the centre. function diagonal(c: i32): f64 { return strength[c] - 100.0 + (momentum[c] - 100.0); } // ── Frame writers: a colour as "#rrggbb" or "#rrggbbaa", written digit by digit (no allocation on the live bar) ── function fbHexByte(v: i32): void { fb_text(unchecked(HEX1[(v >> 4) & 15])); fb_text(unchecked(HEX1[v & 15])); } function fbInk(q: i32, alphaByte: i32): void { // the quadrant's ink from the settings; alpha 255 writes the opaque form fb_text("\"#"); fbHexByte(unchecked(inkR[q])); fbHexByte(unchecked(inkG[q])); fbHexByte(unchecked(inkB[q])); if (alphaByte < 255) fbHexByte(alphaByte); fb_text("\""); } // One scatter row: [coin, x, y, size, colour] and, on a head, the label. function writeRow(c: i32, x: f64, y: f64, size: f64, alphaByte: i32, label: i32): void { // label: 0 none, 1 the name, 2 the name and the move if (rows > 0) fb_text(","); fb_text("["); fb_int(<i64>c); fb_text(","); fb_f64(x, 3); fb_text(","); fb_f64(y, 3); fb_text(","); fb_f64(size, 1); fb_text(","); fbInk(quadrant[c], alphaByte); if (label > 0) { fb_text(",\""); fb_text(unchecked(COIN_NAMES[c])); if (label == 2 && !isNaN(move[c])) { fb_text(move[c] >= 0.05 ? " +" : " "); // a move that rounds to 0.0 carries no sign fb_f64(Math.abs(move[c]) < 0.05 ? 0.0 : move[c], 1); fb_text("%"); } fb_text("\""); } fb_text("]"); rows += 1; } // One axis fitted to the dots: from the lowest to the highest value, 100 always inside, at least REACH_MIN past 100 // on each side, the side of the guide with no dots kept at SIDE_MIN of the axis (its quadrants stay readable), then // 'pad' of room at both ends. Writes axisLo and axisHi. function fitAxis(lo: f64, hi: f64, pad: f64): void { let below = Math.max(100.0 - lo, REACH_MIN); let above = Math.max(hi - 100.0, REACH_MIN); if (below < SIDE_MIN * (below + above)) below = (SIDE_MIN / (1.0 - SIDE_MIN)) * above; if (above < SIDE_MIN * (below + above)) above = (SIDE_MIN / (1.0 - SIDE_MIN)) * below; const total = below + above; axisLo = 100.0 - below - pad * total; axisHi = 100.0 + above + pad * total; } // The map on the live bar: every coin's tail, its far end smallest and faintest, then its head with its name (the // leader's with its move); the axes fitted to the dots with the guides at 100; the chip naming the leader; the four // quadrant tints in the inks the settings hold. function writeMap(lead: i32): void { let loX = 100.0; let hiX = 100.0; let loY = 100.0; let hiY = 100.0; for (let c = 0; c < COINS; c += 1) { unchecked((tailDrawn[c] = 0)); if (quadrant[c] < 0) continue; loX = Math.min(loX, strength[c]); hiX = Math.max(hiX, strength[c]); loY = Math.min(loY, momentum[c]); hiY = Math.max(hiY, momentum[c]); for (let i = 1; i <= tailPoints; i += 1) { // tail points with values, counted back from the head: a tail never jumps a gap const slot = slotBack(i * step); if (slot < 0) break; const tx = unchecked(histX[c * HIST + slot]); const ty = unchecked(histY[c * HIST + slot]); if (isNaN(tx) || isNaN(ty)) break; loX = Math.min(loX, tx); hiX = Math.max(hiX, tx); loY = Math.min(loY, ty); hiY = Math.max(hiY, ty); unchecked((tailDrawn[c] = i)); } } fb_clear(); fb_text("{\"rows\":["); rows = 0; // The leader first: where two labels would collide the pane moves the later one onto a leader line, so a crowded // cluster nudges the names of the coins farther bottom-left, never the leader's. for (let k = 0; k < COINS; k += 1) { const c = order[k]; if (quadrant[c] < 0) continue; const drawn = unchecked(tailDrawn[c]); for (let i = drawn; i >= 1; i -= 1) { // the far end first const slot = slotBack(i * step); const age = f64(drawn - i + 1) / f64(drawn + 1); // near 0 at the far end, near 1 next to the head const size = TAIL_MIN + (TAIL_MAX - TAIL_MIN) * age; const alphaByte = TAIL_ALPHA_MIN + i32(Math.round(f64(TAIL_ALPHA_MAX - TAIL_ALPHA_MIN) * age)); writeRow(c, unchecked(histX[c * HIST + slot]), unchecked(histY[c * HIST + slot]), size, alphaByte, 0); } writeRow(c, strength[c], momentum[c], HEAD_SIZE, 255, c == lead ? 2 : 1); } fitAxis(loX, hiX, PAD_X); fb_text("],\"x_min\":"); fb_f64(axisLo, 3); fb_text(",\"x_max\":"); fb_f64(axisHi, 3); fitAxis(loY, hiY, PAD_Y); fb_text(",\"y_min\":"); fb_f64(axisLo, 3); fb_text(",\"y_max\":"); fb_f64(axisHi, 3); fb_text(",\"badge\":{\"text\":\""); fb_text(unchecked(COIN_NAMES[lead])); fb_text(unchecked(QUADRANT_WORDS[quadrant[lead]])); fb_text("\",\"color\":"); fbInk(quadrant[lead], 255); fb_text("},\"quadrants\":{\"x\":100,\"y\":100,\"colors\":["); fbInk(0, TINT_ALPHA); fb_text(","); fbInk(1, TINT_ALPHA); fb_text(","); fbInk(3, TINT_ALPHA); fb_text(","); fbInk(2, TINT_ALPHA); fb_text("],\"labels\":[\"Leading\",\"Improving\",\"Lagging\",\"Weakening\"]}}"); writeFrameBuffer(rotationRows); } // onStart() runs once before the first bar: the tail step from the chart interval, the three averages from days to // hourly candles, the four inks unpacked, the ring emptied. function onStart(): void { const interval = p_chart_interval_sec(); step = interval > 0.0 && interval <= 300.0 ? 4 : interval <= 1800.0 ? 12 : MAX_STEP; tailPoints = i32(p_tail_points()); smoothLen = i32(p_smooth()) * 24; trendLen = i32(p_trend()) * 24; momLen = i32(p_momentum()) * 24; aSmooth = 2.0 / f64(smoothLen + 1); aTrend = 2.0 / f64(trendLen + 1); aMom = 2.0 / f64(momLen + 1); for (let q = 0; q < 4; q += 1) { const packed = fromPacked(q == 0 ? p_leading_ink() : q == 1 ? p_improving_ink() : q == 2 ? p_weakening_ink() : p_lagging_ink()); inkR[q] = red(packed); inkG[q] = green(packed); inkB[q] = blue(packed); } for (let i = 0; i < COINS * HIST; i += 1) { unchecked((histX[i] = NaN)); unchecked((histY[i] = NaN)); unchecked((histR[i] = NaN)); } } // onBar() runs once per chart bar: fold every hourly candle that closed with it (the first bar carries the backlog), // then read the newest hour's map, sort the coins, count the quadrants; on the live bar write the map and the card. function onBar(): void { const n = in_btc_cells(); if (n > 0) { const view = in_btc_view(); for (let c = 0; c < COINS; c += 1) unchecked((cursor[c] = 0)); for (let i = 0; i + TUPLE <= n; i += TUPLE) foldHour(unchecked(view[i]), unchecked(view[i + 4])); } const head = slotBack(0); const span = (tailPoints > 0 ? tailPoints : 1) * step; const back = slotBack(span); for (let c = 0; c < COINS; c += 1) { let s: f64 = NaN; let m: f64 = NaN; let mv: f64 = NaN; if (head >= 0) { s = unchecked(histX[c * HIST + head]); m = unchecked(histY[c * HIST + head]); const r = unchecked(histR[c * HIST + head]); const r0 = back >= 0 ? unchecked(histR[c * HIST + back]) : NaN; if (!isNaN(r) && !isNaN(r0)) mv = (Math.exp(r - r0) - 1.0) * 100.0; } strength[c] = s; momentum[c] = m; move[c] = mv; quadrant[c] = isNaN(s) || isNaN(m) ? -1 : quadrantOf(s, m); } // The coins by distance top-right, the leader first; coins without values last. for (let c = 0; c < COINS; c += 1) { let k = c; while (k > 0) { const prev = order[k - 1]; if (quadrant[c] < 0 || (quadrant[prev] >= 0 && diagonal(prev) >= diagonal(c))) break; order[k] = prev; k -= 1; } order[k] = c; } let leading = 0; let improving = 0; let weakening = 0; let lagging = 0; let ready = 0; for (let c = 0; c < COINS; c += 1) { const q = quadrant[c]; if (q < 0) continue; ready += 1; if (q == 0) leading += 1; else if (q == 1) improving += 1; else if (q == 2) weakening += 1; else lagging += 1; } const lead = order[0]; if (ready > 0) { out_leading(f64(leading)); out_improving(f64(improving)); out_weakening(f64(weakening)); out_lagging(f64(lagging)); out_leader_quadrant(f64(quadrant[lead])); } if (!bar.isLast()) return; // The card's title: the span the tail covers, in hours up to a day and a half, else in days. sb_clear(); sb_text("last "); if (span < 36) { sb_int(<i64>span); sb_text("h"); } else { sb_int(<i64>Math.round(f64(span) / 24.0)); sb_text("d"); } str_window_text_sb(); if (ready == 0) { // The hourly candles have not arrived yet: one line of words, no map, no headline. sb_clear(); sb_text("Loading the hourly candles for the rotation map"); notice.set(16.0, 12.0).text(str_notice_sb); noticeShown = true; return; } if (noticeShown) { notice.delete(); noticeShown = false; } writeMap(lead); // The headline: the leader and its move against BTC over the tail. sb_clear(); sb_text(unchecked(COIN_NAMES[lead])); if (!isNaN(move[lead])) { sb_text(move[lead] >= 0.05 ? " +" : " "); sb_f64(Math.abs(move[lead]) < 0.05 ? 0.0 : move[lead], 1); sb_text("%"); } str_leader_sb(); } ``` ## How it works **Hourly streams, not chart bars.** `input("close", ohlcv.close)` keeps the chart's own candles first, as the bar grid. `input("btc", candles.cells, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES", interval: "1h", bars: 1500 })` streams BTC's closed hourly candles, the reference every coin is measured against, and eight more streams, `eth` to `link`, read the coins on the same venue at the same depth. Each stream starts at least 1500 hourly candles back (about two months): the first bar carries that backlog and each later bar the hours that closed with it, whatever the chart's interval. Streams, not `ohlcv` pins: a pin hands each chart bar one candle, so it reaches back only over the chart's own bars, too short on a 1m chart for averages of weeks, and a pin finer than the chart is refused, so a `1h` pin would refuse the whole run on a 4h or a 1d chart; a stream reaches back its own depth on any interval ([Multi-timeframe](../core-concepts/multi-timeframe.md#history-the-candles-stream)). An indicator counts against the plan's per-chart indicator limit once per distinct source it reads ([Limits](../reference/limits.md)), and these ten sources use the whole of a Plus chart's ten, so nothing else here reads a source: `bar.time()` would be an eleventh. The tail step reads the chart's interval from `chart.interval_sec()` instead, a hidden setting rather than a source, and the hours pair by offset, not by clock time. **Hours pair by offset, with BTC as the clock.** A block lists its hourly candles oldest first, six numbers each, `[offset_ms, open, high, low, close, volume]` (`TUPLE`), `offset_ms` being the candle's open minus this bar's open. `onBar()` walks BTC's block and folds one hour per BTC candle with `foldHour`, and `coinCloseAt` finds each coin's candle with the same `offset_ms` in that coin's block: both blocks run oldest first, so one cursor per coin walks its block once per bar. A coin hour missing from its block carries the coin's last close (`lastClose`). The first bar folds the whole backlog; after it, a bar folds the hours that closed with it: one every 60 bars on a 1m chart, one per bar on 1h, four per bar on 4h. **Strength and momentum, smoothed in days.** `onStart()` turns the three settings from days into hourly candles (`smooth` 14 is 336, `trend` 18 is 432, `momentum` 9 is 216) and each length n into an EMA weight of 2 / (n + 1). Per coin and per hour, `foldHour` takes r = ln(coin / BTC), the log of the coin's close over BTC's, and keeps s, its EMA over `smooth` days (`smoothed`). The strength is 100 plus how far s sits from its exponentially weighted mean over `trend` days, in units of its usual distance from it, the exponentially weighted standard deviation; the momentum is 100 plus how far the strength sits from its EMA over `momentum` days, in units of that gap's exponentially weighted root mean square, weighted over the same `trend` days. Both variances are divided by the share of weight their average has seen so far (`trendWeight`, `momWeight`), so the first readings are not inflated. The averages keep no list of past values: each hour updates them in a few steps, whatever the lengths. A coin has a strength once it has folded `trend` days of hours, and a momentum once it has `momentum` days of strength on top (27 days at the defaults); the largest settings with the longest tail need 1344 hours, inside the 1500 each stream brings. Every chart folds the same hours with the same averages, so a coin sits at the same place on 1m, 15m and 1h, and the map moves once an hour, when an hourly candle closes. Strength 100 is a smoothed ratio right on its longer average, so a coin crosses the vertical guide when its smoothed ratio moves above that average, and the horizontal one when the strength turns. **The tail follows the chart.** Every coin's strength, momentum and log ratio per hour sit in a ring (`histX`, `histY`, `histR`) of `HIST` slots a coin, 12 points at the 28-hour step plus the head, so the longest tail always fits; `slotBack(back)` finds the hour `back` hours before the newest, or -1 when the ring does not reach it. `onStart()` reads `p_chart_interval_sec()` and sets the tail step: 4 hours on charts up to 5m, so six points cover the last day; 12 hours up to 30m, three days; 28 hours on 1h and coarser, a week (0, an unknown interval, counts as a 15m chart). The tail is sampled back from the head, one point every step, `tail_points` points, so the spacing stays even up to the head. The move is the ratio's change over the same span, `tail_points` times the step (one step at 0 points), in percent, and the card's title names that span from the `window_text` slot, in hours under 36 and in days from 36 on: "last 24h", "last 3d" or "last 7d" at the defaults. **The leader and the counts.** On every bar `onBar()` reads the newest hour from the ring: each coin's strength, momentum and move. `quadrantOf` files each coin under 0 leading, 1 improving, 2 weakening or 3 lagging (strong is x above 100, rising is y above 100), or -1 while either number is NaN, and an insertion sort orders the coins by how far top right they sit, the largest (x - 100) + (y - 100) first (`diagonal`), coins without values last: the first is the leader. The counts `leading`, `improving`, `weakening` and `lagging` are data-only outputs (`none`), written with `leader_quadrant`, the leader's quadrant, on every bar where at least one coin has values. **One frame is the map.** On the live bar only, `writeMap` builds `frame("rotation_rows")` in the frame buffer with `fb_clear`, `fb_text`, `fb_int` and `fb_f64` and sends it with `writeFrameBuffer`: one row `[coin, x, y, size, colour, label]` per tail point and per head, and rows that share a coin form its trail, oldest first and the head last. Every head has a radius of 5 px (`HEAD_SIZE`), the same for every coin; tail dots grow from `TAIL_MIN` (1.5 px) at the far end toward `TAIL_MAX` (2.5 px) next to the head, and their alpha from `TAIL_ALPHA_MIN` (`0x26`) toward `TAIL_ALPHA_MAX` (`0x8c`). A tail stops at its first point without values, so it never jumps a gap. Labels sit on heads only: the leader's carries its move ("SOL +4.1%"), the others the name alone, and a move inside plus or minus 0.05% prints "0.0%" with no sign. `panel.scatter` draws the rows in a pane below the chart (`height_frac: 0.45`): `labels: true` with `label_overlap: "leader"` names each head, and since the leader is written first, where two names collide the pane moves the later one onto a leader line, never the leader's; `trails: true`, `trail_width: 1` and `trail_fade: true` draw a thin line between a coin's rows, fading toward the far end; two dashed `guides` at 100 cross the pane; and `quadrants` tints the four corners and names them. **The axes fit the dots.** Beside `rows`, the frame carries `x_min`, `x_max`, `y_min` and `y_max` from `fitAxis`: each axis spans the lowest to the highest head or tail point with 100 always inside and reaches at least 0.5 past 100 on both sides (`REACH_MIN`, half a usual distance), so a calm hour still shows a cross; the side of a guide with no dots still keeps 30% of the axis (`SIDE_MIN`), so its two quadrants stay readable; then 8% of room is added at both ends on x (`PAD_X`) and 15% on y (`PAD_Y`), where the quadrant words sit in the corners. The guides stay at 100 wherever the dots put them. The frame's `badge` is the chip on the title row, the leader and its quadrant word ("SOL leading") in that quadrant's ink, and its `quadrants` rewrites the four tints per run. **Four inks, written as hex.** `leading_ink`, `improving_ink`, `weakening_ink` and `lagging_ink` are `param.color` settings under the Quadrant colours section. A dot's colour is run data in the frame's rows, so the module writes it: `onStart()` reads the four picks with `fromPacked` and splits each into channels with `red`, `green` and `blue`, and `fbInk` writes a pick into the frame as `#rrggbb` (or `#rrggbbaa` for a tail dot or a tint), digit by digit from a 16-entry table, so a colour never allocates on the live bar. The dots, the trails, the chip and the tints follow the picks, the tints at alpha `0x0c` (`TINT_ALPHA`) in place of the declaration's literal ones. A colour word of the declaration may name a `param.color` instead, as `"@leading_ink"` ([Panel words](../presentation/cards-frames-panels.md#panel-words)); this source writes the picks into the frame, where the dots need them anyway. The card's headline keeps the house ladder: a tile's `colors` takes literals, since `look` is the only word of a card a setting can drive. **The card reads slots and outputs.** `render.hud("rotation_card", { position: "top_left", look: "terminal", title: "Rotation vs BTC, {{window_text}}", columns: 1, width: 210, safe_area: true, tiles: [...] })` is the card. `tile.pill("Leader", "leader", { headline: true, color_by: "leader_quadrant", colors: [...] })` reads the `leader` string slot, the leader and its move written with the `sb_*` builders on the live bar, and colours it by the quadrant ladder; `tile.rows` reads the `improving` and `weakening` outputs as integers. `{{window_text}}` in the title reads the slot the live bar writes ("last 24h", "last 3d" or "last 7d"), and `safe_area: true` starts the card under the legend stack. **One handle is the sentence.** `handles.label({ anchor: "top_right", align: "right", color: "#94a3b8", size: 12, style: "plain", safe_area: true })` declares the defaults and `draw.label(0)` makes the one handle at module load. While no coin has both a strength and a momentum, the live bar writes "Loading the hourly candles for the rotation map" into the `notice` slot and `notice.set(16.0, 12.0).text(str_notice_sb)` draws it in slate 16 px in from the pane's top-right corner and 12 px down, past the price-axis tags, and skips the map and the headline. On the first live bar with a map, `notice.delete()` removes the line. ## Where it runs Any chart, any interval. The coins are always read from Binance Futures, so the chart's own market only changes the candles above the map, and the averages count the streams' hours, not the chart's bars, so the dots sit at the same place on every interval, and 4h and 1d charts show the 1h map with its week-long tail. On a daily chart the first draw takes longer, since a stream starts earlier when the chart has loaded more: the hourly candles reach back to the chart's first day. The map sits in its pane below the chart and the card at the top left of the price pane, under the legend stack (`safe_area: true`). ## When data is missing While no coin has both a strength and a momentum, one slate line at the top right of the price pane reads "Loading the hourly candles for the rotation map" and nothing else is drawn: no map, no chip, no headline. The card's title still names the span, since the `window_text` slot is written before the check; the `leader` slot and the five data outputs wait for a coin with both numbers, and the line goes on the first live bar that draws the map. Each stream brings at least 1500 hours, more than the 27 days a coin needs at the defaults and the 1344 hours of the largest settings with the longest tail, so the line shows while the hourly candles are still on their way. A coin hour missing from its block carries the coin's last close, so a short gap never empties the map; an hour missing from BTC's block is not folded at all, since BTC is the clock. A coin without values has no dot and is not counted, a tail stops at its first point without values, and when the hour the tail reaches back to has no ratio, the leader's label and the headline carry the name alone. ## Customize it - **Tail length.** `tail_points` (6, 0 to 12; 0 draws heads only) sets how many earlier positions each coin trails. The step between them follows the chart (4, 12 or 28 hours), and the move, the headline and the card's title follow the span the tail covers (one step at 0). - **The three averages.** `smooth` (14 days, 1 to 21) is the EMA on the ratio to BTC, `trend` (18 days, 3 to 28) the longer average the strength is measured from, and `momentum` (9 days, 1 to 14) the short average the momentum is measured from. Shorter averages stay smooth on a day-long tail but zigzag on a week-long one: a week of tail needs averages of weeks. Even the largest settings, with the longest tail, need only 1344 of the 1500 hours the streams bring. - **Quadrant colours.** The four `param.color` rows under Quadrant colours (`leading_ink` teal, `improving_ink` sky, `weakening_ink` amber, `lagging_ink` orange) recolour the dots, the trails, the chip and the tints. The card's headline keeps the house ladder, since a tile's `colors` takes literals. - **Other coins.** Change the `symbol` in a coin's input line and the name at the same position in `COIN_NAMES` (keep eight); the venue ids and the spellings are in [Exchange and symbol format](../reference/symbol-format.md). - **Change the look.** The Look row on the indicator's Style page switches the card's look without code, As made (terminal) first ([The Style page](../settings/style-page.md#the-look-row)); glass, for one, is a dark translucent card with round corners and rounded type, light on a light chart. The file writes `look: "terminal"`; bind `look` to a `param.choice` over looks instead (`look: "@card_look"`) and the look becomes a setting, whose own row replaces the Look row ([Looks](../presentation/hud-and-hover-cards.md#looks)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Rotation Map** under **Beyond the time axis**. 2. Press **Run** on any chart, such as BTCUSDT on Binance Futures at 15m: once the hourly candles arrive, the map opens below the chart with the chip on its title row, and the terminal card, titled "Rotation vs BTC, last 3d", appears at the top left of the price pane under the legend. Rest the pointer on a dot for its readout. 3. At the editor's Console prompt, type `last 20 leading` to read how many coins sat in the leading quadrant on each of the last 20 bars; on a 15m chart the count can change only once an hour, when an hourly candle closes. ## Concepts used - [Multi-timeframe](../core-concepts/multi-timeframe.md#history-the-candles-stream) for the `candles` stream, its `interval` and `bars`, the backlog on the first bar, and the pins a chart refuses - [Data sources](../core-concepts/data-sources.md#celled-sources) for celled sources, the `[offset_ms, open, high, low, close, volume]` tuple and the `in_<name>_cells()` / `in_<name>_view()` readers - [Sessions and units](../settings/sessions-and-units.md#the-interval-and-the-colours) for `chart.interval_sec()`, the chart's bar interval as a hidden setting - [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames and the frame buffer, `panel.scatter` with `guides`, `quadrants`, `trails`, `labels`, the frame's axis ends and the title-row `badge`, and the pixel offsets of an anchored handle - [Panel words](../presentation/cards-frames-panels.md#panel-words) for the colour words a `param.color` can drive as `"@name"` - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) for `render.hud`, its anchors, `columns`, `width` and `safe_area` - [Looks](../presentation/hud-and-hover-cards.md#looks) for `look: "terminal"` and a look bound to a `param.choice` - [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the headline `tile.pill` with its ladder and `tile.rows` - [Drawing objects](../presentation/drawing-objects.md) for `handles.label`, `draw.label(id)` and its `set`, `text` and `delete` - [The Style page](../settings/style-page.md#the-look-row) for the Look row and `param.color` - [Colors kit](../functions/colors-kit.md) for `fromPacked`, `red`, `green` and `blue` <!-- source: https://openmarket.xyz/wrun/cookbook/vol-smile --> # Volatility smile ![Volatility smile pane under a BTC chart: three implied volatility curves over a dollar strike axis with a dashed spot marker, at-the-money callouts and a put-skew chip, and the broadsheet card at the top right of the price pane](/wrun/images/vol-smile.png) Implied volatility by strike for three expiries of the chart coin's Deribit option chain, drawn under the chart as three smooth curves over a dollar strike axis, named by role so the words stay true when a horizon setting moves: near, about a week out by default (teal, the widest stroke), mid, about a month (sky) and far, about three months (violet). Out-of-the-money contracts only, the way the market prices the wings: puts below spot, calls at or above it. A dashed marker stands at spot, a callout pins each curve at the money with its expiry's date and IV ("9 Oct 34.1%"), the caption names each horizon's listed expiry ("near 9 Oct, mid 30 Oct, far 25 Dec"), and a chip on the pane's title row names the lean of the nearest curve: "Put skew 4.5 pts (10%)" means the strike 10% below spot trades 4.5 IV points over the strike 10% above it, so puts carry the premium. Rose for put skew, the chart's accent for call skew, slate when the two wings sit within half a point. At the top left of the price pane, under the legend and clear of the newest candles, a card in the broadsheet look (newsprint, serif type, small caps, dotted leaders) answers one number first: the near expiry's implied volatility at the money, in the curve's teal. Under it, two rows: the mid horizon's 25-delta skew (put IV minus call IV at the listed strikes whose |delta| sits nearest 0.25, the desk's usual quote; positive is put skew) and the term, the mid against the far IV at the money (a higher nearer reading means the market pays more for the next month than for the quarter: event risk priced in). The parts are an `options_chain.cells` input pinned to Deribit ([Options kit](../functions/options-kit.md)), a frame feeding a `panel.line` on a number axis with three series, a declared spot marker and a badge that the frame replaces on every run with the at-the-money callouts, the chip and the caption ([Cards, frames and panels](../presentation/cards-frames-panels.md)), a `render.hud` card of a headline `tile.value` and a `tile.rows` in the broadsheet look ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks)), and a `render.label` pinned to the top right for the one sentence when the coin has no chain ([Plotting](../presentation/plotting.md)). This is also the `vol-smile` template: the **Volatility Smile** card under **Beyond the time axis** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Volatility Smile: implied volatility by strike for three expiries of the chart coin's Deribit chain (near, mid and // far: about a week, a month and three months out by default), out-of-the-money contracts only (puts below spot, calls at or above, the way // the market prices the wings), drawn under the chart as three smooth curves over a dollar strike axis: a dashed // marker at spot, a callout on each curve's at-the-money point and a chip naming the lean of the nearest curve (IV // at the put wing minus IV at the call wing, both the same percent from spot). The trader sees whether puts or calls // carry the premium (the left wing higher means put skew), how much steeper the near smile is than the far one // (event risk sits in the near curve), and on the card the nearest expiry's IV at the money, the mid horizon's // 25-delta skew (the desk's standard quote, read at the strikes nearest 25 delta) and the mid against far term. Each // curve is named by its role and called out with its expiry's date, so the words stay true when a horizon setting // moves. The chain is live only: history bars draw nothing and cost nothing; everything is measured on the last bar, // from the chain's own clock. section("Expiries and strikes"); param.int("near_days", 7, { min: 1, max: 30, label: "Near horizon, days", description: "Days to the first horizon: the listed expiry closest to this many days away (at least 12 h out)" }); param.int("mid_days", 30, { min: 7, max: 120, label: "Mid horizon, days", description: "Days to the second horizon; a later expiry than the first" }); param.int("far_days", 90, { min: 30, max: 365, label: "Far horizon, days", description: "Days to the third horizon; a later expiry than the second" }); param.number("window_pct", 25, { min: 5, max: 60, label: "Strike grid, percent", description: "Strike grid: percent around spot, each side" }); param.int("points", 41, { min: 21, max: 81, label: "Grid strikes", description: "Strikes on the common grid" }); param.int("wing_pct", 10, { min: 3, max: 30, label: "Skew wing, percent", description: "The chip's skew: IV this percent below spot minus IV this percent above it, on the nearest drawn expiry" }); input("close", ohlcv.close); // the chart's own close: spot for the grid, the out-of-the-money split and the at-the-money read input("chain", options_chain.cells, { max_cells: 4000, venue: "deribit", description: "Deribit's chain of the chart's coin" }); // [strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega] per contract; a BTC chain is about 1,550 contracts output("atm_iv_1w", none, overlay, { description: "Implied volatility at the money, near horizon (about a week by default), percent; live bar only" }); // data-only: the card reads them and the Console can too output("atm_iv_1m", none, overlay, { description: "Implied volatility at the money, mid horizon (about a month by default), percent; live bar only" }); output("atm_iv_3m", none, overlay, { description: "Implied volatility at the money, far horizon (about three months by default), percent; live bar only" }); output("skew_25d", none, overlay, { description: "25-delta skew of the mid horizon (the nearest drawn one when it lists nothing): put IV minus call IV at the strikes nearest |delta| 0.25, IV points; above 0 is put skew" }); output("wing_skew", none, overlay, { description: "The chip's skew: IV at the put wing minus IV at the call wing (wing_pct from spot) on the nearest drawn expiry, IV points; above 0 is put skew" }); output("term_1m_3m", none, overlay, { description: "Mid horizon ATM IV minus far horizon ATM IV, IV points; above 0 the nearer term is the dearer" }); output("expiry_days_1w", none, overlay, { description: "Days from the chain's read time to the near horizon's expiry" }); string("skew_word", { max_bytes: 24 }); // the card's skew row: "+2.7 pts" string("term_word", { max_bytes: 32 }); // the card's term row: "32.8% vs 36.5%" string("notice", { max_bytes: 48 }); // the one sentence when the coin has no chain const smile_rows = frame("smile_rows", { max_bytes: 12288 }); // one row per grid strike: [strike, iv per horizon], null where the horizon lists nothing // The smile pane: three smooth curves over a dollar strike axis (the nearest expiry the widest stroke, every curve // with a halo), the value axis in percent and pinned per run so a short curve's gaps never squash the picture and its // peak keeps clear of the chips, a dashed spot marker, a callout pinned to each curve at the money with its expiry's // date and the skew chip on the title row; the marker list, the chip, the caption and the axis range are written per // run into the frame. The series are named by role (near, mid, far), true whatever the horizon settings read. panel.line({ name: "smile", title: "Vol smile, Deribit", x: "number", place: "below", frame: smile_rows, height_frac: 0.32, chrome: "grid", smooth: true, glow: true, hover_card: true, legend_style: "chips", x_format: "usd", x_decimals: 1, x_title: "Strike", y_title: "Implied volatility", format: "%", decimals: 1, badge: { text: "Skew", color: "#94a3b8" }, markers: [{ x: "spot", label: "Spot", line_style: "dashed", badge: true }], series: [ { name: "Near", color: "#2dd4bf", width: 2.5 }, { name: "Mid", color: "#38bdf8", width: 1.5 }, { name: "Far", color: "#a78bfa", width: 1.5 }, ], }); // The card, in the broadsheet look (newsprint, serif type, small caps, dotted leaders): the nearest expiry's IV at // the money leads as the headline in the curve's teal, then the mid horizon's 25-delta skew and the mid against far // term as two rows. Every tile reads the newest row, the live bar's. It sits at the top left under the legend, clear // of the newest candles and of the one-sentence notice at the top right. The look is a literal (the kit refuses a // setting here); the dialog's Style page carries the Look row, phosphor among its twelve, for the viewer's switch. render.hud("smile_card", { position: "top_left", safe_area: true, // under the legend look: "broadsheet", title: "Implied volatility, Deribit", columns: 1, width: 300, tiles: [ tile.value("ATM IV, near expiry", "atm_iv_1w", { format: "%", headline: true, color: "#2dd4bf", hint: "Implied volatility at the money for the listed expiry nearest the near horizon (a week out by default): what the market pays for a move over that horizon" }), tile.rows([ ["25d skew, mid", "skew_word"], ["Term, mid vs far", "term_word"], ]), ], }); render.label("notice", { position: "top_right", text: "notice", offset: [12, 8], style: "knockout", color: "#94a3b8", size: 12 }); // the one sentence, written on the live bar only when the coin has no chain const CURVES = 3; // the horizons, in the panel's series order const CURVE_NAMES: string[] = ["Near", "Mid", "Far"]; // the series, by role const CAPTION_NAMES: string[] = ["near ", "mid ", "far "]; const CURVE_INK: string[] = ["#2dd4bf", "#38bdf8", "#a78bfa"]; // teal, sky, violet: the series colours, for the callouts const MAX_STRIKES = 256; // out-of-the-money strikes one expiry lists const MAX_EXPIRIES = 64; // distinct live expiries a chain lists const TUPLE = 10; // f64s per contract const DAY_MS = 86400000.0; const MIN_AHEAD_MS = 43200000.0; // an expiry inside 12 h is settling, not a horizon const Z_25D = 0.6744897501960817; // the standard normal quantile at 0.75: the Black-Scholes 25-delta strikes when the chain serves no deltas const MONTHS: string[] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; // Per horizon: the out-of-the-money points at its expiry, sorted by strike, and the readings taken from them. const cStrike = new StaticArray<f64>(CURVES * MAX_STRIKES); const cIv = new StaticArray<f64>(CURVES * MAX_STRIKES); // percent const cDelta = new StaticArray<f64>(CURVES * MAX_STRIKES); // |delta| as the venue serves it, 0 when it serves none const cOi = new StaticArray<f64>(CURVES * MAX_STRIKES); // open interest, to settle a strike listed twice const cCount = new StaticArray<i32>(CURVES); const cExpiry = new StaticArray<f64>(CURVES); // the expiry the horizon shows, ms; NaN when none is listed const cDays = new StaticArray<f64>(CURVES); // days from the chain's clock to that expiry const cAtm = new StaticArray<f64>(CURVES); // IV at spot, percent; NaN when the curve does not cover spot const cKept = new StaticArray<i32>(CURVES); // 1 while the horizon's curve is drawn const cDay = new StaticArray<i32>(CURVES); // the expiry's UTC calendar day and month (1..12) const cMonth = new StaticArray<i32>(CURVES); const expiryMs = new StaticArray<f64>(MAX_EXPIRIES); // the chain's live expiries, sorted ascending let expiryCount = 0; let targetDays: f64[] = [7.0, 30.0, 90.0]; // settings, read in onStart() let windowPct = 0.25; let points = 41; let wingPct = 0.1; let measured = false; // the live bar carried a usable chain: at least one horizon drawn let skew: f64 = NaN; // the 25-delta skew of the mid horizon (the nearest drawn one when it lists nothing), IV points let wingSkew: f64 = NaN; // the chip: the nearest drawn curve's put wing minus its call wing, IV points let nearest = -1; // the nearest drawn horizon (0 while the first is listed) // The chain's live expiries: sorted insertion of each distinct one function noteExpiry(ms: f64): void { let lo = 0; let hi = expiryCount; while (lo < hi) { const mid = (lo + hi) >> 1; if (expiryMs[mid] < ms) lo = mid + 1; else hi = mid; } if (lo < expiryCount && expiryMs[lo] == ms) return; if (expiryCount >= MAX_EXPIRIES) return; for (let i = expiryCount; i > lo; i -= 1) expiryMs[i] = expiryMs[i - 1]; expiryMs[lo] = ms; expiryCount += 1; } function pickExpiry(targetMs: f64, after: i32): i32 { // the listed expiry closest to the target; one later than the previous horizon's when the two meet; -1 when none is left let best = -1; let bestGap = Infinity; for (let i = 0; i < expiryCount; i += 1) { const gap = Math.abs(expiryMs[i] - targetMs); if (gap < bestGap) { bestGap = gap; best = i; } } if (best <= after) best = after + 1; return best < expiryCount ? best : -1; } // One horizon's points: sorted insertion by strike; a strike listed twice (two settlement families) keeps the heavier open interest function insertPoint(c: i32, strike: f64, iv: f64, delta: f64, oi: f64): void { const base = c * MAX_STRIKES; let lo = 0; let hi = cCount[c]; while (lo < hi) { const mid = (lo + hi) >> 1; if (cStrike[base + mid] < strike) lo = mid + 1; else hi = mid; } if (lo < cCount[c] && cStrike[base + lo] == strike) { if (oi > cOi[base + lo]) { cIv[base + lo] = iv; cDelta[base + lo] = delta; cOi[base + lo] = oi; } return; } if (cCount[c] >= MAX_STRIKES) return; for (let i = cCount[c]; i > lo; i -= 1) { cStrike[base + i] = cStrike[base + i - 1]; cIv[base + i] = cIv[base + i - 1]; cDelta[base + i] = cDelta[base + i - 1]; cOi[base + i] = cOi[base + i - 1]; } cStrike[base + lo] = strike; cIv[base + lo] = iv; cDelta[base + lo] = delta; cOi[base + lo] = oi; cCount[c] += 1; } // The out-of-the-money points of one expiry: puts below spot, calls at or above; IV normalized to percent (a chain // whose largest IV at the expiry is under 3 serves fractions, 0.45 for 45%); IV at or below 0, or NaN, skipped. function readPoints(c: i32, cells: StaticArray<f64>, n: i32, expiry: f64, spot: f64): void { cCount[c] = 0; let maxIv = 0.0; for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) if (cells[i + 1] == expiry && cells[i + 6] > maxIv) maxIv = cells[i + 6]; const scale = maxIv > 0.0 && maxIv <= 3.0 ? 100.0 : 1.0; for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { if (cells[i + 1] != expiry) continue; const strike = cells[i]; const iv = cells[i + 6] * scale; if (!(strike > 0.0) || !(iv > 0.0)) continue; const otm = cells[i + 2] < 0.0 ? strike < spot : strike >= spot; if (!otm) continue; insertPoint(c, strike, iv, Math.abs(cells[i + 5]), cells[i + 3]); } } function ivAt(c: i32, price: f64): f64 { // linear interpolation between the horizon's two neighbouring listed strikes; NaN outside its listed range const count = cCount[c]; const base = c * MAX_STRIKES; if (count < 2 || isNaN(price)) return NaN; if (price < cStrike[base] || price > cStrike[base + count - 1]) return NaN; let lo = 0; let hi = count - 1; while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (cStrike[base + mid] <= price) lo = mid; else hi = mid; } const k0 = cStrike[base + lo]; const k1 = cStrike[base + hi]; if (price == k0 || k1 == k0) return cIv[base + lo]; return cIv[base + lo] + ((cIv[base + hi] - cIv[base + lo]) * (price - k0)) / (k1 - k0); } // The 25-delta skew of one horizon: the out-of-the-money put whose |delta| sits nearest 0.25 against the call whose // delta does, put IV minus call IV. The mid horizon (a month by default) is the one quoted (a week out, the 25-delta // strikes sit two or three percent from spot and miss the wings the eye reads). A chain that serves no deltas falls back to the // Black-Scholes 25-delta strikes from the horizon's own ATM vol and time to expiry, read off the interpolated curve. function skew25(c: i32, spot: f64): f64 { const base = c * MAX_STRIKES; let put = -1; let call = -1; let putGap = Infinity; let callGap = Infinity; for (let i = 0; i < cCount[c]; i += 1) { const d = cDelta[base + i]; if (!(d > 0.0) || d >= 1.0) continue; const gap = Math.abs(d - 0.25); if (cStrike[base + i] < spot) { if (gap < putGap) { putGap = gap; put = i; } } else if (gap < callGap) { callGap = gap; call = i; } } if (put >= 0 && call >= 0) return cIv[base + put] - cIv[base + call]; const sigma = cAtm[c] / 100.0; const years = cDays[c] / 365.0; if (!(sigma > 0.0) || !(years > 0.0)) return NaN; const drift = 0.5 * sigma * sigma * years; const spread = Z_25D * sigma * Math.sqrt(years); return ivAt(c, spot * Math.exp(drift - spread)) - ivAt(c, spot * Math.exp(drift + spread)); } function civilDate(c: i32, ms: f64): void { // UTC calendar day and month of an epoch-ms instant (days since 1970 to civil, no Date) const z = <i64>Math.floor(ms / DAY_MS) + 719468; const era = (z >= 0 ? z : z - 146096) / 146097; const doe = z - era * 146097; const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; const doy = doe - (365 * yoe + yoe / 4 - yoe / 100); const mp = (5 * doy + 2) / 153; cDay[c] = <i32>(doy - (153 * mp + 2) / 5 + 1); cMonth[c] = <i32>(mp < 10 ? mp + 3 : mp - 9); } // The chain's clock: when its gammas were priced, not the bar's open. The chart prices each contract's gamma by // Black-Scholes from its mark IV and underlying at the moment it reads the chain, so the contract nearest the money // (|delta| nearest 0.5), solved for the time to expiry its gamma implies, names that moment. A venue that serves its // own greeks gives no such answer: the bar's open stands in. function chainClockMs(cells: StaticArray<f64>, n: i32, barOpenMs: f64): f64 { let best = -1; let bestGap = 0.25; // |delta| within 0.25 of 0.5 let nearest = Infinity; // the nearest listed expiry: the chain lists none that has passed for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { if (cells[i + 1] < nearest) nearest = cells[i + 1]; const gap = Math.abs(Math.abs(cells[i + 5]) - 0.5); if (cells[i] > 0.0 && cells[i + 4] > 0.0 && cells[i + 6] > 0.0 && cells[i + 7] > 0.0 && gap < bestGap) { bestGap = gap; best = i; } } if (best < 0) return barOpenMs; const sigma = cells[best + 6] > 3.0 ? cells[best + 6] / 100.0 : cells[best + 6]; // percent a year, or a fraction const m = Math.log(cells[best + 7] / cells[best]); const q = cells[best + 4] * cells[best + 7]; // gamma times spot = pdf(d1) / u, u = sigma * sqrt(T), d1 = m / u + u / 2 let lo = Math.sqrt(2.0 * (Math.sqrt(1.0 + m * m) - 1.0)); // where pdf(d1) / u peaks: past it the gamma falls as T grows let hi = 10.0; for (let k = 0; k < 80; k += 1) { // bisection on the falling side const u = 0.5 * (lo + hi); const d1 = m / u + 0.5 * u; if (Math.exp(-0.5 * d1 * d1) / (2.5066282746310002 * u) > q) lo = u; else hi = u; } const u = 0.5 * (lo + hi); const clock = cells[best + 1] - ((u * u) / (sigma * sigma)) * 31536000000.0; return clock > barOpenMs - 86400000.0 && clock < nearest ? clock : barOpenMs; // anything else is no reading } // The chain, measured on the live bar: three horizons of the one chain, each a later expiry than the last function measure(spot: f64, barOpenMs: f64): bool { for (let c = 0; c < CURVES; c += 1) { cKept[c] = 0; cCount[c] = 0; cExpiry[c] = NaN; cDays[c] = NaN; cAtm[c] = NaN; } expiryCount = 0; skew = NaN; wingSkew = NaN; nearest = -1; const n = in_chain_cells(); if (n < TUPLE || isNaN(spot)) return false; const cells = in_chain_view(); // the live chain in place: the first n values of the build's own buffer const nowMs = chainClockMs(cells, n, barOpenMs); // the chain's clock: a coarse bar's open would shift the horizons for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) if (cells[i + 1] > nowMs + MIN_AHEAD_MS) noteExpiry(cells[i + 1]); if (expiryCount == 0) return false; let kept = 0; let after = -1; // the last horizon's expiry index: the next horizon takes a later one for (let c = 0; c < CURVES; c += 1) { const idx = pickExpiry(nowMs + targetDays[c] * DAY_MS, after); if (idx < 0) continue; after = idx; readPoints(c, cells, n, expiryMs[idx], spot); if (cCount[c] < 2) continue; cExpiry[c] = expiryMs[idx]; cDays[c] = (expiryMs[idx] - nowMs) / DAY_MS; cAtm[c] = ivAt(c, spot); civilDate(c, expiryMs[idx]); cKept[c] = 1; if (nearest < 0) nearest = c; kept += 1; } if (cKept[1] == 1) skew = skew25(1, spot); else if (nearest >= 0) skew = skew25(nearest, spot); for (let c = 0; c < CURVES; c += 1) { // the chip reads the nearest curve that lists both wings if (cKept[c] == 0) continue; const lean = ivAt(c, spot * (1.0 - wingPct)) - ivAt(c, spot * (1.0 + wingPct)); if (!isFinite(lean)) continue; wingSkew = lean; break; } return kept > 0; } // The panel frame: the common strike grid centred on spot (one row sits exactly at spot, where the callouts pin), // each drawn horizon interpolated onto it and null outside its listed strikes; the value axis pinned to the curves // with a margin, the caption naming the expiries, the skew chip, the spot marker and the at-the-money callouts. function fbDate(c: i32): void { // 30 Oct fb_int(<i64>cDay[c]); fb_text(" "); fb_text(MONTHS[cMonth[c] - 1]); } function writeSmile(spot: f64): void { const decimals = spot >= 1000.0 ? 1 : spot >= 10.0 ? 2 : 5; const mid = (points - 1) / 2; const step = (2.0 * windowPct * spot) / f64(points - 1); fb_clear(); fb_text("{\"rows\":["); let yLo = Infinity; let yHi = -Infinity; let written = 0; for (let r = 0; r < points; r += 1) { const x = spot + f64(r - mid) * step; if (x <= 0.0) continue; if (written > 0) fb_text(","); fb_text("["); fb_f64(x, decimals); for (let c = 0; c < CURVES; c += 1) { fb_text(","); const iv = cKept[c] == 1 ? ivAt(c, x) : NaN; if (isFinite(iv)) { if (iv < yLo) yLo = iv; if (iv > yHi) yHi = iv; } fb_f64(iv, 1); // null (JSON's gap) where the horizon lists nothing at this strike } fb_text("]"); written += 1; } fb_text("]"); if (isFinite(yLo) && isFinite(yHi)) { // the value axis follows the curves, not the gaps; room under the floor for the three callouts const range = yHi - yLo; let above = range * 0.25; // headroom over the highest wing, so it never runs under the series chips if (above < 3.0) above = 3.0; let below = range * 0.3; if (below < 3.0) below = 3.0; const lo = yLo - below; fb_text(",\"y_min\":"); fb_f64(lo < 0.0 ? 0.0 : lo, 1); fb_text(",\"y_max\":"); fb_f64(yHi + above, 1); } fb_text(",\"caption\":\""); let named = 0; for (let c = 0; c < CURVES; c += 1) { // each drawn horizon and its expiry's date, in the series' order: "near 9 Oct" if (cKept[c] == 0) continue; if (named > 0) fb_text(", "); fb_text(CAPTION_NAMES[c]); fbDate(c); named += 1; } fb_text("\",\"badge\":{\"text\":\""); // the chip: the nearest curve's lean at the wings, rose for puts, the accent for calls if (isNaN(wingSkew)) fb_text("Skew n/a\",\"color\":\"#94a3b8\"}"); else { const flat = Math.abs(wingSkew) < 0.5; fb_text(flat ? "Flat skew " : wingSkew > 0.0 ? "Put skew " : "Call skew "); fb_f64(Math.abs(wingSkew), 1); fb_text(" pts ("); fb_int(<i64>Math.round(wingPct * 100.0)); fb_text("%)\""); if (flat) fb_text(",\"color\":\"#94a3b8\""); else if (wingSkew > 0.0) fb_text(",\"color\":\"#fb7185\""); fb_text("}"); } fb_text(",\"markers\":[{\"x\":\"spot\",\"label\":\"Spot\",\"line_style\":\"dashed\",\"badge\":true}"); for (let c = 0; c < CURVES; c += 1) { if (cKept[c] == 0 || !isFinite(cAtm[c])) continue; fb_text(",{\"x\":"); fb_f64(spot, decimals); fb_text(",\"valign\":\"point\",\"series\":"); fb_str(CURVE_NAMES[c]); fb_text(",\"label\":\""); fbDate(c); // the callout names its curve's expiry: "9 Oct 34.1%" fb_text(" "); fb_f64(cAtm[c], 1); fb_text("%\",\"color\":\""); fb_text(CURVE_INK[c]); fb_text("\",\"badge\":true}"); } fb_text("]}"); writeFrameBuffer(smile_rows); } function onStart(): void { targetDays[0] = p_near_days(); targetDays[1] = p_mid_days(); targetDays[2] = p_far_days(); windowPct = p_window_pct() / 100.0; points = i32(p_points()); wingPct = p_wing_pct() / 100.0; } // onBar() runs once per bar: history rows carry an empty block and cost one read each; the live bar measures the // chain, writes the panel frame and the card's words, or the one notice when the coin has no chain. function onBar(): void { const close = bar.close(); const t = bar.time(); if (bar.isLast()) measured = measure(close, t * 1000.0); // the bar's open, the clock's stand-in if (isNaN(close)) return; const live = bar.isLast() && measured; out_atm_iv_1w(live ? cAtm[0] : NaN); out_atm_iv_1m(live ? cAtm[1] : NaN); out_atm_iv_3m(live ? cAtm[2] : NaN); out_skew_25d(live ? skew : NaN); out_wing_skew(live ? wingSkew : NaN); out_term_1m_3m(live && cKept[1] == 1 && cKept[2] == 1 ? cAtm[1] - cAtm[2] : NaN); out_expiry_days_1w(live ? cDays[0] : NaN); if (!bar.isLast()) return; if (measured) { writeSmile(close); sb_clear(); // the skew row: signed IV points (a reading inside a twentieth of a point prints as 0.0, unsigned) if (isNaN(skew)) sb_text("n/a"); else { const tenths = Math.round(skew * 10.0) / 10.0; if (tenths > 0.0) sb_text("+"); else if (tenths < 0.0) sb_text("-"); sb_f64(Math.abs(tenths), 1); sb_text(" pts"); } str_skew_word_sb(); sb_clear(); // the term row: the one-month and three-month readings at the money if (cKept[1] == 1 && isFinite(cAtm[1]) && cKept[2] == 1 && isFinite(cAtm[2])) { sb_f64(cAtm[1], 1); sb_text("% vs "); sb_f64(cAtm[2], 1); sb_text("%"); } else sb_text("n/a"); str_term_word_sb(); } else { sb_clear(); sb_text("No option chain for this coin"); str_notice_sb(); sb_clear(); sb_text("n/a"); str_skew_word_sb(); sb_clear(); sb_text("n/a"); str_term_word_sb(); } } ``` ## How it works **The chain is one input, read on the live bar.** `input("chain", options_chain.cells, { max_cells: 4000, venue: "deribit" })` serves the chart coin's Deribit chain, ten numbers per contract (`[strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega]`, a BTC chain about 1,550 contracts); `in_chain_cells()` is the count and `in_chain_view()` the block in place. The chain fills the newest row only, so `measure()` runs under `bar.isLast()` and history bars draw nothing and cost nothing. The chart's own `close` is spot: the grid's centre, the out-of-the-money split and the at-the-money read. **Three horizons of one chain.** Every distance runs from the chain's clock, the moment the chart priced the chain, never the live bar's open (which on a 1w chart can sit days back): the chart prices each contract's gamma by Black-Scholes from its mark IV and underlying when it reads the chain, so `chainClockMs()` solves the gamma of the contract whose delta sits nearest 0.5 for the time to expiry it implies, and the bar's open stands in only where a venue serves its own greeks. `measure()` collects the chain's live expiries (one inside 12 hours is settling, not a horizon), then picks for `near_days`, `mid_days` and `far_days` the listed expiry closest to each target, each later than the previous one. `readPoints()` keeps one expiry's out-of-the-money contracts (puts below spot, calls at or above), IV normalized to percent, sorted by strike, a strike listed twice keeping the heavier open interest. `ivAt()` interpolates between the two neighbouring listed strikes and reads NaN outside the listed range, which is how a short near curve ends where its listings end. **The frame is the pane.** `writeSmile()` builds the `smile_rows` frame with the `fb_*` builders: `points` rows on a grid `window_pct` each side of spot, one row exactly at spot, each row `[strike, iv per horizon]` with `null` where a horizon lists nothing at that strike. Beside the rows it writes `y_min` and `y_max` (the value axis pinned to the curves, floored at 0, with room under them for the callouts and a quarter of their range over the highest wing, so it never runs under the series chips), the `caption` naming each drawn horizon's expiry (`near 9 Oct, mid 30 Oct, far 25 Dec`), the `badge` (the chip's word and colour) and the `markers` list: the dashed spot line plus one `valign: "point"` callout per drawn curve, pinned to the named series at the spot row, inked in that series' colour and labelled with its expiry's date and IV. `panel.line` draws it below the chart on a number axis with `x_format: "usd"`, `format: "%"` with one decimal, `chrome: "grid"`, `smooth`, `glow`, `hover_card` and `legend_style: "chips"`, the nearest expiry the widest stroke. **Two skews, two readers.** `wing_skew` is the chip: on the nearest drawn curve, IV `wing_pct` below spot minus IV the same percent above it, so the chip names the lean the picture shows; within half a point it reads "Flat skew" in slate, else "Put skew" in rose or "Call skew" in the chart's accent, the percent from spot in the text. `skew_25d` is the card's row: on the mid horizon (the nearest drawn one when it lists nothing), the out-of-the-money put whose |delta| sits nearest 0.25 against the call whose delta does, put IV minus call IV from the chain's own deltas; a chain that serves no deltas falls back to the Black-Scholes 25-delta strikes from the horizon's own ATM vol and time to expiry, read off the interpolated curve. A week out, the 25-delta strikes sit two or three percent from spot and miss the wings the eye reads, which is why the mid horizon, a month by default, is the quoted one and the chip reads the wings. **The card reads outputs and slots.** `atm_iv_1w` feeds the headline `tile.value`, "ATM IV, near expiry", with `format: "%"` (two decimals, "31.37%", where the callout prints one, "9 Oct 31.4%"), in the curve's teal, with a `hint` that says what the number means; `atm_iv_1m`, `atm_iv_3m`, `term_1m_3m` and `expiry_days_1w` are data-only outputs beside it (the names keep the default horizons: `_1w` is the near one, `_1m` the mid, `_3m` the far), every one written on the live bar and NaN on every history bar. The two rows read string slots built with the `sb_*` builders on the live bar: `skew_word` ("+2.7 pts", rounded to tenths first and signed only when a tenth is nonzero) and `term_word` ("32.8% vs 36.5%"). The row labels name the horizons by role ("25d skew, mid", "Term, mid vs far"). `look: "broadsheet"` is a literal, `columns: 1` and `width: 300` shape the card, and `position: "top_left"` with `safe_area: true` seats it under the legend, over the oldest candles on screen instead of the newest. **The notice is a label.** `render.label("notice", { position: "top_right", text: "notice", offset: [12, 8], style: "knockout" })` is pinned to the top right over the `notice` slot, written on the live bar only when the coin has no chain, so no history bar carries text. ## Where it runs Any market whose coin Deribit lists options for (BTC, ETH, SOL and the rest of Deribit's list), on any interval: the chain is a live snapshot served by the chart, so a BTC perpetual at 15m and at 1h draw the same curves and the same numbers, and an ETH spot chart reads ETH's chain. The strike axis prints dollar strikes ("$65.0K" to "$105.0K" on a BTC chart near 85K) with the percent from spot under each tick. A coin Deribit does not list refuses the run by name before any fetch ("Deribit lists no options for coin 'X'"). ## When data is missing A served coin whose chain carries no usable expiry shows the one sentence "No option chain for this coin" at the top right and draws nothing else: the frame is not written, so no empty pane mounts, the card's headline prints a dash and its two rows read "n/a". Every history bar draws nothing (the chain is the live bar's only), so the outputs carry a number on the last bar alone. Inside a drawn chain, a horizon that lists nothing at a grid strike writes `null` there and its curve stops at its listed range, so a near expiry that lists a narrower range than the quarter draws shorter; a horizon with no later expiry left is not drawn and the caption names only the drawn ones; the term row reads "n/a" unless both the mid and the far horizons are drawn and cover spot; the chip reads "Skew n/a" when no curve lists both wings. ## Customize it - **Other horizons.** `near_days` (7, 1 to 30), `mid_days` (30, 7 to 120) and `far_days` (90, 30 to 365) each pick the listed expiry closest to that many days out, at least 12 hours away and each later than the previous one; the chips keep their role names and the callouts and the caption follow the expiries read. - **A wider or finer grid.** `window_pct` (25, 5 to 60) sets the strike window as a percent of spot on each side and `points` (41, 21 to 81) the strikes on it; one row always sits exactly at spot, where the callouts pin. - **The chip's wings.** `wing_pct` (10, 3 to 30) sets how far from spot the chip reads its two wings; the percent prints in the chip's text. - **The chip's number on the card.** `wing_skew` is already a data-only output: add `["Wing skew", "wing_skew", "0.0"]` as a third row of `tile.rows` and the card quotes the picture too. - **Change the look.** The card's look is the literal `look: "broadsheet"` (no setting binds it); the Look row on the indicator's Style page switches it without code, "As made" first, and `phosphor` (green mono type on near black, with scanlines) is the second look this example was shot in ([The Style page](../settings/style-page.md#the-look-row), [Looks](../presentation/hud-and-hover-cards.md#looks)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Volatility Smile** under **Beyond the time axis**. 2. Press **Run** on a chart of a coin Deribit lists, such as BTCUSDT at 15m: the smile pane mounts under the chart with the three curves, the spot marker, the callouts and the chip, and the broadsheet card appears at the top left of the price pane, under the legend. 3. At the editor's Console prompt, type `last 20 atm_iv_1w` to read the nearest expiry's at-the-money IV on the last 20 bars: only the last bar carries a number, the chain being live only. ## Concepts used - [Options kit](../functions/options-kit.md) for `options_chain.cells`, the ten-number contract tuple, `venue`, `max_cells`, `in_chain_cells()` and `in_chain_view()` - [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames, the `fb_*` builders and `writeFrameBuffer`, `panel.line` on a number axis, markers, `valign: "point"` callouts, badges and captions - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) for `render.hud`, its anchors, `offset`, `columns` and `width` - [Looks](../presentation/hud-and-hover-cards.md#looks) for `broadsheet` and `phosphor` and what each draws - [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the headline `tile.value`, its `hint` and `format`, and `tile.rows` - [The Style page](../settings/style-page.md#the-look-row) for the Look row that switches the look without code - [Plotting](../presentation/plotting.md) for `render.label` pinned by `position`, the fixed-position text - [Strings and text](../functions/text-formatting.md) for the `sb_*` builders and `str_<slot>_sb()` <!-- source: https://openmarket.xyz/wrun/cookbook/correlation-matrix --> # Correlation matrix ![An 8 by 8 correlation grid below a BTC chart, teal where the markets moved together with the gold and euro rows empty, and a black signal card at the top right reading the average, the most tied and the most free market](/wrun/images/correlation-matrix.png) How this chart's market moved with eight crypto perpetuals over the last `window` bars, as a market by market grid below the chart: the Binance Futures BTC, ETH, SOL, BNB, XRP, DOGE, ADA and AVAX perpetuals. One row and one column per market, the chart's own market first on both axes and its row label lit. A teal cell is a pair that moved together over the window, a violet cell a pair that moved apart, a cell in the chart's own surface colour no relation; every cell prints its correlation with two decimals and opens a hover card ("Market BTC · vs ETH"). An empty cell is a pair that shared too few bars in the window (a coin listed after the window began, or a market whose candles the chart's bars cannot join); its row and column keep their names. An Avg row under the grid gives every market its average with the others. The pane's legend reads the title and a count, "Correlation of returns, 96 bars · BTC moves with 0 of 7": how many markets moved with the chart's market at or above the `strong` setting, counted over the markets that could be measured. A card at the top right, under the chart's High tag, in the signal look (black paper with a thin border and a large headline, a look that keeps its paper on a light chart), reads the chart market's average correlation with the others as the headline (teal at or above `strong`, slate between, violet below zero), then the market it is most tied to and the one it is most free of, each with its reading. When the chart is one of the eight markets (a Binance Futures BTC or ETH perpetual chart), that row carries the market's name and the grid is 8 by 8; on any other crypto market the first row is named "Chart" and the grid is 9 by 9. Correlation is written for crypto charts: on a chart whose candles carry no volume (forex, gold, silver) it draws no grid, the card's headline reads "Not crypto", and one sentence under the card names the way out. The parts are ten inputs over nine sources, the chart's own `ohlcv.close` first and its `ohlcv.volume` from the same candles, then eight `ohlcv.close` inputs pinned by `exchange` and `symbol` to Binance Futures perpetuals with `missing: "nan"` ([Data sources](../core-concepts/data-sources.md), [Multi-source and aggregation](../core-concepts/multi-source.md)), a frame feeding `panel.heatmap` on category axes with a palette scale, a highlight row, a caption and a summary row ([Cards, frames and panels](../presentation/cards-frames-panels.md)), a `render.hud` card of a headline `tile.pill` and a `tile.rows` in the signal look ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards)), and a label handle for the one sentence that stands in for the grid while the window loads or on a chart that is not crypto ([Drawing objects](../presentation/drawing-objects.md)). This is also the `correlation-matrix` template: the **Correlation Matrix** card under **Beyond the time axis** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Correlation Matrix: how this chart's market moved with eight crypto perpetuals over the last 'window' bars (the // Binance Futures BTC, ETH, SOL, BNB, XRP, DOGE, ADA and AVAX perpetuals), as a market by market heatmap below the // chart. Teal cells moved together, violet cells moved apart, the surface colour is no relation, and an empty cell is // a pair with too few shared bars in the window (a coin listed after the window began, or one whose candles the // chart's bars cannot join); its row keeps its name. The chart's own market is the first row and column; when it is // one of the eight, that row carries the market's name and the grid is 8 by 8. The card reads the chart market's // average correlation with the others, the market it is most tied to and the one it is most free of; the panel's // legend counts how many moved with it above the 'strong' setting, and an Avg row under the grid gives every market // the same reading. The trader sees in one glance whether the market is trading on its own or as one of the crowd, // and which market to watch as a lead or a hedge. Correlation is written for crypto charts: on a chart whose candles // carry no volume (forex, gold, silver) it says so in one sentence with the way out, and draws no grid. section("Window"); param.int("window", 96, { min: 30, max: 500, label: "Window in bars", description: "Bars in the correlation window" }); param.number("strong", 0.7, { min: 0.3, max: 0.95, step: 0.05, label: "Strong correlation", description: "Correlation at or above which two markets count as moving together" }); // Inputs: the chart's own close is the grid and the first row, its volume tells a crypto chart from a quote-only // one (forex, gold and silver candles carry none; the same candles, so no extra source); the eight pins join by // timestamp and read NaN on a bar their market has no candle for, so a missing candle never fabricates a return. input("close", ohlcv.close, { description: "This chart's close: the first row and column of the grid" }); input("volume", ohlcv.volume, { description: "This chart's volume: candles that never carry any (forex, gold, silver) mark a chart that is not crypto" }); input("btc", ohlcv.close, { exchange: "BINANCE_FUTURES", symbol: "BTCUSDT", missing: "nan", description: "Bitcoin, Binance Futures perpetual" }); input("eth", ohlcv.close, { exchange: "BINANCE_FUTURES", symbol: "ETHUSDT", missing: "nan", description: "Ether, Binance Futures perpetual" }); input("sol", ohlcv.close, { exchange: "BINANCE_FUTURES", symbol: "SOLUSDT", missing: "nan", description: "Solana, Binance Futures perpetual" }); input("bnb", ohlcv.close, { exchange: "BINANCE_FUTURES", symbol: "BNBUSDT", missing: "nan", description: "BNB, Binance Futures perpetual" }); input("xrp", ohlcv.close, { exchange: "BINANCE_FUTURES", symbol: "XRPUSDT", missing: "nan", description: "XRP, Binance Futures perpetual" }); input("doge", ohlcv.close, { exchange: "BINANCE_FUTURES", symbol: "DOGEUSDT", missing: "nan", description: "Dogecoin, Binance Futures perpetual" }); input("ada", ohlcv.close, { exchange: "BINANCE_FUTURES", symbol: "ADAUSDT", missing: "nan", description: "Cardano, Binance Futures perpetual" }); input("avax", ohlcv.close, { exchange: "BINANCE_FUTURES", symbol: "AVAXUSDT", missing: "nan", description: "Avalanche, Binance Futures perpetual" }); // Outputs are data-only, written on the live bar: the card, the Console and a watch read the same numbers. output("avg_corr", none, overlay, { format: "0.00", description: "The chart market's average correlation with the other markets in the grid" }); output("tone", none, overlay, { description: "0 moving apart, 1 loosely related, 2 moving together, NaN with no reading: the headline's colour" }); output("strong_count", none, overlay, { description: "How many of the other markets correlate with the chart's market at or above 'strong'" }); output("served_count", none, overlay, { description: "How many of the other markets had enough bars in the window to be measured" }); output("bars", none, overlay, { format: "int", description: "The window in bars, read by the card's title" }); string("avg_text", { max_bytes: 24 }); // "0.62 avg" string("tied_text", { max_bytes: 24 }); // "ETH 0.91" string("free_text", { max_bytes: 24 }); // "AVAX 0.12" string("note", { max_bytes: 80 }); // the one sentence: the window still loading, or a chart that is not crypto // The one sentence: a slate knockout label at the top right of the price pane, under the card. handles.label({ text: "note", anchor: "top_right", align: "right", color: "#94a3b8", size: 11, style: "knockout", padding: 6, safe_area: true }); const note = draw.label(0); const NOTE_Y: f64 = 200.0; // pixels down from the top-right corner: under the card, which ends about 172 px down // The grid: one cell per market pair, violet at -1 through the chart surface at 0 to teal at +1, every cell printed // with two decimals, a hover card per cell, the chart's own row lit in the row labels. const grid = frame("grid", { max_bytes: 8192 }); panel.heatmap({ name: "corr_grid", title: "Correlation of returns", x: "category", place: "below", frame: grid, scale: "palette", palette: ["#a78bfa", "theme.bg", "#2dd4bf"], min: -1, max: 1, row_title: "Market", col_title: "vs", format: "0.00", hover_card: true, height_frac: 0.35 }); // The card, in the signal look, below the chart's own High tag: the window in the title, the average correlation as the headline (violet apart, // slate loose, teal together; slate words when there is no reading), then the most tied and the most free market // with their readings. The headline is a big number: its words stay short enough to fit the card. render.hud("card", { position: "top_right", look: "signal", title: "Correlation, last {{bars:int}} bars", columns: 1, width: 250, offset: [0, 24], safe_area: true, tiles: [ tile.pill("Average with the others", "avg_text", { headline: true, color_by: "tone", colors: ["#a78bfa", "#94a3b8", "#2dd4bf"], color: "#94a3b8" }), tile.rows([["Most tied", "tied_text"], ["Most free", "free_text"]]), ] }); const SERIES = 9; // the chart's market plus the eight pins const MAX_WINDOW = 500; const MAX_RING = MAX_WINDOW + 1; // 'window' returns need 'window' + 1 closes const NAMES: StaticArray<string> = ["Chart", "BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX"]; const closes = new StaticArray<f64>(SERIES * MAX_RING); // the ring of closes, one row per series const rets = new StaticArray<f64>(SERIES * MAX_WINDOW); // the window's log returns per series, oldest first const corr = new StaticArray<f64>(SERIES * SERIES); // the live grid, NaN where a pair has too few bars const order = new StaticArray<i32>(SERIES); // the series behind each grid row: the chart first, then the pins const colAvg = new StaticArray<f64>(SERIES); // each column's average with the other markets (the Avg row) let window = 96; let strong = 0.7; let ring = 97; let minPairs = 48; // the params, read in onStart() let head = -1; let count = 0; // the ring: head is the newest slot, count the slots in use let rows = SERIES; let chartName = "Chart"; // the grid's size and the chart row's name this run let volumeSeen = false; // a bar of the chart carried volume: every crypto market trades some, quote-only candles never do function readSeries(s: i32): f64 { switch (s) { case 1: return in_btc(); case 2: return in_eth(); case 3: return in_sol(); case 4: return in_bnb(); case 5: return in_xrp(); case 6: return in_doge(); case 7: return in_ada(); case 8: return in_avax(); default: return bar.close(); } } function slotAt(k: i32, used: i32): i32 { return (head - used + 1 + k + MAX_RING) % MAX_RING; } // the k-th oldest of 'used' slots function sameClose(a: f64, b: f64): bool { return Math.abs(a - b) <= 1e-9 * Math.max(1.0, Math.abs(a)); } // At or above 'strong' as the cells print it, two decimals: a 0.6955 cell reads 0.70 and counts at 0.70. function isStrong(r: f64): bool { return Math.round(r * 100.0) >= Math.round(strong * 100.0); } // The pin that is this chart's own market, if any: its closes equal the chart's on every bar both have (the forming // bar may lag by a tick, so one mismatch is allowed). That row is dropped and the chart row takes its name. function duplicatePin(used: i32): i32 { for (let p = 1; p < SERIES; p += 1) { let present = 0; let equal = 0; for (let k = 0; k < used; k += 1) { const slot = slotAt(k, used); const a = closes[slot]; const b = closes[p * MAX_RING + slot]; if (isNaN(a) || isNaN(b)) continue; present += 1; if (sameClose(a, b)) equal += 1; } if (present >= 2 && equal >= present - 1) return p; } return 0; } // Log returns of every series over the window, NaN where either close is missing. function buildReturns(used: i32): i32 { const n = used - 1; for (let s = 0; s < SERIES; s += 1) { for (let k = 0; k < n; k += 1) { const a = closes[s * MAX_RING + slotAt(k, used)]; const b = closes[s * MAX_RING + slotAt(k + 1, used)]; rets[s * MAX_WINDOW + k] = a > 0.0 && b > 0.0 ? Math.log(b / a) : NaN; } } return n; } // Pearson correlation of two series over the bars both have; NaN under 'minPairs' bars or a flat series. function pearson(a: i32, b: i32, n: i32): f64 { let m = 0; let sx = 0.0; let sy = 0.0; let sxx = 0.0; let syy = 0.0; let sxy = 0.0; for (let k = 0; k < n; k += 1) { const x = rets[a * MAX_WINDOW + k]; const y = rets[b * MAX_WINDOW + k]; if (isNaN(x) || isNaN(y)) continue; m += 1; sx += x; sy += y; sxx += x * x; syy += y * y; sxy += x * y; } if (m < minPairs) return NaN; const vx = sxx - (sx * sx) / f64(m); const vy = syy - (sy * sy) / f64(m); if (!(vx > 0.0) || !(vy > 0.0)) return NaN; const r = (sxy - (sx * sy) / f64(m)) / Math.sqrt(vx * vy); return r > 1.0 ? 1.0 : r < -1.0 ? -1.0 : r; } function buildGrid(n: i32): void { for (let i = 0; i < rows; i += 1) { for (let j = i; j < rows; j += 1) { const r = pearson(order[i], order[j], n); corr[i * SERIES + j] = r; corr[j * SERIES + i] = r; } } for (let j = 0; j < rows; j += 1) { // the Avg row: each column against the other markets let sum = 0.0; let m = 0; for (let i = 0; i < rows; i += 1) { if (i == j) continue; const r = corr[i * SERIES + j]; if (isNaN(r)) continue; sum += r; m += 1; } colAvg[j] = m > 0 ? sum / f64(m) : NaN; } } function writeGrid(strongCount: i32, served: i32): void { // [column market, row market, correlation] per cell, null where unmeasured fb_clear(); fb_text("{\"title\":\"Correlation of returns, "); fb_int(<i64>window); fb_text(" bars\",\"caption\":\""); fb_text(chartName); fb_text(" moves with "); fb_int(<i64>strongCount); fb_text(" of "); fb_int(<i64>served); fb_text("\",\"highlight\":{\"row\":"); fb_str(chartName); fb_text("},\"rows\":["); for (let i = 0; i < rows; i += 1) { for (let j = 0; j < rows; j += 1) { if (i > 0 || j > 0) fb_text(","); fb_text("["); fb_str(order[j] == 0 ? chartName : NAMES[order[j]]); fb_text(","); fb_str(order[i] == 0 ? chartName : NAMES[order[i]]); fb_text(","); fb_f64(corr[i * SERIES + j], 2); fb_text("]"); } } fb_text("],\"summary\":[{\"label\":\"Avg\",\"values\":["); for (let j = 0; j < rows; j += 1) { if (j > 0) fb_text(","); fb_f64(colAvg[j], 2); } fb_text("]}]}"); writeFrameBuffer(FRAME_GRID); } // A state with no grid: the one sentence under the card, and the card's headline for that state with a dash per row. function sayInstead(sentence: string, headline: string): void { sb_clear(); sb_text(sentence); note.set(0.0, NOTE_Y).text(str_note_sb); sb_clear(); sb_text(headline); str_avg_text_sb(); sb_clear(); sb_text("-"); str_tied_text_sb(); sb_clear(); sb_text("-"); str_free_text_sb(); } // onStart() runs once before the first bar: read the params and size the ring to the window. function onStart(): void { window = i32(p_window()); strong = p_strong(); ring = window + 1; minPairs = (window + 1) / 2; } // onBar() runs once per bar: push every close into the ring and note whether the chart's candles carry volume; on // the live bar, measure the grid and write the panel, the card's words and the outputs, or the one sentence when // the chart is not crypto or the window is not full yet. function onBar(): void { head = (head + 1) % MAX_RING; if (count < ring) count += 1; for (let s = 0; s < SERIES; s += 1) closes[s * MAX_RING + head] = readSeries(s); if (in_volume() > 0.0) volumeSeen = true; if (!bar.isLast()) return; out_bars(f64(window)); if (!volumeSeen) { // no candle carried volume: a forex, gold or silver chart, so no grid sayInstead("Correlation is written for crypto charts: switch to a BTC or ETH perp", "Not crypto"); return; } if (count < window) { // fewer bars than the window: no grid yet sayInstead("Loading more history for the correlation window", "Loading"); return; } note.delete(); const used = count < ring ? count : ring; const dup = duplicatePin(used); chartName = dup > 0 ? NAMES[dup] : "Chart"; rows = 0; order[rows] = 0; rows += 1; for (let p = 1; p < SERIES; p += 1) if (p != dup) { order[rows] = p; rows += 1; } buildGrid(buildReturns(used)); // The chart's row: its average with the others, the most tied and most free markets, the count above 'strong'. let sum = 0.0; let served = 0; let strongCount = 0; let tied = -1; let free = -1; for (let j = 1; j < rows; j += 1) { const r = corr[j]; if (isNaN(r)) continue; sum += r; served += 1; if (isStrong(r)) strongCount += 1; if (tied < 0 || r > corr[tied]) tied = j; if (free < 0 || r < corr[free]) free = j; } const avg = served > 0 ? sum / f64(served) : NaN; out_avg_corr(avg); out_strong_count(f64(strongCount)); out_served_count(f64(served)); out_tone(isNaN(avg) ? NaN : avg < 0.0 ? 0.0 : isStrong(avg) ? 2.0 : 1.0); sb_clear(); if (served > 0) { sb_f64(avg, 2); sb_text(" avg"); } else sb_text("No pairs"); str_avg_text_sb(); sb_clear(); if (tied >= 0) { sb_text(NAMES[order[tied]]); sb_text(" "); sb_f64(corr[tied], 2); } else sb_text("-"); str_tied_text_sb(); sb_clear(); if (free >= 0) { sb_text(NAMES[order[free]]); sb_text(" "); sb_f64(corr[free], 2); } else sb_text("-"); str_free_text_sb(); writeGrid(strongCount, served); } ``` ## How it works **Nine closes ride one grid.** `input("close", ohlcv.close)` is the chart's own close: the grid every other input is joined onto, and the first row and column. `input("volume", ohlcv.volume)` reads the volume of the same candles, so it adds no source; it only tells a crypto chart from a quote-only one (below). The eight pins (`btc`, `eth`, `sol`, `bnb`, `xrp`, `doge`, `ada` and `avax`, every one on `BINANCE_FUTURES`) are joined by timestamp at the chart's interval and read NaN on a bar their market has no candle for, by `missing: "nan"`, so a missing candle never fabricates a return. `ADAUSDT` and `AVAXUSDT` replaced the gold and euro pins on `FX_OTC`, so every pinned market is served wherever crypto is. Every bar pushes all nine closes into a ring sized at load for the largest window (`MAX_WINDOW` 500, plus one close: `window` returns need `window` + 1 closes). **The grid is measured on the live bar.** `onBar()` returns after the push and the volume check until `bar.isLast()`. On the live bar it writes `bars`, then stops at either state that draws no grid (below). Past them, `buildReturns()` turns the last `window` + 1 closes of every series into per-bar log returns (NaN where either close is missing), and `pearson()` measures each pair over the bars both markets have: fewer than `(window + 1) / 2` shared bars, or a flat series, reads NaN, an empty cell, and the rest is clamped to [-1, 1]. `buildGrid()` fills the pairs and the Avg row, each column's average with the other markets. The chart's row gives the card its numbers: the average over the served markets, the most tied and the most free, and the count at or above `strong`. `isStrong()` compares the correlation as the cells print it, rounded to two decimals, so a 0.6955 cell that reads 0.70 counts at a `strong` of 0.70, and the headline's teal follows the printed average the same way. **A duplicate pin folds into the chart row.** `duplicatePin()` looks for a pin whose closes equal the chart's on every bar both have (one mismatch allowed, since the forming bar may lag by a tick). That pin's row is dropped and the chart row takes its name, so a Binance Futures BTC chart reads an 8 by 8 grid with "BTC" first, and a Binance spot ETH chart reads 9 by 9 with "Chart" first and the ETH perpetual at 1.00. **One frame is the grid.** `writeGrid()` builds `frame("grid")` through the `fb_*` builder and sends it with `writeFrameBuffer(FRAME_GRID)`: a `title` ("Correlation of returns, 96 bars"), a `caption` ("BTC moves with 0 of 7"), a `highlight` on the chart's row, one `[column market, row market, correlation]` row per cell with `null` where a pair is unmeasured, and one `summary` row, "Avg". `panel.heatmap` reads it below the chart on category axes: `scale: "palette"` with `palette: ["#a78bfa", "theme.bg", "#2dd4bf"]` between `min: -1` and `max: 1` paints violet at -1, the chart's surface at 0 and teal at +1, with fixed bounds; `format: "0.00"` prints every cell; `row_title: "Market"` and `col_title: "vs"` word the hover card; `height_frac: 0.35` is the pane's share of the chart. The caption prints on the pane's legend after the title. **The card reads slots and outputs.** `avg_corr`, `tone`, `strong_count`, `served_count` and `bars` are data-only outputs (`none`) written on the live bar (`bars` in every state, the other four once the grid is measured), so the card, the Console and a watch read the same numbers. The words are string slots built with `sb_*` on the same bar ([Strings and text](../functions/text-formatting.md)): `avg_text` ("0.49 avg"), `tied_text` ("ETH 0.69") and `free_text` ("AVAX 0.31"). `render.hud("card", ...)` at `top_right` in the signal look: `title: "Correlation, last {{bars:int}} bars"` reads the `bars` output into the title, the headline `tile.pill` prints `avg_text` coloured by the `tone` ladder (0 violet, moving apart; 1 slate, loosely related; 2 teal, at or above `strong`), and `tile.rows` lists Most tied and Most free. The pill also declares `color: "#94a3b8"`, the ladder's fallback where `tone` is not finite, so with no reading the words print in slate while `tone` stays NaN for a watch. The signal look sets the headline at 38 px, so the words of a state with no reading stay short enough for the 250 px card: "Not crypto", "Loading", "No pairs". `offset: [0, 24]` drops the card under the chart's High tag, `safe_area: true` keeps it clear of the price-axis tags, and `columns: 1` with `width: 250` keeps it narrow. **Volume tells a crypto chart.** Every crypto market trades some volume, while forex, gold and silver candles are quotes that carry none, so any bar whose `in_volume()` is above 0 marks the chart as crypto (`volumeSeen`). On the live bar this check comes first: a chart where no bar carried volume gets no grid. `writeGrid()` never runs, so the frame stays unwritten and the pane is absent (no empty pane), and the card and the sentence say why (below). Volume, not the bar clock: reading `bar.time()` would add a `time` source, a tenth, and the chart counts an indicator against your plan's per-chart indicator limit once per distinct source it reads, while the chart's volume rides the candles its close already reads ([How many sources you can open](../core-concepts/data-sources.md#how-many-sources-you-can-open)). **One sentence instead of a grid.** `handles.label({ text: "note", anchor: "top_right", style: "knockout", color: "#94a3b8", safe_area: true, ... })` declares one label kind pinned to the top right of the price pane, and `draw.label(0)` is its one handle. `sayInstead(sentence, headline)` writes a state with no grid: the sentence into the `note` slot, placed by `note.set(0.0, NOTE_Y).text(str_note_sb)` at `NOTE_Y` (`200.0` pixels down from the top-right corner, under the card, which ends about 172 px down), the headline into `avg_text`, and a dash into `tied_text` and `free_text`. A chart that is not crypto gets "Correlation is written for crypto charts: switch to a BTC or ETH perp" with the headline "Not crypto"; while fewer bars than `window` are loaded, it gets "Loading more history for the correlation window" with "Loading". Once the grid is measured, `note.delete()` removes the sentence. ## Where it runs Crypto charts at any interval, spot and perpetual, on any venue: the eight pins are Binance Futures perpetuals, served wherever crypto is, and they join the chart's bars by timestamp at its interval. A Binance Futures BTC chart reads an 8 by 8 grid with BTC first; a Binance spot ETH chart reads 9 by 9 with "Chart" first and the ETH perpetual at 1.00. A forex, gold or silver chart carries no volume, so it gets the "Not crypto" card and the sentence instead of a grid (below). A US stock or CME chart carries volume, so the grid runs over its session bars, and a pin whose candles cannot join them stays an empty row with its name. The chart's own round button at the bottom left sits over the last row's label in the panel's gutter (the AVAX row); the hover card still names the row. ## When data is missing With fewer bars loaded than `window`, the chart draws no grid yet: the card's headline reads "Loading" in slate with a dash on each row (a HUD card is never hidden per run), and one sentence sits under the card, "Loading more history for the correlation window". Once the window is full the sentence goes and the grid draws. On a chart whose candles carry no volume (forex, gold, silver) no grid is drawn at all: the frame stays unwritten, so the panel is absent (no empty pane), the headline reads "Not crypto" in slate with a dash on each row, and one slate sentence sits under the card, "Correlation is written for crypto charts: switch to a BTC or ETH perp". A market the chart's bars cannot join over the window (a coin listed after the window began, or a pin whose candles miss the chart's bar times) keeps its row and column with their names and empty cells, and the legend's "N of M" counts only the markets that could be measured. A pair with fewer than half the window in shared bars, or a flat series, is an empty cell; when no market can be measured against the chart's own, the headline reads "No pairs" and the Most tied and Most free rows print a dash. `missing: "nan"` fills the bars a served market has no candle for; a pinned market the chart does not serve at all is another case. Today the chart stops the whole run when a pin is declined or answers empty, with a message that the pinned market's candle data is unavailable, whatever `missing` says. The eight pins are served wherever crypto is, so this reaches only a pin swapped to a market the chart does not serve. ## Customize it - **A longer window.** `window` (96 bars, from 30 to 500) is how many bars the returns are measured over; a pair needs at least half of it in shared bars, the grid waits until that many bars are loaded, and the card's title and the pane's legend read the new count. - **A stricter "moves with".** `strong` (0.7, from 0.3 to 0.95 in steps of 0.05) is the correlation at or above which a market counts in the legend's count and the headline turns teal, compared at the two decimals the cells print. - **Other markets.** A pin is one input line and one name: edit the line's `exchange`, `symbol` and `description` to another crypto market (spellings on [Exchange and symbol format](../reference/symbol-format.md)), and its name at the same place in `NAMES`, the list the grid's rows and the card's words print ("Chart" first, then the pins in the order `readSeries()` reads them). Keep `missing: "nan"` so a missing candle never fabricates a return, and pick a market the chart serves: a pin it declines stops the run today (above). - **A taller pane.** `height_frac: 0.35` is the pane's share of the chart (0.05 to 0.9); raise it when a 9 by 9 grid crowds the row labels on a short chart. - **Change the look.** This source writes `look: "signal"` as a literal, so the Look row on the indicator's Style page switches the card between the twelve looks without code, "As made" first ([The Style page](../settings/style-page.md#the-look-row)). Write `look: "@name"` over a `param.choice` of looks and the look becomes a setting, its row the look's one control in place of the Look row ([Looks](../presentation/hud-and-hover-cards.md#looks)). The grid's inks are literals too, and every colour word of a panel declaration, a `palette` entry included, may name a `param.color` as "@name" ([Panel words](../presentation/cards-frames-panels.md#panel-words)): `palette: ["@apart", "theme.bg", "@together"]` beside two `param.color` settings puts violet and teal on the Style page. The pill's ladder and the sentence's slate stay literals, since a tile and a `handles.*` default take no setting. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Correlation Matrix** under **Beyond the time axis**. 2. Press **Run** on BTCUSDT on Binance Futures at 15m: the grid fills the pane below the chart with BTC first, the pane's legend counts the markets that moved with it, and the signal card appears at the top right under the High tag. Rest the pointer on a cell to read its pair. 3. At the editor's Console prompt, type `outputs` to read the newest value of every output: the average, its tone, the strong and served counts and the window. Every output is written on the live bar, so the newest row is the whole story. ## Concepts used - [Data sources](../core-concepts/data-sources.md) for `ohlcv.close`, `ohlcv.volume`, the `missing: "nan"` policy and how the chart counts sources - [Multi-source and aggregation](../core-concepts/multi-source.md) for pinning another market by `exchange` and `symbol` - [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames, the `fb_*` builder, `panel.heatmap`, its palette scale, `caption`, `highlight` and `summary`, and the panel colour words a `param.color` can drive - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) for `render.hud`, `offset` and `safe_area` - [Looks](../presentation/hud-and-hover-cards.md#looks) for `look: "signal"`, what it draws, and a look bound to a `param.choice` - [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the headline `tile.pill`, its ladder with its fallback `color`, and `tile.rows` - [The Style page](../settings/style-page.md#the-look-row) for the Look row that switches the look without code - [Drawing objects](../presentation/drawing-objects.md) for `handles.label`, the knockout style and the `draw.label` handle - [Strings and text](../functions/text-formatting.md) for the `sb_*` builders and the string slots <!-- source: https://openmarket.xyz/wrun/cookbook/venue-share --> # Venue share ![Venue share donut and venue table under BTC candles, with a paper dial card at the top right naming the widest premium](/wrun/images/venue-share.png) Where BTC is trading right now. Below the candles, a donut gives each venue's share of the last 24 hours of dollar volume (this chart's venue in teal, Bybit sky, OKX violet, Coinbase rose, Binance spot slate, the total in the hole). Under it, a table lists every venue with its last price, its premium to this chart's price in basis points (signed, in the chart's up colour above and down colour below, bold and tinted once it is wide), its 24h dollar volume as an inline bar and its share. A card at the top right, in the dial look (paper, a printed dial's type, the headline after a led), leads with the widest premium ("Coinbase +4.6 bp"), in slate while it is within the highlight and in the chart's up or down colour once it is wide, then the largest venue with its share and the high-low spread across the venues. The legend names the hours in use ("Venue Share 24h"). Every number is measured on the live bar; history bars only fill the volume window. The parts are the chart's own close and volume plus four pinned closes and volumes, Bybit BTCUSDT, OKX BTC-USDT-SWAP, Coinbase BTC-USD and Binance spot BTCUSDT, each with `missing: "nan"` ([Multi-source and aggregation](../core-concepts/multi-source.md), [Data sources](../core-concepts/data-sources.md)); two frames feeding a `panel.pie` donut and a `panel.table` of styled cells ([Cards, frames and panels](../presentation/cards-frames-panels.md)); five `param.color` venue inks and a `param.text` for the chart's own row name ([Setting kinds](../settings/kinds.md)); `chart.interval_sec()` turning hours into bars ([Sessions and units](../settings/sessions-and-units.md#chart-context)); a `render.hud` card in the `dial` look ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards)); and one `draw.label` handle for the line of words on a chart that is not a BTC market ([Drawing objects](../presentation/drawing-objects.md)). This is also the `venue-share` template: the **Venue Share** card under **Beyond the time axis** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Venue Share: where BTC is trading right now. The trader sees a donut below the candles with each venue's share of // the last 24 hours of dollar volume (this chart's venue in teal, Bybit sky, OKX violet, Coinbase rose, Binance spot // slate, the total in the hole), under it a table of venue, last price, premium to this chart's price in basis // points (signed, up colour above, down colour below, bold and tinted past the highlight), 24h dollar volume as an // inline bar and the share; and a card at the top right in the dial look: the widest premium as its headline // ("Coinbase +12.3 bp"), the largest venue with its share, and the high-low spread across the venues. Every number // is measured on the live bar; history bars only fill the volume window. On a chart that is not a BTC market the // example writes one line of words at the top right and nothing else. param.int("hours", 24, { min: 1, max: 168, label: "Window in hours", description: "Hours of dollar volume behind the shares (bars = hours / the chart interval, at most 10080 bars)" }); param.number("highlight_bp", 5, { min: 1, max: 50, step: 0.5, label: "Wide premium, basis points", description: "A premium at or past this many basis points reads wide: bold and tinted in the table, the card's tone" }); param.text("chart_label", "This chart", { max_bytes: 24, label: "Chart venue name", hint: "How this chart's venue is named in the donut and the table" }); section("Venue colours"); param.color("chart_ink", "#2dd4bf", { label: "This chart" }); param.color("bybit_ink", "#38bdf8", { label: "Bybit perp" }); param.color("okx_ink", "#a78bfa", { label: "OKX perp" }); param.color("coinbase_ink", "#fb7185", { label: "Coinbase" }); param.color("binance_ink", "#94a3b8", { label: "Binance spot" }); chart.interval_sec(); // the bar interval in seconds, so hours become bars without reading the bar time legend({ title: "{{hours}}h" }); // Inputs: the chart's own candles are the grid and the reference price; four BTC venues ride beside them as pinned // closes and volumes, NaN on a bar the venue did not report (missing: "nan"), so a silent venue contributes nothing. input("close", ohlcv.close); input("volume", ohlcv.volume); input("bybit_close", ohlcv.close, { symbol: "BTCUSDT", exchange: "BYBIT", missing: "nan", description: "Bybit BTCUSDT perpetual close" }); input("bybit_volume", ohlcv.volume, { symbol: "BTCUSDT", exchange: "BYBIT", missing: "nan", description: "Bybit BTCUSDT perpetual volume, coins" }); input("okx_close", ohlcv.close, { symbol: "BTC-USDT-SWAP", exchange: "OKEX_SWAP", missing: "nan", description: "OKX BTC-USDT perpetual swap close" }); input("okx_volume", ohlcv.volume, { symbol: "BTC-USDT-SWAP", exchange: "OKEX_SWAP", missing: "nan", description: "OKX BTC-USDT perpetual swap volume, coins" }); input("coinbase_close", ohlcv.close, { symbol: "BTC-USD", exchange: "COINBASE", missing: "nan", description: "Coinbase BTC-USD spot close" }); input("coinbase_volume", ohlcv.volume, { symbol: "BTC-USD", exchange: "COINBASE", missing: "nan", description: "Coinbase BTC-USD spot volume, coins" }); input("binance_close", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE", missing: "nan", description: "Binance BTCUSDT spot close" }); input("binance_volume", ohlcv.volume, { symbol: "BTCUSDT", exchange: "BINANCE", missing: "nan", description: "Binance BTCUSDT spot volume, coins" }); // Outputs: data only, one value per bar, so the Console reads the card's numbers and they have history. output("widest_bp", none, overlay, { format: "0.0", unit: " bp", description: "The widest venue premium to this chart's price, basis points, signed" }); output("spread_bp", none, overlay, { format: "0.0", unit: " bp", description: "Highest venue price minus the lowest, as basis points of this chart's price" }); output("top_share", none, overlay, { format: "%", decimals: 1, description: "The largest venue's share of the window's dollar volume, percent" }); output("premium_tone", none, overlay, { description: "The card's tone: 0 the widest premium is within the highlight (slate), 1 above and wide (up colour), 2 below and wide (down colour)" }); // The card's words, written on the live bar only. string("headline_text", { max_bytes: 32 }); // "Coinbase +12.3 bp" string("largest_text", { max_bytes: 32 }); // "Bybit 34.1%" string("spread_text", { max_bytes: 16 }); // "23.4 bp" string("notice_text", { max_bytes: 48 }); // the one line of words on a chart that is not a BTC market string("window_text", { max_bytes: 8 }); // "24h": the card title's window, written on the live bar // The donut and the table, written on the live bar only; the frames carry the rows and the per-run words. const shareRows = frame("share_rows", { max_bytes: 2048 }); const venueRows = frame("venue_rows", { max_bytes: 4096 }); panel.pie({ name: "share", title: "Share of dollar volume", x: "category", place: "below", frame: shareRows, hole: 0.62, labels: false, legend_style: "title", hole_total: true, hole_caption: "24h volume", slice_gap: 2, format: "usd", chrome: "none", height_frac: 0.25 }); // the venues ride the title's chips and the table, so the ring and its hole text keep the whole pane panel.table({ name: "venues", title: "Venues", x: "category", place: "below", frame: venueRows, height_frac: 0.185, position: "top_center", font_size: 10, cell_padding: 3, valign: "middle", header_text_color: "theme.muted", grid_color: "theme.grid", grid_width: 1, grid_lines: "rows", border_width: 0, column_widths: [120, 100, 100, 150, 80], series: [ { name: "Venue" }, { name: "Last", align: "right", format: "price" }, { name: "Premium", align: "right", format: "0.0", signed: true, unit: " bp" }, { name: "24h volume", align: "right", format: "usd" }, { name: "Share", align: "right", format: "%", decimals: 1 }, ], }); // The card, in the dial look (paper, a printed dial's type): the widest premium leads as the headline, lit in the // chart's up or down colour once it is wide, then the largest venue and the high-low spread as two rows. render.hud("venues_card", { position: "top_right", safe_area: true, look: "dial", title: "BTC across venues, {{window_text}}", columns: 1, width: 290, tiles: [ tile.pill("Widest premium", "headline_text", { headline: true, font_size: 20, color_by: "premium_tone", colors: ["#64748b", "theme.up", "theme.down"] }), tile.rows("Across the venues", [["Largest venue", "largest_text"], ["High-low spread", "spread_text"]]), ], }); // The one line of words on a chart that is not a BTC market: a slate label in pane pixels from the top-right corner, // under the card. handles.label({ anchor: "top_right", align: "right", color: "#94a3b8", size: 11 }); const notice = draw.label(0); const VENUES = 5; // this chart, then the four pins const MAX_BARS = 10080; // 168 hours of 1-minute bars: the ring is sized for the largest window at load const NAMES: StaticArray<string> = ["This chart", "Bybit", "OKX", "Coinbase", "Binance spot"]; const HEX: StaticArray<string> = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"]; const usdRing = new StaticArray<f64>(VENUES * MAX_BARS); // each venue's dollar volume per bar, 0 where it did not report const usdSum = new StaticArray<f64>(VENUES); // the window sums, recounted from the ring on the live bar const liveClose = new StaticArray<f64>(VENUES); // this bar's close per venue const premium = new StaticArray<f64>(VENUES); // this bar's premium to the chart, basis points const order = new StaticArray<i32>(VENUES); // the venues sorted by dollar volume, largest first const inks = new StaticArray<i32>(VENUES); // the venue colours, packed, read from the settings let chartLabel: string = "This chart"; let windowBars = 96; // bars in the window, from the hours setting and the chart interval let highlightBp = 5.0; let head = 0; // the ring's write cursor and how many slots hold a bar let filled = 0; function fbColor(c: i32): void { // a packed colour as "#rrggbb" into the frame, no allocation const r = red(c); const g = green(c); const b = blue(c); fb_text('"#'); fb_text(HEX[(r >> 4) & 15]); fb_text(HEX[r & 15]); fb_text(HEX[(g >> 4) & 15]); fb_text(HEX[g & 15]); fb_text(HEX[(b >> 4) & 15]); fb_text(HEX[b & 15]); fb_text('"'); } function fbName(v: i32): void { // a venue's name as a JSON string if (v == 0) fb_str(chartLabel); else fb_str(NAMES[v]); } function usdOf(volume: f64, close: f64): f64 { // a bar's dollar volume, 0 where the venue did not report return isNaN(volume) || isNaN(close) || volume <= 0.0 ? 0.0 : volume * close; } function onStart(): void { const interval = p_chart_interval_sec(); // 0 when the chart did not fill it: a 15-minute bar is assumed const seconds = p_hours() * 3600.0; let bars = interval > 0.0 ? i32(Math.round(seconds / interval)) : i32(Math.round(seconds / 900.0)); if (bars < 1) bars = 1; if (bars > MAX_BARS) bars = MAX_BARS; windowBars = bars; highlightBp = p_highlight_bp(); chartLabel = pt_chart_label(); if (chartLabel.length == 0) chartLabel = "This chart"; inks[0] = fromPacked(p_chart_ink()); inks[1] = fromPacked(p_bybit_ink()); inks[2] = fromPacked(p_okx_ink()); inks[3] = fromPacked(p_coinbase_ink()); inks[4] = fromPacked(p_binance_ink()); } // onBar() runs once per bar: this bar's dollar volume per venue into the ring and the premiums to the chart's close; // the window sums and the card's numbers on every bar (data only); the words, the donut and the table on the live bar. function onBar(): void { const close = bar.close(); liveClose[0] = close; liveClose[1] = in_bybit_close(); liveClose[2] = in_okx_close(); liveClose[3] = in_coinbase_close(); liveClose[4] = in_binance_close(); usdRing[head] = usdOf(bar.volume(), close); usdRing[MAX_BARS + head] = usdOf(in_bybit_volume(), liveClose[1]); usdRing[2 * MAX_BARS + head] = usdOf(in_okx_volume(), liveClose[2]); usdRing[3 * MAX_BARS + head] = usdOf(in_coinbase_volume(), liveClose[3]); usdRing[4 * MAX_BARS + head] = usdOf(in_binance_volume(), liveClose[4]); head = (head + 1) % windowBars; if (filled < windowBars) filled += 1; // The window sums, recounted from the slots (a running sum keeps a rounding crumb after a quiet stretch). let total = 0.0; for (let v = 0; v < VENUES; v += 1) { let sum = 0.0; const base = v * MAX_BARS; for (let i = 0; i < filled; i += 1) sum += usdRing[base + i]; usdSum[v] = sum; total += sum; } // Is this a BTC market: the chart's close against the mean of the venues that reported on this bar. let reference = 0.0; let reported = 0; for (let v = 1; v < VENUES; v += 1) { if (isNaN(liveClose[v]) || liveClose[v] <= 0.0) continue; reference += liveClose[v]; reported += 1; } const isBtc = reported > 0 && !isNaN(close) && close > 0.0 && Math.abs(close / (reference / f64(reported)) - 1.0) <= 0.1; // The premiums, the widest one, the high-low spread and the largest venue. let widest = 0; // the pinned venue with the widest premium, 0 while none reported let widestAbs = -1.0; let high = close; let low = close; let largest = 0; for (let v = 0; v < VENUES; v += 1) { const venueClose = liveClose[v]; const served = v == 0 || (!isNaN(venueClose) && venueClose > 0.0 && usdSum[v] > 0.0); premium[v] = v == 0 ? 0.0 : served && close > 0.0 ? (venueClose / close - 1.0) * 10000.0 : NaN; if (v > 0 && served) { if (Math.abs(premium[v]) > widestAbs) { widestAbs = Math.abs(premium[v]); widest = v; } if (venueClose > high) high = venueClose; if (venueClose < low) low = venueClose; } if (usdSum[v] > usdSum[largest]) largest = v; } const widestBp = widest > 0 ? premium[widest] : NaN; const spreadBp = close > 0.0 && high >= low ? ((high - low) / close) * 10000.0 : NaN; const topShare = total > 0.0 ? (usdSum[largest] / total) * 100.0 : NaN; const tone = isNaN(widestBp) || Math.abs(widestBp) < highlightBp ? 0.0 : widestBp > 0.0 ? 1.0 : 2.0; if (isBtc) { out_widest_bp(widestBp); out_spread_bp(spreadBp); out_top_share(topShare); out_premium_tone(tone); } if (!bar.isLast()) return; const hours = p_hours(); sb_clear(); sb_f64(hours, 0); sb_text("h"); str_window_text_sb(); // the card's title reads the window, so it follows the hours setting if (!isBtc) { // not a BTC market: the card states it in the quiet tone, the one line of words sits under it, nothing else draws out_premium_tone(0.0); sb_clear(); sb_text("Not a BTC market"); str_headline_text_sb(); sb_clear(); sb_text("n/a"); str_largest_text_sb(); sb_clear(); sb_text("n/a"); str_spread_text_sb(); sb_clear(); sb_text("Venue share is written for BTC markets"); str_notice_text_sb(); notice.set(16, 210).text(str_notice_text_sb); return; } // The card's words. sb_clear(); if (widest > 0) { sb_text(NAMES[widest]); sb_text(widestBp >= 0.0 ? " +" : " "); sb_f64(widestBp, 1); sb_text(" bp"); } else sb_text("No venue reported"); str_headline_text_sb(); sb_clear(); if (largest == 0) sb_text(chartLabel); else sb_text(NAMES[largest]); if (!isNaN(topShare)) { sb_text(" "); sb_f64(topShare, 1); sb_text("%"); } str_largest_text_sb(); sb_clear(); if (isNaN(spreadBp)) sb_text("n/a"); else { sb_f64(spreadBp, 1); sb_text(" bp"); } str_spread_text_sb(); // The venues sorted by dollar volume, largest first (insertion sort over five rows). for (let v = 0; v < VENUES; v += 1) order[v] = v; for (let i = 1; i < VENUES; i += 1) { const key = order[i]; let j = i - 1; while (j >= 0 && usdSum[order[j]] < usdSum[key]) { order[j + 1] = order[j]; j -= 1; } order[j + 1] = key; } // The donut: one slice per venue that traded in the window, in its own colour, big and small slices interleaved // (largest, smallest, second largest, ...) so two thin labels never sit side by side; the hole prints the total. let served = 0; for (let k = 0; k < VENUES; k += 1) if (usdSum[order[k]] > 0.0) served += 1; fb_clear(); fb_text('{"hole_caption":"'); fb_f64(hours, 0); fb_text('h volume","rows":['); let slices = 0; for (let k = 0; k < served; k += 1) { const v = order[(k & 1) == 0 ? k >> 1 : served - 1 - (k >> 1)]; if (slices > 0) fb_text(","); fb_text("["); fbName(v); fb_text(","); fb_f64(usdSum[v], 0); fb_text(","); fbColor(inks[v]); fb_text("]"); slices += 1; } fb_text("]}"); writeFrameBuffer(FRAME_SHARE_ROWS); // The table: venue (in its donut colour), last, premium (signed, coloured by sign, bold and tinted when wide), // dollar volume with an inline bar against the largest venue, share; a venue with no volume in the window reads // "not served" and leaves the donut. const biggest = usdSum[largest]; fb_clear(); fb_text('{"title":"Venues, last '); fb_f64(hours, 0); fb_text('h","caption":"'); fb_int(filled); fb_text("/"); fb_int(windowBars); fb_text(' bars loaded","rows":['); for (let k = 0; k < VENUES; k += 1) { const v = order[k]; if (k > 0) fb_text(","); fb_text('[{"text":'); fbName(v); fb_text(',"text_color":'); fbColor(inks[v]); fb_text(',"font_weight":"bold"}'); if (usdSum[v] <= 0.0 || isNaN(liveClose[v])) { // the lane served nothing in the window fb_text(',{"text":"not served","text_color":"theme.muted"},"","",""]'); continue; } fb_text(","); fb_num(liveClose[v]); if (v == 0) fb_text(',{"text":"reference","text_color":"theme.muted"}'); else { const bp = premium[v]; const wide = Math.abs(bp) >= highlightBp; fb_text(',{"value":'); fb_f64(bp, 1); if (Math.abs(bp) < 0.05) fb_text(',"text_color":"theme.muted"'); else { fb_text(bp > 0.0 ? ',"text_color":"theme.up"' : ',"text_color":"theme.down"'); if (wide) fb_text(bp > 0.0 ? ',"background_color":"theme.up","opacity":0.16,"font_weight":"bold"' : ',"background_color":"theme.down","opacity":0.16,"font_weight":"bold"'); } fb_text("}"); } fb_text(',{"value":'); fb_f64(usdSum[v], 0); fb_text(',"bar":'); fb_f64(biggest > 0.0 ? usdSum[v] / biggest : 0.0, 3); fb_text(',"color":'); fbColor(inks[v]); fb_text("},"); fb_f64(total > 0.0 ? (usdSum[v] / total) * 100.0 : 0.0, 1); fb_text("]"); } fb_text("]}"); writeFrameBuffer(FRAME_VENUE_ROWS); } ``` ## How it works **Four venues ride the chart's grid.** `input("close", ohlcv.close)` and `input("volume", ohlcv.volume)` are the chart's own candles, the grid and the reference price. The four pins (`bybit_close` and `bybit_volume` on `BYBIT` `BTCUSDT`, `okx_close` and `okx_volume` on `OKEX_SWAP` `BTC-USDT-SWAP`, `coinbase_close` and `coinbase_volume` on `COINBASE` `BTC-USD`, `binance_close` and `binance_volume` on `BINANCE` `BTCUSDT`) fetch each venue's candles at the chart's interval and join them to the chart's bars by timestamp; each declares `missing: "nan"`, so a bar the venue did not report reads NaN and contributes nothing. Volumes are in coins on every venue, so a bar's dollar volume is its volume times its close (`usdOf`), 0 where the venue did not report. Ten inputs in all; `bar.time()` would take an input slot of its own, so hours become bars through the chart interval instead. **Hours become bars through the chart interval.** `chart.interval_sec()` declares a hidden setting the chart fills before `onStart()` (900 on a 15m chart), and `onStart()` divides `hours` (24) in seconds by it to size the window: 96 bars on a 15m chart, at most 10080 (168 hours of one-minute bars, the ring's size), and a 15-minute bar is assumed when the interval reads 0. `usdRing` holds every venue's dollar volume per bar; on every bar the five window sums are recounted from the slots (a running sum would keep a rounding crumb after a quiet stretch) and the total is their sum. `legend({ title: "{{hours}}h" })` prints the window after the name. **The premiums are measured on the live bar.** A pinned venue is served on a bar while it has a close on that bar and dollar volume in the window; its premium is its close over the chart's close, minus one, in basis points, and the chart's own row is the reference. `widest_bp` is the served premium of the largest size, signed; `spread_bp` the highest served close minus the lowest, as basis points of the chart's close; `top_share` the largest venue's share of the window's dollar volume; `premium_tone` 0 while the widest premium is within `highlight_bp` (5), 1 above and wide, 2 below and wide. The four are data-only outputs written on every bar, so they have history and the Console reads them. Whether this is a BTC market is decided on the bar too: the chart's close against the mean of the venue closes that reported, within 10 percent; past it nothing is written. **Two frames feed the donut and the table.** On the live bar the venues are sorted by dollar volume, largest first, and `share_rows` is built through the frame buffer (`fb_clear`, `fb_text`, `fb_str`, `fb_f64`, `writeFrameBuffer`) as one `[name, value, color]` slice per venue that traded in the window, interleaved (largest, smallest, second largest, ...) so two thin labels never sit side by side; the frame's `hole_caption` names the hours ("24h volume") and `hole_total: true` prints the total in the hole. `labels: false` keeps the slice names off the ring and `legend_style: "title"` puts them in chips on the pane's title row, one per slice in its colour, so at `height_frac: 0.25` the ring and the hole's total and caption keep the whole pane (the table carries every share). `venue_rows` carries five rows of styled cells for `panel.table`: the name bold in its venue colour; the last price in the chart's own digits (`format: "price"`); the premium as a `value` printed through the column's `format: "0.0"`, `signed: true` and `unit: " bp"`, in `theme.up` or `theme.down` by sign (`theme.muted` within 0.05 bp), with a `background_color` tint at `opacity` 0.16 and `font_weight` bold once it is at or past the highlight; the dollar volume as a `value` with a `bar` against the largest venue in the venue's colour; and the share to one decimal. The frame's `title` names the hours ("Venues, last 24h") and its `caption` the bars loaded ("96/96 bars loaded"). The two panes take 0.165 and 0.185 of the chart's height, the table placed `top_center`. **The venue colours are settings.** A panel's frame colours take no `"@name"` binding, so the five inks are `param.color` settings under the **Venue colours** section, read once in `onStart()` through `fromPacked(p_chart_ink())` and its four twins and written into both frames as `"#rrggbb"` by `fbColor` (the `red`, `green` and `blue` channel readers over a 16-entry hex table, no allocation); the Style page shows them as ordinary colour pickers. The chart's own row is "This chart" unless `chart_label`, a `param.text` read with `pt_chart_label()`, names it. **The card reads slots and a ladder.** `render.hud("venues_card", { position: "top_right", safe_area: true, look: "dial", title: "BTC across venues, {{window_text}}", columns: 1, width: 290, tiles: [...] })` is one card of two tiles, its title reading the `window_text` slot ("24h", written from `hours` on the live bar), so the title follows the window: a headline `tile.pill` at `font_size` 20 reading the `headline_text` slot ("Coinbase +4.6 bp", or "No venue reported" while no pin is served), coloured by the `premium_tone` ladder (`["#64748b", "theme.up", "theme.down"]`: slate within the highlight, the chart's up or down colour past it, so the tone reads on the dial's paper and on a black look alike), and a `tile.rows` of `largest_text` ("This chart 49.1%") and `spread_text` ("4.6 bp"). The slots are written on the live bar only. The dial look sets the paper (`#EBE8E1`), the ink and the led the pill draws as, and `safe_area: true` keeps the card under the pane action bar and clear of the price-axis tags. On a chart that is not a BTC market the card still draws (a HUD card has no hide): it reads "Not a BTC market" in the quiet tone with "n/a" rows, and the `draw.label` handle declared with `handles.label({ anchor: "top_right", align: "right", color: "#94a3b8", size: 11 })` prints the one line of words in pane pixels under it. ## Where it runs BTC charts on any venue: BTCUSDT on Binance Futures at 15m in the picture, where the chart's own candles are half of the five-venue dollar volume, and a 1h chart of the same market reads the same total and shares (the window is 24 hours on both; the premiums move a few tenths of a basis point between the two as the venues' last prices tick). A chart that is itself one of the four pinned venues (Bybit BTCUSDT) lists that venue twice, as "This chart" and as its pin. On a chart finer than one minute the window clamps at 10080 bars: the caption counts the clamped bars while the card's title still names the hours. On any other market (ETH, a stock, FX) the run still mounts: no output is written, the card states "Not a BTC market" with "n/a" rows, and one line of words sits under it. ## When data is missing On a chart that is not a BTC market the chart prints one sentence at the top right, "Venue share is written for BTC markets", and the card reads "Not a BTC market" with "n/a" in both rows. A bar a venue did not report reads NaN (`missing: "nan"`) and adds nothing to that venue's window; a venue with no dollar volume in the window and no live close keeps its row, reads "not served" in the Last column with the rest blank, leaves the donut and is left out of the widest premium and the spread, and while no pin is served on the live bar the headline reads "No venue reported". While the loaded history is shorter than the window the shares are measured over the bars loaded and the caption says so ("48/96 bars loaded"). The four pins as written are served; a pin changed to a market the chart cannot load refuses the whole run by name before any bar, in a toast and on the overlay's **Could not load** chip ([Exchange and symbol format](../reference/symbol-format.md)). ## Customize it - **More or fewer hours.** `hours` (24; 1 to 168) sets the window behind the shares and the table; bars are hours over the chart interval, at most 10080, and the legend, the card's title, the hole's caption and the table's title follow it. - **A tighter highlight.** `highlight_bp` (5; 1 to 50 in steps of 0.5): a premium at or past it reads bold and tinted in the table, and the card's headline takes the chart's up or down colour. - **Name and colour the venues.** `chart_label` ("This chart", up to 24 bytes) names the chart's own row in the donut and the table ("Binance perp" on a Binance perp chart); the five inks under **Venue colours** are colour pickers on the Style page, and each reruns the module. - **Another venue.** Swap one pin's `exchange` and `symbol` for another BTC market the chart serves, such as `BYBIT_SPOT` and `BTCUSDT` ([Exchange and symbol format](../reference/symbol-format.md)), rename it in `NAMES`, and Run again; the premiums and the shares follow. - **Change the look.** The card is written in the `dial` look; the Look row on the indicator's Style page switches it without code ([The Style page](../settings/style-page.md#the-look-row)), and `terminal` (black, mono upper-case labels, an amber title bar) is the second look this example was shot in ([Looks](../presentation/hud-and-hover-cards.md#looks)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Venue Share** under **Beyond the time axis**. 2. Press **Run** on a BTC chart, such as BTCUSDT on Binance Futures at 15m: the donut and the table fill two panes below the candles, the card appears at the top right and the legend reads the hours ("Venue Share 24h"). 3. At the editor's Console prompt, type `last 20 widest_bp` to read the widest venue premium, in basis points, on the last 20 bars. ## Concepts used - [Multi-source and aggregation](../core-concepts/multi-source.md) for a pinned `ohlcv` input on another market, and [Data sources](../core-concepts/data-sources.md) for `missing: "nan"` - [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames, the `fb_*` builders, `panel.pie` and the styled cells of `panel.table` - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) for `render.hud`, `safe_area` and `width`, [Looks](../presentation/hud-and-hover-cards.md#looks) for `dial` and `terminal`, and [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the headline `tile.pill` and `tile.rows` - [Setting kinds](../settings/kinds.md) for `param.color` and `param.text` and their readers, and [The Style page](../settings/style-page.md#the-look-row) for the Look row and the colour pickers - [Sessions and units](../settings/sessions-and-units.md#chart-context) for `chart.interval_sec()` - [Colors](../functions/colors-kit.md) for `fromPacked` and the `red`, `green` and `blue` channel readers - [Drawing objects](../presentation/drawing-objects.md) for `handles.label` with an `anchor` and the `draw.label` handle - [Exchange and symbol format](../reference/symbol-format.md) for the venue ids and a pin refused by name <!-- source: https://openmarket.xyz/wrun/cookbook/iv-surface --> # Volatility surface ![Implied volatility surface under BTC candles: six ATM tiles by expiry, the expiry by moneyness heatmap with the ATM row outlined, and a glass card reading the nearest expiry's ATM IV](/wrun/images/iv-surface.png) What the options market charges for the chart's coin, laid out as one picture under the candles. First a row of tiles, one per expiry, nearest first, each with its date and its at-the-money implied volatility (its days to expiry sit in the tile's hover card, and on the tile once the panel is maximized): read left to right they are the term structure. Under the tiles, the surface: implied volatility across the six nearest Deribit expiries as columns and nine moneyness buckets as rows, from +20% of spot at the top down to -20% at the bottom, the ATM row outlined. The cells run from the chart's own background through sky to violet, so the darkest band is the ATM row (the cheapest options), the wings brighten as the smile lifts, and the nearest expiry's wings are the brightest of all: a short-dated smile is the steepest. A cell past the strikes its expiry lists stays empty, so a short-dated expiry that lists only part of the range shows a gap at its wings instead of copies of its edge strike. Rest the pointer on a cell for its exact IV. The caption beside the surface's title names the term structure, "Contango: 1w 31.4%, 3m 36.6%": contango while the three-month ATM sits above the one-week ATM, backwardation while it sits below. A card at the top left under the legend, in the glass look, answers one question first: the nearest expiry's ATM implied volatility as the headline (21.50% in the picture), then two rows with dotted leaders, the 25-delta skew of that expiry (put IV minus call IV in IV points, positive while puts carry the premium) and the one-week ATM against the three-month ATM. The legend carries the term word, "term Contango", after the indicator's name. On a coin with no chain, one slate sentence at the top right says so. The parts are an `options_chain.cells` input pinned to Deribit ([Options kit](../functions/options-kit.md), [Data sources](../core-concepts/data-sources.md)), two frames feeding a `panel.tiles` and a `panel.heatmap` with a highlighted ATM row and a caption written per run ([Cards, frames and panels](../presentation/cards-frames-panels.md)), a `render.hud` card in the glass look ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks)), a `render.label` with a corner `position` for the one sentence, and a `render.legend` entry carrying the term word ([Legend](../presentation/legend.md)). This is also the `iv-surface` template: the **Volatility Surface** card under **Beyond the time axis** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Volatility Surface: the chart coin's live Deribit options chain as implied volatility across the nearest expiries // (columns, nearest first) and nine moneyness buckets (rows, +20% of spot at the top down to -20%), a heatmap below // the candles: the darkest band is the at-the-money row, the wings brighten as the smile lifts, and the nearest // expiry's wings are the brightest of all (a short-dated smile is the steepest); a cell past the strikes its expiry // lists stays empty, never a copy of the edge strike's IV. Under it, one tile per expiry with // its ATM implied volatility and days to expiry: the term structure read left to right. The card at the top left // answers one question first: what does the nearest expiry's ATM option cost, as implied volatility; then the // 25-delta skew of that expiry (put IV minus call IV: positive while puts carry the premium) and the one-week ATM // against the three-month ATM (contango while the far point sits above the near one). The chain is live only: // history bars draw nothing and cost nothing; everything is measured on the last bar, from the chain's own clock. section("Surface"); param.int("expiries", 6, { min: 2, max: 6, label: "Expiries", description: "Expiries on the surface and the tiles: the N nearest listed expiries at least 12 h away" }); param.number("range_pct", 20, { min: 5, max: 40, label: "Moneyness range, percent", description: "Moneyness range: percent of spot each side, split into nine buckets" }); input("close", ohlcv.close); // the chart's own close: spot for the moneyness grid and the out-of-the-money split input("chain", options_chain.cells, { max_cells: 2000, venue: "deribit", description: "Deribit's live options chain of the chart's coin" }); // [strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega] per contract; a BTC chain is about 1,550 contracts // The readings, data-only: the card's tiles read them and the Console can too. Each is NaN on history bars. output("atm_near", none, overlay, { format: "%", description: "ATM implied volatility of the nearest expiry, percent: interpolated between the two listed strikes around spot; live bar only" }); output("skew_25d", none, overlay, { description: "25-delta skew of the nearest expiry, IV points: the put nearest 25 delta minus the call nearest 25 delta" }); output("atm_1w", none, overlay, { description: "ATM implied volatility of the listed expiry nearest one week away, percent" }); output("atm_3m", none, overlay, { description: "ATM implied volatility of the listed expiry nearest three months away, percent" }); output("term_spread", none, overlay, { description: "Three-month ATM minus one-week ATM, IV points: above 0 is contango, below 0 backwardation" }); string("skew_text", { max_bytes: 32 }); // the card's skew row: "+4.2 pts, puts bid" string("term_text", { max_bytes: 32 }); // the card's term row: "33.0% to 41.2%" string("term_word", { max_bytes: 16 }); // the legend entry: Contango or Backwardation string("notice", { max_bytes: 64 }); // the one sentence at the top right when the market has no chain render.legend("term", { text: "term_word", color: "theme.muted" }); // the legend reads "term Contango" render.label("notice_tag", { position: "top_right", text: "notice", color: "#94a3b8", style: "plain", offset: [12, 8] }); const heat = frame("heat", { max_bytes: 8192 }); // the surface: one cell per expiry and moneyness bucket const termRows = frame("term_rows", { max_bytes: 2048 }); // the tiles: one per expiry // The term structure first, right under the candles: six tiles in one row, label the expiry, value its ATM IV, // caption the days to expiry (the caption shows in the tile's hover card, and on the tile itself once the panel is // maximized; the chart's own floating button sits at the bottom left of the cell, so the tiles stay above the // surface). Together the two panels take 0.35 of the chart. panel.tiles({ name: "term_tiles", title: "ATM implied volatility by expiry", x: "category", place: "below", frame: termRows, columns: 6, accent: "neutral", hover_card: true, maximize: true, format: "0.0", unit: "%", height_frac: 0.12 }); // The surface: expiry on x, moneyness on y, IV percent in the cell, a sequential palette from the chart's background // (the cheapest options, at the money) through sky to violet (the dearest wings); the ATM row outlined, the term word // as the caption written per run, and a hover card on every cell titled by the cell's own two labels, "-5% · 7 Oct", // over its IV. No axis nouns: a row or column title only prefixes that card title, and the short one fits the card. panel.heatmap({ name: "surface", title: "Implied volatility, Deribit", x: "category", place: "below", frame: heat, scale: "palette", palette: ["theme.bg", "#38bdf8", "#a78bfa"], highlight: { row: "ATM" }, hover_card: true, maximize: true, format: "0.0", unit: "%", height_frac: 0.23 }); // The card, in the glass look, at the top left under the legend (the top right keeps the notice): the nearest ATM IV // leads as the headline, then the two rows with dotted leaders. render.hud("surface_card", { position: "top_left", look: "glass", title: "Vol surface, Deribit", columns: 1, width: 236, safe_area: true, tiles: [ tile.value("ATM IV, nearest expiry", "atm_near", { format: "%", headline: true, hint: "Implied volatility of an at-the-money option on the nearest listed expiry at least 12 h away, interpolated between the two strikes around spot" }), tile.rows([ ["25d skew", "skew_text"], ["1w vs 3m", "term_text"], ], { leader: "dots" }), ], }); const MAX_EXPIRIES = 64; // distinct live expiries a chain lists const MAX_SURFACE = 6; // expiries on the surface (the setting's cap) const BUCKETS = 9; // moneyness buckets, -range .. +range in eight steps const MAX_STRIKES = 256; // out-of-the-money strikes one expiry lists const TUPLE = 10; // f64s per contract: strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega const DAY_MS = 86400000.0; const MIN_AHEAD_MS = 43200000.0; // an expiry inside 12 h is settling, not a point on the surface const MONTHS: StaticArray<string> = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const expiryMs = new StaticArray<f64>(MAX_EXPIRIES); // the chain's live expiries, sorted ascending const curveStrike = new StaticArray<f64>(MAX_STRIKES); // one expiry's out-of-the-money points, sorted by strike: rebuilt per expiry const curveIv = new StaticArray<f64>(MAX_STRIKES); // percent const curveOi = new StaticArray<f64>(MAX_STRIKES); // open interest, to settle a strike listed twice const bucketPct = new StaticArray<f64>(BUCKETS); // the moneyness grid, percent of spot, -range first const surfIv = new StaticArray<f64>(MAX_SURFACE * BUCKETS); // the surface: IV percent per expiry and bucket, NaN where the expiry lists no strike on both sides const surfAtm = new StaticArray<f64>(MAX_SURFACE); // ATM IV per surface expiry const surfDays = new StaticArray<f64>(MAX_SURFACE); // days from the chain's clock to each surface expiry const surfDay = new StaticArray<i32>(MAX_SURFACE); // the expiry's UTC day of month and month (1..12), for the labels const surfMonth = new StaticArray<i32>(MAX_SURFACE); let expiries = 6; // settings, read in onStart() let rangePct = 20.0; let expiryCount = 0; // live expiries seen let surfaceCount = 0; // expiries on the surface this run let curveCount = 0; // points in the scratch curve let ivScale = 1.0; // 100 when the chain serves IV as a fraction (0.45 for 45%), 1 when it serves percent let nearIdx = -1; // the headline's expiry: the nearest surface expiry with a curve let skew25: f64 = NaN; // the readings, measured on the live bar let atm1w: f64 = NaN; let atm3m: f64 = NaN; let oneExpiry = false; // the one-week and three-month picks are the same listed expiry: no term word let civilDay = 0; let civilMonth = 0; function onStart(): void { expiries = i32(p_expiries()); if (expiries > MAX_SURFACE) expiries = MAX_SURFACE; rangePct = p_range_pct(); for (let k = 0; k < BUCKETS; k += 1) bucketPct[k] = -rangePct + (f64(k) * 2.0 * rangePct) / f64(BUCKETS - 1); } // ── The expiry list: sorted insertion of a distinct live expiry ── function noteExpiry(ms: f64): void { let lo = 0; let hi = expiryCount; while (lo < hi) { const mid = (lo + hi) >> 1; if (expiryMs[mid] < ms) lo = mid + 1; else hi = mid; } if (lo < expiryCount && expiryMs[lo] == ms) return; if (expiryCount >= MAX_EXPIRIES) return; for (let i = expiryCount; i > lo; i -= 1) expiryMs[i] = expiryMs[i - 1]; expiryMs[lo] = ms; expiryCount += 1; } function nearestExpiry(targetMs: f64): i32 { // the live expiry closest to a target instant let best = 0; for (let i = 1; i < expiryCount; i += 1) if (Math.abs(expiryMs[i] - targetMs) < Math.abs(expiryMs[best] - targetMs)) best = i; return best; } // ── One expiry's out-of-the-money curve: puts below spot, calls at or above, sorted by strike ── function insertPoint(strike: f64, iv: f64, oi: f64): void { let lo = 0; let hi = curveCount; while (lo < hi) { const mid = (lo + hi) >> 1; if (curveStrike[mid] < strike) lo = mid + 1; else hi = mid; } if (lo < curveCount && curveStrike[lo] == strike) { // listed twice: the contract with more open interest speaks if (oi > curveOi[lo]) { curveIv[lo] = iv; curveOi[lo] = oi; } return; } if (curveCount >= MAX_STRIKES) return; for (let i = curveCount; i > lo; i -= 1) { curveStrike[i] = curveStrike[i - 1]; curveIv[i] = curveIv[i - 1]; curveOi[i] = curveOi[i - 1]; } curveStrike[lo] = strike; curveIv[lo] = iv; curveOi[lo] = oi; curveCount += 1; } function buildCurve(cells: StaticArray<f64>, n: i32, expiry: f64, spot: f64): void { curveCount = 0; for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { if (cells[i + 1] != expiry) continue; const strike = cells[i]; const iv = cells[i + 6] * ivScale; if (!(strike > 0.0) || !(iv > 0.0)) continue; const otm = cells[i + 2] < 0.0 ? strike < spot : strike >= spot; if (!otm) continue; insertPoint(strike, iv, cells[i + 3]); } } function ivAt(price: f64): f64 { // linear interpolation between the two listed strikes around the price; NaN past the listed ends (no copy of the edge strike) if (curveCount == 0 || isNaN(price)) return NaN; if (price < curveStrike[0] || price > curveStrike[curveCount - 1]) return NaN; let lo = 0; let hi = curveCount - 1; while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (curveStrike[mid] <= price) lo = mid; else hi = mid; } const k0 = curveStrike[lo]; const k1 = curveStrike[hi]; if (price == k0 || k1 == k0) return curveIv[lo]; return curveIv[lo] + ((curveIv[hi] - curveIv[lo]) * (price - k0)) / (k1 - k0); } function skewAt(cells: StaticArray<f64>, n: i32, expiry: f64): f64 { // put IV minus call IV at the contracts nearest 25 delta, IV points let putIv: f64 = NaN; let callIv: f64 = NaN; let putGap = Infinity; let callGap = Infinity; for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { if (cells[i + 1] != expiry) continue; const iv = cells[i + 6] * ivScale; const delta = cells[i + 5]; if (!(iv > 0.0) || isNaN(delta)) continue; const gap = Math.abs(Math.abs(delta) - 0.25); if (cells[i + 2] < 0.0) { if (gap < putGap) { putGap = gap; putIv = iv; } } else if (gap < callGap) { callGap = gap; callIv = iv; } } return putIv - callIv; } function civilFromDays(days: i64): void { // days since 1970-01-01 -> month and day of month (proleptic Gregorian) const z = days + 719468; const era = (z >= 0 ? z : z - 146096) / 146097; const doe = z - era * 146097; const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; const doy = doe - (365 * yoe + yoe / 4 - yoe / 100); const mp = (5 * doy + 2) / 153; civilDay = i32(doy - (153 * mp + 2) / 5 + 1); civilMonth = i32(mp < 10 ? mp + 3 : mp - 9); } // ── The chain's clock: when its gammas were priced, not the bar's open ── // The chart prices each contract's gamma by Black-Scholes from its mark IV and underlying at the moment it reads the // chain, so the contract nearest the money (|delta| nearest 0.5), solved for the time to expiry its gamma implies, // names that moment. A venue that serves its own greeks gives no such answer: the bar's open stands in. function chainClockMs(cells: StaticArray<f64>, n: i32, barOpenMs: f64): f64 { let best = -1; let bestGap = 0.25; // |delta| within 0.25 of 0.5 let nearest = Infinity; // the nearest listed expiry: the chain lists none that has passed for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { if (cells[i + 1] < nearest) nearest = cells[i + 1]; const gap = Math.abs(Math.abs(cells[i + 5]) - 0.5); if (cells[i] > 0.0 && cells[i + 4] > 0.0 && cells[i + 6] > 0.0 && cells[i + 7] > 0.0 && gap < bestGap) { bestGap = gap; best = i; } } if (best < 0) return barOpenMs; const sigma = cells[best + 6] > 3.0 ? cells[best + 6] / 100.0 : cells[best + 6]; // percent a year, or a fraction const m = Math.log(cells[best + 7] / cells[best]); const q = cells[best + 4] * cells[best + 7]; // gamma times spot = pdf(d1) / u, u = sigma * sqrt(T), d1 = m / u + u / 2 let lo = Math.sqrt(2.0 * (Math.sqrt(1.0 + m * m) - 1.0)); // where pdf(d1) / u peaks: past it the gamma falls as T grows let hi = 10.0; for (let k = 0; k < 80; k += 1) { // bisection on the falling side const u = 0.5 * (lo + hi); const d1 = m / u + 0.5 * u; if (Math.exp(-0.5 * d1 * d1) / (2.5066282746310002 * u) > q) lo = u; else hi = u; } const u = 0.5 * (lo + hi); const clock = cells[best + 1] - ((u * u) / (sigma * sigma)) * 31536000000.0; return clock > barOpenMs - 86400000.0 && clock < nearest ? clock : barOpenMs; // anything else is no reading } // ── The chain, measured on the live bar ── function measure(cells: StaticArray<f64>, n: i32, spot: f64, barOpenMs: f64): bool { const nowMs = chainClockMs(cells, n, barOpenMs); // the chain's clock: a coarse bar's open would stretch every day count expiryCount = 0; surfaceCount = 0; nearIdx = -1; skew25 = NaN; atm1w = NaN; atm3m = NaN; oneExpiry = false; let maxIv = 0.0; for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { if (cells[i + 1] > nowMs + MIN_AHEAD_MS) noteExpiry(cells[i + 1]); if (cells[i + 6] > maxIv) maxIv = cells[i + 6]; } if (expiryCount == 0) return false; ivScale = maxIv > 0.0 && maxIv <= 3.0 ? 100.0 : 1.0; // a chain whose largest IV is under 3 serves fractions (0.45 for 45%) surfaceCount = expiries < expiryCount ? expiries : expiryCount; for (let e = 0; e < surfaceCount; e += 1) { buildCurve(cells, n, expiryMs[e], spot); for (let k = 0; k < BUCKETS; k += 1) surfIv[e * BUCKETS + k] = curveCount >= 2 ? ivAt(spot * (1.0 + bucketPct[k] / 100.0)) : NaN; surfAtm[e] = curveCount >= 2 ? ivAt(spot) : NaN; surfDays[e] = (expiryMs[e] - nowMs) / DAY_MS; civilFromDays(i64(Math.floor(expiryMs[e] / DAY_MS))); surfDay[e] = civilDay; surfMonth[e] = civilMonth; if (nearIdx < 0 && !isNaN(surfAtm[e])) nearIdx = e; } if (nearIdx < 0) return false; skew25 = skewAt(cells, n, expiryMs[nearIdx]); const w = nearestExpiry(nowMs + 7.0 * DAY_MS); const m = nearestExpiry(nowMs + 90.0 * DAY_MS); oneExpiry = w == m; buildCurve(cells, n, expiryMs[w], spot); atm1w = curveCount >= 2 ? ivAt(spot) : NaN; buildCurve(cells, n, expiryMs[m], spot); atm3m = curveCount >= 2 ? ivAt(spot) : NaN; return true; } // ── The frames: the surface and the tiles, written on the live bar ── function fbMoneyness(pct: f64): void { // "+20%", "-7.5%", "ATM" fb_text("\""); if (pct == 0.0) fb_text("ATM"); else { fb_text(pct > 0.0 ? "+" : "-"); const a = Math.abs(pct); fb_f64(a, a == Math.floor(a) ? 0 : 1); fb_text("%"); } fb_text("\""); } function fbExpiryLabel(e: i32): void { // "10 Oct": the expiry's UTC day and month fb_text("\""); fb_int(surfDay[e]); fb_text(" "); fb_text(MONTHS[surfMonth[e] - 1]); fb_text("\""); } function writeSurface(): void { fb_clear(); fb_text("{\"rows\":["); let first = true; for (let k = BUCKETS - 1; k >= 0; k -= 1) { // the top row is the highest strike bucket, like the price axis for (let e = 0; e < surfaceCount; e += 1) { if (!first) fb_text(","); first = false; fb_text("["); fbExpiryLabel(e); fb_text(","); fbMoneyness(bucketPct[k]); fb_text(","); fb_f64(surfIv[e * BUCKETS + k], 1); // null where the expiry lists nothing fb_text("]"); } } fb_text("],\"caption\":\""); if (isNaN(atm1w) || isNaN(atm3m)) fb_text("Term structure: no reading"); else if (oneExpiry) fb_text("Term structure: one expiry listed"); else { // "Contango: 1w 31.1%, 3m 36.6%" (the title row shows about 30 characters of caption) fb_text(atm3m > atm1w ? "Contango: 1w " : "Backwardation: 1w "); fb_f64(atm1w, 1); fb_text("%, 3m "); fb_f64(atm3m, 1); fb_text("%"); } fb_text("\"}"); writeFrameBuffer(FRAME_HEAT); } function writeTiles(): void { fb_clear(); fb_text("{\"rows\":["); for (let e = 0; e < surfaceCount; e += 1) { if (e > 0) fb_text(","); fb_text("["); fbExpiryLabel(e); fb_text(","); fb_f64(surfAtm[e], 1); fb_text(",\""); fb_f64(surfDays[e], 1); fb_text(" days\"]"); } fb_text("]}"); writeFrameBuffer(FRAME_TERM_ROWS); } // onBar() runs once per bar: history rows carry an empty block and cost one read; the live bar measures the chain, // writes the readings as numbers (NaN on history rows), the two frames and the card's words. function onBar(): void { const close = bar.close(); let read = false; // the live bar carried a chain and a surface expiry has a curve let chainCells = -1; // f64 values the live bar carried; -1 on history bars if (bar.isLast() && !isNaN(close)) { chainCells = in_chain_cells(); if (chainCells >= TUPLE) read = measure(in_chain_view(), chainCells, close, bar.time() * 1000.0); // the bar's open, the clock's stand-in } out_atm_near(read ? surfAtm[nearIdx] : NaN); out_skew_25d(read ? skew25 : NaN); out_atm_1w(read ? atm1w : NaN); out_atm_3m(read ? atm3m : NaN); out_term_spread(read ? atm3m - atm1w : NaN); if (!bar.isLast()) return; if (read) { writeSurface(); writeTiles(); sb_clear(); if (isNaN(skew25)) sb_text("no delta in the chain"); else { sb_signed(skew25, 1); sb_text(skew25 >= 0.0 ? " pts, puts bid" : " pts, calls bid"); } str_skew_text_sb(); sb_clear(); if (isNaN(atm1w) || isNaN(atm3m)) sb_text("no reading"); else if (oneExpiry) sb_text("one expiry listed"); else { sb_f64(atm1w, 1); sb_text("% to "); sb_f64(atm3m, 1); sb_text("%"); } str_term_text_sb(); sb_clear(); if (!isNaN(atm1w) && !isNaN(atm3m) && !oneExpiry) sb_text(atm3m > atm1w ? "Contango" : "Backwardation"); str_term_word_sb(); sb_clear(); str_notice_sb(); // no notice: the chain was read } else { // No chain to measure: the one sentence at the top right, the card's rows say why, nothing else is drawn. sb_clear(); sb_text("No option chain for this coin"); str_notice_sb(); sb_clear(); sb_text("no chain"); str_skew_text_sb(); sb_clear(); sb_text("no chain"); str_term_text_sb(); sb_clear(); str_term_word_sb(); } } ``` ## How it works **The chain is one input.** `input("chain", options_chain.cells, { max_cells: 2000, venue: "deribit" })` carries Deribit's live options chain of the chart's coin as cells of ten values per contract, `[strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega]`; a BTC chain is about 1,550 contracts. The chain is the live row's only: history bars carry an empty block and cost one read, so everything is measured under `bar.isLast()`, and the five data-only readings (`atm_near`, `skew_25d`, `atm_1w`, `atm_3m`, `term_spread`) are NaN on history bars. The chart's own `close` is spot, for the moneyness grid and the out-of-the-money split. **The surface is read off curves.** On the live bar the chain's expiries at least 12 h away are sorted and the nearest `expiries` make the surface. Every distance runs from the chain's clock, the moment the chart priced the chain, never the live bar's open (which on a 1w chart can sit days back): the chart prices each contract's gamma by Black-Scholes from its mark IV and underlying when it reads the chain, so `chainClockMs()` solves the gamma of the contract whose delta sits nearest 0.5 for the time to expiry it implies, and the bar's open stands in only where a venue serves its own greeks; a tile's days to expiry count from it too. Per expiry the out-of-the-money contracts (puts below spot, calls at or above) form an IV curve by strike, a strike listed twice settled by the contract with more open interest. Each bucket's price (spot times 1 plus the bucket's percent) is read off that curve by linear interpolation between the two listed strikes around it, and the ATM value is the same read at spot. Past the expiry's first or last listed strike there is nothing to interpolate, so `ivAt()` reads NaN and the cell is written `null`: the heatmap leaves it empty rather than paint the edge strike's IV there as if it were measured. IV arrives as a fraction or as percent depending on the venue: a chain whose largest IV is at or under 3 serves fractions (0.45 for 45%) and is scaled by 100, otherwise the values are already percent. **Skew and term are two more reads.** The 25-delta skew takes the put and the call of the nearest surface expiry whose delta sits nearest 0.25 in size, put IV minus call IV in IV points. The one-week and three-month points are the listed expiries nearest 7 and 90 days away over the whole chain, not only the surface's expiries, so the term word holds even when the six nearest expiries all sit inside a month; `term_spread` is the three-month ATM minus the one-week ATM, above 0 contango. When the two picks are the same listed expiry there is no term word. **Two frames feed two panels.** `frame("term_rows")` carries one `[label, value, caption]` row per expiry (the expiry's UTC day and month, its ATM IV, its days to expiry) and feeds `panel.tiles` with `columns: 6`, `accent: "neutral"` (IV has no sign, so the text keeps its ink) and `format: "0.0"` with `unit: "%"`, so a tile reads `27.1%`. `frame("heat")` carries one `[expiry, moneyness, value]` cell per expiry and bucket, written from +20% down so the top row is the highest strike bucket, like the price axis; it feeds `panel.heatmap` with `scale: "palette"` and a `palette` from `theme.bg` through sky to violet, `highlight: { row: "ATM" }` outlining the middle row, `hover_card: true` printing each cell's exact value under the pointer under a title of the cell's own two labels ("-5% · 7 Oct"), and the frame's `caption` written per run (the title row shows about 30 characters of it). The heatmap declares no `row_title` or `col_title`: a row or column title only prefixes that hover card's title ("Moneyness -5% · Expiry 7 Oct"), so leaving them out keeps the card short enough to read whole. Both frames are written with the `fb_*` builders and `writeFrameBuffer`, and the two panels take 0.35 of the chart together. **The card reads an output and two slots.** `render.hud("surface_card", { position: "top_left", look: "glass", columns: 1, width: 236, safe_area: true, tiles: [...] })` names the anchor, the look and two tiles. `tile.value("ATM IV, nearest expiry", "atm_near", { format: "%", headline: true, hint: "..." })` leads as the headline and reads the data-only output, its `hint` saying how the number is read; `tile.rows` with `leader: "dots"` reads two string slots written on the live bar, `skew_text` ("+0.3 pts, puts bid", from `sb_signed`) and `term_text` ("31.4% to 36.6%"). `safe_area: true` keeps the card under the legend, and the top right stays free for the notice. **The term word rides the legend, the notice the corner.** `render.legend("term", { text: "term_word", color: "theme.muted" })` prints the slot's words after the indicator's name as "term Contango", in the theme's muted ink. `render.label("notice_tag", { position: "top_right", text: "notice", color: "#94a3b8", style: "plain", offset: [12, 8] })` is a label placed by a corner `position` rather than by `x` and `y`: it prints the `notice` slot in slate at the top right, and the slot is empty while the chain was read. ## Where it runs BTC and ETH charts on any venue: the chain is Deribit's for the chart's coin, so the candles' venue and interval only set the close the moneyness grid takes as spot. The chain is read on the live bar only and history bars cost nothing. Below the candles the tiles mount first and the surface under them, in declaration order; the chart's own floating round button sits at the bottom left of the cell, so the tiles stay above it and the surface's row labels sit left of it. With the default settings the surface is six expiries by nine buckets, 54 cells, over a row of six tiles. ## When data is missing A coin Deribit does not list shows the one sentence `No option chain for this coin` at the top right and draws nothing else: the card's skew and term rows read "no chain", the headline has no number behind it, the legend carries no term word, and the five readings are NaN. History bars read NaN on every bar but the live one, so the surface has no history. A chain with no delta column makes the skew row read "no delta in the chain"; when the one-week or three-month pick has no curve the caption reads "Term structure: no reading" and the term row "no reading"; when both picks are the same listed expiry the caption reads "Term structure: one expiry listed", the term row says so and the legend carries no term word. An expiry whose curve has fewer than two out-of-the-money strikes leaves its column's cells empty, and a bucket past an expiry's listed strikes leaves that one cell empty. ## Customize it - **Fewer expiries.** `expiries` (6, from 2 to 6) sets how many of the nearest listed expiries at least 12 h away make the surface and the tiles; the one-week and three-month picks still read the whole chain. - **A wider or narrower smile.** `range_pct` (20, from 5 to 40) is the moneyness range each side of spot, split into nine buckets; the row labels follow it ("+10%", "-7.5%"). - **Another wing.** The skew reads the put and the call nearest 0.25 delta in `skewAt`; change the number to read a 10-delta wing. - **Another corner.** `position` on the card takes any of the nine anchors, `top_left` to `bottom_right`; the top right holds the notice sentence, so keep the two apart. - **Change the look.** The card declares `look: "glass"`; the Look row on the indicator's Style page switches it to any of the twelve shipped looks without code, "As made" first ([The Style page](../settings/style-page.md#the-look-row)). This example's second look is cockpit, one pick away on that row. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Volatility Surface** under **Beyond the time axis**. 2. Press **Run** on a BTC or ETH chart on any venue (BTCUSDT on Binance Futures at 15m, say): the six tiles and the surface mount below the candles, the glass card at the top left under the legend, and the legend reads the term word. Rest the pointer on a cell for its exact IV. 3. At the editor's Console prompt, type `last 20 atm_near` to read the nearest expiry's ATM implied volatility on the last 20 bars: only the live bar carries a number, since the chain is live only. ## Concepts used - [Options kit](../functions/options-kit.md) for `options_chain.cells`, the ten-value contract tuple and why the chain is the live row's only - [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames, `panel.tiles`, `panel.heatmap`, `highlight`, captions and `writeFrameBuffer` - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) for `render.hud`, its anchors, `width` and `safe_area`, and a `render.label` with a `position` as a corner readout - [Looks](../presentation/hud-and-hover-cards.md#looks) for `look: "glass"` and what it draws - [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the headline `tile.value`, its `hint` and `tile.rows` with `leader: "dots"` - [The Style page](../settings/style-page.md#the-look-row) for the Look row that switches the look without code - [Legend](../presentation/legend.md) for `render.legend` and a string slot's words in the legend - [Text formatting](../functions/text-formatting.md) for the `sb_*` and `fb_*` builders and `sb_signed` <!-- source: https://openmarket.xyz/wrun/cookbook/seasonality-grid --> # Seasonality grid ![Weekday by hour grid of average 15-minute moves under a BTC chart, teal and orange cells with the best cell outlined, and a broadsheet card at the top right naming Fri 04:00 with its hit rate and the worst cell](/wrun/images/seasonality-grid.png) A calendar of returns under the chart, shaped by the chart's interval and folded from a fixed span of the chart's own market, read as closed candles, however few bars the chart has loaded. On any chart under a day it is a weekday by UTC-hour grid, Mon to Sun by 00 to 23, each cell the average move of that hour, its hourly candle's open to close in percent, over the newest 26 weeks: one sample per week, so a 1m, a 15m and a 1h chart show the same grid. On a daily or coarser chart it is a year by month grid: one cell per month, the month's return (its last close over the last close of the month before) as signed percent, the years as rows, newest first, at most `max_rows`, and an Avg row under them; the running month shows its move so far at the live price and counts in no average until it closes. Teal cells gained, orange cells lost, the strongest cells brightest, and zero reads as the chart's background; the best cell is outlined and named in the panel caption ("Best: Fri 08:00", "Best: Oct"), and the pointer opens a readout on any cell. A card at the top right, in the broadsheet look (newsprint, serif type, small caps, dotted leaders), carries the span in its title ("Seasonality, 26 weeks" or "Seasonality, since 2019"), the best cell with its average as the headline ("Fri 08:00 +0.25%", "October +18.2%"), then two rows: the hit rate (the share of that cell's samples that closed up, with the count) and the worst cell. While no cell has `min_samples` samples there is no grid: one sentence in slate at the top right of the price pane says how much history it found and the way out, and the card's headline reads "Not enough history". The parts are the chart's own close, the bar grid ([Data sources](../core-concepts/data-sources.md)); two `candles` streams of the same market, `hours` at `1h` and `days` at `1d`, each a block of closed candles on every bar, the backlog on the first ([Multi-timeframe](../core-concepts/multi-timeframe.md#history-the-candles-stream)); `chart.interval_sec()`, read once in `onStart()` to pick the grid ([Sessions and units](../settings/sessions-and-units.md#chart-context)); two `param.int` settings labelled **Years shown** and **Min samples** ([Setting kinds](../settings/kinds.md)); one frame feeding one `panel.heatmap` whose title, caption, outlined cell and Avg row are written per run ([Cards, frames and panels](../presentation/cards-frames-panels.md)); a `render.hud` card of a headline `tile.pill` and a `tile.rows` with dotted leaders in the broadsheet look ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks)); and a label handle for the one sentence ([Drawing objects](../presentation/drawing-objects.md)). This is also the `seasonality-grid` template: the **Seasonality Grid** card under **Beyond the time axis** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Seasonality Grid: a calendar of returns below the chart, folded from a fixed span of history whatever the chart has // loaded. On a chart under a day, a weekday x UTC-hour grid: each cell the average move of that hour (its open to its // close) over the newest 26 weeks of hourly candles. On a daily or coarser chart, a year x month grid: each cell the // month's return (its last close over the month before's), over up to 12 years of daily candles. Teal cells gained, // orange cells lost, the strongest cells brightest, the best cell outlined. The trader sees when this market tends to // move (the month that pays, the hour of the week that drifts), how often the best cell held, and which cell hurt most. chart.interval_sec(); // the chart's bar interval in seconds, written by the chart: under a day the weekday grid, else the monthly grid param.int("max_rows", 12, { min: 3, max: 12, label: "Years shown", description: "Years kept in the monthly grid, newest first (the weekday grid always has its 7 rows)" }); param.int("min_samples", 3, { min: 1, max: 20, label: "Min samples", description: "Samples a cell needs before it can be named best or worst: years behind a month, weeks behind a weekday hour" }); input("close", ohlcv.close); // the chart's own candles: the bar grid the two streams ride // The two spans, read as closed candles of the chart's own market however few bars the chart has loaded: the weekday grid // folds the hourly one on any chart under a day, the monthly grid folds the daily one on a daily or coarser chart. input("hours", candles.cells, { interval: "1h", bars: 4368, description: "The newest 26 weeks of closed hourly candles: the weekday grid" }); input("days", candles.cells, { interval: "1d", bars: 4392, description: "The newest 12 years of closed daily candles: the monthly grid" }); output("best_avg", none, overlay, { description: "The best cell's average return, percent" }); // data-only, so the Console and a hover can read them output("worst_avg", none, overlay, { description: "The worst cell's average return, percent" }); output("hit_rate", none, overlay, { description: "Share of the best cell's samples that closed up, percent" }); string("best_text", { max_bytes: 32 }); // "October +12.4%" or "Tue 14:00 +0.21%": the card's headline string("worst_text", { max_bytes: 32 }); // the worst cell the same way string("hit_text", { max_bytes: 32 }); // "65% (17 of 26)" string("span_text", { max_bytes: 24 }); // "26 weeks" or "since 2019": the history the grid folds string("words", { max_bytes: 128 }); // the one sentence when the history is too short handles.label({ size: 11 }); // the one sentence, top right, in slate const gridRows = frame("grid_rows", { max_bytes: 16384 }); // the heatmap: one cell per month and year, or per weekday and hour // The grid under the chart: a signed scale around zero (teal gained, orange lost, the chart's background at zero, the strongest // cells brightest), signed percent in every cell, a readout under the pointer; the title, the caption, the outlined best // cell and the monthly grid's Avg row are written per run in the frame. panel.heatmap({ name: "grid", title: "Seasonality", x: "category", place: "below", frame: gridRows, scale: "signed", positive_color: "#2dd4bf", negative_color: "#f86800", format: "%", decimals: 2, signed: true, hover_card: true, height_frac: 0.35 }); // The card, in the broadsheet look (newsprint, serif type, small caps, dotted leaders): the span in the title, the best cell // and its average as the headline, then the hit rate and the worst cell as two rows. The viewer picks another look on the // Style page's Look row. render.hud("card", { position: "top_right", look: "broadsheet", title: "Seasonality, {{span_text}}", columns: 1, width: 250, safe_area: true, tiles: [ tile.pill("Best cell", "best_text", { headline: true }), tile.rows([["Hit rate", "hit_text"], ["Worst", "worst_text"]], { leader: "dots" }), ] }); const MAX_YEARS = 12; const MONTHS = 12; const DAYS = 7; const HOURS = 24; // ring sizes for the largest settings const DAY_SEC: f64 = 86400.0; const HOUR_SEC: f64 = 3600.0; const WEEK_SEC: f64 = 604800.0; const MONTH_SHORT: StaticArray<string> = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const MONTH_LONG: StaticArray<string> = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; const DAY_NAMES: StaticArray<string> = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const SLATE: i32 = rgba(148, 163, 184, 255); const monthRet = new StaticArray<f64>(MAX_YEARS * MONTHS); // the monthly grid: a return per year slot and month, NaN where empty const yearOfSlot = new StaticArray<i32>(MAX_YEARS); // the year each ring slot holds const hourSum = new StaticArray<f64>(DAYS * HOURS); // the weekday x hour grid: summed hour moves, samples, up samples const hourCount = new StaticArray<i32>(DAYS * HOURS); const hourUp = new StaticArray<i32>(DAYS * HOURS); const monthAvg = new StaticArray<f64>(MONTHS); // the Avg row: each month's mean over the closed years shown, NaN below min_samples const words = draw.label(0); // the one sentence let daily = false; let maxRows = 12; let minSamples = 3; // read in onStart() let lastDayClose: f64 = NaN; let lastDayOpen: f64 = NaN; // the daily fold: the newest closed day let prevMonthEnd: f64 = NaN; // the close that ended the month before the newest one: the base of its return let curMonthKey = -1; let curYear = -1; let curSlot = 0; let curMonth = 0; let yearsSeen = 0; // the month and year row being filled let firstHourOpen: f64 = NaN; let lastHourOpen: f64 = NaN; // the hourly fold's span let wordsShown = false; let civYear = 0; let civMonth = 0; // civil()'s answer // The UTC calendar date of epoch seconds (days to civil), into civYear and civMonth (1..12). function civil(t: f64): void { const z = i64(Math.floor(t / DAY_SEC)) + 719468; const era = (z >= 0 ? z : z - 146096) / 146097; const doe = z - era * 146097; const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; const doy = doe - (365 * yoe + yoe / 4 - yoe / 100); const mp = (5 * doy + 2) / 153; civMonth = i32(mp < 10 ? mp + 3 : mp - 9); civYear = i32(yoe + era * 400 + (civMonth <= 2 ? 1 : 0)); } function monthKeyOf(t: f64): i32 { civil(t); return civYear * 12 + civMonth - 1; } function sbSigned(v: f64, decimals: i32): void { if (v >= 0.0) sb_text("+"); sb_f64(v, decimals); sb_text("%"); } // "+12.4%", "-0.03%" function sbHour(h: i32): void { if (h < 10) sb_text("0"); sb_int(h); sb_text(":00"); } // "14:00" function fbHour(h: i32): void { fb_text("\""); if (h < 10) fb_text("0"); fb_int(h); fb_text("\""); } // the hour column key, "14" function fbHourText(h: i32): void { if (h < 10) fb_text("0"); fb_int(h); fb_text(":00"); } // "14:00" inside a caption function sbCount(n: i32, one: string, many: string): void { sb_int(n); sb_text(n == 1 ? one : many); } // "1 week", "26 weeks" function showWords(): void { // the sentence: the label at the top right, and the card's headline says why it has no cell str_words_sb(); words.set(56, 14).text(str_words_sb).anchor(ANCHOR_TOP_RIGHT).align(ALIGN_RIGHT).color(SLATE); wordsShown = true; sb_clear(); sb_text("Not enough history"); str_best_text_sb(); } function hideWords(): void { if (wordsShown) { words.delete(); wordsShown = false; } } // onStart() runs once before the first bar: read the settings and the chart's interval, clear the rings. function onStart(): void { daily = p_chart_interval_sec() >= DAY_SEC; maxRows = i32(p_max_rows()); if (maxRows < 1) maxRows = 1; if (maxRows > MAX_YEARS) maxRows = MAX_YEARS; minSamples = i32(p_min_samples()); if (minSamples < 1) minSamples = 1; for (let i = 0; i < MAX_YEARS * MONTHS; i += 1) monthRet[i] = NaN; for (let i = 0; i < DAYS * HOURS; i += 1) { hourSum[i] = 0.0; hourCount[i] = 0; hourUp[i] = 0; } } // The daily stream into its months: a new month closes the one before on the last daily close seen; the newest month is // rewritten by every day that closes in it. A month whose calendar month before it holds no close stays empty (the first // month of the stream, a month after a gap), so a return never spans two months. function foldDays(t: f64): void { const block = in_days_view(); const cells = in_days_cells(); // f64 cells, six per candle: [offset_ms, open, high, low, close, volume] const n = cells < block.length ? cells : block.length; // -1 (no history on this bar) and 0 fold nothing for (let i = 0; i + 6 <= n; i += 6) { const close = block[i + 4]; if (!(close > 0.0)) continue; const open = t + block[i] / 1000.0; // the day's open, epoch seconds const key = monthKeyOf(open); if (key != curMonthKey) { // a new month: the last close seen ended the month before, when that month is the one just before prevMonthEnd = curMonthKey >= 0 && key == curMonthKey + 1 ? lastDayClose : NaN; curMonthKey = key; curMonth = civMonth - 1; if (civYear != curYear) { // a new year row: the next ring slot (the oldest row is reused once the ring is full) curYear = civYear; curSlot = yearsSeen % MAX_YEARS; yearsSeen += 1; yearOfSlot[curSlot] = civYear; for (let m = 0; m < MONTHS; m += 1) monthRet[curSlot * MONTHS + m] = NaN; } } if (!isNaN(prevMonthEnd)) monthRet[curSlot * MONTHS + curMonth] = (close / prevMonthEnd - 1.0) * 100.0; // final once the month's last day is in lastDayClose = close; lastDayOpen = open; } } // The hourly stream into its weekday hours: each hour's move, its open to its close in percent (every price move inside // the hour summed), is one sample of the cell for the weekday and UTC hour it opened in, Monday first. function foldHours(t: f64): void { const block = in_hours_view(); const cells = in_hours_cells(); // f64 cells, six per candle, as the daily stream const n = cells < block.length ? cells : block.length; for (let i = 0; i + 6 <= n; i += 6) { const open = block[i + 1]; const close = block[i + 4]; if (!(open > 0.0) || !(close > 0.0)) continue; const at = t + block[i] / 1000.0; // the hour's open, epoch seconds const day = i64(Math.floor(at / DAY_SEC)); // 1970-01-01 was a Thursday: (day + 3) % 7 is 0 on a Monday const cell = i32((day + 3) % 7) * HOURS + i32(i64(Math.floor(at / HOUR_SEC)) % 24); const move = (close / open - 1.0) * 100.0; hourSum[cell] += move; hourCount[cell] += 1; if (move > 0.0) hourUp[cell] += 1; if (isNaN(firstHourOpen)) firstHourOpen = at; lastHourOpen = at; } } // The monthly grid, on the live bar of a daily or coarser chart. Rows newest year first, every month a cell (null where // the history has none), then the Avg row: each month's mean, the number the best month is picked by. The month still // running shows the live price's move so far and counts in no average until it closes. function writeCalendar(): void { const rows = yearsSeen < maxRows ? yearsSeen : maxRows; const forming = curMonthKey >= 0 && !isNaN(lastDayOpen) && monthKeyOf(lastDayOpen + DAY_SEC) == curMonthKey; // the newest month has days to come let best = -1; let worst = -1; let bestAvg = -Infinity; let worstAvg = Infinity; let bestUp = 0; let bestN = 0; let mostN = 0; for (let m = 0; m < MONTHS; m += 1) { // a month's samples are its closed returns across the years shown let sum = 0.0; let n = 0; let up = 0; for (let k = 0; k < rows; k += 1) { const slot = (curSlot - k + MAX_YEARS) % MAX_YEARS; if (forming && slot == curSlot && m == curMonth) continue; const v = monthRet[slot * MONTHS + m]; if (isNaN(v)) continue; sum += v; n += 1; if (v > 0.0) up += 1; } if (n > mostN) mostN = n; monthAvg[m] = NaN; if (n < minSamples) continue; const avg = sum / f64(n); monthAvg[m] = avg; if (avg > bestAvg) { bestAvg = avg; best = m; bestUp = up; bestN = n; } if (avg < worstAvg) { worstAvg = avg; worst = m; } } sb_clear(); if (yearsSeen > 0) { sb_text("since "); sb_int(yearOfSlot[(curSlot - rows + 1 + MAX_YEARS) % MAX_YEARS]); } str_span_text_sb(); if (best < 0) { // no month has min_samples closed years: one sentence and its way out, never an empty grid sb_clear(); if (mostN == 0) sb_text("No closed month in this market's history yet: open the 1h chart for the weekday grid"); else { sb_text("Only "); sbCount(mostN, " year", " years"); sb_text(" of monthly history here, "); sb_int(minSamples); sb_text(" needed: open the 1h chart for the weekday grid"); } showWords(); return; } hideWords(); const hit = (100.0 * f64(bestUp)) / f64(bestN); out_best_avg(bestAvg); out_worst_avg(worstAvg); out_hit_rate(hit); sb_clear(); sb_text(MONTH_LONG[best]); sb_text(" "); sbSigned(bestAvg, 1); str_best_text_sb(); sb_clear(); sb_text(MONTH_LONG[worst]); sb_text(" "); sbSigned(worstAvg, 1); str_worst_text_sb(); sb_clear(); sb_f64(hit, 0); sb_text("% ("); sb_int(bestUp); sb_text(" of "); sb_int(bestN); sb_text(")"); str_hit_text_sb(); const live = bar.close(); const liveRet = forming && live > 0.0 && !isNaN(prevMonthEnd) ? (live / prevMonthEnd - 1.0) * 100.0 : NaN; fb_clear(); fb_text("{\"title\":\"Monthly returns\",\"caption\":\"Best: "); fb_text(MONTH_SHORT[best]); // the worst rides the card fb_text("\",\"highlight\":{\"col\":\""); fb_text(MONTH_SHORT[best]); fb_text("\"},\"summary\":[{\"label\":\"Avg\",\"values\":["); for (let m = 0; m < MONTHS; m += 1) { if (m > 0) fb_text(","); fb_f64(monthAvg[m], 2); } fb_text("]}],\"rows\":["); let first = true; for (let k = 0; k < rows; k += 1) { // newest year first; every month emitted so the columns keep their calendar order const slot = (curSlot - k + MAX_YEARS) % MAX_YEARS; for (let m = 0; m < MONTHS; m += 1) { if (!first) fb_text(","); first = false; const v = forming && slot == curSlot && m == curMonth && !isNaN(liveRet) ? liveRet : monthRet[slot * MONTHS + m]; fb_text("[\""); fb_text(MONTH_SHORT[m]); fb_text("\",\""); fb_int(yearOfSlot[slot]); fb_text("\","); fb_f64(v, 2); fb_text("]"); } } fb_text("]}"); writeFrameBuffer(FRAME_GRID_ROWS); } // The weekday x hour grid, on the live bar of a chart under a day. Mon..Sun rows, 00..23 UTC columns, each cell the // average move of that hour over the weeks folded (null where no hour closed); the best cell is outlined. function writeHours(): void { const weeks = isNaN(firstHourOpen) ? 0 : i32(Math.round((lastHourOpen - firstHourOpen + HOUR_SEC) / WEEK_SEC)); // the span, in whole weeks let best = -1; let worst = -1; let bestAvg = -Infinity; let worstAvg = Infinity; let mostN = 0; for (let i = 0; i < DAYS * HOURS; i += 1) { const n = hourCount[i]; if (n > mostN) mostN = n; if (n < minSamples) continue; const avg = hourSum[i] / f64(n); if (avg > bestAvg) { bestAvg = avg; best = i; } if (avg < worstAvg) { worstAvg = avg; worst = i; } } sb_clear(); sbCount(weeks, " week", " weeks"); str_span_text_sb(); if (best < 0) { // no weekday hour has min_samples weeks behind it: one sentence and its way out, never an empty grid sb_clear(); if (mostN == 0) sb_text("No hourly history for this market yet: switch to a market that has traded for a few weeks"); else { sb_text("Only "); sbCount(mostN, " week", " weeks"); sb_text(" of hourly history here, "); sb_int(minSamples); sb_text(" needed: lower Min samples in the settings"); } showWords(); return; } hideWords(); const bestN = hourCount[best]; const bestUp = hourUp[best]; const hit = (100.0 * f64(bestUp)) / f64(bestN); out_best_avg(bestAvg); out_worst_avg(worstAvg); out_hit_rate(hit); sb_clear(); sb_text(DAY_NAMES[best / HOURS]); sb_text(" "); sbHour(best % HOURS); sb_text(" "); sbSigned(bestAvg, 2); str_best_text_sb(); sb_clear(); sb_text(DAY_NAMES[worst / HOURS]); sb_text(" "); sbHour(worst % HOURS); sb_text(" "); sbSigned(worstAvg, 2); str_worst_text_sb(); sb_clear(); sb_f64(hit, 0); sb_text("% ("); sb_int(bestUp); sb_text(" of "); sb_int(bestN); sb_text(")"); str_hit_text_sb(); fb_clear(); fb_text("{\"title\":\"Average hourly move by weekday (UTC)\",\"caption\":\"Best: "); fb_text(DAY_NAMES[best / HOURS]); fb_text(" "); fbHourText(best % HOURS); // the worst rides the card fb_text("\",\"highlight\":{\"row\":\""); fb_text(DAY_NAMES[best / HOURS]); fb_text("\",\"col\":"); fbHour(best % HOURS); fb_text("},\"rows\":["); let first = true; for (let d = 0; d < DAYS; d += 1) for (let h = 0; h < HOURS; h += 1) { // every cell emitted so both axes keep their order if (!first) fb_text(","); first = false; const i = d * HOURS + h; fb_text("["); fbHour(h); fb_text(",\""); fb_text(DAY_NAMES[d]); fb_text("\","); if (hourCount[i] > 0) fb_f64(hourSum[i] / f64(hourCount[i]), 4); else fb_text("null"); fb_text("]"); } fb_text("]}"); writeFrameBuffer(FRAME_GRID_ROWS); } // onBar() runs once per bar: fold the closed candles this bar carries from the stream its grid reads (the first bar // carries the whole backlog), then on the live bar write that grid, the card's words and the outputs. function onBar(): void { const t = bar.time(); if (daily) foldDays(t); else foldHours(t); if (!bar.isLast()) return; if (daily) writeCalendar(); else writeHours(); } ``` ## How it works **The chart's interval picks the grid.** `chart.interval_sec()` declares a hidden setting the chart fills with its bar interval in seconds before the first bar, and `onStart()` reads it once through `p_chart_interval_sec()`: 86400 or more, a daily or coarser chart, picks the year by month grid and its `days` stream; anything shorter, 0 included (an unknown interval), picks the weekday by hour grid and its `hours` stream. `onBar()` then folds only that stream, on every bar, the live bar included, and on the live bar (`bar.isLast()`) writes only that grid, the card's words and the outputs. The chart replays the forming bar from a snapshot of the module taken after the last closed bar, so these plain sums never count a candle twice ([Repainting](../core-concepts/repainting.md)). Both streams are declared, so both load on every chart, and only the picked one is folded. **Two streams, two fixed spans.** `input("hours", candles.cells, { interval: "1h", bars: 4368 })` asks for the newest 26 weeks of closed hourly candles (26 weeks of 168 hours), and `input("days", candles.cells, { interval: "1d", bars: 4392 })` for the newest 12 years of closed daily candles (12 years of 366 days), both of the chart's own market, since neither names a `symbol` or an `exchange`. A stream reaches its `bars` back however few bars the chart has loaded (further when the chart has loaded more; the card's title reads the span actually folded), so the grid does not follow the chart's window: a 1m, a 15m and a 1h chart fold the same hourly candles and show the same weekday grid. The first bar carries the backlog, every candle closed up to it, and each later bar the candles that closed since the bar before, so a forming candle never arrives and none arrives twice ([Multi-timeframe](../core-concepts/multi-timeframe.md#history-the-candles-stream)). Each candle is six numbers, `[offset_ms, open, high, low, close, volume]`, oldest first: `in_hours_cells()` counts those numbers, not candles (-1 or 0 on a bar that carries none), so the folds step by six through `in_hours_view()`, and `in_days_view()` the same way; the candle opened at `bar.time() + offset_ms / 1000` epoch seconds, the instant its cell is keyed on. `close` stays the first input, the bar grid the two streams ride. **Why not a pin or the chart's bars.** A scalar `ohlcv` input pinned to `interval: "1h"` hands each chart bar one hourly close, so it covers only the chart's own window plus its pre-roll (a few hours of closes on a 1m chart), and a pin finer than the chart is refused, which would stop the run on every daily chart. The chart's own bars are only what it has loaded (on BTCUSDT on Binance Futures, about 290 on a 1h chart and 481 on a 1d chart), which gives each weekday hour or each month one or two samples, under the default `min_samples` of 3. **An hour is one sample a week.** `foldHours()` turns each hourly candle into one sample, its open to close in percent (`(close / open - 1) * 100`, every price move inside the hour summed), and adds it to the cell of the weekday and UTC hour the candle opened in, Monday first (1970-01-01 was a Thursday, so `(day + 3) % 7` is 0 on a Monday). Each cell keeps its summed moves, its sample count and its count of samples that closed up (`hourSum`, `hourCount`, `hourUp`). A weekday hour comes round once a week, so its count is the weeks behind it, and the cell prints the average of its samples. The hour's own candle reads the same on every chart interval, where an average of a 1m chart's bar returns would round to 0.00%. **A month is its last close over the month before's.** `foldDays()` walks the daily candles into calendar months by the UTC date of each day's open (`civil()` turns epoch seconds into the year and the month). A day in a new month closes the month before on the last daily close seen, and every day that closes in a month rewrites that month's cell in `monthRet` as its close over the close that ended the month before, in percent, so the cell is final once the month's last day is in. The stream's first month has no close before it and stays empty, and so does a month whose calendar month before it holds no candle (a gap in the history): the close that ended a month only bases the next month's return when the two months are adjacent (`key == curMonthKey + 1`), so a return never spans two months. A new year takes the next of 12 ring slots (`MAX_YEARS`, each slot's year in `yearOfSlot`), and once 12 years are held the oldest row is reused. The month still running shows the live price's move so far, `bar.close()` over the close that ended the month before, and counts in no average: a month joins the averages once the day after its newest closed day falls in the next month. **One frame is the grid.** `frame("grid_rows")` carries `[x key, y key, value]` cells: `[month, year, return]` on the monthly grid, newest year first, and `[hour, weekday, average]` on the weekday grid, `null` where the history has none (`fb_f64` writes a NaN month as `null`), so every cell is written and both axes keep their calendar order, since the panel orders keys as the rows first name them. Beside the rows the frame carries what changes per run: `title` ("Monthly returns" or "Average hourly move by weekday (UTC)"), `caption`, which names the best cell only ("Best: Oct", "Best: Fri 08:00"; the worst rides the card, so the title row stays short enough to read), `highlight`, the best month's column (`{"col": "Oct"}`) or the best weekday-hour cell (`{"row": "Fri", "col": "08"}`), which the panel outlines, and on the monthly grid `summary`, one row labelled "Avg" under the years: each month's mean over the closed years shown, `null` below `min_samples`, the number the best month is picked by. The declared `panel.heatmap` holds what does not change: `scale: "signed"` around zero with teal `positive_color` and orange `negative_color`, `format: "%"` with `decimals: 2` and `signed: true` so a cell prints "+2.28%" or "-0.27%", `hover_card: true` for the readout, `height_frac: 0.35` for the pane's share of the chart. The panel prints a cell's value when the cell is at least 34 px wide and 11 px tall; no word toggles it. The grid is built in the frame buffer (`fb_clear`, `fb_text`, `fb_int`, `fb_f64`) and sent with `writeFrameBuffer(FRAME_GRID_ROWS)`, allocation-free on every live tick. **Best and worst need samples.** A cell qualifies once it holds `min_samples` (3) samples: closed years behind a month, counted over the year rows shown with the running month skipped, or weeks behind a weekday hour. Among the qualifying cells the highest average is best and the lowest is worst, so the caption, the outline and the card only ever name a cell that has `min_samples`. `best_avg`, `worst_avg` and `hit_rate` (the share of the best cell's samples that closed up, in percent) are data-only outputs written on the live bar once a cell qualifies, so the Console and a hover can read the numbers behind the card. **The card reads slots.** The headline is a `tile.pill` on the `best_text` slot, so the cell's name rides with its number ("Fri 08:00 +0.25%" with two decimals, "October +18.2%" with one; a `tile.value` would print the number alone), and `tile.rows` with `leader: "dots"` reads `hit_text` ("65% (17 of 26)", so the count shows) and `worst_text` ("Fri 18:00 -0.27%", "June -8.5%"). The title `"Seasonality, {{span_text}}"` resolves the `span_text` slot on the chart: the hourly span folded, in whole weeks ("26 weeks"), or the oldest year row shown ("since 2019"), written on the live bar before the sample check, so the span reads even while no cell qualifies. Every line is built with the `sb_*` builder and sent with `str_<slot>_sb()` ([Strings and text](../functions/text-formatting.md)). `look: "broadsheet"` sets the paper, the serif type, the small-caps title, the headline at 20 and the dotted leaders; `width: 250` and `safe_area: true` keep the card clear of the legend and the pane action bar at the top right. **The sentence is a label handle.** `handles.label({ size: 11 })` declares the kind and `draw.label(0)` makes the one handle. While no cell qualifies, `showWords()` sends the sentence to the `words` slot, sets the label 56 px in from the pane's top right corner and 14 px down (`ANCHOR_TOP_RIGHT`, right-aligned, in slate), and writes "Not enough history" into `best_text` for the card's headline; the first live write that finds a best cell deletes the label (`hideWords()`). ## Where it runs Every market with candles, on any chart from 1m up: crypto perpetuals and spot, gold, prediction markets. It reads only the chart's own market (its candles, an hourly stream and a daily stream), never another one, so no market refuses it, and every cell is keyed on UTC. On a market that pauses (gold), the hours it does not trade stay empty cells. A young market shows the sentence until a cell has `min_samples` samples: at the default 3, a chart under a day draws the grid once one weekday hour has closed in three different weeks, and a daily chart once one calendar month holds three closed returns, which takes over two years; until then the daily chart's sentence points to the 1h chart and its weekday grid. Both streams load on every chart, so a daily chart also fetches the hourly candles back to its first bar, which its grid never reads. On a weekly chart the legend carries one warning: the hourly stream asks back to the chart's first bar, and hourly candles reach back only 25,000 hours ([How far back history reaches](../core-concepts/multi-timeframe.md#how-far-back-history-reaches)). The monthly grid does not read them and is whole. ## When data is missing Until one cell has `min_samples` samples there is no grid: the live bar writes no frame and no outputs (`best_avg`, `worst_avg` and `hit_rate` stay unwritten), and one sentence in slate stands at the top right of the price pane with the history found and the way out. On a chart under a day it reads "Only 2 weeks of hourly history here, 3 needed: lower Min samples in the settings", or "No hourly history for this market yet: switch to a market that has traded for a few weeks" before any hour has closed; on a daily or coarser chart, "Only 2 years of monthly history here, 3 needed: open the 1h chart for the weekday grid", or "No closed month in this market's history yet: open the 1h chart for the weekday grid". The card stays up: its title still reads the span, its headline reads "Not enough history", and the Hit rate and Worst rows print a dash, since their slots are never written. Cells the history has not reached stay empty: the hours a paused market does not trade, the months of the newest year still to come, and the stream's first month, which has no close before it to measure from (the daily history of BTCUSDT on Binance Futures starts in November 2019, so its 2019 row holds December only). The running month shows its move so far at the live price but counts in no average until it closes. ## Customize it - **More or fewer year rows.** `max_rows` (12, 3 to 12), the **Years shown** row, is the years kept in the monthly grid, newest first, and the card's "since" year is the oldest of them; the weekday grid always has its 7 rows. - **A stricter best cell.** `min_samples` (3, 1 to 20), the **Min samples** row, is the samples a cell needs before it can be named best or worst: years behind a month, weeks behind a weekday hour. A higher number waits for more history before the grid draws, and since the monthly grid counts only the years shown, a `min_samples` above `max_rows` keeps a daily chart on the sentence. - **A longer or shorter span.** The spans are the streams' `bars`: 4368 hourly candles (26 weeks) on `hours` and 4392 daily candles (12 years) on `days`. Change either in the source and Run again: `bars` takes up to 5000 (about 30 weeks of hourly candles), and the monthly grid keeps at most 12 year rows whatever the daily span. - **Other colours.** `positive_color` and `negative_color` on the panel are colour literals in this source (the house teal `#2dd4bf` and orange `#f86800`), so here another pair is a source edit. Declare a `param.color` and write `"@<name>"` in either word, and that colour becomes a setting: the build writes the setting's default in its place, and the chart paints the trader's pick ([Panel words](../presentation/cards-frames-panels.md#panel-words)). - **Change the look.** The card is made in the broadsheet look, a literal `look` in this source, so the Look row on the indicator's Style page switches it to any of the twelve shipped looks without code, "As made" first ([The Style page](../settings/style-page.md#the-look-row)). Bind `look` to a `param.choice` over looks instead (`look: "@card_look"`) and that setting's row becomes the look's one control, with no Look row for the card ([Looks](../presentation/hud-and-hover-cards.md#looks)). The grid look (paper, a bold headline at 44, a red accent) is the second one this example was shot in. ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Seasonality Grid** under **Beyond the time axis**. 2. Press **Run** on BTCUSDT on Binance Futures at 15m: the weekday by hour grid fills below the chart with the best cell outlined, and the broadsheet card names it at the top right. Switch the chart to 1m or 1h for the same grid, and to 1D for the year by month grid with its Avg row; no scroll back is needed, since each grid reads its own stream. 3. At the editor's Console prompt, type `hit_rate` to read the best cell's hit rate on the live bar, or `outputs` for `best_avg`, `worst_avg` and `hit_rate` together. ## Concepts used - [Data sources](../core-concepts/data-sources.md) for `ohlcv.close`, the chart's own candles and the bar grid the streams ride - [Multi-timeframe](../core-concepts/multi-timeframe.md#history-the-candles-stream) for the `candles` stream: `interval`, `bars`, the six-number candle, the backlog on the first bar and the `in_<name>_cells()` / `in_<name>_view()` loop - [How far back history reaches](../core-concepts/multi-timeframe.md#how-far-back-history-reaches) for the 25,000 hourly candles behind the warning on a weekly chart - [Sessions and units](../settings/sessions-and-units.md#chart-context) for `chart.interval_sec()` and `p_chart_interval_sec()` - [Setting kinds](../settings/kinds.md) and [Options on a setting](../settings/options.md) for the two `param.int` settings and their `label` - [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames, `panel.heatmap`, the signed scale, the per-run `title`, `caption`, `highlight` and `summary`, and the frame buffer - [Panel words](../presentation/cards-frames-panels.md#panel-words) for `positive_color`, `negative_color` and a colour word bound to a `param.color` - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) for `render.hud`, its anchors, `width` and `safe_area` - [Looks](../presentation/hud-and-hover-cards.md#looks) for `look: "broadsheet"`, the grid look and `look` bound to a `param.choice` - [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the headline `tile.pill` and `tile.rows` with `leader: "dots"` - [The Style page](../settings/style-page.md#the-look-row) for the Look row that switches the look without code - [Strings and text](../functions/text-formatting.md) for the `sb_*` builders and string slots - [Drawing objects](../presentation/drawing-objects.md) for `handles.label` and the `draw.label` handle <!-- source: https://openmarket.xyz/wrun/cookbook/depth-curve --> # Depth curve ![Depth curve pane under a BTC chart, the teal bid and orange ask staircases meeting at the dashed mid with wall callouts, and the phosphor book card at the top right reading Asks 1.2x bids](/wrun/images/depth-curve.png) The market's resting order book as the classic depth curve under the chart: a pane with price across and dollars up, the bids in teal stepping up to the left of the mid, the asks in orange stepping up to the right, both as staircases (a book is steps, never a smooth curve) filled to the axis. The steeper side is the heavier side. A dashed slate marker stands at the mid with its price ("Mid 85,115"), and a callout pins on each of the two biggest single levels per side ("$26.1M wall"), where the staircase jumps. A chip on the pane's title row gives the verdict within `near_pct` of the mid: "Bids heavier 1.4x", "Asks heavier 1.3x", "Balanced book", or "Thin near the mid" when nothing rests in the band. At the top left of the price pane, under the legend and clear of the newest candles, a card in the phosphor look (green monospace type with a glow on a near-black green card, scanlines over it, the same paper on a light chart) titled "Order book within 1%" leads with one big line, "Bids 1.4x asks" (teal when the bids are heavier, orange when the asks are, slate when balanced), then two rows: "Biggest wall" ("$26.1M bid at 85,100") and "Spread" ("up to 25 (0.03%)" when the touch sits inside one step of the book's grouping, else the percent of the mid and the price gap, as in "0.07% (0.0001)"). Everything is read from the live snapshot the chart serves, about once a second on the live bar; history bars only feed the data outputs and draw nothing. The parts are a `book.cells` input, one `[price, size, side]` tuple per level ([Order flow](../functions/order-flow-kit.md)), a frame feeding a `panel.line` on a number axis with two step series filled to the axis, a dashed mid marker, wall callouts pinned at an exact `y`, a badge and a caption ([Cards, frames and panels](../presentation/cards-frames-panels.md)), and a `render.hud` card in the phosphor look whose headline carries the one sentence when the market has no book ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks)). This is also the `depth-curve` template: the **Depth Curve** card under **Beyond the time axis** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Depth Curve: the market's resting order book as the classic depth curve under the chart. Cumulative bid dollars step // up to the left of the mid in teal, cumulative ask dollars step up to the right in orange, each side filled to the // axis, a dashed marker at the mid, and a callout pinned on the two biggest single walls of each side ("$26.1M wall"). // The chip on the panel's title row names the heavier side within near_pct of the mid ("Bids heavier 1.4x"). A card at // the top left of the price pane, under the legend and clear of the newest candles (phosphor by default, glass as the // other look), leads with the same ratio as its one big number and reads the biggest wall and the spread under it. The trader sees which side of the book is heavier, how steep each // side is, and where the walls sit, all from the live snapshot the chart serves about once a second. // A market without an order book (gold, prediction markets) says so on the card, "No order book", and draws nothing else. section("Reach"); param.number("range_pct", 2.0, { min: 0.5, max: 10.0, step: 0.5, label: "Curve reach, percent", description: "How far from the mid the curve reaches, percent each side" }); param.number("near_pct", 1.0, { min: 0.1, max: 5.0, step: 0.1, label: "Ratio band, percent", description: "The band the ratio and the chip read, percent each side of the mid" }); // Inputs: the chart's own candles set the grid, then the book: one [price, size, side] row per level, side +1 a bid // and -1 an ask, sizes in coins. The chart serves its own market's book at its own grouping, up to 500 levels a side, // so 1000 rows hold any bar; block_size is required by the declaration and the chart does not read it. input("close", ohlcv.close); input("book", book.cells, { max_cells: 1000, block_size: 10 }); // Outputs: data-only, one value per bar, so a watch or an alert can read the book too. output("side", none, overlay, { description: "0 asks heavier, 1 balanced or no book, 2 bids heavier, within near_pct of the mid" }); output("ratio", none, overlay, { format: "0.00", description: "Bid dollars over ask dollars within near_pct of the mid" }); output("bids_near", none, overlay, { color: "#2dd4bf", format: "usd", description: "Dollars bid within near_pct of the mid" }); output("asks_near", none, overlay, { color: "#f86800", format: "usd", description: "Dollars offered within near_pct of the mid" }); output("spread_pct", none, overlay, { format: "%", description: "Best ask minus best bid, as a percent of the mid" }); output("bid_wall", none, overlay, { color: "#2dd4bf", format: "price", description: "The price of the biggest bid within range_pct of the mid" }); output("bid_wall_usd", none, overlay, { format: "usd", description: "Dollars resting at the bid wall" }); output("ask_wall", none, overlay, { color: "#f86800", format: "price", description: "The price of the biggest ask within range_pct of the mid" }); output("ask_wall_usd", none, overlay, { format: "usd", description: "Dollars resting at the ask wall" }); string("near_text", { max_bytes: 8 }); // "1%": the card title's band, written on the live bar string("ratio_text", { max_bytes: 40 }); // "Bids 1.4x asks", or "No order book" when the market has none string("wall_text", { max_bytes: 40 }); // "$26.1M bid at 85,100" string("spread_text", { max_bytes: 32 }); // "0.0001% (0.10)" const depth = frame("depth", { max_bytes: 65536 }); // the curve: one row per level, [price, bid dollars | null, ask dollars | null] // The depth curve: price across, dollars up, both sides stepped (a book is a staircase, never a smooth curve) and // filled to the axis in their own ink; the mid marker, the four wall callouts and the chip are written per run. panel.line({ name: "depth_curve", title: "Order book depth", x: "number", place: "below", frame: depth, series: [ { name: "Bids", color: "#2dd4bf", width: 1.5, style: "step", fill: true, fill_color: "#2dd4bf55" }, { name: "Asks", color: "#f86800", width: 1.5, style: "step", fill: true, fill_color: "#f8680055" }, ], chrome: "grid", legend_style: "title", hover_card: true, x_format: "usd", x_title: "Price", y_title: "Resting, USD", format: "usd", decimals: 0, y_zero: true, height_frac: 0.32, }); // The card: the heavier side as the one big number, then the biggest wall and the spread as two rows; at the top left // under the legend, so it covers the oldest candles on screen, never the newest. render.hud("depth_card", { position: "top_left", safe_area: true, look: "phosphor", // the other look of this example is glass: every HUD gets a Look row on the Style page title: "Order book within {{near_text}}", columns: 1, width: 300, tiles: [ tile.pill("Heavier side", "ratio_text", { headline: true, font_size: 20, color_by: "side", colors: ["#f86800", "#94a3b8", "#2dd4bf"] }), tile.rows([["Biggest wall", "wall_text"], ["Spread", "spread_text"]]), ], }); const LEVELS = 500; // the chart's book carries at most 500 levels a side const ASKS_HEAVIER = 0.0; // the side ladder, in the colours' order const BALANCED = 1.0; const BIDS_HEAVIER = 2.0; const LEAN = 1.05; // the ratio either side of 1 before a side is named const bidPx = new StaticArray<f64>(LEVELS); // the last snapshot with both sides, copied per bar, sorted on the live bar const bidSz = new StaticArray<f64>(LEVELS); const askPx = new StaticArray<f64>(LEVELS); const askSz = new StaticArray<f64>(LEVELS); const bidCum = new StaticArray<f64>(LEVELS); // cumulative dollars from the touch outward, per sorted level const askCum = new StaticArray<f64>(LEVELS); let rangePct = 2.0; // settings, read in onStart() let nearPct = 1.0; let bids: i32 = 0; // levels in the stored snapshot let asks: i32 = 0; let bookBar: i32 = -1; // the bar index the stored snapshot came from, -1 while no bar carried both sides let barIndex: i32 = -1; let mid: f64 = NaN; // this bar's readings, NaN without a usable snapshot let bestBid: f64 = NaN; let bestAsk: f64 = NaN; let bidsNear: f64 = NaN; let asksNear: f64 = NaN; let bidWall: f64 = NaN; let bidWallUsd: f64 = NaN; let askWall: f64 = NaN; let askWallUsd: f64 = NaN; let bidWall2: f64 = NaN; // the second biggest wall per side, for the callouts let bidWall2Usd: f64 = NaN; let askWall2: f64 = NaN; let askWall2Usd: f64 = NaN; let decimals: i32 = 2; // the market's price decimals, read off the book's tick let tick: f64 = NaN; // the book's own price step (the smallest gap between neighbouring levels) // readBook() copies this bar's snapshot into the side arrays. The chart hands the bids best first and the asks from // the highest price down, but every read goes by price and side, never by position. False when the bar has no usable // snapshot (none delivered, a side missing, or a crossed book); the arrays then keep the newest usable one. function readBook(): bool { const n = in_book_cells(); // numbers delivered this bar: 0 for an empty block, -1 when the bar carries none if (n < 6) return false; const cells = in_book_view(); let hiBid = -Infinity; let loAsk = Infinity; let nb: i32 = 0; let na: i32 = 0; for (let i = 0; i + 2 < n; i += 3) { // first pass: is the snapshot usable? (nothing is stored yet) const price = cells[i]; if (!(price > 0.0) || !(cells[i + 1] > 0.0)) continue; // an empty level is served but rests nothing if (cells[i + 2] > 0.0) { nb += 1; if (price > hiBid) hiBid = price; } else if (cells[i + 2] < 0.0) { na += 1; if (price < loAsk) loAsk = price; } } if (nb == 0 || na == 0 || !(loAsk > hiBid)) return false; nb = 0; na = 0; for (let i = 0; i + 2 < n; i += 3) { // second pass: copy the levels, at most LEVELS a side const price = cells[i]; const size = cells[i + 1]; if (!(price > 0.0) || !(size > 0.0)) continue; if (cells[i + 2] > 0.0) { if (nb < LEVELS) { bidPx[nb] = price; bidSz[nb] = size; nb += 1; } } else if (cells[i + 2] < 0.0) { if (na < LEVELS) { askPx[na] = price; askSz[na] = size; na += 1; } } } bids = nb; asks = na; bookBar = barIndex; return true; } function clearReadings(): void { mid = NaN; bestBid = NaN; bestAsk = NaN; bidsNear = NaN; asksNear = NaN; bidWall = NaN; bidWallUsd = NaN; askWall = NaN; askWallUsd = NaN; bidWall2 = NaN; bidWall2Usd = NaN; askWall2 = NaN; askWall2Usd = NaN; } // measure() reads the stored snapshot: the touch and the mid, the dollars within near_pct of the mid per side, and // the two biggest single levels within range_pct per side. function measure(): void { clearReadings(); let hiBid = -Infinity; let loAsk = Infinity; for (let k = 0; k < bids; k++) if (bidPx[k] > hiBid) hiBid = bidPx[k]; for (let k = 0; k < asks; k++) if (askPx[k] < loAsk) loAsk = askPx[k]; bestBid = hiBid; bestAsk = loAsk; mid = (hiBid + loAsk) * 0.5; const near = mid * nearPct / 100.0; const reach = mid * rangePct / 100.0; bidsNear = 0.0; asksNear = 0.0; for (let k = 0; k < bids; k++) { const price = bidPx[k]; const dollars = price * bidSz[k]; const away = mid - price; if (away <= near) bidsNear += dollars; if (away <= reach) { if (isNaN(bidWallUsd) || dollars > bidWallUsd) { bidWall2 = bidWall; bidWall2Usd = bidWallUsd; bidWall = price; bidWallUsd = dollars; } else if (isNaN(bidWall2Usd) || dollars > bidWall2Usd) { bidWall2 = price; bidWall2Usd = dollars; } } } for (let k = 0; k < asks; k++) { const price = askPx[k]; const dollars = price * askSz[k]; const away = price - mid; if (away <= near) asksNear += dollars; if (away <= reach) { if (isNaN(askWallUsd) || dollars > askWallUsd) { askWall2 = askWall; askWall2Usd = askWallUsd; askWall = price; askWallUsd = dollars; } else if (isNaN(askWall2Usd) || dollars > askWall2Usd) { askWall2 = price; askWall2Usd = dollars; } } } } // Insertion sort of one side's price and size pairs by price: bids descending (best first), asks ascending. The // chart's own order is already sorted one way or the other, so this costs little, and it runs on the live bar only. function sortSide(px: StaticArray<f64>, sz: StaticArray<f64>, n: i32, ascending: bool): void { for (let i = 1; i < n; i++) { const p = px[i]; const s = sz[i]; let j = i - 1; while (j >= 0 && (ascending ? px[j] > p : px[j] < p)) { px[j + 1] = px[j]; sz[j + 1] = sz[j]; j -= 1; } px[j + 1] = p; sz[j + 1] = s; } } // The book's price step and the decimals the market prints in, read off the smallest gap between neighbouring levels // of the sorted sides (the chart serves the book at its own grouping, so the step is the grouping, not the exchange tick). function readTick(): void { tick = Infinity; for (let k = 1; k < bids && k < 40; k++) { const gap = bidPx[k - 1] - bidPx[k]; if (gap > 0.0 && gap < tick) tick = gap; } for (let k = 1; k < asks && k < 40; k++) { const gap = askPx[k] - askPx[k - 1]; if (gap > 0.0 && gap < tick) tick = gap; } if (!(tick < Infinity)) { tick = NaN; decimals = 2; return; } let d: i32 = 0; let t = tick; while (d < 6 && Math.abs(t - Math.round(t)) > 1e-6) { t *= 10.0; d += 1; } decimals = d; } // The cumulative dollars at one level of a sorted side (the point a wall callout pins on); NaN when the level is // outside the drawn reach. function cumAt(px: StaticArray<f64>, cum: StaticArray<f64>, n: i32, price: f64): f64 { for (let k = 0; k < n; k++) if (px[k] == price) return cum[k]; return NaN; } // Text sinks: 0 writes into the string builder (the card's slots), 1 into the frame buffer (the panel's labels). function putText(sink: i32, s: string): void { if (sink == 0) sb_text(s); else fb_text(s); } function putInt(sink: i32, v: i32): void { if (sink == 0) sb_int(v); else fb_int(v); } function putFixed(sink: i32, v: f64, d: i32): void { if (sink == 0) sb_f64(v, d); else fb_f64(v, d); } // A price with thousands separators and the market's decimals: 85,115 or 0.1412. function putPrice(sink: i32, v: f64, d: i32): void { let scale = 1.0; for (let i = 0; i < d; i++) scale *= 10.0; const rounded = Math.round(v * scale) / scale; const whole = Math.floor(rounded); let group = 1.0; while (whole >= group * 1000.0) group *= 1000.0; let rest = whole; let first = true; while (group >= 1.0) { const g = Math.floor(rest / group); if (!first) { putText(sink, ","); if (g < 100.0) putText(sink, "0"); if (g < 10.0) putText(sink, "0"); } putInt(sink, i32(g)); rest -= g * group; group /= 1000.0; first = false; } if (d > 0) { putText(sink, "."); let frac = i32(Math.round((rounded - whole) * scale)); if (frac >= i32(scale)) frac = i32(scale) - 1; let place = i32(scale) / 10; while (place > 1 && frac < place) { putText(sink, "0"); place /= 10; } putInt(sink, frac); } } // Money as $26.1M, $950.0K, $1.2B, one decimal at K and up, whole dollars under 1K. function putUsd(sink: i32, x: f64): void { putText(sink, "$"); if (x >= 1.0e9) { putFixed(sink, x / 1.0e9, 1); putText(sink, "B"); } else if (x >= 1.0e6) { putFixed(sink, x / 1.0e6, 1); putText(sink, "M"); } else if (x >= 1.0e3) { putFixed(sink, x / 1.0e3, 1); putText(sink, "K"); } else putFixed(sink, x, 0); } // One wall callout in the frame's markers list: pinned on the side's staircase at the wall's price, at the exact // cumulative value there (an exact y, because the lane pins a named series' callout on the first series only). function fbWallMarker(price: f64, dollars: f64, y: f64, color: string): void { if (isNaN(price) || isNaN(y)) return; fb_text(',{"x":'); fb_num(price); fb_text(',"valign":"point","y":'); fb_f64(y, 0); fb_text(',"color":'); fb_str(color); fb_text(',"badge":true,"label":"'); putUsd(1, dollars); fb_text(' wall"}'); } // The curve frame, from the stored snapshot: rows ascending by price (the far bid up to the best bid, then the best // ask up to the far ask), each row one side's cumulative dollars from the touch outward and null on the other side. function writeCurve(side: f64, ratio: f64): void { const reach = mid * rangePct / 100.0; let nb: i32 = 0; let cum = 0.0; for (let k = 0; k < bids; k++) { if (mid - bidPx[k] > reach) break; cum += bidPx[k] * bidSz[k]; bidCum[k] = cum; nb = k + 1; } let na: i32 = 0; cum = 0.0; for (let k = 0; k < asks; k++) { if (askPx[k] - mid > reach) break; cum += askPx[k] * askSz[k]; askCum[k] = cum; na = k + 1; } fb_clear(); fb_text('{"title":"Depth within '); fb_f64(rangePct, rangePct == Math.floor(rangePct) ? 0 : 1); fb_text('% of the mid","rows":['); let rows: i32 = 0; for (let k = nb - 1; k >= 0; k--) { if (rows > 0) fb_text(","); fb_text("["); fb_num(bidPx[k]); fb_text(","); fb_f64(bidCum[k], 0); fb_text(",null]"); rows += 1; } for (let k = 0; k < na; k++) { if (rows > 0) fb_text(","); fb_text("["); fb_num(askPx[k]); fb_text(",null,"); fb_f64(askCum[k], 0); fb_text("]"); rows += 1; } fb_text('],"markers":[{"x":'); fb_num(mid); fb_text(',"label":"Mid '); putPrice(1, mid, decimals); fb_text('","line_style":"dashed","color":"#94a3b8","badge":true}'); fbWallMarker(bidWall, bidWallUsd, cumAt(bidPx, bidCum, nb, bidWall), "#2dd4bf"); fbWallMarker(bidWall2, bidWall2Usd, cumAt(bidPx, bidCum, nb, bidWall2), "#2dd4bf"); fbWallMarker(askWall, askWallUsd, cumAt(askPx, askCum, na, askWall), "#f86800"); fbWallMarker(askWall2, askWall2Usd, cumAt(askPx, askCum, na, askWall2), "#f86800"); fb_text('],"badge":{"text":"'); if (side == BIDS_HEAVIER) { fb_text("Bids heavier "); fb_f64(ratio, 1); fb_text("x"); } else if (side == ASKS_HEAVIER) { fb_text("Asks heavier "); fb_f64(1.0 / ratio, 1); fb_text("x"); } else if (isNaN(ratio)) fb_text("Thin near the mid"); else fb_text("Balanced book"); fb_text('","color":'); fb_str(side == BIDS_HEAVIER ? "#2dd4bf" : side == ASKS_HEAVIER ? "#f86800" : "#94a3b8"); fb_text("}"); if (bookBar != barIndex) { fb_text(',"caption":"Last snapshot, '); fb_int(barIndex - bookBar); fb_text(' bars ago"'); } fb_text("}"); writeFrameBuffer(FRAME_DEPTH); } function onStart(): void { rangePct = p_range_pct(); nearPct = p_near_pct(); } // onBar() runs once per bar: read the snapshot and write the data outputs; on the live bar write the card's words and // the curve from the newest snapshot the chart delivered. function onBar(): void { barIndex += 1; if (readBook()) measure(); else clearReadings(); const ratio = bidsNear > 0.0 && asksNear > 0.0 ? bidsNear / asksNear : NaN; let side = BALANCED; if (ratio >= LEAN) side = BIDS_HEAVIER; else if (ratio <= 1.0 / LEAN) side = ASKS_HEAVIER; // a NaN ratio passes neither test and stays balanced const spreadPct = isNaN(mid) ? NaN : ((bestAsk - bestBid) / mid) * 100.0; out_side(side); out_ratio(ratio); out_bids_near(bidsNear); out_asks_near(asksNear); out_spread_pct(spreadPct); out_bid_wall(bidWall); out_bid_wall_usd(bidWallUsd); out_ask_wall(askWall); out_ask_wall_usd(askWallUsd); if (!bar.isLast()) return; sb_clear(); sb_f64(nearPct, nearPct == Math.floor(nearPct) ? 0 : 1); sb_text("%"); str_near_text_sb(); if (bookBar < 0) { // no bar ever carried both sides of a book: the short sentence that fits the headline, nothing else sb_clear(); sb_text("No order book"); str_ratio_text_sb(); return; } if (isNaN(mid)) measure(); // the live bar carries no snapshot: the card and the curve read the newest one instead sortSide(bidPx, bidSz, bids, false); // bids best first, asks best first: the curve walks each side from the touch sortSide(askPx, askSz, asks, true); readTick(); const r = bidsNear > 0.0 && asksNear > 0.0 ? bidsNear / asksNear : NaN; let s = BALANCED; if (r >= LEAN) s = BIDS_HEAVIER; else if (r <= 1.0 / LEAN) s = ASKS_HEAVIER; sb_clear(); if (s == BIDS_HEAVIER) { sb_text("Bids "); sb_f64(r, 1); sb_text("x asks"); } else if (s == ASKS_HEAVIER) { sb_text("Asks "); sb_f64(1.0 / r, 1); sb_text("x bids"); } else if (isNaN(r)) sb_text("Nothing resting within the band"); else sb_text("Balanced book"); str_ratio_text_sb(); sb_clear(); if (isNaN(bidWallUsd) && isNaN(askWallUsd)) sb_text("-"); else if (isNaN(askWallUsd) || bidWallUsd >= askWallUsd) { putUsd(0, bidWallUsd); sb_text(" bid at "); putPrice(0, bidWall, decimals); } else { putUsd(0, askWallUsd); sb_text(" ask at "); putPrice(0, askWall, decimals); } str_wall_text_sb(); sb_clear(); const gap = bestAsk - bestBid; const sp = (gap / mid) * 100.0; if (!isNaN(tick) && gap <= tick * 1.0001) { // the touch sits inside one step of the book's grouping sb_text("up to "); putPrice(0, tick, decimals); sb_text(" ("); sb_f64(sp, sp >= 0.01 ? 2 : 4); sb_text("%)"); } else { sb_f64(sp, sp >= 0.01 ? 2 : 4); sb_text("% ("); putPrice(0, gap, decimals); sb_text(")"); } str_spread_text_sb(); writeCurve(s, r); } ``` ## How it works **The book is a block of tuples.** `input("book", book.cells, { max_cells: 1000, block_size: 10 })` hands `onBar()` this bar's snapshot as one `[price, size, side]` tuple per level, side +1 a bid and -1 an ask, sizes in coins, up to 500 levels a side, so 1000 tuples hold any bar; `block_size` is required by the declaration and the chart keeps its own grouping. `in_book_cells()` counts the numbers delivered (tuples times three), 0 for an empty block and -1 for a bar that carries none, and `in_book_view()` is the cells in place. `readBook()` walks whole tuples by price and side, never by position, and copies the snapshot into the side arrays only when both sides are present and the book is not crossed; otherwise the arrays keep the newest usable one and `bookBar` remembers which bar it came from. **Every bar writes the numbers.** The mid is (best bid + best ask) / 2 and a level's dollars are price times size. `measure()` sums the dollars within `near_pct` of the mid per side and finds the two largest single levels per side within `range_pct`; the ratio is bid dollars over ask dollars in the band, and `side` reads 2 (bids heavier) at `LEAN` (1.05) and up, 0 (asks heavier) at 1/1.05 and under, 1 between or when nothing rests in the band. The nine data-only outputs (`side`, `ratio`, `bids_near`, `asks_near`, `spread_pct`, `bid_wall`, `bid_wall_usd`, `ask_wall`, `ask_wall_usd`) are written on every bar, so a watch or an alert can read the book too; a bar without a usable snapshot writes NaN to them (`side` reads 1). The sort, the tick and the words wait for `bar.isLast()`. **One frame is the curve.** On the live bar `sortSide()` orders each side best first, and `writeCurve()` builds the `depth` frame in the frame buffer with the `fb_*` builders, allocation-free: one row per level within `range_pct`, `[price, bid dollars | null, ask dollars | null]`, ascending by price, each side's cumulative dollars from the touch outward, so the bids fall toward the mid, the asks rise away from it, and the two staircases meet at the mid with the spread as the gap between them. `panel.line` draws it below the chart on a number axis (`x: "number"`, `x_format: "usd"`, so the price ticks read "$85.0K") with `style: "step"` on both series and `fill: true` with a 55-alpha `fill_color`, so the area reads without hiding the grid. `chrome: "grid"` is what the step series, the fills and the markers need; the value axis prints whole dollars (`format: "usd", decimals: 0`), `y_zero: true` keeps zero on it, `hover_card: true` opens a readout under the pointer, and `height_frac: 0.32` gives the pane about a third of the chart. **Markers, callouts, chip and caption ride the frame.** Beside `rows`, the frame carries `markers`: the mid as a dashed slate line (`line_style: "dashed"`, `badge: true`, its label "Mid 85,115" through `putPrice()`), and up to four wall callouts, each a marker with `valign: "point"` at the wall's price and an exact `y`, the cumulative dollars at that level from `cumAt()`, so the dot sits on top of the jump and `fbWallMarker()` prints "$26.1M wall" beside it through `putUsd()`. The frame's `badge` is the chip on the title row, its text and colour following `side` (teal, orange or slate); the `caption` ("Last snapshot, 3 bars ago") is written only when the live bar carried no snapshot and the curve reads an older one. The frame's `title` prints the reach ("Depth within 2% of the mid"). **The card reads slots and a ladder.** `render.hud("depth_card", { position: "top_left", safe_area: true, look: "phosphor", title: "Order book within {{near_text}}", columns: 1, width: 300, tiles: [...] })` is the card: a `tile.pill` headline reading the `ratio_text` slot at `font_size: 20`, coloured by the `side` ladder (`color_by: "side"`, orange, slate, teal), and a `tile.rows` of `wall_text` and `spread_text`. The slots are written on the live bar with the `sb_*` builders, and the title takes the band from the `near_text` slot ("1%"), since a HUD title resolves outputs and slots, not settings. Prices print with thousands separators and the decimals the book's step needs: `readTick()` reads the smallest gap between neighbouring levels (25 gives 0 decimals, 0.0001 gives 4) and `putPrice()` writes them digit by digit into either sink, so BTC prints "85,115" and a sub-dollar alt prints "0.1412". **The spread is the book's step.** The chart serves the book at its own grouping (25-dollar steps on BTCUSDT), so the best bid and best ask groups sit one step apart whatever the exchange spread is: a touch inside one step reads "up to 25 (0.03%)", and a wider gap prints the percent of the mid and the price gap ("0.07% (0.0001)"), with 2 decimals at 0.01% and up and 4 under. `spread_pct` carries the same percent as a data-only output. ## Where it runs Any market the chart serves an order book for: crypto perpetuals and spot on Binance, Bybit, OKX, Hyperliquid and the other venues, on any interval (15m and 1h alike): the curve reads the live snapshot, not the bars. No market refuses the run by name: where there is no book the lane answers an empty block on every bar and the run goes on without a toast, so the card does the telling (below). The book and the candles are two lanes, so the mid can sit a few dollars off the last trade while the bar forms. The pane sits below the chart and the card at the top left of the price pane, under the legend (`safe_area: true`), where it covers the oldest candles on screen instead of the newest. ## When data is missing On a market without an order book (gold and the other FX_OTC pairs, stocks, a prediction market when the lane serves none) no bar ever carries both sides, so the card's headline prints the short sentence `No order book`, whole at the headline's 20 px, in slate through the `side` ladder; the Biggest wall and Spread rows print a dash (their slots are never written) and the pane is not drawn (the frame is never written). When the live bar carries no usable snapshot (none delivered, a side missing, or a crossed book) the curve and the card read the newest bar that had one and the pane's caption says how many bars ago that was ("Last snapshot, 3 bars ago"). Bars before the first usable snapshot write NaN to every data output. When a side has nothing resting within `near_pct` the ratio is NaN: the chip reads "Thin near the mid" and the headline "Nothing resting within the band", both in slate; when no level sits within `range_pct` the Biggest wall row prints a dash. ## Customize it - **Reach.** `range_pct` (2, from 0.5 to 10 in steps of 0.5) sets how far from the mid the curve reaches, percent each side; the pane's title follows it ("Depth within 2% of the mid") and the walls are searched within the same reach. - **The band.** `near_pct` (1, from 0.1 to 5 in steps of 0.1) is the band the ratio, the chip and the card's headline read; the card's title prints it ("Order book within 1%"). - **A wider lean.** `LEAN` (1.05) is the ratio either side of 1 before a side is named; raise it in the source and more books read as "Balanced book". - **Other inks.** The bid and ask colours are literals on the two series (teal `#2dd4bf`, orange `#f86800`), repeated on the wall callouts, the chip and the headline ladder: a panel series takes a literal, not a `param.color`, so change them in the source and Run again. - **Change the look.** The card is made in `phosphor`; the Look row on the indicator's Style page switches it to glass, this example's other look, or any other shipped look without code ([The Style page](../settings/style-page.md#the-look-row)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Depth Curve** under **Beyond the time axis**. 2. Press **Run** on a chart with an order book, such as BTCUSDT on Binance Futures at 15m: the depth curve fills the pane under the chart with the chip on its title row, and the phosphor card appears at the top left of the price pane, under the legend. 3. At the editor's Console prompt, type `last 20 ratio` to read bid dollars over ask dollars within the band on the last 20 bars. ## Concepts used - [Order flow](../functions/order-flow-kit.md) for `book.cells`, its `[price, size, side]` tuple, `max_cells` and the `in_book_cells()` / `in_book_view()` loop - [Data sources](../core-concepts/data-sources.md) for the `book` source and the empty block a market without a book answers - [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames and the `fb_*` builders, `panel.line` on a number axis, step series and fills, markers, callouts with `valign: "point"` and `y`, the `badge` chip and the `caption` - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) for `render.hud`, its anchors, `safe_area` and `width` - [Looks](../presentation/hud-and-hover-cards.md#looks) for `look: "phosphor"`, `glass` and what each draws - [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the headline `tile.pill` and `tile.rows` - [The Style page](../settings/style-page.md#the-look-row) for the Look row that switches the look without code - [Strings and text](../functions/text-formatting.md) for the `sb_*` builders and string slots <!-- source: https://openmarket.xyz/wrun/cookbook/yield-curve --> # Yield curve ![Treasury curve pane below a BTC chart with today's teal curve, the 2Y and 10Y callouts, the 2s10s chip and the broadsheet card at the top right](/wrun/images/yield-curve.png) The US Treasury par curve as it prints today, against a week ago and a month ago, in a pane below the chart: the nine tenors evenly spaced across, 1M to 30Y, so the front end reads as wide as the long end, yield in percent on the y axis, today's curve in teal and wider under a halo, the week curve in sky, the month curve in dashed slate, each tenor a dot. Today's 2Y and 10Y points are called out on the curve with their yields, and the 2s10s spread (10Y minus 2Y) sits in a chip on the pane's title row, teal while the curve is normal and rose while it is inverted, and as the headline of a broadsheet card at the top right (newsprint, serif type, small caps, dot leaders) with the 10Y and 3M yields under it. The legend names the curves the way the settings do (Today, Week curve, Month curve), and the pane's caption says how far back the week and month curves really are. The trader reads whether the curve is steep, flat or inverted at the front (a negative 2s10s is an inverted curve), and which way it moved over the week and the month, beside the coin's price. The parts are nine `economic.value` inputs pinned to FRED's daily series DGS1MO through DGS30 on the default `carry` policy ([Data sources](../core-concepts/data-sources.md)), two `param.int` day settings turned into bars by `chart.interval_sec()` ([Sessions and units](../settings/sessions-and-units.md)), a frame feeding a `panel.line` on a category axis with three series, two point callouts, a badge, a caption and a value axis set per run ([Cards, frames and panels](../presentation/cards-frames-panels.md)), a `render.hud` card of a headline `tile.pill` and a `tile.rows` in the broadsheet look ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards)), and a `render.label` at the top centre for the one sentence ([Plotting](../presentation/plotting.md)). This is also the `yield-curve` template: the **Yield Curve** card under **Beyond the time axis** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Yield Curve: the US Treasury par curve as it prints today against a week ago and a month ago, the tenors evenly spaced // across a pane below the chart, today's 2Y and 10Y points called out, the 2s10s spread (10Y minus 2Y) named in a // chip on the pane and on a broadsheet card at the top right with the 10Y and 3M yields under it. The trader sees // whether the curve is steep, flat or inverted at the front, and which way it moved over the week and the month, // beside the coin's price. The yields are FRED's daily constant-maturity series (DGS1MO to DGS30), in percent, on the // default carry policy: every bar reads the newest print at or before its day, the chart's first bar included (the // chart also fetches the prints from before its window), so a 1m chart that opens in the middle of a day still has // today's curve. A new print first shows on the first bar of its day; the week and month curves read the newest print // at or before their day. param.int("week", 7, { min: 1, max: 60, label: "Week curve, days back", description: "Days back for the week curve, the second line" }); param.int("month", 30, { min: 2, max: 365, label: "Month curve, days back", description: "Days back for the month curve, the third line" }); chart.interval_sec(); // the bar interval in seconds, written by the chart: days become bars without reading the clock input("close", ohlcv.close); // the primary input: the chart's own candles define the grid the daily prints land on input("y1m", economic.value, { publisher: "FRED", series: "DGS1MO", description: "1-month Treasury yield, percent" }); input("y3m", economic.value, { publisher: "FRED", series: "DGS3MO", description: "3-month Treasury yield, percent" }); input("y1", economic.value, { publisher: "FRED", series: "DGS1", description: "1-year Treasury yield, percent" }); input("y2", economic.value, { publisher: "FRED", series: "DGS2", description: "2-year Treasury yield, percent" }); input("y5", economic.value, { publisher: "FRED", series: "DGS5", description: "5-year Treasury yield, percent" }); input("y7", economic.value, { publisher: "FRED", series: "DGS7", description: "7-year Treasury yield, percent" }); input("y10", economic.value, { publisher: "FRED", series: "DGS10", description: "10-year Treasury yield, percent" }); input("y20", economic.value, { publisher: "FRED", series: "DGS20", description: "20-year Treasury yield, percent" }); input("y30", economic.value, { publisher: "FRED", series: "DGS30", description: "30-year Treasury yield, percent" }); output("spread", none, overlay, { description: "2s10s: the 10Y yield minus the 2Y yield in percentage points, below zero while the curve is inverted" }); string("spread_text", { max_bytes: 16 }); // "+0.12", the card's headline, or "no print" string("y10_text", { max_bytes: 16 }); // "4.12%", or "no print" string("y3m_text", { max_bytes: 16 }); string("note", { max_bytes: 96 }); // the one slate sentence, with its way out, while no tenor has a print on this chart const curveRows = frame("curve_rows", { max_bytes: 4096 }); // nine rows: [tenor, today, the week curve, the month curve], null for a tenor without a print // The curve: three smooth lines over the tenors, evenly spaced so the front end reads as wide as the long end, today in // teal and wider under the halo, the week curve in sky, the month curve in dashed slate, each tenor a dot; the 2Y and // 10Y callouts, the 2s10s chip, the value axis with headroom under the chip and the caption with each curve's real // distance ride the frame. panel.line({ name: "curve", title: "US Treasury curve", x: "category", place: "below", frame: curveRows, chrome: "grid", smooth: true, points: true, glow: true, hover_card: true, legend_style: "chips", x_title: "Tenor", y_title: "Yield", format: "%", decimals: 2, height_frac: 0.32, series: [{ name: "Today", color: "#2dd4bf", width: 2.5 }, { name: "Week curve", color: "#38bdf8", width: 1.5 }, { name: "Month curve", color: "#94a3b8", width: 1.5, line_style: "dashed" }] }); // The card, in the broadsheet look (newsprint, serif type, small caps, dot leaders; the Style page's Look row switches // it, classic included): the 2s10s spread as the headline, then the 10Y and the 3M yields. render.hud("yields", { position: "top_right", look: "broadsheet", title: "US Treasury curve, latest print", columns: 1, width: 240, safe_area: true, tiles: [ tile.pill("2s10s, 10Y minus 2Y, pts", "spread_text", { headline: true }), tile.rows([["10Y", "y10_text"], ["3M", "y3m_text"]]), ] }); // The sentence, only while no tenor has a print: top centre, clear of the card, and drawn whole (a corner label is // centred on its anchor, so at a right-hand corner half of a long sentence would run past the pane edge). render.label("note_label", { position: "top_center", text: "note", color: "#94a3b8", style: "knockout", offset: [0, 8] }); const TENORS = 9; const TENOR_NAMES: StaticArray<string> = ["1M", "3M", "1Y", "2Y", "5Y", "7Y", "10Y", "20Y", "30Y"]; // the x keys, in order const OBS = 256; // prints kept per tenor: the value and the bar it first showed on; a print equal to the newest kept one is not kept again const NO_PRINT = "no print"; // a card value without a print: words, never the dash an unwritten slot shows const obsBar = new StaticArray<i32>(TENORS * OBS); const obsVal = new StaticArray<f64>(TENORS * OBS); const obsHead = new StaticArray<i32>(TENORS); // the newest kept slot per tenor, -1 before the first print const obsCount = new StaticArray<i32>(TENORS); const today = new StaticArray<f64>(TENORS); // the live bar's three curves, NaN for a tenor without a print const weekAgo = new StaticArray<f64>(TENORS); const monthAgo = new StaticArray<f64>(TENORS); const todaySlot = new StaticArray<i32>(TENORS); // the kept print each curve read per tenor, -1 for none const weekSlot = new StaticArray<i32>(TENORS); const monthSlot = new StaticArray<i32>(TENORS); let barIndex = -1; // bars since the oldest loaded one let intervalSec = 900.0; // settings, read in onStart() let weekBars = 672; let monthBars = 2880; let weekFallback = 0; // how the week and month curves were found on the live bar: 0 at their day, 1 the oldest print loaded, 2 none let monthFallback = 0; let weekDup = false; // the week curve repeats today's print for every tenor (no older print loaded): it is not drawn twice let monthDup = false; // the month curve repeats the week's let weekDays = 7.0; // the real distance of each curve in days, for the caption let monthDays = 30.0; function onStart(): void { intervalSec = p_chart_interval_sec(); if (intervalSec <= 0.0) intervalSec = 900.0; // interval unknown: count as a 15-minute chart weekBars = i32(Math.round((f64(p_week()) * 86400.0) / intervalSec)); monthBars = i32(Math.round((f64(p_month()) * 86400.0) / intervalSec)); if (weekBars < 1) weekBars = 1; if (monthBars <= weekBars) monthBars = weekBars + 1; for (let k = 0; k < TENORS; k += 1) { obsHead[k] = -1; obsCount[k] = 0; } } // Keep this bar's print of tenor k, unless it repeats the newest kept one: every bar of a day reads the day's print and // a weekend bar Friday's, so a print is kept once, on the bar it first shows on. function keep(k: i32, v: f64): void { const h = obsHead[k]; if (h >= 0 && obsVal[k * OBS + h] == v) return; const next = (h + 1) % OBS; obsBar[k * OBS + next] = barIndex; obsVal[k * OBS + next] = v; obsHead[k] = next; if (obsCount[k] < OBS) obsCount[k] += 1; } // The newest kept print of tenor k at or before bar 'at': its slot, or -1 when every kept print landed later. function slotAtOrBefore(k: i32, at: i32): i32 { const n = obsCount[k]; const h = obsHead[k]; for (let j = 0; j < n; j += 1) { const s = (h + OBS - j) % OBS; if (obsBar[k * OBS + s] <= at) return s; } return -1; } function oldestSlot(k: i32): i32 { const n = obsCount[k]; return n == 0 ? -1 : (obsHead[k] + OBS - (n - 1)) % OBS; } function daysBack(bar: i32): f64 { return (f64(barIndex - bar) * intervalSec) / 86400.0; } // One curve 'back' bars behind the live bar into 'into': a tenor's print at or before that bar, else the oldest print // loaded (fallback 1), else NaN (fallback 2). Returns the fallback of the curve as a whole, records the slot each tenor // read and leaves the curve's real distance in days. function curveAt(back: i32, into: StaticArray<f64>, slots: StaticArray<i32>): i32 { const at = barIndex - back; let fallback = 2; let oldestBar = -1; for (let k = 0; k < TENORS; k += 1) { let s = slotAtOrBefore(k, at); if (s >= 0) { into[k] = obsVal[k * OBS + s]; slots[k] = s; fallback = 0; continue; } s = oldestSlot(k); slots[k] = s; if (s < 0) { into[k] = NaN; continue; } into[k] = obsVal[k * OBS + s]; if (fallback == 2) fallback = 1; if (obsBar[k * OBS + s] > oldestBar) oldestBar = obsBar[k * OBS + s]; } if (fallback == 1 && oldestBar >= 0) { if (into == weekAgo) weekDays = daysBack(oldestBar); else monthDays = daysBack(oldestBar); } return fallback; } // True when curve 'a' read the same kept print as curve 'b' at every tenor: the same line twice. function sameCurve(a: StaticArray<i32>, b: StaticArray<i32>): bool { for (let k = 0; k < TENORS; k += 1) if (a[k] != b[k]) return false; return true; } let lo: f64 = Infinity; // the drawn curves' lowest and highest yield on the live bar let hi: f64 = -Infinity; function widen(v: f64): void { if (!isNaN(v)) { if (v < lo) lo = v; if (v > hi) hi = v; } } function fbPct(v: f64): void { // a yield as "4.12%" inside a JSON string the caller opened fb_f64(v, 2); fb_text("%"); } function fbCallout(tenor: string, v: f64, color: string): void { // a callout on today's curve at a tenor, named and valued fb_text('{"x":'); fb_str(tenor); fb_text(',"valign":"point","series":"Today","badge":true,"label":"'); fb_text(tenor); fb_text(" "); fbPct(v); fb_text('","color":"'); fb_text(color); fb_text('"}'); } function fbDays(d: f64): void { fb_int(i32(Math.round(d))); fb_text("d back"); } function fbCurveNote(fallback: i32, dup: bool, same: string, days: f64): void { // "7d back", "oldest, 16d back", "same as today" or "no older print" if (fallback == 2 || (dup && fallback == 1)) { // nothing older than the newer curve is loaded fb_text("no older print"); return; } if (dup) { fb_text("same as "); fb_text(same); return; } // read at its day, the very prints of the newer curve: drawn once, named here if (fallback == 1) fb_text("oldest, "); fbDays(days); } // The card's three values, each written only when it is known, else "no print": every slot is written, so no tile // ever shows the dash an unwritten slot reads as. function writeCard(spread: f64, y10: f64, y3m: f64): void { sb_clear(); if (isNaN(spread)) sb_text(NO_PRINT); else { if (spread >= 0.0) sb_text("+"); sb_f64(spread, 2); } str_spread_text_sb(); sb_clear(); if (isNaN(y10)) sb_text(NO_PRINT); else { sb_f64(y10, 2); sb_text("%"); } str_y10_text_sb(); sb_clear(); if (isNaN(y3m)) sb_text(NO_PRINT); else { sb_f64(y3m, 2); sb_text("%"); } str_y3m_text_sb(); } // onBar() runs once per bar: keep each tenor's print where it first shows; on the live bar read today's curve, the // week's and the month's, write the card's slots and the pane's frame. Nothing is drawn per bar. function onBar(): void { barIndex += 1; // Under carry a tenor reads NaN only before its series' first print, so a NaN is skipped, never kept. const v1m = in_y1m(); if (!isNaN(v1m)) keep(0, v1m); const v3m = in_y3m(); if (!isNaN(v3m)) keep(1, v3m); const v1 = in_y1(); if (!isNaN(v1)) keep(2, v1); const v2 = in_y2(); if (!isNaN(v2)) keep(3, v2); const v5 = in_y5(); if (!isNaN(v5)) keep(4, v5); const v7 = in_y7(); if (!isNaN(v7)) keep(5, v7); const v10 = in_y10(); if (!isNaN(v10)) keep(6, v10); const v20 = in_y20(); if (!isNaN(v20)) keep(7, v20); const v30 = in_y30(); if (!isNaN(v30)) keep(8, v30); if (!bar.isLast()) return; let printed = 0; for (let k = 0; k < TENORS; k += 1) { const h = obsHead[k]; todaySlot[k] = h; today[k] = h < 0 ? NaN : obsVal[k * OBS + h]; if (h >= 0) printed += 1; } if (printed == 0) { // no tenor has a print on this chart yet: the one sentence with its way out, the card in words str_note("No Treasury prints reached this chart yet: open the 1h chart or pan back a day"); writeCard(NaN, NaN, NaN); return; } str_note(""); // the sentence is absent while a print stands (an empty text draws no label) weekDays = f64(weekBars) * intervalSec / 86400.0; monthDays = f64(monthBars) * intervalSec / 86400.0; weekFallback = curveAt(weekBars, weekAgo, weekSlot); monthFallback = curveAt(monthBars, monthAgo, monthSlot); weekDup = sameCurve(weekSlot, todaySlot); monthDup = sameCurve(monthSlot, weekSlot); const spread = today[6] - today[3]; // 2s10s, NaN while 2Y or 10Y has no print out_spread(spread); writeCard(spread, today[6], today[1]); fb_clear(); fb_text('{"rows":['); for (let k = 0; k < TENORS; k += 1) { fb_text(k == 0 ? "[" : ",["); fb_str(TENOR_NAMES[k]); fb_text(","); fb_f64(today[k], 2); fb_text(","); fb_f64(weekDup ? NaN : weekAgo[k], 2); fb_text(","); fb_f64(monthDup ? NaN : monthAgo[k], 2); fb_text("]"); } fb_text('],"markers":['); let callouts = 0; if (!isNaN(today[3])) { fbCallout("2Y", today[3], "#2dd4bf"); callouts += 1; } if (!isNaN(today[6])) { if (callouts > 0) fb_text(","); fbCallout("10Y", today[6], "#2dd4bf"); } fb_text("]"); lo = Infinity; // the value axis: the drawn curves' range, with headroom above it so the chip never sits on a line hi = -Infinity; for (let k = 0; k < TENORS; k += 1) { widen(today[k]); if (!weekDup) widen(weekAgo[k]); if (!monthDup) widen(monthAgo[k]); } if (hi >= lo) { const pad = Math.max(0.05, (hi - lo) * 0.1); fb_text(',"y_min":'); fb_f64(lo - pad, 2); fb_text(',"y_max":'); fb_f64(hi + 3.0 * pad, 2); } if (!isNaN(spread)) { fb_text(',"badge":{"text":"2s10s '); if (spread >= 0.0) fb_text("+"); fb_f64(spread, 2); fb_text('","color":"'); fb_text(spread < 0.0 ? "#fb7185" : "#2dd4bf"); fb_text('"}'); } fb_text(',"caption":"Week '); // at most 64 characters: how far back each curve really is fbCurveNote(weekFallback, weekDup, "today", weekDays); fb_text("; month "); fbCurveNote(monthFallback, monthDup, "week", monthDays); fb_text('"}'); writeFrameBuffer(FRAME_CURVE_ROWS); } ``` ## How it works **Nine pins are the inputs.** `input("y10", economic.value, { publisher: "FRED", series: "DGS10" })` and its eight twins (`DGS1MO`, `DGS3MO`, `DGS1`, `DGS2`, `DGS5`, `DGS7`, `DGS20`, `DGS30`) read FRED's daily constant-maturity yields in percent; the chart's own close is the first input, so the daily prints land on the chart's bars. Under the default `carry` policy every bar reads the newest print at or before its day, the window's first bar included (the chart also fetches the prints from before its window), so a 1m chart that opens in the middle of a day still has today's curve; a new print first shows on the first bar of its day. A tenor reads NaN only before its series' first print, and `onBar()` skips a NaN. **Prints ride a ring, not bars.** Each tenor keeps its last 256 distinct prints with the bar each landed on (`obsVal` and `obsBar`, a head and a count per tenor). `keep()` skips a print equal to the newest kept one, so the later bars of a day and a weekend bar (each carrying the newest print) never count as a new observation: a print is kept once, on the bar it first shows on (the window's first bar for the print that stood before the window), and the ring covers 256 prints on any interval. A week ago is `slotAtOrBefore()`: the newest kept print at or before the bar `weekBars` behind the live bar. When every kept print landed later, `curveAt()` falls back to the oldest print loaded and records the curve's real distance in days from the bar it read; `sameCurve()` then tells whether the week curve read the same prints as today's, or the month the same as the week's, in which case that curve is written as `null` instead of being drawn twice (its legend chip stays). **Days become bars through the chart's interval.** `week` (7) and `month` (30) are days. `chart.interval_sec()` is a hidden setting the chart fills with the bar interval in seconds, read in `onStart()` through `p_chart_interval_sec()`, so `weekBars` is 672 on a 15m chart, 168 on 1h and 7 on 1d, with `monthBars` 2880, 720 and 30; an unknown interval (0) counts as 15m, and the month is kept at least one bar beyond the week. **One frame is the curve.** On the live bar only, the frame builder (`fb_clear()`, `fb_text()`, `fb_str()` and `fb_f64()`, sent with `writeFrameBuffer(FRAME_CURVE_ROWS)`) writes `curve_rows`: nine rows of `[tenor, today, week curve, month curve]` keyed by the tenor's name from `TENOR_NAMES` ("1M" to "30Y"), two `markers` with `valign: "point"` pinned to the `Today` series at x "2Y" and x "10Y" (the 2Y and 10Y callouts, each a badge printing its yield), a `badge` reading "2s10s +0.46" in teal or rose by the spread's sign, a `y_min` and `y_max` around the drawn curves with three times the margin above them as below, so the chip on the title row never sits on the long end, and a `caption` of at most 64 characters ("Week 7d back; month oldest, 12d back"). `panel.line({ name: "curve", x: "category", place: "below", ... })` draws the frame below the chart, every tenor the same width, with `smooth`, `points` and `glow` on, `legend_style: "chips"` and `format: "%"`; today's series is wider (2.5 against 1.5) and the month curve carries `line_style: "dashed"`. **The card reads three slots and one output.** `spread` is the only output, data-only, the 10Y minus the 2Y in percentage points on the live bar; `spread_text` ("+0.46"), `y10_text` and `y3m_text` ("5.24%") are string slots built with `sb_clear()` and `sb_f64()` and sent with `str_spread_text_sb()` and its twins. `writeCard()` writes all three on the live bar, each as a number when it is known and as "no print" when it is not, so no tile ever shows the dash an unwritten slot reads as. The `render.hud` card sits at `top_right` in one column, 240 px wide, with `safe_area: true` so it starts under the pane action bar and clear of the price-axis tags; its headline `tile.pill` reads `spread_text` and its `tile.rows` the 10Y and 3M slots. The broadsheet look draws the pill as text on newsprint and the rows with dot leaders, and the Style page's Look row switches the look without code. **The sentence is a positioned label.** `render.label("note_label", { position: "top_center", text: "note", color: "#94a3b8", style: "knockout", offset: [0, 8] })` prints the `note` slot in slate on a knockout at the top centre of the price pane, clear of the card and the legend. The slot is written on the live bar: the sentence while no tenor has a print, and empty otherwise (an empty text draws no label), so the label is absent while the yields are served; on that path the card's three slots read "no print". Nothing is drawn per bar: the ring fills on every bar, and the pane, the card and the output are written on the live bar alone. ## Where it runs Every chart the economic lane serves. The yields do not depend on the chart's market, so the same curve draws beside a crypto perpetual (BTCUSDT on Binance Futures at 1m, 15m and 1h) and beside gold (XAU/USD at 15m and 1h), and on a 1d chart the day settings read as bars one to one. How many curves the pane draws depends on how many days the chart has loaded: a 1m chart holds a few hours, so it draws today's curve and its caption reads "no older print" for the week and the month; a 15m chart holds about four days, so the week curve falls back to the oldest print loaded ("Week oldest, 4d back") and the month, which would read the same print, is not drawn twice; a 1h chart holds about twelve days, enough for the week curve at its day and the month curve at the oldest print. No market refuses the run by name. ## When data is missing If FRED answers nothing for one of the nine series, the chart stops the run with its own message that the economic series data is unavailable: on the default `carry` policy an empty series has nothing to carry. If no tenor has a print on this chart, one sentence sits at the top centre of the price pane, "No Treasury prints reached this chart yet: open the 1h chart or pan back a day", the card's three values read "no print", and the curve pane is not opened: the frame and the `spread` output are not written on that bar. A single tenor without a print is written `null` in its row (no dot) and its callout is left out; while the 2Y or the 10Y has none, the spread reads NaN, the chip is left off the pane and the card's headline reads "no print". The card never shows a dash: each of its values is a number when known, else "no print". When the loaded history is shorter than a week or a month, the curve falls back to the oldest print loaded and the caption says how far back that is ("month oldest, 12d back"); when that oldest print is today's at every tenor, the curve is not drawn (its legend chip stays) and its caption part reads "no older print", the usual state on a 1m chart. A curve read at its day that finds the same prints as the newer one (a 3-day week curve on a Monday reads Friday's print, today's newest) is drawn once too, and its caption part reads "same as today" ("same as week" for the month curve). ## Customize it - **Further back.** `week` (**Week curve, days back**: 7 days, 1 to 60) and `month` (**Month curve, days back**: 30 days, 2 to 365) set how far back the week and month curves read; the chart's interval turns the days into bars, so the same numbers read right on 1m, 15m, 1h and 1d charts, and the month is kept at least one bar beyond the week. - **Another spread.** The 2s10s is `today[6] - today[3]`, the 10Y and 2Y slots of the tenor list (`TENOR_NAMES` runs 1M, 3M, 1Y, 2Y, 5Y, 7Y, 10Y, 20Y, 30Y). For the 5s30s read `today[8] - today[4]`, and move the two `fbCallout()` calls to "5Y" and "30Y" with their slots so the callouts follow the spread. - **Tenor in years.** A category axis gives every tenor the same width. For a true maturity scale, write each row's years (0.083 to 30) with `fb_num()` in place of its name, give the callouts their years as `x`, and declare `x: "number"` with `x_format: "auto"`, `x_decimals: 0` and `x_unit: "Y"`; 1M to 2Y then pack into the first few percent of the axis. - **Change the look.** The card is made in the broadsheet look (newsprint paper, serif type, small caps in the title, dot leaders in the rows); the Look row on the indicator's Style page switches it without code, "As made" first and classic among the twelve, which is the second look this example was shot in ([The Style page](../settings/style-page.md#the-look-row), [Looks](../presentation/hud-and-hover-cards.md#looks)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Yield Curve** under **Beyond the time axis**. 2. Press **Run** on any chart, such as BTCUSDT on Binance Futures at 1h: the curve pane opens below the chart with its chips, callouts and caption, and the broadsheet card appears at the top right of the price pane. 3. At the editor's Console prompt, type `last 20 spread` to read the 2s10s in percentage points: it is written on the live bar only, so only the last row carries a number. ## Concepts used - [Data sources](../core-concepts/data-sources.md) for the `economic` source, its `publisher` and `series` words, and the default `carry` policy on a daily series - [Sessions and units](../settings/sessions-and-units.md) for `chart.interval_sec()` and the other facts the chart fills before the first bar - [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames and the `fb_*` builder, `panel.line` on a category axis, point callouts, the badge, the caption and a per-run value axis - [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) for `render.hud`, its anchors, `width` and `safe_area` - [Looks](../presentation/hud-and-hover-cards.md#looks) for the broadsheet look's paper, type and dot leaders - [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the headline `tile.pill` and `tile.rows` - [The Style page](../settings/style-page.md#the-look-row) for the Look row that switches the look without code - [Plotting](../presentation/plotting.md) for the `render.label` placed by `position` <!-- source: https://openmarket.xyz/wrun/cookbook/strategy-ma-cross --> # Moving-average cross ![Moving-average cross strategy with ribbon, cross marks and Strategy Tester](/wrun/images/strategy-ma-cross.png) A strategy: long when the fast average crosses above the slow one, flat when it crosses back under. The fast average (sky) and the slow average (violet) on price, a ribbon between them, amber while the fast average leads and orange while it trails, and a mark on the bar of each cross at the price where the lines met: amber for a cross up (the entry fills at the next open), orange for a cross down (the position closes at the next open). The Strategy Tester under the chart holds the fills on the candles, equity against buy and hold, net profit, win rate, profit factor, drawdown, Sharpe and the trade list. The parts are `strategy(...)` declared in the source with the order calls in `onBar()` ([Strategies overview](../strategies/overview.md)), a `range` between the two averages tinted by a data-only output ([Styling](../presentation/styling.md)), and two `shape` outputs for the cross marks. This is also the `strategy-ma-cross` template: the **Moving-Average Cross** card under **Strategies** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Moving-Average Cross: long when the fast average crosses above the slow one, flat when it crosses back under. // The trader sees both averages on price, a ribbon between them tinted by which one leads (amber while the fast // average is on top, orange while it trails), a mark on the bar of each cross at the price where the lines met, // and the Strategy Tester under the chart with the fills, the equity curve and the numbers. // strategy({...}) makes the package a strategy: equity, sizing (50% of equity per entry), commission and slippage live here. strategy({ initialCapital: 10000, qtyType: "percentOfEquity", qtyValue: 50, commissionPercent: 0.05, slippageBps: 2 }); param.int("fast_len", 9, { min: 1, max: 200, label: "Fast length in bars", row: "lengths", description: "Fast average length in bars" }); // two whole numbers on one dialog line: rows naming the same row word sit side by side param.int("slow_len", 21, { min: 2, max: 400, label: "Slow length in bars", row: "lengths", description: "Slow average length in bars" }); const fastLine = output("fast_ma", line, overlay, { color: "#38bdf8", width: 2, label: "Fast", format: "price", description: "Simple moving average of the close over fast_len bars" }); // the handle names it for the hover card output("slow_ma", line, overlay, { color: "#a78bfa", width: 2, label: "Slow", format: "price", description: "Simple moving average of the close over slow_len bars" }); output("trend", none, overlay, { description: "1 while the fast average is above the slow one, else 0" }); hover(fastLine, [block.value("Fast", "fast_ma", { format: "price" }), block.rows([["Slow", "slow_ma", "price"], ["Trend", "trend", "int"]])]); // the card the fast average opens under the cursor output("entry_cross", shape, overlay, { color: "#f8c000", width: 6, description: "The price where the fast average crossed above the slow one; the long entry fills at the next open" }); output("exit_cross", shape, overlay, { color: "#f86800", width: 6, description: "The price where the fast average crossed below the slow one; the position closes at the next open" }); range("fast_ma", "slow_ma", { color_by: "trend", colors: ["rgba(248, 104, 0, 0.14)", "rgba(248, 192, 0, 0.14)"] }); // the ribbon, tinted by the trend let fastSma = new Sma(9); // onStart() rebuilds both at the chosen lengths let slowSma = new Sma(21); const cross = new Cross(); // +1 on the bar the fast average crosses above the slow one, -1 below, 0 otherwise // onStart() runs once before the first bar: read each setting (i32() = whole bars). function onStart(): void { fastSma = new Sma(i32(p_fast_len())); slowSma = new Sma(i32(p_slow_len())); } // onBar() runs once per bar: fold the close into both windows and leave while either is still warming up (nothing // written, no orders); then place the orders (an order fills at the next bar's open, never earlier) and write the // outputs. The cross marks are NaN on every bar without a cross, so nothing is drawn there. function onBar(): void { const close = bar.close(); const fast = fastSma.update(close); const slow = slowSma.update(close); if (isNaN(fast) || isNaN(slow)) return; const crossed = cross.update(fast, slow); const trend = fast > slow ? 1.0 : 0.0; if (crossed == 1) strategy.long("L").send(); // a market entry under the id L, sized by the settings above if (crossed == -1) strategy.closeAll(); // every open entry closes at the next open const crossPrice = (fast + slow) * 0.5; // where the two averages met on this bar out_fast_ma(fast); out_slow_ma(slow); out_trend(trend); out_entry_cross(crossed == 1 ? crossPrice : NaN); out_exit_cross(crossed == -1 ? crossPrice : NaN); } ``` ## How it works **The file is the strategy.** `strategy({ initialCapital: 10000, qtyType: "percentOfEquity", qtyValue: 50, commissionPercent: 0.05, slippageBps: 2 })` makes the package a strategy: 10,000 starting equity, 50% of equity per entry, 0.05% commission, 2 bps slippage. The primary input is the chart's own close (`bar.close()`, no input line needed), the candles the broker fills against. **Orders fill at the next open.** `onBar()` folds the close into two `Sma` windows (`fast_len` 9, `slow_len` 21) and returns early while either is warming up (nothing written, no orders); `Cross.update(fast, slow)` is +1 on the bar the fast average crosses above the slow one and -1 on the way back. On a cross, `strategy.long("L").send()` places a market entry under the id L and `strategy.closeAll()` closes every open entry; both fill at the next bar's open, never earlier. **The look is three outputs.** `trend` (1 while the fast average is above the slow one) is data-only and drives `range("fast_ma", "slow_ma", { color_by: "trend", colors: [orange, amber] })`, the ribbon; `entry_cross` and `exit_cross` carry the price where the two averages met on a cross bar (NaN elsewhere), the two marks. ## Where it runs Every market with candles: perps, spot, prediction markets, FX. The Strategy Tester replays it in your browser over the chart's loaded window and recomputes as you pan; orders fill against the chart's own candles. The **Strategies** group of the starter list shows where the chart has the Strategy Tester. ## When data is missing The first `slow_len` bars are warm-up: nothing written, no orders, nothing drawn. A market whose candles stop (a resolved prediction market) leaves the last position where the tester closed it. ## Customize it - **Other lengths.** `fast_len` and `slow_len` are settings; the tester replays any pair. - **Both directions.** Add `strategy.short("S").send()` on a cross down in place of the flat exit ([Writing strategies](../strategies/writing-strategies.md)). - **Costs and sizing.** The five numbers in `strategy({ ... })` are the file's: change them and Run again. The tester's **Strategy settings** opens the overlay's settings, where a param changes a strategy field only when the file links it, the way the risk setting is linked into `qtyValue` in [Your first strategy](../strategies/first-strategy.md#5-tune-it-honestly); [Risk-sized reversion](strategy-risk-reversion.md) sizes each entry from its stop instead. - **Alert on the trades.** Once the indicator is published and on a chart, the alert dialog adds the strategy's own choices: "Strategy order placed", "Strategy trade opened or closed", "Strategy position" and "Strategy equity" ([Alerts](../functions/alerts.md)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Moving-Average Cross** under **Strategies**. 2. Press **Run**: the two averages, the ribbon and the cross marks draw on price, and the Strategy Tester under the chart shows the fills, the equity against buy and hold, and the trade list. ## Concepts used - [Strategies overview](../strategies/overview.md) for `strategy(...)`, the order builders and where they fill - [Reading the tester](../strategies/reading-the-tester.md) for the numbers under the chart - [Styling](../presentation/styling.md) for `range` bands and colour ladders <!-- source: https://openmarket.xyz/wrun/cookbook/strategy-risk-reversion --> # Risk-sized reversion ![RSI reversion strategy with entry, stop and target rails](/wrun/images/strategy-risk-reversion.png) A strategy: buy the dip as RSI climbs back out of oversold, protect it with a stop under the entry and a target above it (both sized in ATR), leave on recovery; each entry is sized so a trade stopped out loses the risk setting's percent of the equity (1% by default), never buying more than the equity holds. RSI (violet) in its own pane under the chart, with the oversold level (30, dashed), the recovery level (55, dotted sky) and the overbought level (70, dashed). On price, the rails of each trade from the fill bar to the bar it closed: the entry (sky), the stop (orange, dashed) and the target (amber, dashed). The open trade's rails are bright; past trades' rails stay dim, the newest 20 kept. The Strategy Tester under the chart holds the fills, equity against buy and hold, the numbers and the trade list. The parts are an entry sized from its stop with `qty(...)` and `strategy.equity()` ([Writing strategies](../strategies/writing-strategies.md)), a stop and a limit exit re-armed each bar the trade is held ([Your first strategy](../strategies/first-strategy.md)), the `Rsi`, `Atr` and `Cross` helpers ([TA library](../functions/ta-library.md)), and line handles in chart time and price for the rails ([Drawing objects](../presentation/drawing-objects.md)). This is also the `strategy-risk-reversion` template: the **Risk-Sized Reversion** card under **Strategies** in the editor's starter list, and it compiles as written. ## The wrun indicator ```typescript // Risk-Sized Reversion: buy the dip as RSI climbs back out of oversold, protect it with a stop under the entry and a // target above it, both sized in ATR so they fit the market and the interval, and leave on recovery. Each entry is // sized so a trade stopped out loses risk_pct of the equity, never buying more than the equity holds. // The trader sees RSI in its own pane with the oversold, recovery and overbought levels, and on price the rails of // each trade: the entry (sky), the stop (orange, dashed) and the target (amber, dashed), drawn from the fill bar to // the bar the trade closed; the open trade's rails are bright, past trades' rails stay dim. The Strategy Tester under // the chart holds the fills and the numbers. // The settings sit on two pages of the dialog: page() opens one, and the rows declared after it land on it. page("Signal"); param.int("rsi_len", 14, { min: 2, max: 200, label: "RSI length in bars", description: "RSI length in bars" }); param.range("rsi_band", [30, 70], { min: 5, max: 95, label: "Oversold / overbought", hint: "A dip must climb back through the low end to enter; the high end is drawn as the upper reference" }); // a low..high pair on one slider: p_rsi_band_lo() and p_rsi_band_hi() param.number("recovery", 55, { min: 30, max: 90, label: "Recovery level", description: "RSI level that ends the trade" }); page("Risk"); param.number("risk_pct", 1, { min: 0.1, max: 10, step: 0.1, label: "Risk per trade, percent", description: "Percent of the equity a trade loses at its stop: each entry is sized to it, never past what the equity buys" }); // strategy({...}) makes the package a strategy; every entry passes its own size, so the default order size is the cap: all of the equity. strategy({ initialCapital: 10000, qtyType: "percentOfEquity", qtyValue: 100, commissionPercent: 0.05, slippageBps: 2 }); param.int("atr_len", 14, { min: 2, max: 200, label: "ATR length in bars", description: "ATR length in bars, the unit of the stop and the target" }); param.number("stop_atr", 1.5, { min: 0.1, max: 10, step: 0.1, label: "Stop distance in ATR", description: "Stop distance under the entry price, in ATR" }); param.number("target_atr", 3, { min: 0.1, max: 20, step: 0.1, label: "Target distance in ATR", description: "Target distance above the entry price, in ATR" }); const rsiLine = output("rsi", line, lower, { color: "#a78bfa", width: 2, label: "RSI", format: "0.0", description: "Wilder RSI of the close, 0 to 100" }); // the handle names it for the hover card output("oversold_line", line, lower, { color: "#94a3b8", width: 1, line_style: "dashed", label: "Oversold", format: "int", description: "The oversold level" }); output("recovery_line", line, lower, { color: "#38bdf8", width: 1, line_style: "dotted", label: "Recovery", format: "int", description: "The recovery level" }); output("overbought_line", line, lower, { color: "#94a3b8", width: 1, line_style: "dashed", label: "Overbought", format: "int", description: "The overbought level" }); handles.line({ color: "#38bdf8", width: 1 }); // the rails: line handles in chart time and price hover(rsiLine, [block.value("RSI", "rsi", { format: "0.0" }), block.rows([["Oversold", "oversold_line", "int"], ["Recovery", "recovery_line", "int"], ["Overbought", "overbought_line", "int"]])]); // the card the RSI line opens under the cursor: its value and the three levels const SKY = rgba(56, 189, 248, 255); const ORANGE = rgba(248, 104, 0, 255); const AMBER = rgba(248, 192, 0, 255); const TRADES_KEPT = 20; // rails ride a ring: the newest 20 trades keep theirs, 3 line handles each, never past 60 const rails: LineHandle[] = []; for (let i = 0; i < TRADES_KEPT * 3; i += 1) rails.push(draw.line(i)); // handle objects allocate once, at module load let rsi = new Rsi(14); // onStart() rebuilds it at the chosen length let atr = new Atr(14); // the unit of the stop and the target const dip = new Cross(); // +1 on the bar RSI crosses above the oversold level const recovery = new Cross(); // +1 on the bar RSI crosses above the recovery level let oversoldLevel = 30.0; let recoveryLevel = 55.0; let overboughtLevel = 70.0; let stopAtr = 1.5; let targetAtr = 3.0; let riskPct = 1.0; let stopDistance: f64 = NaN; // the entry's stop and target distances, fixed when its order goes out let targetDistance: f64 = NaN; let atrValue: f64 = NaN; // this bar's ATR let barTime: f64 = NaN; // this bar's open time in epoch seconds let prevBarTime: f64 = NaN; let intervalSec = 0.0; // the bar spacing, measured from consecutive bars, so a rail ends at the bar's close let inTrade = false; let tradeCount = 0; let railBase = 0; // the first of the open trade's three handles let entryTime: f64 = NaN; let entryPrice: f64 = NaN; let stopPrice: f64 = NaN; let targetPrice: f64 = NaN; // A fill landed on this bar: place the stop and the target the order was sized by around the entry price, start the rails. function openRails(): void { entryPrice = strategy.positionAvgPrice(); if (!isFinite(entryPrice) || !isFinite(barTime) || !isFinite(stopDistance)) return; // every draw coordinate must be finite inTrade = true; tradeCount += 1; entryTime = barTime; stopPrice = entryPrice - stopDistance; targetPrice = entryPrice + targetDistance; railBase = ((tradeCount - 1) % TRADES_KEPT) * 3; const right = barTime + intervalSec; rails[railBase].set(entryTime, entryPrice, right, entryPrice).color(SKY).style(LineStyle.Solid).width(1.5).opacity(1.0); rails[railBase + 1].set(entryTime, stopPrice, right, stopPrice).color(ORANGE).style(LineStyle.Dashed).width(1.0).opacity(1.0); rails[railBase + 2].set(entryTime, targetPrice, right, targetPrice).color(AMBER).style(LineStyle.Dashed).width(1.0).opacity(1.0); } // While the trade is open, every bar extends the three rails to the bar's close. function extendRails(): void { const right = barTime + intervalSec; rails[railBase].setXy2(right, entryPrice); rails[railBase + 1].setXy2(right, stopPrice); rails[railBase + 2].setXy2(right, targetPrice); } // The trade closed on this bar: its rails stay where they ended, dimmed. function closeRails(): void { inTrade = false; rails[railBase].opacity(0.5); rails[railBase + 1].opacity(0.5); rails[railBase + 2].opacity(0.5); } // onStart() runs once before the first bar: read each setting through its p_ reader. function onStart(): void { rsi = new Rsi(i32(p_rsi_len())); oversoldLevel = p_rsi_band_lo(); recoveryLevel = p_recovery(); overboughtLevel = p_rsi_band_hi(); atr = new Atr(i32(p_atr_len())); stopAtr = p_stop_atr(); targetAtr = p_target_atr(); riskPct = p_risk_pct(); } // onBar() runs once per bar: fold the close into the RSI and leave while it warms up (nothing written, no orders); // then keep the rails in step with the position (the getters read the position after this bar's fills), place the // orders (they fill at the next bar's open) and write the outputs. function onBar(): void { const close = bar.close(); prevBarTime = barTime; barTime = bar.time(); if (isFinite(prevBarTime) && barTime > prevBarTime) intervalSec = barTime - prevBarTime; atrValue = atr.update(bar.high(), bar.low(), close); const value = rsi.update(close); // this bar's RSI, NaN until the window is full if (isNaN(value) || isNaN(atrValue)) return; const dipped = dip.update(value, oversoldLevel); const recovered = recovery.update(value, recoveryLevel); const size = strategy.positionSize(); if (size > 0.0 && !inTrade) openRails(); if (inTrade) extendRails(); if (size == 0.0 && inTrade) closeRails(); if (dipped == 1 && size == 0.0 && atrValue > 0.0 && close > 0.0) { // a market entry under the id Dip stopDistance = stopAtr * atrValue; targetDistance = targetAtr * atrValue; const equity = strategy.equity(); let qty = (equity * riskPct) / 100.0 / stopDistance; // stopped out at the stop, the trade loses risk_pct of the equity if (qty * close > equity) qty = equity / close; // never more than the equity buys strategy.long("Dip").qty(qty).send(); } if (size > 0.0 && isFinite(stopPrice) && isFinite(targetPrice)) { strategy.exit("Protect").from("Dip").stop(stopPrice).limit(targetPrice).send(); // re-armed each bar held } if (recovered == 1) strategy.closeAll(); // the signal exit: every open entry closes at the next open out_rsi(value); out_oversold_line(oversoldLevel); out_recovery_line(recoveryLevel); out_overbought_line(overboughtLevel); } ``` ## How it works **The risk is a setting.** `risk_pct` (**Risk per trade, percent**, 1, from 0.1 to 10) is the share of the equity a trade loses at its stop. On the signal bar the entry fixes its stop distance, `stop_atr` times this bar's ATR, and its quantity: the equity times `risk_pct` over 100, divided by the stop distance, so a fill stopped out loses `risk_pct` of the equity (before costs and any gap through the stop). A quantity whose notional would pass the equity is cut to what the equity buys, so with a tight stop on a short interval the entry buys the whole equity and risks less. `strategy.long("Dip").qty(qty).send()` passes the size on the order, so the `strategy({ ... })` declaration's own size, `qtyType: "percentOfEquity"` at `qtyValue: 100`, is the cap and never decides an entry. 10,000 starting equity, 0.05% commission and 2 bps slippage are the file's. **Three rules.** Entry: RSI (`rsi_len` 14) crosses above the low end of `rsi_band` (30, the oversold level) while flat, and the sized `strategy.long("Dip")` fills at the next open. Protection: `strategy.exit("Protect").from("Dip").stop(stopPrice).limit(targetPrice).send()` re-armed every bar the trade is held, the stop `stop_atr` (1.5) ATR under the fill price and the target `target_atr` (3) ATR above it, the ATR (`atr_len` 14) of the signal bar, the distances the entry was sized by. Exit: RSI crosses above `recovery` (55) and `strategy.closeAll()` closes every open entry at the next open. The high end of `rsi_band` (70, the overbought level) is drawn as a reference line only. **Rails follow the position.** `strategy.positionSize()` and `strategy.positionAvgPrice()` read the position after the bar's fills: the bar a fill lands fixes the entry, the stop and the target and opens three line handles at the bar's open time; every bar held extends them to the bar's close; the bar the position goes flat dims them. Twenty trades keep their rails on a ring of 60 handle ids. ## Where it runs Every market with candles: perps, spot, prediction markets, FX. The Strategy Tester replays it in your browser over the chart's loaded window and recomputes as you pan; orders fill against the chart's own candles. The **Strategies** group of the starter list shows where the chart has the Strategy Tester. ## When data is missing The first `rsi_len` and `atr_len` bars are warm-up: nothing written, no orders, no rails. A trade still open when the loaded history ends keeps its bright rails extended to the last bar. ## Customize it - **Tighter protection.** `stop_atr` and `target_atr` are settings in ATR, so they fit the market and the interval; a tighter stop buys more at the same risk, up to the whole equity. - **Risk more or less.** `risk_pct` moves every entry's size and reruns the strategy over the same candles. - **A different dip.** Move the two ends of `rsi_band` (one slider in the dialog) and `recovery`; the reference lines follow. - **Shorts too.** Mirror the rules with `strategy.short(...)` on RSI leaving overbought ([Writing strategies](../strategies/writing-strategies.md)). - **Alert on the trades.** Once the indicator is published and on a chart, the alert dialog adds the strategy's own choices: "Strategy order placed", "Strategy trade opened or closed", "Strategy position" and "Strategy equity" ([Alerts](../functions/alerts.md)). ## Run it 1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Risk-Sized Reversion** under **Strategies**. 2. Press **Run**: RSI and its three levels draw in a pane under the chart, each trade's rails draw on price, and the Strategy Tester under the chart shows the fills, the equity against buy and hold, and the trade list. ## Concepts used - [Your first strategy](../strategies/first-strategy.md) for the protective exit - [Writing strategies](../strategies/writing-strategies.md) for the order builders, `qty(...)` on an entry and the position and equity getters - [Drawing objects](../presentation/drawing-objects.md) for line handles in chart time and price <!-- source: https://openmarket.xyz/wrun/faq/general --> # General FAQ Frequently asked questions about wrun: getting started, data sources and context, technical indicators, plotting, troubleshooting, language features, performance, and the error messages people search for. ## Getting started **What is a wrun indicator?** One file in TypeScript syntax. You write it in the chart's editor, **Run** compiles it in your browser and draws it on the chart, and **Publish** puts it on OpenMarket for you, the people you invite, or everyone. Once published it runs in each reader's browser, or on OpenMarket's servers when its code is **Protected**. Every output is a number series: a line, a shape, a box coordinate, and every drawn one is a value an alert can follow. The module has no filesystem, no network, and no order capability. [Your first indicator](../getting-started/primer-first-indicator.md) walks through the first one. **Do I need to know TypeScript?** The file is AssemblyScript: TypeScript syntax over fixed-width numbers. You need about a page of it: `let x: f64 = NaN` declares a number, `i32(...)` turns a float into a whole number, `function`, `if`, `for`, and `class` work as in TypeScript, and there is no `any`, no closures capturing locals, and no dynamic typing. The **Moving Average** starter (`sma-codefirst`) is a handful of lines with a comment on every statement; if you can read it, you can write one. **Why does an indicator declare its outputs instead of drawing them?** An indicator declares what it reads and writes at the top of the file and computes numbers once per bar in `onBar()`; the chart draws from the declarations. That is what lets one file compile in your browser, compute the same way in each reader's browser or on OpenMarket's servers, and carry alerts that run in OpenMarket's cloud. [What is wrun](../getting-started/introduction.md) has the full picture. **I have a file written with `init`, `state`, `finalize` and `reset`. Does it still work?** Yes, unchanged: a file that exports those four functions builds and runs exactly as it did, next to files written with `onBar()`. [Execution model](../core-concepts/execution-model.md#the-four-function-form) describes that form. ## Data sources and context **How does an indicator know which market the chart shows?** Nothing you write. `bar.close()`, or an `input("close", ohlcv.close)` with no pin, follows the chart's own market and interval, and the module never sees a market name. A fixed market is a pinned secondary input, `input("btc", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES" })`: the chart fetches that market's candles and joins them to yours bar by bar ([Data sources](../core-concepts/data-sources.md)). **Why is the start of my line empty?** The TA classes return `NaN` until their window fills, and a `NaN` output draws nothing on that bar. That is warm-up, not a bug: a 200-bar average has no value on bar 50. Load more history if the line never appears, and read [Execution model](../core-concepts/execution-model.md) for how warm-up works. **Can I read the previous bar?** Yes, with `History` from `./sdk/stats`: `push()` the value once per bar in `onBar()`, then `ago(1)` is the previous bar's value, and `max()`, `min()`, `mean()` and `sum()` read the whole window ([Stats, history and lists](../functions/stats-history-lists.md#history-xn-from-pine)). There is no `close[1]` index, and `onBar()` sees exactly one bar. By hand it is the same idea: keep the value in a module-level variable when you see it, and a window is a `StaticArray<f64>` you fill as a ring buffer, sized from the param's `max` so it is allocated once: ```typescript param("bars", 5, { min: 2, max: 50, description: "Bars in the trailing window" }); output("change", line, lower, { description: "Close minus the previous bar's close" }); output("window_high", line, overlay, { description: "Highest high of the trailing window" }); const MAX_BARS = 50; const highs = new StaticArray<f64>(MAX_BARS); let n: i32 = 5; let cursor: i32 = 0; let count: i32 = 0; let prevClose: f64 = NaN; function onStart(): void { n = i32(p_bars()); } function onBar(): void { const close = bar.close(); // The previous close is whatever we kept from the previous call: there is no close[1] to read. const change = isNaN(prevClose) ? NaN : close - prevClose; prevClose = close; highs[cursor] = bar.high(); cursor = (cursor + 1) % n; if (count < n) count += 1; if (count < n || isNaN(change)) return; let h = -Infinity; for (let i = 0; i < n; i++) if (highs[i] > h) h = highs[i]; out_change(change); out_window_high(h); } ``` **Can I analyze multiple markets in one indicator?** Yes: one pinned `ohlcv` input per market, `symbol` and `exchange` always together. The first input stays unpinned and defines the grid; the chart fetches each pinned market's candles at the chart's interval and joins them by timestamp under the input's `missing` policy, live from that market's own feed. Other sources (trades, funding, open interest, liquidations) always read the chart's own market. Stocks, ETFs, forex, gold and silver pin the same way ([Stocks, forex and gold](../core-concepts/multi-source.md#stocks-forex-and-gold)), and an alert reads the pinned markets too ([Alerts](../functions/alerts.md)): ```typescript // The first input is the grid: it follows the chart's own market. input("close", ohlcv.close); // Pinned inputs read fixed markets; symbol and exchange always pin together. input("btc_close", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES" }); input("eth_close", ohlcv.close, { symbol: "ETHUSDT", exchange: "BINANCE_FUTURES" }); output("eth_btc", line, lower, { description: "ETH priced in BTC" }); output("vs_btc", line, lower, { unit: "%", description: "This market's close as a percent of BTC's" }); function onBar(): void { const btc = in_btc_close(); if (isNaN(btc) || btc <= 0.0) return; out_eth_btc(in_eth_close() / btc); out_vs_btc((bar.close() / btc) * 100.0); } ``` **Can I write an indicator without a data source?** Not without the chart's own candles. A file with `onBar()` and no `input(...)` line runs on them: the chart's close is the bar grid the module walks, whether or not the code reads `bar.close()`. It still needs an output, and **Run** refuses a file without one by name (`a file with onBar() needs at least one output(...) statement: declare what the Indicator draws, e.g. output("value", line, overlay), and write it in onBar() with out_value(...)`). A constant you want to draw is an output written to the same value on every bar. **Can I declare inputs inside `if`, a loop, or a function?** No. Declarations are read from the text before anything runs, so they are top-level statements of the entry file only; one inside a function is refused with `input(...) declarations must be top-level statements, not inside a function, class, or expression`. Every input is fetched before the first bar, so there is no such thing as a conditional subscription. **How do I read the order book?** Declare a celled input over the `book` class behind the first input line, `input("close", ohlcv.close)` then `input("book", book.cells, { max_cells: 1000, block_size: 10 })`, and scan its `[price, size, side]` tuples in `onBar()` through the generated `in_book_cells()` count and `in_book_view()` buffer. The chart serves the book it loads for its own market, up to 500 price levels a side, so a full book is up to 1,000 tuples and a smaller `max_cells` refuses the run; `block_size` is required by the declaration, but the chart does not read it. [Order flow](../functions/order-flow-kit.md#depth-window-scans-by-hand) has the bid and ask sums as worked scans. **How does an indicator handle data gaps?** With a declared policy per input rather than interpolation. A secondary input carries its last value forward by default (`missing: "carry"`), delivers `NaN` on bars without an observation under `missing: "nan"`, or `0` under `missing: "zero"`. Line plots do not interpolate across `NaN`; a gap is a gap. When you want your own fill, do it in `onBar()`: ```typescript input("close", ohlcv.close); // A sparse feed: bars with no liquidation deliver NaN instead of repeating the last value. input("liqs", liquidations.liquidations, { missing: "nan" }); output("liq", line, lower, { description: "Liquidation volume this bar, 0 on quiet bars" }); output("last_liq", line, lower, { description: "The most recent liquidation volume, carried forward" }); let lastLiq: f64 = NaN; function onBar(): void { const x = in_liqs(); let liq: f64; if (isNaN(x)) { liq = 0.0; // your own zero policy } else { liq = x; lastLiq = x; // your own forward fill } out_liq(liq); out_last_liq(lastLiq); } ``` On the primary input, `"nan"` and `"zero"` do more: they densify the grid, so a naturally sparse feed becomes one row per bar ([Data sources](../core-concepts/data-sources.md)). ## Technical indicators **Why am I getting NaN in my calculations?** Three usual causes: a TA class that has not warmed up yet, a division by zero, or an input under `missing: "nan"` on a bar with no observation. Check with `isNaN(x)` (there is no `isnum`; `!isNaN(x)` is the same test), and decide per value: return from `onBar()` before any write (nothing is drawn for that bar), write `NaN` to that one output, or substitute. A two-line `nz` helper covers the substitution case ([Best practices](best-practices.md)). **Which TA classes exist?** 52 stateful classes in `./sdk/ta` (`Sma`, `Ema`, `Rsi`, `Atr`, `Bb`, `Macd`, `Stoch`, `Adx`, `Supertrend`, an anchored `Vwap`, and the rest). The [TA library](../functions/ta-library.md) lists every class with its constructor, `update()` arguments, and fields. `./sdk/ta-plus` adds 30 more of the same shape (`Dema`, `Tema`, `Trix`, `Kama`, `Aroon`, `Vortex`, the volume lines, Pine's two-length `Dmi`, `SourceStoch` and `SourceVwap` over any source, `HeikinAshi`, and the rest) on [Extra indicators](../functions/extra-indicators.md). The kit pages beside it cover what sits around the math: [Strings and text](../functions/text-formatting.md), the [Clock and sessions kit](../functions/time-and-sessions-kit.md), the [Colors](../functions/colors-kit.md), the [Order flow](../functions/order-flow-kit.md), the [Levels kit](../functions/levels-kit.md) and the [Market structure kit](../functions/market-structure-kit.md). **How do I use a param as a period?** Read it in `onStart()` and cast it: `sma = new Sma(i32(p_period()))`. Params are `f64`; a period is `i32`; the cast is explicit and the compiler refuses to guess. ## Plotting and visualization **How do I draw several lines on the same chart?** One `output(...)` per line. `overlay` puts it on the price pane, `lower` in its own pane; `color`, `width`, `opacity`, and `description` are per output ([Plotting](../presentation/plotting.md)). **Can I plot conditional signals?** Yes: a `shape` output draws a mark at its value only on bars where a second, data-only output named in `shape_where` is nonzero. The decision is a number: ```typescript param("len", 14, { min: 2, max: 200 }); param("level", 30, { min: 5, max: 50, description: "RSI at or below this counts as oversold" }); output("rsi", line, lower, { color: "#a78bfa" }); // The mark sits at the output's value (this bar's low) and draws only on bars where the gate is 1. output("buy", shape, overlay, { color: "#22c55e", shape_where: "oversold" }); output("oversold", none); let rsi = new Rsi(14); let level: f64 = 30.0; function onStart(): void { rsi = new Rsi(i32(p_len())); level = p_level(); } function onBar(): void { const value = rsi.update(bar.close()); if (isNaN(value)) return; out_rsi(value); out_buy(bar.low()); out_oversold(value <= level ? 1.0 : 0.0); } ``` **How do I change plot colors dynamically?** With a palette and a decision: `color_by` names a data-only output, and each bar's value indexes `colors` (floored; a missing or out-of-range value falls back to entry 0): ```typescript param("fast", 10, { min: 1, max: 200 }); param("slow", 30, { min: 2, max: 400 }); // Each bar's trend value (0 or 1) indexes the palette: red below the slow average, green above. output("price", line, overlay, { width: 2, color_by: "trend", colors: ["#ef4444", "#22c55e"] }); output("trend", none); let fast = new Sma(10); let slow = new Sma(30); function onStart(): void { fast = new Sma(i32(p_fast())); slow = new Sma(i32(p_slow())); } function onBar(): void { const close = bar.close(); const f = fast.update(close); const s = slow.update(close); if (isNaN(s)) return; out_price(close); out_trend(f > s ? 1.0 : 0.0); } ``` **Can I write an output inside a conditional or a loop?** The writers are ordinary calls in `onBar()`, so `if (...) out_x(a); else out_x(b);` is fine, and so is computing a value in a loop and writing it once. Each output holds one value per bar: the last write wins, so writing the same output in a loop is a longer way of writing it once. To draw nothing for one output on a bar, write `NaN` to it, or leave it unwritten. **What is the difference between `line`, `area`, `histogram`, and `scatter`?** They are the plot kinds an output can declare, and they change the look, not the value: `line` joins the points, `area` fills under them, `histogram` and `bar` draw a column per bar, `scatter` draws a point per bar, `candle` draws four outputs as a candle, `shape` draws a gated mark, and `none` computes without drawing. [Plotting](../presentation/plotting.md) lists the options each one takes. **How does positioning work for shapes and text?** A `shape` output draws at (this bar, its value); there is no separate `location` argument, so put the mark at the bar's low or high by writing that price. Text comes from string slots: `render.text` draws one mark per bar at (bar, a named output), `render.label` draws ONE label at (`x`, `y`) where `x` is an output in epoch seconds (the `time` source), and boxes and segments place themselves by bar offsets from the current bar ([Drawing objects](../presentation/drawing-objects.md)). **My line hugs the bottom of the price chart.** It is a small-magnitude series drawn on the price axis. Declare it `lower`. **My box draws with no fill.** Its `opacity` is `0`; the default is 0.2. (A named color never gets that far: the sheet check refuses it and asks for hex, `rgb()`, or `hsl()`.) **My mark draws on every bar.** A `shape` output without `shape_where` draws wherever it has a value. Add a data-only gate output and name it in `shape_where`; write `0` to the gate on quiet bars. ## Common issues and troubleshooting **My indicator isn't displaying anything. What's wrong?** Check, in this order: the editor's Console (a build that stopped at any stage draws nothing, and after a **Run** of a draft it says when an output was `NaN` on every bar); whether `onBar()` ever reaches its writes (a TA class that needs more bars than the chart loaded returns `NaN` on every bar); whether the output you expect is declared `none`; whether the chart's market serves the source the input reads (the chart says so by name, and the overlay's legend shows **Could not load**); and whether a pin was refused, or reads nothing on this market. [Debugging](debugging.md) is the full workflow. **Undefined identifier errors.** `Cannot find name 'out_sma'` means the accessor does not exist: you renamed or removed the declaration, or misspelled the name. Accessors are generated from the declarations, one per name, lowercased with every run of characters outside `[a-z0-9_]` collapsed to `_`: `param("fast.len", ...)` is `p_fast_len()`, `input("BTC-Close", ...)` is `in_btc_close()`. Capitals are fine either way: the accessor also answers to the spelling your file uses, so `param("fastLen", ...)` is read by `p_fastLen()` as well as `p_fastlen()`. Update the call; the compiler's hint names the declaration an `out_`, `p_`, or `in_` name comes from. A plain variable you forgot to declare is the same message from the compiler. **How do I debug my indicator?** Three ways, all in the editor. Log text with the debug log: `string("debug", { max_bytes: 256 })` and `str_debug(text)` in `onBar()` print each bar's line in the Console. Read numbers at the Console prompt: after a **Run**, type an output's name, `last 20` and an output's name, or an expression. Or declare the suspect value as an output: a `lower` line to see its shape, a `none` output to read it at the prompt without drawing it. [Debugging](debugging.md) has the workflow and the usual suspects. **Why isn't my indicator updating in real time?** It is, at the chart's pace: the forming bar re-evaluates as live updates arrive, at most about once a second per indicator (updates in between are coalesced, and the last one is never dropped). Each time, the chart restores the module's state as it stood after the last closed bar and runs the forming bar again, so a live value never double-counts. The order book, trades, funding, liquidations, and the volume profile arrive as they happen; open interest, implied volatility, skew, the options chain, and ETF flows are polled, so they move when their poll lands. A draft's overlay stops with its session: **Run** it again after a reload. **Can I use `null` instead of `NaN` for missing data?** No. Outputs are `f64`, and `f64` has no `null`: `NaN` is the one value that means "nothing here", and the chart draws a gap for it. Writing `0` draws a zero. `isNaN(x)` is the test, and there is no `null` to compare against. **The chart refuses my symbol or interval pin.** The chart serves pins with restrictions: a market pin only on a secondary `ohlcv` input, one market per pin and never a CME Group venue (`CME`, `CBOT`, `NYMEX`, `COMEX`, `GLOBEX`), an interval pin only coarser than the chart and a whole multiple of it (on `ohlcv`, `odds`, `funding`, and `oi`) and never `3d` on a stock, forex or gold market, and no market or interval pin on the first input. Anything else, an exchange id outside the [list](../reference/symbol-format.md) included, is refused by name in the Console before anything is fetched ([Limitations](../reference/limitations.md), [Multi-timeframe](../core-concepts/multi-timeframe.md)). ## Language features and syntax **Does it support `switch` statements?** Yes, over integers, with the same fallthrough rules as TypeScript (write `break`). Bucket a float into a small integer first, then branch: ```typescript param("len", 14, { min: 2, max: 200 }); output("score", line, lower, { description: "-1 oversold, 0 neutral, 1 overbought" }); let rsi = new Rsi(14); // A switch runs over integers: bucket the value first, then branch. Each case breaks. function zone(value: f64): i32 { if (value <= 30.0) return 0; if (value >= 70.0) return 2; return 1; } function onStart(): void { rsi = new Rsi(i32(p_len())); } function onBar(): void { const value = rsi.update(bar.close()); if (isNaN(value)) return; let score: f64 = 0.0; switch (zone(value)) { case 0: score = -1.0; break; case 2: score = 1.0; break; default: score = 0.0; break; } out_score(score); } ``` **Are objects and arrays supported?** Yes, with static types. `StaticArray<f64>` is the fixed-size buffer for windows, `Array<f64>` grows, `Map` exists, and a `class` with typed fields and methods is the struct. Arrays are homogeneous, `any` does not exist, and allocation belongs in `onStart()` or at module start, never in `onBar()` ([Collections](../core-concepts/collections.md), [User-defined types](../core-concepts/user-defined-types.md)). **Can an indicator read another indicator's values?** No. Each overlay runs its own module, and no declaration reads another indicator's output. Combine the logic into one file. **Can I use inputs and TA classes inside my own functions?** Yes. `bar.close()` and the `in_<name>()` readers work in `onBar()` and in any function it calls, and a value passes on as an `f64` argument; a TA class is an ordinary object you can hold in a module-level variable or pass to a function. What a function cannot do is capture a local from an enclosing function (AssemblyScript closures cannot), so shared state is module-level ([User functions](../core-concepts/user-functions.md)). **Does it support alerts?** Yes, from the chart. Publish the indicator, add it to a chart, and open the alert dialog from its bell in the legend, the right-click menu, or **Alert on** > **Indicators** in the sidebar's alert menu; pick a drawn output and a condition (crossing, greater or less than, a channel, moving up or down). The file can also name a ready-made signal with `alert(name, { when, message })` over a data-only gate. The alert runs in OpenMarket's cloud on the chart's own market, with the overlay's settings ([Alerts](../functions/alerts.md)). **Can trades be executed from an indicator?** No. The module is sandboxed: no filesystem, no network, no order capability. A strategy (`strategy({...})` in the file) places orders only in the Strategy Tester's simulation, filled against the chart's own candles ([Strategies overview](../strategies/overview.md)). **Can I show text?** Through a string slot and a renderer (`render.label` for one tag, `render.text` per bar, `render.table` for a grid), which switches the file to the second runtime contract; outputs and params stay numbers ([Drawing objects](../presentation/drawing-objects.md)). **Can I move or delete a shape after drawing it?** Yes, with a drawing handle: a line, box, label, or polyline the module creates, updates, and deletes across bars by an id. Boxes and segments declared at the top of the file work differently: each bar decides what they draw, and one that should end is a `when` gate that turns `0` ([Drawing objects](../presentation/drawing-objects.md)). **How many shapes can I have?** 16 declared boxes and 16 segments, each drawn once per bar; 64 renderers, 64 declared drawings, and 64 string slots; drawing handles up to 500 live per kind and 1,500 in all; and at most 2,000 drawings on the chart per run. [Limits](../reference/limits.md) has every cap. ## Performance and optimization **My indicator is running slowly. How can I optimize it?** The module runs once per loaded bar, and again on the forming bar as live updates arrive, so the cost that matters is per-bar work. Allocate every buffer once, in `onStart()` or at module start, sized from a param's `max`; never `new` anything inside `onBar()` (memory that grows once the bars start stops the run). Keep loops bounded by a param with a declared `max`. Compute a value once and keep it in a variable instead of recomputing it for every output that needs it. The compiled module is a few kilobytes and a 20-bar average over 5,000 bars is instant; an array that grows on every call is the one pattern that makes a long history slow. **Should I use `const` for constants?** Yes: a module-level `const MAX_BARS = 500` is a compile-time constant, the right size for a `StaticArray`, and it costs nothing per bar. A value that depends on a param is a module-level `let` assigned once in `onStart()`. ## Common error messages **What does "Conversion from type 'f64' to 'i32' requires an explicit cast" mean?** You handed a float to something that wants a whole number: a class period, a loop bound, an array index, or a counter declared without a type (`let count = 0` is an integer). Write `i32(p_period())`, and declare float variables as `f64` (`let value: f64 = 0.0`). The full list of messages, each with its fix, is in [Common errors](common-errors.md). ## Still have questions? [Common errors](common-errors.md) lists every message with its fix, [Debugging](debugging.md) is the workflow for an indicator that runs but draws the wrong thing, and [Limitations](../reference/limitations.md) says what is deliberately out of scope today. For everything else, join the discussion on [Discord](https://discord.gg/hjQRzQtbNu) or read the [Quick reference](../reference/quick-reference.md). <!-- source: https://openmarket.xyz/wrun/faq/best-practices --> # Best practices Guidelines for writing wrun indicators that are fast, honest about warm-up, and safe to replay: where state lives, what belongs in `onStart()` and what in `onBar()`, why the generated accessors beat slot literals, and the handful of habits that keep a file fast and readable. ## Code structure ### Variable declarations An indicator has three kinds of state, and each has one right home. **Module-level `let` for anything that survives between bars.** This is where accumulators, the previous bar's close, running session highs, and the TA objects live. Give every one a type and a starting value (`let cvd: f64 = 0.0`, `let prevClose: f64 = NaN`). **Locals inside `onBar()` for this bar's arithmetic.** A `const typical = (high + low + close) / 3.0` that nothing needs next bar belongs in the function, not at module level. It is cheaper and it cannot go stale. **A `StaticArray<f64>` ring buffer for a window.** A window you index into (the value five bars back) is a buffer you fill yourself: allocate it once, sized from the param's `max`, walk it with a cursor, and count how full it is. `History` from `./sdk/stats` is that buffer ready-made, with `ago(n)` for the value `n` bars back ([Stats, history and lists](../functions/stats-history-lists.md#history-xn-from-pine)). The zone tracker and the [previous-bar FAQ sample](general.md) show the shape. ### Allocate in `onStart()` or at module start, never in `onBar()` `onBar()` runs once per loaded bar and again on the forming bar as live updates arrive. Anything allocated there is allocated thousands of times, an array that grows on every call is the one pattern that makes a long history slow, and the chart stops a module whose memory grows once the bars start ("The Indicator allocated memory after init() ... the sandbox forbids growth once the bars start."). Size buffers from the param's declared `max`, at module start, and let `onStart()` set the live length: ```typescript param("window", 100, { min: 10, max: 500, description: "Bars the rank is measured against" }); output("rank", line, lower, { unit: "%", description: "Share of the window's closes below this bar's close" }); // Allocate once, at module start, sized from the param's max: never inside onBar(). const MAX_WINDOW = 500; const closes = new StaticArray<f64>(MAX_WINDOW); let n: i32 = 100; let cursor: i32 = 0; let count: i32 = 0; function onStart(): void { n = i32(p_window()); } function onBar(): void { const close = bar.close(); let below = 0; for (let i = 0; i < count; i++) if (closes[i] < close) below += 1; closes[cursor] = close; cursor = (cursor + 1) % n; if (count < n) count += 1; if (count < n) return; out_rank((100.0 * f64(below)) / f64(n)); } ``` The TA classes follow the same rule: `new Sma(period)` allocates its window in the constructor and `update()` never allocates, which is why they are constructed in `onStart()` and fed in `onBar()`. ### Nothing to reset There is no reset step to write. The chart runs the module once over the loaded bars, and the forming bar does not depend on any reset: the chart replays that bar from a snapshot of the module's memory taken after the last closed bar, so a live value never double-counts. Give every module-level `let` its starting value where it is declared, build what depends on a param in `onStart()`, and let a `count` field say how much of a buffer is valid instead of clearing the buffer. ### Accessors over slot literals Params, inputs, and outputs reach the code by name: `p_period()`, `bar.close()`, `in_funding()`, `out_sma(value)`, generated from the declarations on every check. Underneath, the host passes values positionally, and the raw calls (`getFloat(0)`, `setOutput(0, ...)`) bind by position, so adding a declaration above them silently rebinds them. The editor refuses the raw form in your source (`Raw slot literal 0 passed to getFloat(): raw positional slots rebind silently when the sheet changes`) and names the accessor to use instead. The same rule keeps a rename honest: rename `output("sma", ...)` to `output("average", ...)` and the compiler points at every stale `out_sma` ([Declarations and the sheet](../reference/declarations.md)). ## Technical indicators ### Read the period once, cast it once Params are `f64`; a period is `i32`. Read each param in `onStart()`, cast it there (`new Sma(i32(p_period()))`), and keep the class in a module-level variable. Reading `p_period()` in `onBar()` works but does the cast on every bar for nothing, and constructing a class in `onBar()` allocates on every bar. ### Be honest about warm-up A window that is not full has no value, and the honest output is `NaN`. Two ways to write it, both correct: `return` from `onBar()` before any write while nothing on the bar is meaningful (every output stays `NaN`, and the bar draws nothing), or write `NaN` to the one output that is still warming while the others draw ([Execution model](../core-concepts/execution-model.md)). What is never correct is a placeholder that draws as if it were true. The chart reads no warm-up count: every bar has a row, and what `onBar()` writes alone decides what is drawn. ### Name what the module computes, not how it is drawn A decision is an output too. Emit `1` or `0` from a data-only `none` output and let a declaration turn it into a look (`shape_where`, `color_by`, a box's `when`). That keeps the numeric surface reusable: the same gate that draws a mark can drive a declared `alert(...)`, and you can read it by name at the Console prompt. ## Plotting and visualization ### Describe every declaration `label` on a setting is its name in the settings dialog (`description` stands in when there is none, and `hint` is the words behind the row's glyph); on an input `description` documents the input in the sheet **Run** derives; on an output it is the note the Console prompt shows beside the output's name. Write them the way you would want to read them a month later: "Bars of history that define normal volume" beats "lookback". Give small-magnitude series (oscillators, percentages, counts) the `lower` panel and a `unit` (`%`, `price`, or a short label) so the axis formats itself. ### One namespace for shape names Outputs, boxes, segments, renderers, and drawings share one namespace. A box named `range` beside an output named `range` is refused at the sheet (`box name 'range' is already taken by an output`). Name shapes for what they draw (`demand_zone`, `pdh_line`) and outputs for what they compute (`demand_top`, `pdh`). ## Debugging ### Probe with an output Log it or draw it. The debug log is the indicator's `print()`: a string slot named `debug`, written in `onBar()`, prints each bar's line in the editor's Console. For a number, declare the suspect intermediate as `none` (readable by name at the Console prompt, never drawn) or as a `lower` line to see its shape across every bar, then delete the declaration when you are done. [Debugging](debugging.md) is the full workflow. ## Error prevention ### NaN checks and safe division `NaN` is the only "nothing here" value, and it propagates: `NaN + 1` is `NaN`, and `NaN > 0` is `false`. Test with `isNaN(x)` before a comparison that decides something, and guard every division by a value that can be zero. The two helpers most indicators need are two lines each: ```typescript param("len", 20, { min: 1, max: 200 }); output("ratio", line, lower, { description: "Volume over its average, 0 while the average warms" }); output("smooth", line, lower, { description: "Close average, last good value carried across gaps" }); // nz: a number, or the fallback when it is NaN. function nz(value: f64, fallback: f64): f64 { return isNaN(value) ? fallback : value; } // Division that refuses to blow up: NaN when the denominator is zero or missing. function safeDiv(numerator: f64, denominator: f64): f64 { return isNaN(denominator) || denominator == 0.0 ? NaN : numerator / denominator; } let avgVolume = new Sma(20); let avgClose = new Sma(20); let lastGood: f64 = NaN; function onStart(): void { avgVolume = new Sma(i32(p_len())); avgClose = new Sma(i32(p_len())); } function onBar(): void { const volume = bar.volume(); out_ratio(nz(safeDiv(volume, avgVolume.update(volume)), 0.0)); // fixnan: keep the last non-NaN value instead of showing a gap. const smooth = avgClose.update(bar.close()); if (!isNaN(smooth)) lastGood = smooth; out_smooth(lastGood); } ``` ### Buffer bounds A ring buffer never reads past what it has been given: keep a `count` beside the `cursor` and only scan `count` entries until the buffer is full. There is no `barIndex` to compare against and no negative index to worry about; the buffer's own bookkeeping is the bound. A `History` from `./sdk/stats` keeps that bookkeeping for you: `ago(n)` reads `NaN` until it holds `n + 1` values ([Stats, history and lists](../functions/stats-history-lists.md#history-xn-from-pine)). ### `missing` on sparse primaries A feed that only has rows when something happened (liquidations, for one) makes a poor primary input as-is: the grid has holes. Declare `missing: "zero"` (or `"nan"`) on it and the grid densifies to one row per bar, with the fill on the quiet bars. On a secondary sparse input the same policies decide what the module sees on bars without an observation; the default carries the last value forward, which is right for a coarse candle and wrong for a volume you would double-count ([Data sources](../core-concepts/data-sources.md)). ## Performance optimization ### Avoid redundant calculations Compute a value once and keep it in a local for every output that needs it; do not fold the same input into two classes when one class and a copy will do. Loops are fine when they are bounded by a param with a declared `max`; a loop whose bound comes from history length is the thing to avoid. ### Keep the row cheap `onBar()` should be arithmetic and writes. Building a string per bar is fine inside the string channel's shared buffer (`sb_clear` / `sb_text` / `sb_f64` allocate nothing), but text built with `+` or `toString()` allocates on every bar, and memory that grows once the bars start stops the run; build every per-bar line with `sb_*`. ## Canvas cost A price canvas costs what it draws. A TPO costs rows x letters x the periods on screen: the chart folds every row from the candles and paints every letter as a block. The folding counts toward the "is slowing your chart" notice like your script's own time, and a live tick refolds only the forming period. On BTC at 15m the chart's 75-dollar rows make about 230 blocks a day; 1.5-dollar rows would make about 9,300. ### Prefer `"auto"` Leave `row_height` out. `"auto"` takes the row from what the chart serves for the market (the bucket width of your `cells`, else the market's TPO or volume profile catalog) times the chart's row table, so your canvas draws the rows the chart's own footprint, TPO or profile would: on BTC at 15m, a TPO gets about 25 to 90 rows a day, each tall enough for its letters ([Row height](../presentation/price-canvases.md#row-height)). ### When to declare a number - **A market with no catalog, whose tick you know.** `"auto"` falls back to a quarter of the median candle range there; a whole number of ticks (`row_height: 0.25` on a market that trades in 0.05 steps) keeps every row on a price the market can print. - **A deliberately coarse profile.** Fewer, taller rows than auto, such as `row_height: 250` for a BTC day profile read at a glance. Size the number from the range: a period's high minus its low, divided by `row_height`, is the rows it holds, and under 512 the chart keeps your height. ### The coarsening notice `tpo 'tpo': row_height 1 coarsened to 8 (512 rows per day is the chart's limit)`, a warning row in the Console and the warning on the legend chip, means a period held more than 512 rows at the height you declared, so the chart doubled it until the period fit (at most 6 times). The TPO still draws, at the coarser height, and the notice repeats on every run. To clear it, declare the height it names or a larger one, or remove `row_height` and let `"auto"` pick. On `"auto"` (`row_height auto coarsened from 1.5 to 12`) the market's own unit is too fine for how far its price moves in a period: declare a `row_height` of at least the height the notice names. ## Code organization ### Group related code Read top to bottom: declarations, state, helpers, then `onStart()` and `onBar()` in the order the chart calls them. A complete indicator laid out that way: ```typescript // 1. Declarations: settings and outputs, each with the description the settings dialog and the Console prompt show. param("fast", 9, { min: 2, max: 100, description: "Fast EMA length" }); param("slow", 21, { min: 5, max: 400, description: "Slow EMA length" }); output("fast", line, overlay, { color: "#38bdf8", description: "Fast EMA" }); output("slow", line, overlay, { color: "#f59e0b", width: 2, description: "Slow EMA" }); output("entry", shape, overlay, { color: "#22c55e", shape_where: "is_entry", description: "Fast crossed above slow" }); output("is_entry", none); // 2. State: everything that lives between bars, with its starting value. let fast = new Ema(9); let slow = new Ema(21); let cross = new Cross(); // 3. Lifecycle: onStart reads settings, onBar folds the bar and writes the row. function onStart(): void { fast = new Ema(i32(p_fast())); slow = new Ema(i32(p_slow())); cross = new Cross(); } function onBar(): void { const close = bar.close(); const fastValue = fast.update(close); const slowValue = slow.update(close); const crossed = cross.update(fastValue, slowValue); if (isNaN(slowValue)) return; out_fast(fastValue); out_slow(slowValue); out_entry(bar.low()); out_is_entry(crossed == 1 ? 1.0 : 0.0); } ``` ### Comment the why The declarations already say what the indicator reads and writes, and the accessors keep the code readable, so comments earn their place explaining a decision: why a bucket folds in only on the next bucket's first bar, why a session that was already running stays `NaN`, why a pinned input reads `NaN` on a stock chart. The cookbook recipes are written that way. <!-- source: https://openmarket.xyz/wrun/faq/common-errors --> # Common errors The messages a wrun indicator author meets in the chart, each with its cause and its fix, in the order they arrive: the checks the editor runs on your code (declarations, lint, the sheet, the compiler, the module), then what a **Run** reports, then what the chart itself says. Every heading is the text the editor prints, so a browser find on the message you got lands on its fix. If your indicator ran but drew nothing, the problem is probably not an error at all. Skip to [Ran but blank](#ran-but-blank) at the end. ## Where errors show - **In the editor's Console.** The editor checks your code as you type, and **Run** reuses those checks before it runs anything. Each problem is a row in the Console with its line number, its stage in brackets (`declarations`, `lint`, `metadata`, `validate`), and the message; a compiler row carries the compiler's message and a hint that links to these docs. The same text shows as a squiggle under the span, and clicking a row jumps to its line. The status bar counts errors and warnings (its tooltip reads **Open Problems**) and opens the Console, and a blocked **Run** says "Cannot run: 2 error(s) must be fixed first." - **After a Run.** What the run itself finds prints as a `compute` row with a "Fix:" line, next to the indicator's debug log ([Debugging](debugging.md)). - **On the chart.** An overlay whose run fails keeps its last good drawing; its legend shows a **Could not load** chip with the message and a **Retry**. An overlay that never drew anything also raises a one-time toast that starts "Indicator could not load:". The checks are staged, so one problem hides the ones behind it: a declaration error stops the build before the sheet is checked, and a sheet error stops it before the compiler runs. Declaration errors arrive together, one row per finding, each on the line of the declaration it names. ## `option 'top' takes an output handle, not a string literal` **Symptom:** the `declarations` stage refuses a `box(...)` or `segment(...)` line with `option 'top' takes an output handle, not a string literal (bind a handle with a top-level const h = output(...) and pass h)`. **Cause:** a box or segment coordinate is the value `output(...)` returned, not the output's name. `box("band", { top: "hi", ... })` passes a string where a handle goes. **Fix:** bind the output to a top-level const and pass the const. The same rule covers `bottom`, `yFrom`, `yTo`, `when`, and an output-valued `from` / `to`: ```typescript param("period", 20, { min: 1, max: 200 }); output("sma", line, overlay); // Bind each coordinate output to a const: the box names the handle, never the string. const hi = output("hi", none); const lo = output("lo", none); box("band", { top: hi, bottom: lo, color: "#38bdf8", opacity: 0.15, borderWidth: 0 }); let sma = new Sma(20); function onStart(): void { sma = new Sma(i32(p_period())); } function onBar(): void { const value = sma.update(bar.close()); out_sma(value); out_hi(value * 1.01); out_lo(value * 0.99); } ``` ## `segment 'ray' option 'yTo' references 'top', which is not an output handle` **Symptom:** `segment 'ray' option 'yTo' references 'top', which is not an output handle; bind the output first (const top = output("...", ...)) and pass that const`. **Cause:** the identifier you passed is a variable, but not one bound to an `output(...)` call. **Fix:** bind it with `const`, `let`, or `var` at the top level (the binding may sit below the shape that uses it). The sheet records the output's name, never the handle. ## `output(...) declarations must be top-level statements` **Symptom:** `output(...) declarations must be top-level statements, not inside a function, class, or expression` (or the same for `param`, `input`, `box`, `segment`, `render.text`, and the rest). **Cause:** declarations are read from the text without running it, so one inside `onStart()`, inside an `if`, or inside a class is invisible to the sheet and refused. **Fix:** move the declaration to the top level of the file. Declarations are static: they cannot depend on a param or a condition. ## `param options accept only { required, min, max, description }, not 'step'` **Symptom:** the family's option list, followed by the option you wrote. On a typed setting the list is longer: `param.int options accept only { required, min, max, description, label, step, group, row, hint, when, hide, slider, unit, unit_default, confirm }, not 'tooltip'`. **Cause:** every family lists its options in the message. Plain `param(...)` is the number field and keeps its four keys: it takes any value inside `min`..`max` and the settings dialog labels its row with `description`. The dialog's words (`label`, `step`, `group`, `row`, `hint`, `when`, `hide`, `slider`, `unit`, `unit_default`, `confirm`, and `tz` on a session) belong to the typed settings, `param.int(...)`, `param.bool(...)` and the rest. A tooltip is `hint`, and there is no `constraints` object. A number-field key on a kind that is not a number field is refused by name too: `param.bool 'x' takes no min option (its value is not a number field)`. **Fix:** declare the setting with the kind that takes the option (`param.int("length", 20, { min: 5, max: 500, step: 5 })`), or drop the option. The option list per kind is on [Options on a setting](../settings/options.md), the full grammar in [Declarations and the sheet](../reference/declarations.md#the-declaration-grammar). ## `param 'symbol' uses a reserved name` **Symptom:** `param 'symbol' uses a reserved name (the chart keeps symbol, exchange, interval, transformations, ticksPerBar, currency, runMode, devViewerTier, ... beside script settings, so a param under that name would be read as the chart's own); rename it`, or `param '__style__x' uses the reserved prefix __style__ (the chart's own style rows); rename it`, or `param.range 'band' derives param 'band_lo', which is already declared; rename one`. **Cause:** a setting's name is a key the chart saves beside the overlay's own keys, so the chart's own names and its Style rows' prefix are off limits; a range, list, session, unit menu or `market.*` call also derives names of its own (`band_lo`, `levels_n`, `rth_tz`, `offset_unit`, `market_tick_size`), which another setting cannot take. **Fix:** rename the setting. Every other name is free, `smooth`, `width` and `opacity` included; the reserved list is on [What the build checks](../settings/checks.md). ## `output 'h' color references "@k", which is not a param.color` **Symptom:** `output 'h' color references "@k", which is not a param.color`, `output 'h' color references "@nope", which names no declared param (a param.color)`, `input 'c2' interval references "@k", which is not a param.timeframe`, or `output 'h' binds a param to its colour or line style, but the output is data-only (plot none): there is nothing to paint`. **Cause:** a string that starts with `@` names a setting, and the spot decides the kind: `color` and a `colors` entry take a `param.color`, `line_style` a `param.choice` over `solid`, `dashed`, `dotted`, an input's `interval` a `param.timeframe`, its `symbol` a `param.symbol`. A `none` output draws nothing, so there is nothing for a color to paint. **Fix:** declare the setting with the kind the spot needs and name it after the `@`, or write the literal. ## `duplicate output name 'sma'` **Symptom:** `duplicate output name 'sma'`, `duplicate param name`, `duplicate input name`, `duplicate box name`, or `duplicate render declaration name`. **Cause:** names are unique per family, and shapes share one namespace with outputs (below). **Fix:** rename one of them. ## `expected a string literal` **Symptom:** `expected a string literal`, `expected a numeric literal`, `option 'from' takes an output handle or a bar offset literal, not a string literal`, or `option 'panel' takes "overlay" or "lower" (a string literal), not 'price'`. **Cause:** a name built from a variable, a default computed from an expression, a bar offset spelled as a string, or a panel outside the two names. The extractor reads literals only, because the code never runs while it is checked. **Fix:** use literals: `param("period", 20, ...)`, `from: -4`, `panel: "lower"`. ## `This indicator declares nothing` **Symptom:** `This indicator declares nothing. Add param(...), input(...), and output(...) statements (imported from ./sdk/declare) at the top level of the source; the metadata sheet is derived from them.` **Cause:** the editor builds the sheet from your declarations, and there are none: an empty tab, code with no `param` / `input` / `output` statement and no `onBar()`, or a file with `onBar()` that also exports one of `init`, `state`, `finalize` or `reset` (that one export makes it a file of the four functions, whose declarations need their import lines). A file with `onBar()` and no declaration is told it needs an output instead (below). **Fix:** start from **New indicator** or a template in the editor's Explorer, keep the `//@lang=wrun-ts` line first, and declare what the file reads and writes at the top level. ## `This looks like Pine Script, not an Indicator` **Symptom:** `This looks like Pine Script, not an Indicator.`, on the first line of a paste. **Cause:** the tab holds code from another language. A wrun indicator is TypeScript syntax compiled in your browser. **Fix:** start from a template, keep the `//@lang=wrun-ts` line first, declare `param(...)` / `output(...)` (and an `input(...)` for anything beyond the chart's own candles), and write the bar's work in `function onBar(): void`. ## `code-first declarations need at least one input(...) and one output(...) statement` **Symptom:** `code-first declarations need at least one input(...) and one output(...) statement (found 0 input(s), 1 output(s))`, in a file with the four functions (`init`, `state`, `finalize`, `reset`). **Cause:** such a file has no bar grid to walk without an input, and nothing to compute without an output. A file with `onBar()` needs no input line (the chart's own candles are its grid) and hears the next message instead when it has no output. **Fix:** declare the primary input (`input("close", ohlcv.close)` is the usual one) and at least one output, even a `none`. ## `a file with onBar() needs at least one output(...) statement` **Symptom:** `a file with onBar() needs at least one output(...) statement: declare what the Indicator draws, e.g. output("value", line, overlay), and write it in onBar() with out_value(...)`, on the file's first declaration (or on the `onBar` line when there is none). **Cause:** a file with `onBar()` runs on the chart's own candles without an `input(...)` line, but it still has to declare what it draws: with no `output(...)` there is no row to fill. **Fix:** declare at least one output, even a `none`, and write it in `onBar()` through its `out_<name>()` writer. ## `onBar must be a function with no arguments that returns nothing` **Symptom:** on the hook's own line: `onBar must be a function with no arguments that returns nothing: write function onBar(): void { ... } (the build calls it once per bar)`, or the same for `onStart` (ending `(the build calls it once, before the first bar)`) and `onReset` (ending `(the build calls it when the host resets the run)`). **Cause:** a hook with an argument (`onBar(x: i32)`), a return type other than `void` or none at all (`function onBar() {}`), a type parameter (`onBar<T>()`), or the name `onStart` / `onReset` bound to something that is not a function (`const onReset = 1;`). **Fix:** write the hook exactly as the message shows: a plain top-level function with no arguments and `: void`. An arrow (`const onBar = (): void => {}`) is not seen as a hook at all; the file then counts as one without `onBar()`, and with no import lines its declarations are not read (`This indicator declares nothing`, above). ## `uses the prefix __wrun_lean_, which the build keeps for the functions it adds around onBar()` **Symptom:** on the line of its first use: `'__wrun_lean_x' uses the prefix __wrun_lean_, which the build keeps for the functions it adds around onBar(); rename it`. **Cause:** in a file with `onBar()` the build adds its own functions around yours under that prefix, so a name of yours that starts the same way would collide with them. **Fix:** rename it; any other spelling is free. ## `is already declared with another feed; rename the declared input` **Symptom:** on the line of the first `bar.close()`: `bar.close() reads the chart's own ohlcv.close through an input named 'close', and input 'close' is already declared with another feed; rename the declared input (open, high, low, close, volume and bar_t are the bar's own names)`, or the same for `open`, `high`, `low`, `volume` and `bar_t` (`bar.time()`). **Cause:** `bar.close()` adds an input named `close` on the chart's own close to the sheet (or reuses an `input("close", ohlcv.close)` line with no options), and the file declares an input under that name that reads something else: a pinned market, another interval, a `missing` policy, or another source. **Fix:** rename the declared input (`input("btc", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES" })`) and read it with `in_btc()`. The same name is fine in a file that never reads that field through `bar`. ## `strategy.<call>(...) needs a top-level strategy({ ... }) declaration` **Symptom:** on the first order call: `strategy.<call>(...) needs a top-level strategy({ ... }) declaration in src/indicator.ts (an empty strategy() enables the channel with the engine's defaults)`. **Cause:** `strategy.long("L").send()` or another order call or getter in a file that never declares `strategy(...)`, so the sheet has no strategy section for the Strategy Tester to run. **Fix:** add `strategy({ ... })` (or an empty `strategy()`) at the top level with the other declarations ([Strategies overview](../strategies/overview.md)). ## `celled input 'profile' needs max_cells` **Symptom:** `celled input 'profile' needs max_cells, e.g. input("profile", volume_profile.cells, { max_cells: 512 }) (guests preallocate from it and the host refuses bigger blocks)`. **Cause:** a celled input (`volume_profile`, `book`, `intrabar`, `trade_volume_by_size`, `options_chain`) preallocates its block once, so the cap is part of the declaration. **Fix:** add `max_cells`, sized for the largest block the bar can carry. A block over the cap is refused at run, never truncated (below). ## `input 'btc-close' and 'btc_close' both escape to accessor 'in_btc_close'` **Symptom:** the `declarations` stage says the two names `both escape to accessor 'in_btc_close'; rename one so generated accessors stay unambiguous`. **Cause:** an accessor name is the declared name lowercased with every run of characters outside `[a-z0-9_]` collapsed to `_`. Two names that escape to the same identifier would silently share one accessor. **Fix:** rename one of the two so the escaped forms differ. Prefer snake_case names in the file and the accessors read as written. ## `param names 'fastLen' and 'fastlen' differ only by case` **Symptom:** the `declarations` stage says `param names 'fastLen' and 'fastlen' differ only by case; the build reads names without case (both would be 'fastlen'), so rename one`, on the second declaration's line. Outputs, inputs, string slots, frames, renderers, drawings, levels and panels say the same with their own word; box, segment and alert names keep their spelling, so `Zone` and `zone` are two boxes. **Cause:** names may carry capitals, but the build reads them without case: params, inputs, string slots and frames are stored lowercase, and every accessor is lowercase (`p_fastLen()` and `p_fastlen()` are one reader). Two names that differ only by case would be one setting, or share one accessor. **Fix:** rename one of the two. ## `raw positional slots rebind silently when the sheet changes` **Symptom:** a `lint` row per finding, such as `Raw slot literal 0 passed to getFloat(): raw positional slots rebind silently when the sheet changes: use in_close() from ./gen/inputs in state() or p_period() from ./gen/params in init()`. **Cause:** `getFloat(0)`, `setOutput(0, ...)`, and the raw host imports bind by position and rebind when a declaration is added above them. **Fix:** use the accessor the message names (in a file with `onBar()`, read it there). The raw host calls stay available for variable-index access only. ## `default above max` **Symptom:** a `metadata` row on the param's line: `Param 'period' (default): default above max`, `default below min`, or `min must be <= max`. **Cause:** the derived sheet is checked with the same schema the registry and the runtime use; a default outside its own range fails there. **Fix:** move the default inside the range, or widen the range. ## `an output cannot color itself with color_by` **Symptom:** a `metadata` row on the output's line: `Output 'price' (color_by): an output cannot color itself with color_by`, `color_by 'regime' does not match a declared output`, or `color_by needs 'colors' with at least 2 entries`. **Cause:** `color_by` names a different, declared output (usually a data-only `none`), and `colors` lists at least two entries. **Fix:** declare the decision output and name it; give the palette two or more entries. `width_by` and `widths` follow the same rules (`width_by needs 'widths' beside it (both or neither)`), and `shape_where` cannot gate its own output (`shape_where 'gate' does not match a declared output` when the gate is missing). ## `has a name the sheet refuses` **Symptom:** `Output 'my line' has a name the sheet refuses: use letters, digits, dots, dashes and underscores, starting with a letter or digit (no spaces).` (or the same for a param, an input, or a string slot). **Cause:** declared names become keys of the sheet and the accessors, and a space or a symbol cannot be one. **Fix:** rename it within the rule: `my_line`, `ema.fast`, `rsi-14`. ## `color must be a hex, rgb() or hsl() color (the fill takes the opacity)` **Symptom:** `boxes.0.color: color must be a hex, rgb() or hsl() color (the fill takes the opacity)`. **Cause:** a named color (`"red"`) cannot carry an alpha channel, and the box fill applies the opacity to its color. **Fix:** use `"#ef4444"`, `"rgb(239, 68, 68)"`, or `"hsl(0, 84%, 60%)"`. Segment and output colors are free-form strings; only the box fill has this rule. ## `a literal bar offset must be within -500..500 bars of the current bar` **Symptom:** `segments.0.x_from: a literal bar offset must be within -500..500 bars of the current bar`. **Cause:** a literal offset (whole or fractional) stays within 500 bars of the current bar. **Fix:** stay within the range, or pass an output handle for a longer or data-driven reach: its per-bar value is truncated to the offset, and any offset clamps to the loaded range. ## `boxes must declare at most 16 entries` **Symptom:** `boxes: boxes must declare at most 16 entries` or `segments: segments must declare at most 16 entries`. **Cause:** sixteen of each per indicator. **Fix:** a repeating pattern is one declaration gated per bar, not one declaration per occurrence. The zone tracker draws every zone a side ever has with one box and a `when` gate; for objects that live and move across bars, use a drawing handle ([Drawing objects](../presentation/drawing-objects.md)). ## `box name 'range' is already taken by an output` **Symptom:** `boxes.0.name: box name 'range' is already taken by an output; box and segment names must be unique across outputs, boxes, segments, renderers, and drawings`. **Cause:** one namespace for everything drawn. **Fix:** rename the shape. ## `the primary input (index 0) cannot declare missing: "carry"` **Symptom:** `inputSources.close.missing: the primary input (index 0) cannot declare missing: "carry": its rows define the request grid, so there is no earlier bar to carry a missing one from; declare "nan" or "zero" to densify the grid instead`. **Cause:** the first `input(...)` is the grid. **Fix:** give it no `missing` policy (the usual case), or `"zero"` / `"nan"` to densify a sparse primary; put `"carry"`, `"zero"`, or `"nan"` on the secondary inputs ([Data sources](../core-concepts/data-sources.md)). ## `source 'trades' requires a side (BUY or SELL)` **Symptom:** `inputSources.buy.side: source 'trades' requires a side (BUY or SELL)` or `inputSources.iv.tenor: source 'implied_volatility' requires a tenor (ONE_D, THREE_D, ONE_W, ONE_M, TWO_M, THREE_M, SIX_M, ONE_Y)`. **Cause:** the per-source knobs: `side` on `trades`, `tenor` on `implied_volatility` and `skew`, `fund` on the ETF feeds (`source 'etf_holdings' requires a fund ...`), `publisher` and `series` on `economic`, `token` on `token_supply`. **Fix:** add the knob to the input's options: `input("buy", trades.volume, { side: "BUY" })`. For a tenor, pick one the chart serves: `ONE_W`, `ONE_M`, `TWO_M`, `THREE_M`, or `SIX_M` (`ONE_D`, `THREE_D` and `ONE_Y` are refused when the run starts, below). ## `feed sources pin a fixed market with symbol AND exchange together` **Symptom:** `inputSources.btc.exchange: feed sources pin a fixed market with symbol AND exchange together (never one alone: symbols are venue-native, so a lone symbol or a lone exchange names a market that does not exist on that venue); pin both, or omit both to follow the selector`. **Cause:** a `symbol` without an `exchange`, or the reverse. **Fix:** pin both, spelled the way the venue spells them ([Exchange and symbol format](../reference/symbol-format.md)), or neither to follow the chart. A Polymarket `odds` input is the exception: it pins the condition id in `symbol` alone. ## `max_bytes must be <= 4096 (the per-slot byte cap)` **Symptom:** `String slot 'note' (max_bytes): max_bytes must be <= 4096 (the per-slot byte cap)`, `renderers.0.size: size must be >= 6`, `renderers.0.cells: table 'stats' declares 2x2 = 4 cells but lists 3`, `renderers.0.text: 'note' does not name a declared string slot`, or `renderers.0.y: 'mid' does not name a declared numeric output`. **Cause:** text fields name string slots, numeric fields name outputs, and the two never substitute for each other; text sizes are 6..64 pixels; a table lists exactly `rows * cols` cells; a slot holds at most 4,096 bytes. **Fix:** declare the slot or output the renderer names, and keep the numbers inside the caps ([Drawing objects](../presentation/drawing-objects.md)). ## `Conversion from type 'f64' to 'i32' requires an explicit cast.` **Symptom:** a compiler row with the squiggle under the value, followed by the hint `Numbers convert explicitly in AssemblyScript: wrap the value in i32(), u32() or f64() (a switch needs an integer, an f64 slot needs f64).` A typical line is `function onStart(): void { sma = new Sma(p_period()); }`, with the squiggle under `p_period()`. **Cause:** the file is AssemblyScript, TypeScript syntax over fixed-width numbers. Params and inputs are `f64`; a class period, a loop bound, an array index, or a counter declared as `let count = 0` (an integer) is `i32`, and the compiler refuses to guess. **Fix:** write `i32(p_period())`, put `i32(...)` around any float used as an integer, and declare float variables as `f64` (`let value: f64 = 0.0`). Going the other way, an integer written to an output widens with `f64(count)`: ```typescript param("period", 20, { min: 1, max: 200 }); output("bars_above", line, lower, { description: "Consecutive bars closing above the average" }); let sma = new Sma(20); let streak: i32 = 0; // an integer counter: declare the type, never let it default from a float let above: bool = false; // a truth value: compare to get one, never assign a number function onStart(): void { // Params are f64; a period is i32, so the cast is explicit. sma = new Sma(i32(p_period())); } function onBar(): void { const close = bar.close(); const average = sma.update(close); if (isNaN(average)) return; above = close > average; streak = above ? streak + 1 : 0; // Outputs are f64; an integer goes out through an explicit widening. out_bars_above(f64(streak)); } ``` ## `Conversion from type 'f64' to 'bool' requires an explicit cast.` **Symptom:** the compiler points at an assignment of a number to a `bool`. **Cause:** `ready = value` where `ready` is a `bool` and `value` an `f64`. A number is not a truth value here. **Fix:** compare: `ready = !isNaN(value)`, `above = close > average`. (`if (value)` on a number does compile, as a nonzero test; write the comparison you mean anyway.) ## `Module 'src/gen/outputs' has no exported member 'out_sma'.` **Symptom:** `Cannot find name 'out_sma'.` at every call site, with the hint `out_sma would be generated from a output("sma", ...) declaration; declare it at the top level, or match the name you declared.` In a file with the four functions, which imports its accessors, the import line fails first: `Module 'src/gen/outputs' has no exported member 'out_sma'.` **Cause:** you renamed or removed a declaration and the generated accessor went with it, or the call misspells the name. The accessors are generated from the declarations on every check, so the compiler names every stale call (and every stale import). **Fix:** update the call: `output("average", ...)` is `out_average(...)`, `param("fast_len", ...)` is `p_fast_len()`, `input("btc_close", ...)` is `in_btc_close()`. ## `Cannot find name 'close'.` **Symptom:** `Cannot find name 'close'.` (or `'hl2'`, `'na'`, `'bar_index'`), usually in code carried over from Pine Script. **Cause:** nothing is in scope by name except the kit, the readers and writers generated from your declarations, the bar's own fields through `bar`, and what you declare yourself. The hint says what the name should become: a bar series is the bar's own field (`close` is `bar.close()`, `high` is `bar.high()`; anything else is a declared input read with `in_<name>()`), and a Pine builtin has its own spelling (`na` is `NaN`, tested with `isNaN(x)`; `hl2` is `(bar.high() + bar.low()) / 2.0`; `bar_index` is a counter you increment in `onBar()`). **Fix:** follow the hint. A `Cannot find name` on something that was never an accessor, a series, or a kit class is a plain undeclared variable. ## `this line uses a syntax form the build cannot read yet in a file with onBar()` **Symptom:** `this line uses a syntax form the build cannot read yet in a file with onBar() (syntax node kind <n>); rephrase the line, or write the file with the four exported functions (init, state, finalize, reset), which take every form the compiler does`, on the line in question. **Cause:** a file with `onBar()` is read through the compiler's own syntax tree to find the names it uses, and that line holds a form the reader does not know. Every form the editor's compiler accepts today is known, so the message is not expected on any file; it is the build refusing loudly rather than guessing. **Fix:** rephrase the line (a plain `function`, `let`, `if`, `for`, `switch`, class or arrow is always readable); if it persists, the message names the other way out. ## `finalize() must be declared as finalize() -> void` **Symptom:** in a file with the four functions (`init`, `state`, `finalize`, `reset`), after a clean compile, the `validate` stage refuses the module on your `export function finalize` line: `finalize() must be declared as finalize() -> void (it is () -> f64 here). Write every output through its out_<output>() writer, then call emitRow().` A missing export reads `Missing export reset(): void`, followed by the line to add and what the function does. A file with `onBar()` never meets either: the build writes those four functions around it. **Cause:** a redesigned signature (`init(args: Array<f64>)`, a `finalize` that returns the value) compiles under AssemblyScript and fails the contract check afterwards. The contract is exactly `init(): void`, `state(): i32`, `finalize(): void`, `reset(): void`: no parameters, and no return value except `state`'s ([Execution model](../core-concepts/execution-model.md#the-four-function-form)). **Fix:** keep the four signatures. Values leave through `out_<name>(value)` and `emitRow()`, never as return values; params arrive through `p_<name>()`, never as arguments. ## `console.* is not available in an Indicator` **Symptom:** the `validate` stage refuses `console.log(...)` with `console.* is not available in an Indicator (it runs in a sandbox with no console). Log through the debug output instead: declare string("debug", { max_bytes: 256 }) next to the outputs and call str_debug(text) in finalize(); the lines show in the editor console.` `Date`, `Math.random()`, and `performance.now()` are refused the same way. **Cause:** the sandbox provides no host functions beyond the indicator's own: no console, no clock, no randomness. **Fix:** log through the debug slot the message names, written in `onBar()` ([Debugging](debugging.md)); read bar time from `bar.time()`; derive any variation from the bar data, so a run is reproducible bar for bar. ## `setOutput writes an output straight to the host` **Symptom:** in a file with `onBar()`, the `declarations` stage says `setOutput writes an output straight to the host, and a file with onBar() hands every output to the host once, after onBar() returns, so that row would overwrite it: write the output with out_<name>(...) instead`, on the import line (or the `@external` line of your own declaration over `wrun_output_f64`). **Cause:** each `out_<name>()` keeps its value for the bar, and the row goes to the host in one step after `onBar()` returns. A value written by slot number, around the writers, would be replaced by that row. **Fix:** write every output through its `out_<name>()` writer. A file that needs writes by slot number keeps the four functions. ## `was never written: finalize() must write every declared output before emitRow()` **Symptom:** after a **Run** of a file with the four functions, a `compute` row: `Output 'sma' (#1) was never written: finalize() must write every declared output before emitRow().`, with `Fix: In finalize(), call out_sma(value) for every bar state() returned 1 on, then emitRow().` The sibling row is `finalize() never called emitRow(): the outputs were written but no row reached the chart.` A file with `onBar()` never meets either: every output starts each bar as `NaN`, an unwritten one draws nothing on that bar, and the row is emitted for you. **Cause:** a `finalize()` that skips a writer on some path (an early `return`, an `if` without an `else`), or one that never calls `emitRow()`. **Fix:** write every declared output on every ready bar (write `NaN` to draw nothing), then call `emitRow()` last. ## `AssemblyScript abort` **Symptom:** a `compute` row such as `AssemblyScript abort: Index out of range at ~lib/array.ts:...`, or one anchored on your own line when the abort came from a `throw` in your code. **Cause:** a trap inside the module: an array read past its length, a `throw`, or an explicit `abort()`. The run stops at that bar. **Fix:** guard the index (keep a `count` beside a ring buffer's `cursor`, and scan only what has been filled), and test a condition instead of throwing. For a window of past values, a `History` from `./sdk/stats` does that guarding: `ago(n)` reads `NaN` when fewer than `n + 1` values were pushed or `n` is outside the window ([Stats, history and lists](../functions/stats-history-lists.md#history-xn-from-pine)). ## `The run did not finish within 20 s.` **Symptom:** the run stops, and the overlay's **Could not load** chip carries the sentence with a **Retry**. **Cause:** a `while` or `for` loop that does not end on some bar, or per-bar work that grows with history. A run queued behind another slow one can meet the deadline too, which is why this one stays on the chip instead of a standing Console row. **Fix:** bound every loop by a param with a declared `max`, keep per-bar work constant, and **Retry**. ## `The Indicator allocated memory after init()` **Symptom:** `The Indicator allocated memory after init() (1048576 to 1114112 bytes): the sandbox forbids growth once the bars start.`, with `Fix: Create arrays, strings and objects once in init() and reuse them per bar; a string built per bar (concatenation, toString()) allocates too.` Past the ceiling itself the run stops with `memory ... bytes exceeds limit 4194304 bytes`. **Cause:** a `new` inside `onBar()`, an array that grows per bar, or text built with `+` or `toString()` on every bar. The memory ceiling is a fixed 4 MiB. (The message's `init()` is the step before the first bar, where `onStart()` runs.) **Fix:** allocate once, at module start or in `onStart()`, sized from a param's declared `max`; build per-bar text with the allocation-free `sb_*` builder ([Best practices](best-practices.md)). ## `celled source class 'tape' is not served by the browser lane yet` **Symptom:** the run is refused before it computes: `celled source class 'tape' is not served by the browser lane yet (input 'prints')`. **Cause:** the chart does not serve that source class ([Limitations](../reference/limitations.md)). **Fix:** read what the chart serves: side-split `trades.volume` for buy and sell volume per bar, `trade_volume_by_size` for the split by trade size, `volume_profile` or `book` for the rest of the celled data ([Data sources](../core-concepts/data-sources.md)). ## `the browser lane serves market pins on secondary ohlcv inputs only` **Symptom:** before anything is fetched: `Input 'funding_btc' (funding) pins the market 'BINANCE_FUTURES/BTCUSDT': the browser lane serves market pins on secondary ohlcv inputs only (typed feeds, cells and time follow the chart's own market)`, or `... on the primary input (index 0): the primary input follows the chart's market; market pins are served on secondary inputs only`. **Cause:** a market pin on a source other than a secondary `ohlcv` input, or on the first input. **Fix:** pin another market's candles only, on a secondary input; everything else follows the chart's market ([Multi-source](../core-concepts/multi-source.md)). ## `the browser lane serves interval pins coarser than the chart only` **Symptom:** before anything is fetched: `Input 'close_1m' pins interval '1m', finer than the chart's: the browser lane serves interval pins coarser than the chart only`. Its siblings: `not a whole multiple of the chart's interval: a pinned leg must be a whole number of chart bars`, `the browser lane serves interval pins on secondary ohlcv inputs and the funding and oi feeds only`, and `the primary input follows the chart's interval; interval pins are served on secondary inputs only`. Two view mistakes stop earlier, at the sheet check: `view "confirmed" needs an interval pin`, and `view "forming" folds rolling buckets up to WEEK`. **Cause:** the chart serves a pinned interval only when it is coarser than the chart's and divides into whole chart bars, and only on those sources; a view reads a pinned leg, so it needs one. **Fix:** pin a coarser interval that is a whole multiple of the chart's (a `4h` pin on a `1h` chart, `DAY` on `4h`), or change the chart's interval ([Multi-timeframe](../core-concepts/multi-timeframe.md)). ## `pins the template's placeholder market` **Symptom:** a prediction-market starter refuses: `Input 'yes' (odds) pins the template's placeholder market (wrun_odds_placeholder_market): paste the market's condition id (0x…) into the input's symbol and Run again`. The other odds refusals: `binds its market per use ... the chart has no market picker yet; pin the market's condition id (0x…) in the input's symbol` and, on a 1s chart, `prediction markets are served on MINUTE and coarser charts`; the sheet check refuses a slug (`odds sources are keyed by the Polymarket conditionId (0x + 64 hex), not the market slug`) and an exchange (`exchange is implicit (POLYMARKET) for odds sources`). **Cause:** an `odds` input names its Polymarket market by condition id in `symbol`; the templates ship a placeholder, and the chart has no picker. **Fix:** paste the market's condition id into the input's `symbol`, drop any `exchange` or `binding`, and **Run** on a one-minute chart or coarser. ## `max_cells is 4, so the evaluation is refused (a block is never truncated)` **Symptom:** the run stops: `input 1 bar at ts ... has 5 cells; max_cells is 4, so the evaluation is refused (a block is never truncated)`. **Cause:** `max_cells` is a contract, not a hint: the module preallocates that many tuples, and a bar whose block is larger would overflow it. **Fix:** raise `max_cells` on the input (`input("profile", volume_profile.cells, { max_cells: 8192 })`) and **Run** again. Nothing is ever truncated on your behalf. On a volume profile the indicator can do without for a bar, `missing: "empty"` reads such a bar as an empty block instead of stopping the run. ## "Drawings capped" on the legend row **Symptom:** the run draws, but the indicator's legend row says "Drawings capped" and its card reads, for example, "2,000 of 2,350 drawings": the chart drew the newest 2,000 and left the oldest out. **Cause:** a segment or box drawn on every bar of a long window, or handles that are created and never deleted. **Fix:** gate the shape with `when` so it draws only where it means something, delete handles you no longer need, or narrow the span. The chart never refuses the run for this; the 2,000 that show are the newest. ## `render result exceeds 8388608 bytes expanded` **Symptom:** a run refuses because the expanded render selection (every selected text mark, label, table cell, shape, and drawing) passed 8 MiB: `render result exceeds 8388608 bytes expanded (wrun_render_result_too_large): fewer rows, shorter strings, or fewer renderers/drawings`. **Cause:** a per-bar text renderer over a long history, or a large table rewritten on every bar. **Fix:** write the slot only on the bars that need a mark (an unwritten slot draws nothing), or move the readout to `render.label`, which keeps one label. The string caps refuse the same way, never truncating: `string slot 0 write of 300 bytes exceeds the slot's max_bytes 256` (raise the declaration's `max_bytes`, up to 4,096), `over the per-row limit 65536; emit less text per bar`, and `over the per-run limit 2097152; emit less text or fewer rows` (text and frames together, a live session counting as one run). ## `data is unavailable for '...', so the indicator cannot compute.` **Symptom:** a toast such as `Volume profile data is unavailable for 'My VP' (the volume_profile source lane answered empty or was declined), so the indicator cannot compute.`, or `Pinned market candle (BINANCE_FUTURES/BTCUSDTT) data is unavailable for ...` for a pin. **Cause:** the chart asked for a source it needs and got nothing back: a market the venue does not serve that source for, or a misspelled pin. **Fix:** switch to a market that serves the source, or correct the pin's spelling ([Exchange and symbol format](../reference/symbol-format.md)). ## Other messages from a run - **`funding field 'rate_open' is not served by the browser lane`**: the chart serves `funding.rate_close` only, as a percent normalized to a one-hour interval. - **A tenor the chart does not serve** (`ONE_D`, `THREE_D` or `ONE_Y` on `implied_volatility` or `skew`): the run is refused naming the tenor. The chart serves five, `ONE_W`, `ONE_M`, `TWO_M`, `THREE_M` and `SIX_M`, from the coin's Deribit summaries. - **A feed the chart in front of you cannot have**: `options_oi`, `options_volume` and `ethena_positions` need a BTC or ETH chart, `long_short_ratio` a chart of 5 minutes or coarser, `token_supply` a chart coarser than one minute and a token the chart lists, `economic` a publisher the chart knows, a pinned `options_chain` venue a coin that venue lists. Each is refused before anything is fetched, with a message naming the input and the reason. - **`Input 'flow' (etf_flow) needs a chart of a coin with listed spot ETFs (BTC, ETH, SOL)`** and **`Input 'flow' (etf_flow) pins fund 'IBIT', not one of the ETH spot ETFs (...)`**: the class follows the chart's coin; open a BTC, ETH, or SOL chart, or declare one of that coin's funds (the message lists them) or `all`. - **`input 'es' pins exchange CME: wrun_pin_venue_refused: CME is CME Group market data and cannot be read from another market's script`**: a CME Group venue (`CME`, `CBOT`, `NYMEX`, `COMEX`, `GLOBEX`) cannot be pinned; pin an ETF on the same market (`SPY/USD` on `POLYGON`) or leave the input out. - **`input 'spy' pins symbol "SPY/USD + CME|ES1!": wrun_pin_symbol_literal: ...`**: a pin names one market; give each market its own input. - **`Input 'spx' pins SPX on POLYGON_INDICES; index series (SPX, NDX, VIX, DJI) are not served yet: ...`**: a warning, not a failure. The input reads its missing fill (`NaN`) on every bar; pin an ETF instead (`SPY/USD`, `QQQ/USD`). - **`Input 'spy' pins interval '3d' on POLYGON/SPY/USD: a multi-day candle on a session venue groups trading days ...`**: a `3d` candle on a stock, forex or gold market would close too early; pin `1d` or `1w` ([Stocks, forex and gold](../core-concepts/multi-source.md#stocks-forex-and-gold)). - **`Nothing was drawn`** and **`is NaN on all ... ready bars`**: see [Ran but blank](#ran-but-blank). ## Messages from the chart - **Indicator checks are unavailable:** (a warning row) and **Run failed before it started:**: the browser could not load the compiler (an offline tab, a blocked worker). Reload; nothing in the file is wrong. - **Publish is available once the Indicator has no errors**: clear the Console's errors first; Publish unlocks once the code builds cleanly. - **The publish window was blocked. Allow popups for this site and try again.**, **OpenMarket did not answer in time. Try again.**, and **Publish failed:** followed by the registry's reason (a version that already exists, for one): the publish itself, not your file ([Publishing](../functions/publishing.md)). - **Where this Indicator runs was fixed by its first version: keep the same sharing choice.**: a later version cannot switch between the browser choices (Compiled, Open source) and Protected. Publish under a new name to move it. - **The source is 300 KB; the limit is 256 KB.**: Open source code is capped at 256 KB; publish the code Compiled (readers get a compiled module, never the source), or trim it. - **Run is paused on CME markets. Switch to a non-CME symbol to run this script.**: drafts cannot read CME data; switch the chart to a non-CME market to **Run**. - **Publish first**, **Publish the current version first**, and **Runs in the browser. Only a Protected Indicator runs in the cloud.**: the editor's **Run on** menu runs in the cloud only for a published Protected indicator whose published version matches your code. - **The console does not run strategy Indicators; Run the tab and inspect its outputs.** and **This Indicator reads ...; the console runs over the chart's candles only.**: the Console prompt compiles expressions over the chart's candles only. **Run** the tab, then read outputs by name at the prompt ([Debugging](debugging.md)). - **Publish the Indicator before adding an alert.**: an alert needs a published version. Publish, then add the alert on the published indicator ([Alerts](../functions/alerts.md)). - **This Indicator is pinned to a different interval than the chart.** and **This Indicator reads a data source alerts cannot evaluate yet.**: OpenMarket's alerts engine reads another market's candles and a coarser pin that is a whole multiple of the chart's interval, over at most 600 bars, but not every pin or feed. A pin on the first input, a finer pin, a custom timeframe, a `forming` view too long for those 600 bars, or a feed alerts do not read keeps its alerts off ([Alerts](../functions/alerts.md)). - **This Indicator reads a market alerts cannot evaluate yet (an index).** and **Indicator alerts are not allowed on this venue.**: an alert evaluates pinned stock, forex and gold candles, but not an index pin or a CME Group market ([Alerts](../functions/alerts.md)). - **This Indicator was built for a runtime version alerts do not support. Rebuild and publish it again.**: the message is the fix. - **Indicator alerts are not enabled yet.** and **Only price alerts are available on CME markets right now.**: alerts on wrun indicators are switched off there. ## Ran but blank An indicator that builds cleanly and draws nothing has no error to show, only a symptom. For a draft, the Console turns the commonest into a warning after a **Run**: `Output 'sma' is NaN on all 480 ready bars, so nothing is drawn for it.` (A file with the four functions can also hear `Nothing was drawn: state() returned 0 on all 500 bars, so every bar counted as warm-up.`) | Symptom | What is happening | Fix | | --- | --- | --- | | nothing on any bar | every output is `NaN` on every bar: a class whose period exceeds the loaded history, or a guard that sends `onBar()` back before it writes | load more history; read the guard at the Console prompt, or log it ([Debugging](debugging.md)) | | a line is empty on every bar | the output is `NaN` on every bar (written so, or never written), or it is declared `none` | find the first `NaN` input; change the plot kind | | a line stops partway | a value that went `NaN` mid-history: a division by zero, a `missing: "nan"` secondary with no more observations | guard the division; choose `"zero"` or `"carry"` | | very few marks | a `shape` gate that rarely fires | usually intentional; lower the threshold to check the gate works | | a pinned input is empty | an index pin, or no candle closed yet | pin an ETF (`SPY/USD`); check for `NaN` | | the newest bar moves, older ones hold | the forming bar re-evaluates as live updates arrive, at most about once a second | expected: a bar's value settles when the bar closes | The workflow that turns a symptom into the line that causes it is in [Debugging](debugging.md). <!-- source: https://openmarket.xyz/wrun/faq/debugging --> # Debugging A practical workflow for finding out why a wrun indicator is wrong: make visible what you cannot see, read the Console, isolate one output, read the numbers at the Console prompt, then check the usual suspects. Work it in order. Your indicator compiles and runs but the signal is wrong, or it draws nothing, or a line is mysteriously flat. There is no `console.log` (the module runs in a sandbox with no console), but there is a debug log that prints in the editor's Console, a prompt that reads the last run's numbers, and one fact that makes indicators easy to debug: every value the module computes can be an output. ## 1. Emit what you cannot see The fastest debugger is an output or a log line. You cannot step through the bar loop, but you can make any intermediate value visible on every bar. Three forms: - **A `lower` line** shows the shape of a value over every bar. Seeing it usually tells you immediately whether it is doing what you think: pinned at zero, flatlining, a spike where there should be none. - **A `none` output** is a probe: computed and written every bar, never drawn, and readable by name at the Console prompt (step 4). It keeps the picture clean while you check a number. - **The debug log** prints text: a string slot named `debug`, written in `onBar()`, prints each bar's line in the editor's Console. ```typescript param("len", 14, { min: 2, max: 200 }); // The value under suspicion, drawn in its own pane so its shape is visible on every bar. output("range_pct", line, lower, { unit: "%", color: "#ef4444", description: "high minus low as a percent of close" }); output("smooth", line, lower, { unit: "%", color: "#f59e0b", description: "EMA of the range" }); // A probe: computed every bar and readable at the Console prompt, never drawn. Delete it when done. output("raw_spread", none, lower, { description: "debug: high minus low before the percent" }); let ema = new Ema(14); function onStart(): void { ema = new Ema(i32(p_len())); } function onBar(): void { const close = bar.close(); const spread = bar.high() - bar.low(); const rangePct = close > 0.0 ? (spread / close) * 100.0 : NaN; if (isNaN(rangePct)) return; out_range_pct(rangePct); out_smooth(ema.update(rangePct)); out_raw_spread(spread); } ``` The debug log is the indicator's `print()`. Declare `string("debug", { max_bytes })`, build a line with the allocation-free `sb_*` builder, and send it with `str_debug_sb()` (or send a finished string with `str_debug(text)`). Write it only on the bars worth reading: a bar that writes nothing prints nothing. ```typescript param("len", 20, { min: 2, max: 200, description: "Bars in the volume average" }); input("volume", ohlcv.volume); output("ratio", line, lower, { description: "Volume over its average" }); // The debug log: a string slot named exactly debug. Each non-empty line prints in the editor's Console. string("debug", { max_bytes: 128 }); let average = new Sma(20); function onStart(): void { average = new Sma(i32(p_len())); } function onBar(): void { const volume = bar.volume(); const mean = average.update(volume); if (isNaN(mean) || mean <= 0.0) return; const ratio = volume / mean; out_ratio(ratio); // Log only the bars worth reading. The builder allocates nothing, so logging never grows memory. if (ratio > 3.0) { sb_clear(); sb_text("spike ratio="); sb_f64(ratio, 2); sb_text(" volume="); sb_f64(volume, 0); str_debug_sb(); } } ``` After a **Run**, the Console prints one line per bar whose slot is not empty, oldest bar first, after the bar's time in ISO 8601 UTC: ```text 2026-09-30T14:00:00.000Z spike ratio=3.41 volume=1824 2026-09-30T19:00:00.000Z spike ratio=4.07 volume=2210 ``` Every run rebuilds the lines, so they always belong to the run on screen, and the Console keeps the newest 400 ("… N earlier print lines truncated to keep the console fast"). The lines print before the chart draws, so they are there even when drawing fails. Build the text with `sb_*`, not with `+` or `toString()`: text built that way allocates on every bar, and memory that grows once the bars start stops the run. When you need the exact number on the chart rather than in the Console, a string slot and a `render.label` print it on the newest bar. It switches the file to the second runtime contract, which **Run** derives for you: ```typescript output("range_pct", line, lower, { unit: "%", color: "#ef4444" }); output("tag_x", none, lower, { description: "This bar's open time, the label's x" }); string("readout", { max_bytes: 32 }); // One label, on the newest bar that wrote the slot: the exact number, read off the chart. render.label("range_readout", { x: "tag_x", y: "range_pct", text: "readout", color: "#f59e0b", size: 12 }); function onBar(): void { const close = bar.close(); const rangePct = close > 0.0 ? ((bar.high() - bar.low()) / close) * 100.0 : NaN; if (isNaN(rangePct)) return; out_range_pct(rangePct); out_tag_x(bar.time()); sb_clear(); sb_text("range% = "); sb_f64(rangePct, 2); str_readout_sb(); } ``` This works for any expression. Lift the part you doubt into a variable, declare an output or a log line for it, and read the answer instead of guessing. Delete the probe and the log when you are done: whoever adds a published indicator gets everything it declares. ## 2. Read the Console If **Run** stops before the chart changes, the message is in the editor's Console, tagged with the stage that produced it, and one problem hides the ones behind it. The status bar's problem counter (its tooltip reads **Open Problems**) opens the Console, and clicking a row jumps to its line: | Stage | What it checked | Typical message | | --- | --- | --- | | `declarations` | the `param` / `input` / `output` / shape statements, read from the text | `option 'top' takes an output handle, not a string literal` | | `lint` | raw positional slot literals in your source | `Raw slot literal 0 passed to getFloat(): raw positional slots rebind silently when the sheet changes` | | `metadata` | the sheet derived from your declarations, against the schema | `Output 'price' (color_by): color_by needs 'colors' with at least 2 entries` | | `compile` | the AssemblyScript compiler, with a hint | `Conversion from type 'f64' to 'i32' requires an explicit cast.` | | `validate` | the built module's exports and the sandbox | `console.* is not available in an Indicator (it runs in a sandbox with no console).` | | `compute` | the run itself, after **Run** | `AssemblyScript abort: Index out of range at ~lib/array.ts:...` | Fix the first one and **Run** again. Every message is listed with its fix in [Common errors](common-errors.md). A run that reaches the chart and draws nothing is usually not an error at all. It is an output that is `NaN` on every bar (`onBar()` returned before writing it, or wrote `NaN`), an output declared `none`, or a source the chart's market does not serve (the chart says which, by name). For a draft, the Console says which after the run: `Output 'range_pct' is NaN on all 480 ready bars, so nothing is drawn for it.` Step 5 covers the rest. ## 3. Isolate When several outputs are wrong at once, stop reasoning about all of them. Reduce the module to one output: leave the others unwritten in `onBar()` (an unwritten output is `NaN`, and `NaN` draws nothing), or switch their plot to `none`, and keep only the suspect series drawn. Once that one is correct, bring the others back one at a time. This is the cheapest way to find which input poisoned a calculation downstream, because a wrong value in an indicator has exactly one place it can come from: the bar's inputs, the state carried from the previous bar, or the arithmetic between them. ## 4. Read the numbers at the Console prompt The chart shows shapes; the Console prompt shows numbers. After a **Run**, type at the prompt ("Type an expression, an output name, or / for commands") to read the last run without compiling anything: ```text range_pct the newest value range_pct[-3] the value three bars back last 20 raw_spread the newest 20 bars (a none output reads like any other) at 2026-01-01T00:00Z range_pct the value on the bar that opened at that time rows the bar count, the first and last bar, the warm-up rows params each param and the value the last run used outputs every output and its last value sheet the sheet derived from your declarations ``` A range reads over the ready bars; warm-up rows are left out. Anything else you type is an expression: it compiles into the current source, runs once per ready bar after your own code for the bar, over the chart's candles, and leaves the overlay alone, so your module-level variables and the kit are in scope. Expressions run over candles only: a strategy indicator, or one that reads another source, is refused ("The console does not run strategy Indicators; Run the tab and inspect its outputs."), and its outputs stay readable by name after a **Run**. `/help` lists the grammar, and `/clear` empties the Console. ## 5. The usual suspects Most "wrong indicator" bugs are one of a handful of patterns. Scan this list against your symptom: - **Line starts blank, then appears.** Warm-up. Anything with a period is `NaN` until it has enough bars, and a `NaN` output draws nothing on those bars. That leading gap is expected; if the line never appears, the chart has fewer bars loaded than the window needs. - **Flat line.** A module-level variable assigned in `onStart()` and never updated in `onBar()`, or an output written from a different variable than the one the bar updates. Log the value per bar and check that it moves. - **Everything is NaN.** A secondary input under `missing: "nan"` on bars without an observation, an arithmetic chain fed by one `NaN` input, or a division by zero. Probe each input as a `none` output and find the first `NaN`. - **Wrong pane.** A percent or a count drawn `overlay` hugs the price axis floor. Declare it `lower`. - **A mark on every bar.** A `shape` output without `shape_where`. Add the gate output. - **The newest bar jumps around.** It is still forming: the chart re-evaluates it as live updates arrive, at most about once a second, starting each time from the module's state as it stood after the last closed bar. A bar's value settles when the bar closes. - **Nothing at all, no error.** `onBar()` never reaches a write (a class whose period exceeds the loaded history), the only rendered output is declared `none`, or the chart's market does not serve the source (the chart names it, for example `celled source class 'tape' is not served by the browser lane yet`, or `Volume profile data is unavailable for ...`). - **A pinned input reads `NaN` everywhere.** An index pin (`POLYGON_INDICES`) reads `NaN`, and the legend says why; any pin reads `NaN` until its first candle closes. A misspelled pin is refused instead: `Pinned market candle (...) data is unavailable for ...` ([Exchange and symbol format](../reference/symbol-format.md)). - **An alert does not match the chart.** An alert runs the published version and the settings of the overlay it was set on, in OpenMarket's cloud, on the chart's own market; the draft in your editor is not what it evaluates. Publish the change, add the new version to the chart, and set the alert on it. A declared `alert(...)` fires on the bar its `when` output turns from zero to nonzero, so a gate that stays at `1` fires once ([Alerts](../functions/alerts.md)). Walk these top to bottom. The fix for each is one line, and the probe from step 1 usually tells you which one you are looking at. ## See also - [Common errors](common-errors.md) for every build and runtime message with its fix. - [Execution model](../core-concepts/execution-model.md) for warm-up, the forming bar, and where an indicator runs. - [Repainting](../core-concepts/repainting.md) for what can change on the forming bar and what cannot. - [Alerts](../functions/alerts.md) for what an alert on an indicator evaluates. <!-- source: https://openmarket.xyz/wrun/strategies/overview --> # Strategies overview A strategy is a wrun indicator that places orders. The file declares `strategy({ ... })` beside its outputs and calls `strategy.long`, `strategy.exit`, `strategy.closeAll` and the position getters inside `onBar()`; the chart's Strategy Tester runs it with the engine's own broker, and its trade list, equity curve and stats come out the same wherever it runs. This page is the map: what a strategy file is, what the Strategy Tester shows, the guarantees, and what each page in this section covers. ## What a strategy file is The smallest useful one: long when the fast EMA crosses above the slow one, flat when it crosses back under. ```typescript // Moving average cross as a strategy: long when the fast EMA crosses above the slow one, flat when it crosses back under. strategy({ initialCapital: 10000, qtyType: "percentOfEquity", qtyValue: 50, commissionPercent: 0.05, slippageBps: 2 }); output("fast", line, overlay, { description: "9-period EMA of close" }); output("slow", line, overlay, { description: "21-period EMA of close" }); const fastEma = new Ema(9); const slowEma = new Ema(21); const cross = new Cross(); function onBar(): void { const fast = fastEma.update(bar.close()); const slow = slowEma.update(bar.close()); if (isNaN(fast) || isNaN(slow)) return; const crossed = cross.update(fast, slow); if (crossed == 1) strategy.long("L").send(); if (crossed == -1) strategy.closeAll(); out_fast(fast); out_slow(slow); } ``` To try it, open the editor from the chart toolbar's **Editor** button, press **New indicator** in the Explorer, keep the `//@lang=wrun-ts` line at the top of the draft and replace the starter under it with this file, then press **Backtest**. A fuller version of this file is a starter in the editor's template picker: the Explorer's **Templates** icon ("Browse starter templates") lists it as **Moving-Average Cross** under **Strategies** (`strategy-ma-cross`), with `Sma` in place of `Ema`, a ribbon between the averages and a mark on each cross ([Moving-average cross](../cookbook/strategy-ma-cross.md)), commented throughout. Three things make it a strategy rather than an indicator: - `strategy({ ... })` at the top level is the broker: starting equity, sizing (`50%` of equity per entry), commission and slippage live in the file, so a published strategy carries its own assumptions. Every field is optional; a bare `strategy()` takes every default. Run derives a `strategy` section into the sheet, and that section is what makes the file a strategy: once it builds, the editor's toolbar shows a **Strategy** badge and the **Run** button reads **Backtest**. - `strategy.long("L").send()` in `onBar()` queues a market order under the id `L`. An order placed on a bar fills no earlier than the next bar's open; nothing in a run can act on information it did not have. - `strategy.closeAll()` is the signal exit. Orders and the position getters (`strategy.positionSize()` and the rest) both go in `onBar()`; a getter reads the position after the bar's fills. The outputs stay outputs: `fast` and `slow` draw on the chart like any indicator's, and once the strategy is published they can carry alerts. A strategy adds four alert choices of its own, "Strategy order placed", "Strategy trade opened or closed", "Strategy position" and "Strategy equity" ([Alerts](../functions/alerts.md)), which is why `strategy.position` and `strategy.equity` are reserved: an output with either name is refused by name. ## What the Strategy Tester shows The file compiles in your browser, and **Backtest** puts the draft on the chart's price pane, where its trade markers land; a drawn output on `lower` gets a pane of its own below. The Strategy Tester docks under the chart with the engine's own result: a **trades list** (entries, exits, per-trade PnL, fees, exit reason), an **equity curve** with its drawdown, **performance stats** (net profit, win rate, profit factor, Sharpe, Sortino, max drawdown, exposure and the rest), and the **open trades and pending orders** as of the last bar, with a marker on the price series for every fill. [Reading the Strategy Tester](reading-the-tester.md) walks the panel. The broker fills every order against the chart's own candles, which is why a strategy's price input reads the chart's `ohlcv` and carries no symbol or exchange pin (any other primary input is refused by name). The run covers the candles the chart has loaded and recomputes as you pan, so panning back tests more history. ## Core guarantees - **Deterministic.** The same file over the same bars produces the same trades wherever it runs: the broker is the engine's own code, pinned to one engine version, run under the engine's own phase order. - **Lookahead-free.** Orders placed while bar N computes fill no earlier than bar N+1's open. The position a guard reads in `onBar()` is the position after that bar's fills, so a flat guard is already flat on the bar its exit filled. - **Honest accounting.** A bar that could have filled two levels settles by the declared `fillModel` and counts in `ambiguousFillCount` (the Tester's **Model-settled fills**); rejected orders (the pyramiding cap, a size the equity cannot fund, an order placed on a live chart's forming bar) are counted, never dropped. The chart attaches no finer-interval data, no order book and no funding data to a strategy run, and the Tester's **Run details** say so: "Fill precision: bar resolution" once the fill model has settled a fill, and "Funding data did not cover N bars" on a perps run that held a position under `funding: "data"`. ## What the broker is, and is not A research tool: fast iteration and honest reporting over bar closes. It settles orders at the next open, resting limits and stops when touched, protective exits under the fill model, isolated margin, liquidation and maker or taker fees on perps. It does not model queue position, partial fills at a level, replenishment, latency, or venue fees beyond the declared commission or maker and taker rates, and on the chart it does not walk finer bars, price fills off a recorded book (`slippageModel: "bookEstimate"` is refused by name) or settle recorded funding (`funding: "data"` counts the bars it could not settle). Each is disclosed by name rather than approximated. ## Section contents - [Build your first strategy](first-strategy.md): an RSI reversion from a sentence to a read result, tuned honestly, then published. - [Writing strategies](writing-strategies.md): the declaration table, the order API, the position and sizing model. - [Fill simulation](fill-simulation.md), [Slippage and costs](slippage-and-costs.md): the phase order, fill prices and fill models; commission, flat slippage, maker and taker rates. - [Perps: margin, liquidation, funding](perps.md): the leveraged model, its formulas and its counters. - [Reading the Strategy Tester](reading-the-tester.md), [Stats reference](stats-reference.md): the panel and every stat. - [Examples](examples.md): the five perps scenarios and a Hyperliquid perp walkthrough, complete files to start from. <!-- source: https://openmarket.xyz/wrun/strategies/first-strategy --> # Build your first strategy One idea taken from a sentence to a backtest you can evaluate honestly: write the strategy as one wrun indicator file in the chart's editor, backtest it, read every part of the Strategy Tester, then tune it like you mean it. It is the strategy sibling of the first indicator primer. ## 1. The idea, in one sentence "When RSI leaves oversold, buy the dip with a quarter of my equity; take the trade off when momentum recovers; protect it with a stop 4% below." ## 2. Write it Open the editor from the chart toolbar's **Editor** button. In the Explorer, press **New indicator**: the draft opens with a moving-average starter under a `//@lang=wrun-ts` line, the marker that makes the tab a wrun indicator. Keep that line and replace everything under it with this file: ```typescript // RSI reversion with a protective stop: buy the dip on the bar RSI crosses up through oversold, protect it 4% under the close, leave on recovery. strategy({ initialCapital: 10000, qtyType: "percentOfEquity", qtyValue: 25, commissionPercent: 0.05, slippageBps: 2 }); param("period", 14, { min: 2, max: 200, description: "RSI length in bars" }); output("rsi", line, lower, { description: "Wilder RSI of close, 0 to 100" }); let rsi = new Rsi(14); const dip = new Cross(); const recovery = new Cross(); function onStart(): void { rsi = new Rsi(i32(p_period())); } function onBar(): void { const close = bar.close(); const value = rsi.update(close); if (isNaN(value)) return; const dipped = dip.update(value, 30.0); const recovered = recovery.update(value, 55.0); if (dipped == 1) strategy.long("Dip").send(); if (strategy.positionSize() > 0) strategy.exit("Protect").from("Dip").stop(close * 0.96).send(); if (recovered == 1) strategy.closeAll(); out_rsi(value); } ``` Reading it top to bottom: - `strategy({ ... })` is the broker. Starting equity, sizing (`25%` of equity per entry), commission and slippage live here, so a published strategy carries its own assumptions. It takes the engine's own setting names; every field is optional. - `Cross.update(a, b)` returns `1` on the bar `a` crosses above `b`, so `dipped == 1` is the bar RSI crosses up through oversold. Returning early from `onBar()` while RSI warms up keeps those bars off the chart and out of the broker's reach: a bar that returns before the order calls places nothing, and its unwritten `rsi` is NaN, which is not drawn. - `strategy.long("Dip").send()` queues a market order under the id `Dip`. It fills at the next bar's open, never earlier. - `strategy.exit("Protect").from("Dip").stop(close * 0.96).send()` arms a protective stop against the named entry, re-placed every bar the position is held, at 4% under that bar's close. `strategy.positionSize()` reads the position after the bar's fills, so the guard is already flat on the bar the stop filled. - `strategy.closeAll()` is the signal exit for the recovery case. Order calls and the getters both belong in `onBar()`. ## 3. Backtest it The editor compiles the file in your browser as you type, and each build derives the sheet from it (the two declarations, the `close` input the file reads through `bar.close()`, and the strategy section). Once the file builds, the toolbar shows a **Strategy** badge and the **Run** button reads **Backtest**. Press it: the draft goes on the chart's price pane, RSI gets a pane of its own below, and the Strategy Tester docks under the chart. The Tester runs the file in your browser over the candles the chart has loaded, lets the broker fill every order against those candles, and recomputes as you pan, so panning back tests more history. A problem in the file stops the build instead, and prints in the editor's **Console** with the stage it came from. ## 4. Read the result critically Four places to look, in order: 1. **Run details.** The info icon in the Tester's header opens "Run details": the chart's interval, the depth ("Follows chart (recomputes as you pan)"), the bars the run covered, the declared fill model and the compute time, then a precision line: "Fill precision: exact" when no bar had to be settled by the fill model, "Fill precision: bar resolution" once one was. The costs are the file's own `commissionPercent` and `slippageBps`: a strategy that declares none is a gross result, whatever its return says. 2. **The numbers.** The Overview's net profit, win rate, profit factor, drawdown, Sharpe and trade count, and the Performance tab's full table, read together, never one alone: a strategy that wins 70% of the time with a payoff ratio of 0.3 loses money. Every stat has an exact formula in the [stats reference](stats-reference.md). 3. **The trades.** The Trades tab lists every closed trade with its exit reason under **Exit via** (`signal`, `stop`, `limit`, `trail`, `closeAll`, or **Liquidated**), then the open trade and the pending orders. A **± fill** badge marks a trade the fill model settled where one bar touched both the stop and the target, and **Model-settled fills** in the Performance tab counts them (`ambiguousFillCount`): the Tester says so instead of hiding it. 4. **The chart.** Every fill is a marker on the bar that filled it. Hover a row in the Trades tab to highlight its trade on the chart, or click it to scroll the chart to the entry, and check that each entry sits where the rule says it should. ## 5. Tune it honestly Results are deterministic, so every change you see is yours. Change things in this order: - **Costs first.** Set `commissionPercent` and `slippageBps` to what you actually pay on your venue. Most retail strategies die here, and it is cheaper to learn that from the Tester than from a book. - **Sizing.** Try `qtyType: "fixed"` with a small quantity against `percentOfEquity`. Compounding changes the drawdown's character, not only the end number. - **The stop.** Tighten `0.96` to `0.99` and watch the **Exit via** mix shift from `signal` to `stop`. A stop narrow relative to the bar range asks intrabar questions the interval cannot answer; read [fill simulation](fill-simulation.md) before tightening further. - **A setting as a parameter.** Link a numeric setting to a param and it becomes a number field in the strategy's settings dialog (the Tester's **Strategy settings** gear opens it). Change the value and the strategy reruns over the same candles at the new risk, without a recompile. ```typescript // The same reversion with the risk per entry as a setting: the strategy's qtyValue reads the risk_pct param when the run starts. const risk = param("risk_pct", 25, { min: 1, max: 100, description: "Percent of equity per entry" }); strategy({ initialCapital: 10000, qtyType: "percentOfEquity", qtyValue: risk, commissionPercent: 0.05, slippageBps: 2 }); param("period", 14, { min: 2, max: 200, description: "RSI length in bars" }); output("rsi", line, lower, { description: "Wilder RSI of close, 0 to 100" }); let rsi = new Rsi(14); const dip = new Cross(); const recovery = new Cross(); function onStart(): void { rsi = new Rsi(i32(p_period())); } function onBar(): void { const close = bar.close(); const value = rsi.update(close); if (isNaN(value)) return; const dipped = dip.update(value, 30.0); const recovered = recovery.update(value, 55.0); if (dipped == 1) strategy.long("Dip").send(); if (strategy.positionSize() > 0) strategy.exit("Protect").from("Dip").stop(close * 0.96).send(); if (recovered == 1) strategy.closeAll(); out_rsi(value); } ``` A fuller version is a starter in the editor's template picker: the Explorer's **Templates** icon ("Browse starter templates") lists it as **Risk-Sized Reversion** under **Strategies** (`strategy-risk-reversion`), with the stop and a target sized in ATR, each entry sized so a trade stopped out loses `risk_pct` of the equity (never more than the equity buys), and each trade's rails drawn on price ([Risk-sized reversion](../cookbook/strategy-risk-reversion.md)), commented throughout. In the file above, `param(...)` returns a handle; passing it as `qtyValue` derives `"qty_value": { "param": "risk_pct" }` into the sheet, and the broker reads the param once, when the run starts. A value outside the setting's rule (here, not a positive number) falls back to the default, the engine's own behavior for a bad override. ## 6. Keep it The editor saves the file to your account as you type, but the strategy on the chart is a draft: it lasts for the session and is not saved with the layout. **Publish** in the editor turns it into a versioned indicator that you, the people you invite, or everyone can add to a chart, with its costs and sizing inside it ([Publishing](../functions/publishing.md)). A published strategy can also carry alerts from the chart: beside its outputs (`rsi` here), the alert dialog offers four choices of its own, "Strategy order placed", "Strategy trade opened or closed", "Strategy position" and "Strategy equity", and OpenMarket's alerts engine evaluates them in the cloud ([Alerts](../functions/alerts.md)). ## 7. What you have, and what you do not You have a deterministic, lookahead-free replay of your rules with disclosed costs and disclosed shortcuts, wherever the file runs. You do not have a promise about the future: no backtest survives contact with a regime change, and a parameter tuned until the curve looks good is a fit to the past. Prefer fewer parameters, realistic costs, and results that stay acceptable when you nudge every setting. Next: the full [order API](writing-strategies.md), [how fills are simulated](fill-simulation.md), and [every stat defined](stats-reference.md). <!-- source: https://openmarket.xyz/wrun/strategies/writing-strategies --> # Writing strategies The `strategy({ ... })` declaration, the order API, the position model and sizing. The chart's Strategy Tester runs the engine's broker, so every rule below is the rule it applies. ## The declaration `strategy(options)` is one top-level statement of the file, beside `param`, `input` and `output`. It takes the engine's setting names; every field is optional and `strategy()` enables the broker with every default. A numeric field may be the handle `param(...)` returns: the setting then shows as a number field in the strategy's settings dialog, starting at the param's default, and changing it reruns the strategy without a recompile. | Setting | Sheet field | Default | Meaning | | --- | --- | --- | --- | | `initialCapital` | `initial_capital` | 10000 | Starting equity, more than 0. | | `currency` | `currency` | `"USD"` | Display label for money-denominated stats. | | `commissionPercent` | `commission_percent` | 0 | Spot commission on every fill, percent of notional. Ignored on perps. | | `slippageBps` | `slippage_bps` | 0 | Adverse basis points on market, stop and trailing fills. Limit fills are exempt: a limit price is a bound. | | `slippageModel` | `slippage_model` | `"fixed"` | `"bookEstimate"` is refused by name when the strategy runs (the chart attaches no order book). | | `qtyType`, `qtyValue` | `qty_type`, `qty_value` | `"percentOfEquity"`, 100 | Default sizing. `"fixed"`: `qtyValue` units per entry. `"percentOfEquity"`: `qtyValue / 100 * equityAtFill / fillPrice`. `"cash"`: `qtyValue / fillPrice`. On perps the last two size margin, and notional is margin times leverage. | | `pyramiding` | `pyramiding` | 1 | Maximum stacked same-direction entries; excess entries are rejected and counted. | | `fillModel` | `fill_model` | `"pessimistic"` | Intrabar ordering assumption when a bar could fill two levels; see [fill simulation](fill-simulation.md). | | `instrument` | `instrument` | `"spot"` | `"perps"` enables isolated leveraged margin, liquidation, maker and taker fees and funding. | | `leverage` | `leverage` | 1 | Perps leverage, more than 0. | | `maintenanceMarginPercent` | `maintenance_margin_percent` | 0.5 | Perps maintenance margin, at least 0 and under 100. | | `makerFeePercent`, `takerFeePercent` | `maker_fee_percent`, `taker_fee_percent` | 0, 0 | Perps fee rates, percent of notional; maker on limit-bound fills, taker on market-crossing fills. Ignored on spot. | | `funding` | `funding` | `"data"` | `"off"` is an exact no-op; `"data"` settles recorded funding when funding data is attached, and counts the bars it cannot settle otherwise (the chart attaches none). | | `onLiquidation` | `on_liquidation` | `"continue"` | `"halt"` rejects every entry after the first liquidation. | Run derives the section into the sheet with the field names in the second column, and checks every value with the engine's rules as the file builds: a value out of range stops the build, with the problem in the editor's **Console**. A strategy's price input reads `ohlcv` on the chart's own market, with no symbol or exchange pin (a file with no `input` line gets the chart's close as that input): the broker fills against the chart's own candles, and any other primary input is refused by name. The names `strategy.position` and `strategy.equity` are reserved for the strategy's own alert choices ("Strategy position" and "Strategy equity"): an output with either name is refused by name. There is no `calc_on_every_tick`: live behavior is fixed, [fill simulation](fill-simulation.md) explains it. ## The order API Every call maps to one engine broker call, with the engine's own validation: ```text strategy.long(id).qty(n).limit(px).stop(px).oca(name).send() // strategy.entry(id, "long", ...) strategy.short(id).qty(n).limit(px).stop(px).oca(name).send() // strategy.entry(id, "short", ...) strategy.exit(id).from(entryId).qty(n).qtyPercent(p) .profit(pts).limit(px).loss(pts).stop(px) .trail(points, offset).oca(name).send() // strategy.exit(id, fromEntry, ...) strategy.close(id) strategy.closeAll() // market, next open strategy.cancel(id) strategy.cancelAll() // pending unfilled orders ``` - A plain `strategy.long(id).send()` is a market order for the next bar's open. `limit` or `stop` makes it a resting order that fills when touched; both on one entry is rejected and counted. Re-issuing an id replaces the pending unfilled order with that id. - `strategy.exit(id)` attaches bracket legs to an entry: `profit` and `loss` in price points, `limit` and `stop` as absolute prices, at least one leg. Without `from` it protects every open entry. Stop, limit and trail legs under one exit id are one-cancels-all. - `trail(points, offset)` activates a trailing stop once the trade's favorable excursion reaches `points`, then ratchets with new extremes at `offset` behind them. - `oca(name)` joins any orders, entries and exits alike, into a one-cancels-all group: when one fills, the rest cancel. - The builders are preallocated singletons: `strategy.long`, `strategy.short` and `strategy.exit` each reset one builder, the setters fill its legs, and `send()` is the only call that reaches the broker, so finish one order before starting the next. An unset leg is absent. There is no `comment`: the engine keeps it on no record. - Getters, valid in `onBar()` beside the order calls: `strategy.positionSize()` (signed), `strategy.positionAvgPrice()`, `strategy.equity()`, `strategy.openProfit()`, `strategy.netProfit()`, `strategy.closedTradeCount()`, `strategy.winTradeCount()`, `strategy.lossTradeCount()`, `strategy.maxDrawdown()`. A crossover entry bracketed by both a stop and a take-profit limit; whichever the market touches first closes the trade and cancels the other side: ```typescript // Trend entries with bracket exits: a stop and a take-profit limit on one exit id, whichever the market touches first closes the trade and cancels the other. strategy({ initialCapital: 10000, qtyType: "fixed", qtyValue: 1, pyramiding: 1, fillModel: "pathHeuristic" }); output("fast", line, overlay, { description: "5-period SMA of close" }); output("slow", line, overlay, { description: "20-period SMA of close" }); const fastSma = new Sma(5); const slowSma = new Sma(20); const cross = new Cross(); function onBar(): void { const close = bar.close(); const fast = fastSma.update(close); const slow = slowSma.update(close); if (isNaN(fast) || isNaN(slow)) return; const crossed = cross.update(fast, slow); if (crossed == 1) strategy.long("Trend").send(); if (strategy.positionSize() > 0) strategy.exit("Protect").from("Trend").stop(close * 0.97).limit(close * 1.05).send(); out_fast(fast); out_slow(slow); } ``` ## Position model The broker nets to one position. An entry in the opposite direction is a reversal: it closes the existing position fully at the same fill, then opens the new one at the requested quantity. `pyramiding` caps same-direction stacking; rejected entries are counted in the broker's `rejectedOrders` rather than thrown. Sizing details worth knowing: - An explicit `qty(...)` on an order always wins over the declaration's `qtyType`. - Sized-at-fill quantities (`percentOfEquity`, `cash`) resolve from the slippage-adjusted fill price, and fractional quantities are legal (the broker does not round lots). - On perps, `percentOfEquity` and `cash` size the isolated margin commitment, not the notional. Explicit quantities and `qtyType: "fixed"` stay direct quantities and are margin-checked. Perps entries also pass an isolated-margin admission check before the opening leg applies; unrealized PnL is not collateral. [Perps](perps.md#margin) has the model. - A computed size is rejected and counted when equity at fill is not positive or the quantity is not finite and positive. Order calls are recorded as rejected, not thrown, when the current bar has non-finite prices or is not confirmed (a live chart's forming bar). On the last bar, unfilled orders remain visible under **Pending orders** in the Strategy Tester's Trades tab. ## Limits At most 64 distinct order ids per run (`closeAll` counts as one; ids are slots, re-issuing one replaces its pending order), 64 bytes of UTF-8 per id, 4096 strategy calls per bar, and 10000 closed trades per run. Each breach refuses the run by name, and every engine rejection stays a counted rejection; the [limits](../reference/limits.md) page lists them. <!-- source: https://openmarket.xyz/wrun/strategies/fill-simulation --> # Fill simulation How the broker decides when and at what price orders fill, how a bar that could have filled two levels is settled, and how the run reports its own precision. The chart's Strategy Tester runs the engine's broker, one bar ahead of your file. ## The execution model Orders are processed at each new confirmed bar in a fixed sequence, before your file's `onBar()` runs on that bar: 1. Protective exits of the open position (stop, limit, trail) are evaluated against the bar's range under the fill model. 2. Pending signal exits from `close` and `closeAll`, in insertion order. 3. Pending entries, in insertion order: market orders at the open, resting limit and stop orders if touched. 4. On perps, the phase-end liquidation check against the final position. 5. Your file runs at the bar's close; the orders it places join the queue for the next bar. Fill prices follow bar mechanics: - **Market fills** execute at the next bar's open, slippage-adjusted. - **Limit fills** execute when the bar trades through the level (long entry: `low <= limit`), at the better of the open and the limit, with no slippage: a limit price is a bound by definition. - **Stop fills** mirror the touch rule and are slippage-adjusted, because stops cross the market. - **Trailing stops** activate when favorable excursion reaches the trail points, then ratchet with new extremes and fill under stop rules with `exitReason: "trail"`. Equity is recorded on every bar (cash plus the open position marked at that bar's close, fees already paid), so drawdown and exposure account for flat periods too. ```text one confirmed bar ----------------------------------------------------------------> [funding settles] [protective exits] [signal exits] [entries fill] perps only stop / limit / close() / market at open, trail vs the closeAll() resting limit / bar's range stop if touched | | v v .................. phase-end liquidation check .................. | v [onBar() runs at bar close] -> orders placed here queue for the NEXT bar ``` Two consequences worth internalizing: your file always sees the bar's completed fills, and nothing your file places can fill until the following bar. The first is why a flat guard works without a flag of its own: ```typescript // The flat guard: an entry only while flat, read from the position AFTER the bar's fills, so the bar an exit filled on can re-arm the entry. strategy({ qtyType: "fixed", qtyValue: 1 }); output("sma", line, overlay, { description: "10-period SMA of close" }); output("position", none, lower, { description: "Signed position after the bar's fills" }); const sma = new Sma(10); function onBar(): void { const close = bar.close(); const mean = sma.update(close); const held = strategy.positionSize(); if (isNaN(mean)) return; if (held == 0 && close > mean) strategy.long("L").send(); if (held > 0 && close < mean) strategy.close("L"); out_sma(mean); out_position(held); } ``` `strategy.positionSize()` inside `onBar()` reads the position after the bar's fills. On the bar the `close("L")` order filled, `held` is already `0`, so the entry guard can re-arm on that very bar; keep entry zones disjoint from your exit prices unless that is what you want. The data-only `position` output records `held` on every row. ## The newest bar On a full run (the first render, or a rerun after you pan back or change a setting) every bar is confirmed, the newest included: an order placed on the last bar rests pending, exactly as the engine's full run treats it. On a live chart the newest bar is the forming bar: the broker marks equity at its close but fills nothing on it, and an order placed on it is rejected and counted, exactly as the engine treats a live update's last bar. Live updates re-evaluate the forming bar on a clone of the committed state, coalesced to at most about once a second rather than on every tick; when the bar closes, the closed row is folded into the committed broker once (fills for the orders queued before it, then the file's own orders) and the next forming bar starts on a fresh clone. A live session therefore equals a full run from bar 0, including the one-time shift of pending-order and stat values between the first render and the first live update. ## Intrabar ordering and fill models A single bar tells you the range it traded, not the path it took. When both a protective stop and a profit limit sit inside one bar's range, the bar alone cannot say which traded first. Decisions certain at bar level never involve an assumption (a leg already marketable at the open fills at the open); the rest settle by the declared `fillModel`: - `"pessimistic"` (default): the stop is assumed to have filled first. The decision is counted in `ambiguousFillCount` (the Strategy Tester's **Model-settled fills**) and the trade is flagged `ambiguousFill` (a **± fill** badge in the Trades tab). - `"pathHeuristic"`: the open moves toward the nearer of high and low first. Natural path resolutions are not counted; only exact distance ties fall back to the pessimistic rule and count. Both models are fully deterministic. The chart attaches no finer-interval bars to a strategy run, so every contested fill settles by the model: the Strategy Tester's **Run details** read "Fill precision: bar resolution" once one has ("Fill precision: exact" while none has), and `fineResolvedCount` stays `0`. Uncontested fills are identical either way. ## Choosing an interval to trust Brackets arm on the bar after the entry fills. On a 1-hour chart that protection gap is an hour of price movement; on a 1-minute chart it is a minute. A strategy whose stops are tight relative to its interval will genuinely behave differently at finer intervals, because it is seeing different information. Backtest at the interval you intend to run on, keep stops and targets wide relative to the interval's typical bar range or move to a finer chart, and treat **Model-settled fills** (`ambiguousFillCount`) as part of the result. <!-- source: https://openmarket.xyz/wrun/strategies/slippage-and-costs --> # Slippage and costs Commission on spot, maker and taker fees on perps, and flat slippage, each declared in the file so a published strategy carries its own assumptions. The book-estimate slippage model is the one piece of the accounting the chart refuses rather than approximates. ## Commission (spot) On `instrument: "spot"` (the default), `commissionPercent` is charged on every fill as a percent of the fill's notional, on entries and exits alike. Fees reduce equity immediately and are reported per trade (`fees` on each trade record) and in the totals as `stats.feesPaid`. ## Maker and taker fees (perps) On `instrument: "perps"`, fills are charged `makerFeePercent` or `takerFeePercent` instead of commission, by how the fill reached the market: - **Taker** (`takerFeePercent`): market-crossing fills. Market entries, stop entries, protective stops, trailing stops, signal exits, `closeAll`, liquidation, and the flattening leg of a reversal a market or stop entry triggered. - **Maker** (`makerFeePercent`): limit-bound fills. Limit entries, take-profit limit legs, and the flattening leg of a reversal a limit entry triggered. - `stats.feesPaid` is exactly `makerFeesPaid + takerFeesPaid` on perps. ```typescript // Perps maker-fee routing: a resting limit entry pays the maker rate, the market close pays the taker rate. strategy({ initialCapital: 10000, instrument: "perps", leverage: 2, makerFeePercent: 0.1, takerFeePercent: 0.05, funding: "off", qtyType: "fixed", qtyValue: 1, pyramiding: 1 }); output("equity", line, lower, { description: "Strategy equity" }); const fastMa = new Sma(4); const slowMa = new Sma(9); const cross = new Cross(); function onBar(): void { const close = bar.close(); const fast = fastMa.update(close); const slow = slowMa.update(close); if (isNaN(fast) || isNaN(slow)) return; const crossed = cross.update(fast, slow); if (crossed == 1) strategy.long("Long").limit(close * 0.999).send(); if (crossed == -1) strategy.close("Long"); out_equity(strategy.equity()); } ``` The resting limit just under the market is a maker fill when a later bar trades through it (`makerFeesPaid` grows); the market close crosses the market and pays the taker rate (`takerFeesPaid` grows); `feesPaid` is their exact sum. ### One fee schedule per instrument Declaring the other instrument's fee settings never double-charges: the broker ignores them. - Perps plus a nonzero `commissionPercent`: fees come from the maker and taker rates only. - Spot plus `makerFeePercent` or `takerFeePercent`: the run charges commission only. `onLiquidation` on spot is ignored the same way. Funding on perps is a holding cashflow, not a fill cost, and has its own stats and series; see [perps](perps.md). ## Flat slippage `slippageBps` applies adversely to every fill that crosses the market: market entries, stop entries, protective stops and trailing stops. Buys fill at `price * (1 + bps / 10000)`, sells mirror. Limit fills, including take-profit legs, are exempt. Slippage is priced into the fill itself: the trade records carry the slipped price, and there is no separate slippage line to subtract, which is why the Strategy Tester shows fees but no slippage figure. Flat slippage is honest about being a constant: it neither grows with your order size nor tightens on liquid pairs. ## The book estimate The book estimate (`slippageModel: "bookEstimate"`) walks recorded order-book depth to price market-crossing fills by size. The chart attaches no order book to a strategy run, so a strategy declaring it is refused by name when it runs (`wrun_strategy_slippage_model_unsupported`) rather than silently priced at the flat rate. Declare `"fixed"` with a `slippageBps` that is realistic for the pair. ## Where the costs show The Strategy Tester's Performance tab carries **Fees paid** (`feesPaid`), and on perps **Maker fees paid** and **Taker fees paid** under it. The costs are the file's own and the Tester has no fee or slippage field: to test another fee tier or slippage, change the declaration, or link the setting to a param and change it in the strategy's settings dialog. ## Practical guidance - Set `slippageBps` to what a taker actually pays on the pair, and `commissionPercent` or the maker and taker rates to your tier. Most retail rules die here. - Size matters: a strategy that trades a fraction of equity and one that trades at 10x see very different fee bills on the same signals, because fees are a percent of notional. - Read **Fees paid** against **Net profit** before reading the win rate: a strategy that crossed the spread 34 times in a quarter has paid for it. <!-- source: https://openmarket.xyz/wrun/strategies/perps --> # Perps: margin, liquidation, funding `instrument: "perps"` switches the broker from spot accounting to isolated-margin perpetual futures: leverage, maker and taker fees, funding settlement and liquidation, each with its own counters, so a result is never quietly shaped by machinery you cannot see. This page names what each mechanic does, with the engine's own formulas, and what the chart does not attach. The five scenarios that pin the numbers are on the [examples](examples.md#the-five-perps-scenarios) page. ## What perps mode changes Perps mode keeps the same order API and one-net-position model. Four mechanics turn on: | Mechanic | What changes | | --- | --- | | Margin | Every entry commits isolated margin of `notional / leverage`; an entry that cannot be margined is rejected and counted. | | Maker and taker fees | Fills are charged `makerFeePercent` or `takerFeePercent` instead of `commissionPercent`. | | Funding | Recorded funding events settle against the open position (`funding: "data"`, the default) when funding data is attached, or funding is off entirely (`funding: "off"`). | | Liquidation | A position that exhausts its margin is force-closed at the broker's implicit liquidation stop, and `onLiquidation` decides whether the run keeps trading or halts. | A spot run carries none of this, and spot economics are unchanged until you opt in: the perps stats report zero (`makerFeesPaid`, `takerFeesPaid`, `fundingPaid`, `fundingEventsApplied`, `fundingUnavailableCount`, `liquidationCount` all `0`, `liquidationHalted: false`) and the perps-only series are omitted. The defaults, echoed by a bare declaration: `instrument: "spot"`, `leverage: 1`, `maintenanceMarginPercent: 0.5`, `makerFeePercent: 0`, `takerFeePercent: 0`, `funding: "data"`, `onLiquidation: "continue"`. ## Margin Each perps entry commits isolated margin equal to the fill's notional divided by `leverage`; `committedMarginSeries` on the output carries the committed total at each bar's close, `0` when flat. Any number above `0` is legal leverage, fractions included; `maintenanceMarginPercent: 0` is legal (the lower bound is inclusive). | Declared sizing | Under perps | | --- | --- | | `qtyType: "percentOfEquity"`, `qtyType: "cash"` | Size the margin commitment. The broker then computes `notional = margin * leverage` and `qty = notional / fillPrice`. | | `qtyType: "fixed"`, an explicit `.qty(...)` | Direct quantities, margin-checked as `abs(qty) * fillPrice / leverage`. | **Leverage scales margin, not PnL.** The same signals with the same fixed quantity produce the same `netProfit` as spot, whatever the leverage, until fees or a liquidation differ. Leverage decides how much equity a position ties up and where liquidation sits, not what a trade earns. ```typescript // Perps margin sizing: percentOfEquity sizes the margin commitment, and notional is that margin times leverage. strategy({ initialCapital: 10000, instrument: "perps", leverage: 10, maintenanceMarginPercent: 0.5, qtyType: "percentOfEquity", qtyValue: 10, makerFeePercent: 0.02, takerFeePercent: 0.05, slippageBps: 0, funding: "off" }); output("close", line, overlay, { description: "Close price" }); let bars: i32 = 0; function onBar(): void { bars += 1; if (bars == 1) strategy.long("MarginLong").limit(100.0).send(); out_close(bar.close()); } ``` On bars priced near 100, that entry fills at 100 with `qtyType: "percentOfEquity"`, `qtyValue: 10` and `leverage: 10`: the open trade has `qty` 100, `committedMargin` 1000 and `fees` 2, with `makerFeesPaid` 2 and `takerFeesPaid` 0. The 10% sizing setting committed 1000 of margin, not 1000 of notional; the notional was margin times leverage. The `bars` counter is a first-bar gate: the file keeps its own row count. ### Admission Entries are admitted against realized-basis equity: `committedMargin + entryFee <= availableMargin`, where the available margin excludes unrealized PnL from the open position. A winning open position does not become collateral for adding exposure. An entry whose fee-inclusive requirement (`notional / leverage` plus the entry's fee) exceeds the available margin is rejected and counted in `stats.rejectedOrders`. Reversals stay atomic: the broker simulates the closing leg, then checks whether the new opening leg fits; if it does not, the whole reversal is rejected and the old position remains open. ## Liquidation ```text long 1 unit at 100, leverage 10, maintenance 0.5% entry 100 -+------------------------------------ | committed margin = 100/10 = 10 | the price may fall ~9.55 before | margin (less maintenance) is gone P_liq ----+---- 90.4522... <- liquidation | +-- lower leverage pushes this line further away ``` The broker maintains an implicit liquidation stop on the final open net position, from the entry fill onward. It is not a user order: it is not in your pending queue, cannot be canceled, and closes the trade with `exitReason: "liquidation"` when the bar proves or assumes the level traded. It uses the bar's traded prices, not a mark price the broker does not have. | Side | Liquidation level | | --- | --- | | Long | `P_liq = (Q * E - M) / (Q * (1 - m))` | | Short | `P_liq = (E + M / Q) / (1 + m)` | `Q` is the absolute quantity, `E` the average entry, `M` the committed isolated margin and `m` the maintenance margin as a fraction. For a single entry the long level reduces to `entry * (1 - 1 / leverage) / (1 - maintenanceMarginPercent / 100)`: a 10x long entered at 100 with 0.5% maintenance liquidates at `90.45226130653266`, and the short mirror at `109.45273631840797`. Three cases the bar decides: - **Entry-bar liquidation** is possible, because a market entry fills at the bar's open and the phase-end check sees the same bar's range. - **Same-bar fills that change the final position.** The bar cannot prove whether the adverse extreme came before or after the change, so the broker assumes liquidation if the final level is reached, clamps the fill into the bar's traded range, counts it in `ambiguousFillCount` and flags the trade. - **A gap through the level.** The fill is the bar price, because that is where the market traded; the loss beyond committed margin is `stats.bankruptcyDeficit`, and equity floors at zero. | `onLiquidation` | Behavior | | --- | --- | | `"continue"` (default) | Trading continues; every liquidation counts in `liquidationCount`. | | `"halt"` | The first liquidation stops the strategy: `liquidationHalted: true`, and every subsequent entry is rejected and counted. | ```typescript // Liquidation with on_liquidation "continue": 25x leverage puts the broker's liquidation stop inside an ordinary swing, and trading goes on after each one. strategy({ initialCapital: 10000, instrument: "perps", leverage: 25, maintenanceMarginPercent: 0.5, funding: "off", onLiquidation: "continue", qtyType: "fixed", qtyValue: 1, pyramiding: 1 }); output("equity", line, lower, { description: "Strategy equity" }); const fastMa = new Sma(4); const slowMa = new Sma(9); const cross = new Cross(); function onBar(): void { const close = bar.close(); const fast = fastMa.update(close); const slow = slowMa.update(close); if (isNaN(fast) || isNaN(slow)) return; const crossed = cross.update(fast, slow); if (crossed == 1) strategy.long("Long").send(); out_equity(strategy.equity()); } ``` At 25x the liquidation level sits about 3.5% under the entry, inside an ordinary swing on most pairs: expect trades closed by `liquidation` (**Liquidated** under **Exit via** in the Strategy Tester's Trades tab), `liquidationCount` counting them, and trading continuing after each one. ## Maker and taker fees Perps ignores `commissionPercent`: a fill pays `makerFeePercent` when it is limit-bound and `takerFeePercent` when it crosses the market. [Slippage and costs](slippage-and-costs.md#maker-and-taker-fees-perps) classifies every fill, shows a worked example, and covers what happens to the other instrument's fee settings. ## Funding `funding: "data"` settles recorded funding events against the open position, and `funding: "off"` disables funding entirely; only those two literals are accepted. The rate is decimal (1 bp is `0.0001`), and positive rates mean longs pay and shorts receive. `fundingPaid` is signed from the strategy's side (positive when the strategy paid), `fundingEventsApplied` counts the settlements applied, and `fundingPaidSeries` is the cumulative series. Funding is a drip against isolated margin. Settlements apply only while a position is open, and debit cash and committed margin together, so the liquidation line moves closer with every settlement paid. A settlement that erodes committed margin to zero or below liquidates the position at that bar's open with zero price PnL, and later same-bar fills cannot rescue it. The chart attaches no funding data to a strategy run. `funding: "data"` therefore settles nothing and counts every bar an open position could not be settled in `fundingUnavailableCount`: partial coverage is counted, never charged, and no rate is ever invented. The Strategy Tester discloses the count on every run it applies to: **Run details** read "Funding data did not cover N bars", and the Overview's **Funding** chart says "No funding was applied on this run." `funding: "off"` is the exact no-op it always was, and a strategy that should not depend on funding declares it. ## Perps stats and series A run's stats always carry the perps fields, zero on spot; the [stats reference](stats-reference.md#perps) defines each exactly. | Stat | Meaning | | --- | --- | | `makerFeesPaid`, `takerFeesPaid` | Fees by fill class. | | `fundingPaid` | Net funding settled, signed from the strategy's side. | | `fundingEventsApplied` | How many funding settlements the run applied. | | `fundingUnavailableCount` | Bars with an open position and no settlement data. | | `liquidationCount` | Trades force-closed by the liquidation stop. | | `liquidationHalted` | `true` once `onLiquidation: "halt"` stopped the run. | | `bankruptcyDeficit` | How far equity would have gone below zero before the floor. | Perps runs also add two bar-aligned series to the output, present on every perps run and omitted on spot: `committedMarginSeries` (margin committed to the open position at each bar close) and `fundingPaidSeries` (cumulative funding paid, ending at `stats.fundingPaid`). The Strategy Tester draws them as the Overview's **Margin** and **Funding** charts, each with **Show on chart**, shows the fee split and the funding in the Performance tab, and the liquidations in the Overview and the Trades tab. ## Validation The settings are checked with the engine's rules as the file builds, and a value out of range stops the build with the problem in the editor's **Console**. A setting linked to a param is checked when the run starts, with the same rules, and an out-of-range value falls back to the default. | Setting | Rule | | --- | --- | | `instrument` | `"spot"` or `"perps"` | | `leverage` | More than 0 | | `maintenanceMarginPercent` | At least 0 and under 100 | | `makerFeePercent`, `takerFeePercent` | At least 0 | | `funding` | `"data"` or `"off"` | | `onLiquidation` | `"continue"` or `"halt"` | <!-- source: https://openmarket.xyz/wrun/strategies/reading-the-tester --> # Reading the Strategy Tester The Strategy Tester is where a strategy's result lives on the chart: it docks under the chart whenever the chart holds a strategy, carries the trades, the equity curve and the stats, and marks each fill on the price series. This page is the map of the panel, and of where its honesty disclosures live. ```text Strategy Tester Tabs one per strategy: its name, its net result in percent, an eye, a menu Header Compare, the bars the run covered, Run details, Strategy settings Overview the headline stats beside the equity curve, a drawdown lane, Buy & hold (a perps strategy adds Margin and Funding) Performance Returns, Risk, Trades, then Long vs short and the P&L distribution Trades Closed trades, Open trades, Pending orders ``` ![The Strategy Tester expanded under the chart on its Overview tab: the strategy tab with its net result, the header, the headline stats beside the equity curve, and the fill markers with their rails on the price pane](/wrun/images/tester-overview.png) - The strategy's tab at the top left carries its name and its net result; the header holds **Compare**, the bars chip ("1,029 bars · to Oct 4"), **Run details** and the gear. - **Overview**: the stats column (Net profit, Win rate, Profit factor, Max drawdown, Sharpe, Trades) beside the equity curve, the **DD** lane under it and **Buy & hold** at the top right. - On the chart, each fill is an arrow on its bar; this strategy also draws the stop and target rails of every trade, and its RSI in the pane below. ## Where it opens Press **Backtest** in the editor, or add a published strategy from the chart's **Indicators** dialog, and the Tester docks under the chart. Collapsed, it is a strip: the strategy's name, a sparkline of its equity, and its **Net**, **Win**, **DD** and **Trades**. Click the strip to expand the panel, or open a strategy from the chip on its legend entry ("Open ... in Strategy Tester"). Each strategy on the chart gets a tab with its name, its net result in percent, and an eye that hides its plots and trade markers together. The tab's menu has **Remove from chart**, **Open settings**, **Hide markers**, and **Trade markers**: the marker shape (**Chevron**, **Bubble** or **Badge**), its size, and the long and short colors. ## Overview A column of headline stats, **Net profit** (with its percent), **Win rate** (with wins and losses), **Profit factor**, **Max drawdown** (in percent and money), **Sharpe** and **Trades** (with the exposure), beside the equity curve, which has a drawdown lane (**DD**) under it and a **Buy & hold** toggle that draws holding the market over the same bars. Hover the curve and the chart highlights the same bar. A perps strategy adds two small charts, **Margin** (the margin committed to the open position at each bar) and **Funding** (the cumulative funding), each with **Show on chart** to project it onto the chart, and a line when a liquidation happened: when and at what price (or how many times, from the first), then whether the strategy continued or halted. ## Performance Three groups, then two cards built from the closed trades: - **Returns**: Net profit, Gross profit / loss, Buy & hold return, Sharpe / Sortino. - **Risk**: Max drawdown, Max runup, Exposure. - **Trades**: Total / win rate, Avg trade / payoff, Fees paid (on perps also Maker fees paid, Taker fees paid, and Funding paid or Funding received), and Model-settled fills. - **Long vs short**: each side's net result, trade count and win rate. - **P&L distribution**: the spread of trade results, the largest win and loss, and the best and worst streaks. Every number has an exact formula in the [stats reference](stats-reference.md). ![The Performance tab: the Returns, Risk and Trades groups, then Long vs short and the P&L distribution](/wrun/images/tester-performance.png) - Three groups left to right: **Returns**, **Risk**, **Trades**; a strategy on perps adds its fee and funding rows to Trades. - **Long vs short** splits the net result and the win rate by side; a side without trades reads "No trades". - **P&L distribution** draws the closed trades from the largest loss to the largest win, with the streaks under it. ## Trades **Closed trades** come first, numbered, with side, quantity, entry and exit, PnL in cash and percent, cumulative PnL, and the exit reason under **Exit via** (`signal`, `stop`, `limit`, `trail`, `closeAll`, or **Liquidated**). A **± fill** badge marks a trade the fill model settled where one bar held both the stop and the target. **Open trades** (quantity, entry, entry price, unrealized PnL and fees) and **Pending orders** (id, side, type, quantity, price and legs) follow in their own sections. Hover a row to highlight its trade on the chart; click it to scroll the chart to the entry. ![The Trades tab: eight closed trades with side, quantity, entry and exit bars, PnL, cumulative PnL and the exit reason under Exit via](/wrun/images/tester-trades.png) - **Exit via** reads the reason per row: `closeAll`, `stop` and `limit` here; the fill badge on row 4 marks the trade the fill model settled. - The column headers sort; **Cum** runs the PnL down the table. - **Open trades** and **Pending orders** follow only when the run ends with a position or a working order; this run ended flat. ## On the chart Every fill is a marker on the bar that filled it, colored by direction in the chart's own candle colors unless you pick others under **Trade markers**; a liquidation exit carries its own mark. Past 150 closed trades a strategy's markers cluster into count pills, and **Run details** says "markers clustered · N trades". ## Run details The info icon in the header opens the run's own disclosure: - **Interval**: the chart's interval. - **Depth**: "Follows chart (recomputes as you pan)". The run covers the candles the chart has loaded and recomputes as you pan; a chip in the header reads the bars it covered and the date of the last one. - **Bars**, **Fill model** (the declared `fillModel`) and **Compute time**. - The precision line: "Fill precision: exact" when no bar had to be settled by the fill model, "Fill precision: bar resolution" once one was. The chart attaches no finer-interval data to a strategy run, so a contested fill is always the fill model's call ([fill simulation](fill-simulation.md)). - On a perps strategy that held a position under `funding: "data"`: "Funding data did not cover N bars". The chart attaches no funding data, so nothing was charged ([perps](perps.md)). ## Compare mode With two or more visible strategies on the chart, **Compare** puts them side by side: a normalized equity chart, a table of Net profit, Win rate, Profit factor, Max DD, Sharpe and Trades per strategy plus a **Combined** column (the equal-notional sum of their PnL), and their **Pairwise correlation**. Trade markers step aside while it is on. ## Strategy settings The gear in the header (**Strategy settings**) opens the strategy's settings dialog. The broker's settings (capital, sizing, costs, fill model, perps) live in the file's `strategy({ ... })`, not in the Tester. Of those, only a setting the author linked to a param shows in the dialog, as a number field beside the file's other params, and changing it reruns the strategy over the same candles ([Writing strategies](writing-strategies.md)). ## Live On a live chart the newest bar is still forming. Live updates re-evaluate it on a copy of the committed broker, coalesced to at most about once a second rather than on every tick: nothing fills on the forming bar, and an order placed on it is counted as rejected. When the bar closes it is folded into the committed broker once, so what the panel shows at any moment equals a full run over the closed bars plus the forming bar's mark. A strategy published as **Protected** runs on OpenMarket's servers with the same broker at the same fidelity, and the Tester reads its result the same way ([Publishing](../functions/publishing.md)). <!-- source: https://openmarket.xyz/wrun/strategies/stats-reference --> # Stats reference Every performance stat a strategy run computes, its exact formula, its edge cases, and where the Strategy Tester shows it. The stats come from the engine's own statistics module, and the Tester reads them from the run's result. Percent-scaled fields end in `Pct` and are already multiplied by 100. One run's stats record: ```json { "netProfit": 123.4, "netProfitPct": 1.234, "grossProfit": 310.2, "grossLoss": 186.8, "profitFactor": 1.66, "totalTrades": 12, "winTrades": 7, "lossTrades": 5, "winRatePct": 58.33, "avgTrade": 10.28, "avgWin": 44.31, "avgLoss": 37.36, "payoffRatio": 1.19, "largestWin": 96.1, "largestLoss": 61.5, "avgBarsInTrade": 9.4, "maxDrawdown": 402.6, "maxDrawdownPct": 3.98, "maxRunup": 640.2, "sharpe": 0.91, "sortino": 1.4, "exposurePct": 22.1, "buyHoldReturnPct": 4.1, "feesPaid": 32.1, "makerFeesPaid": 0, "takerFeesPaid": 0, "fundingPaid": 0, "fundingEventsApplied": 0, "fundingUnavailableCount": 0, "liquidationCount": 0, "liquidationHalted": false, "bankruptcyDeficit": 0, "rejectedOrders": 1, "ambiguousFillCount": 0, "fineResolvedCount": 0, "fineFillCoveragePct": null, "fillResolutionByLane": {}, "bookSlippageFillCount": 0, "bookSlippageUnavailableCount": 0, "bookSlippageAvgBps": null, "long": { "netProfit": 123.4, "totalTrades": 12, "winRatePct": 58.33 }, "short": { "netProfit": 0, "totalTrades": 0, "winRatePct": 0 } } ``` ## Returns <!-- wrun:cards group="stats" --> | Stat | Definition | | --- | --- | | `netProfit` | Sum over closed trades of `pnl - fees` (a trade record's `pnl` is the price move, its `fees` the trade's total). `netProfitPct` is relative to `initialCapital`. | | `grossProfit` / `grossLoss` | Sum of winning trades' net results and the absolute sum of losing trades'. | | `profitFactor` | `grossProfit / grossLoss`; `null` when there are no losses. | | `buyHoldReturnPct` | `(lastConfirmedClose / firstTradableClose - 1) * 100` over the run's confirmed bars: what doing nothing would have returned. The Performance tab's **Buy & hold return** is this number. | In the Tester: **Net profit** (with `netProfitPct`) and **Profit factor** sit in the Overview's stats column, and the Performance tab's **Returns** group shows Net profit, **Gross profit / loss** and Buy & hold return. ## Trades <!-- wrun:cards group="stats" --> | Stat | Definition | | --- | --- | | `totalTrades`, `winTrades`, `lossTrades` | Closed trades and the split; `winRatePct` is `winTrades / totalTrades * 100`. | | `avgTrade`, `avgWin`, `avgLoss` | Mean net result per closed trade, per winner, per loser (`avgLoss` is a magnitude). | | `payoffRatio` | `avgWin / avgLoss`; `null` when there are no losers. Read it with the win rate: 40% winners at a 3.0 payoff is profitable, 70% at 0.3 is not. | | `largestWin`, `largestLoss` | The single best and worst closed trades. If `largestWin` dominates `netProfit`, one trade made the backtest. | | `avgBarsInTrade` | Mean holding time in bars (`exitBar - entryBar`). | | `rejectedOrders` | Orders the broker refused and counted: the pyramiding cap, conflicting or missing legs, a size the equity could not fund, a non-finite or unconfirmed bar (a live chart's forming bar included); on perps also insufficient margin and post-halt entries. | In the Tester: the Overview's **Win rate** is `winRatePct` and the Performance tab's **Avg trade / payoff** is `avgTrade` and `payoffRatio`. The trade counts in both tabs are counted from the trade list (the Overview's **Trades** includes a trade still open), and the **Long vs short** and **P&L distribution** cards (largest win and loss, best and worst streaks) are worked out from the closed trades. ## Risk <!-- wrun:cards group="stats" --> | Stat | Definition | | --- | --- | | `maxDrawdown` | Largest peak-to-trough equity decline over the run, in money; `maxDrawdownPct` is relative to the peak it fell from. The per-bar `drawdown` series is the money distance from the running peak. | | `maxRunup` | Mirror of drawdown: the largest trough-to-peak climb. | | `sharpe` | `mean(r) / sampleStd(r) * sqrt(barsPerYear)` over per-bar equity returns on confirmed bars, `barsPerYear = 31,536,000,000 / intervalMs`; zero with no variance. Annualized from the run's interval, so compare across runs at the same interval. | | `sortino` | Sharpe with the downside deviation (returns below zero) in the denominator. | | `exposurePct` | Bars with a nonzero position over all confirmed bars, times 100. | In the Tester: **Max drawdown** (`maxDrawdownPct` and `maxDrawdown`) and **Sharpe** sit in the Overview, where the equity curve's **DD** lane is the drawdown bar by bar and **Trades** carries the exposure; the Performance tab's **Risk** group shows Max drawdown, **Max runup** and **Exposure**, and **Sharpe / Sortino** sits under Returns. ## Costs <!-- wrun:cards group="stats" --> | Stat | Definition | | --- | --- | | `feesPaid` | Total fees across all fills, already subtracted from equity and `netProfit` (funding excluded). Spot: `commissionPercent` of each fill's notional. Perps: exactly `makerFeesPaid + takerFeesPaid`. | | `makerFeesPaid` / `takerFeesPaid` | Perps fees by fill class; both `0` on spot even with rates declared. | | `fundingPaid` | Net funding settled against open perps positions, signed from the strategy's side; `0` under `funding: "off"`, on spot, and on the chart, which attaches no funding data to a strategy run. | | `fundingEventsApplied` | Settlements applied to an open position. | | `fundingUnavailableCount` | Bars an open perps position had no covering funding data under `funding: "data"`: on the chart every such bar. The disclosure counter; no rate is ever invented. | Slippage is not a separate stat line: it is priced into every fill. In the Tester: the Performance tab's **Trades** group carries **Fees paid**, and on perps **Maker fees paid**, **Taker fees paid**, and **Funding paid** (or **Funding received** when the strategy was paid); **Run details** show `fundingUnavailableCount` as "Funding data did not cover N bars". ## Perps <!-- wrun:cards group="stats" --> | Stat | Definition | | --- | --- | | `liquidationCount` | Trades force-closed by the broker's liquidation stop; those trades carry `exitReason: "liquidation"`. One liquidation can close several open trade records in the same net position. | | `liquidationHalted` | `true` when `onLiquidation: "halt"` stopped the run at the first liquidation; every subsequent entry counts as rejected. | | `bankruptcyDeficit` | The shortfall a liquidation fill left beyond the position's committed margin: how far equity would have gone below zero before the floor. | Perps runs also add `committedMarginSeries` and `fundingPaidSeries` beside the stats; both are omitted on spot. In the Tester: a liquidated trade reads **Liquidated** under **Exit via**, and the Overview says when and at what price the first liquidation happened, how many there were, and whether the strategy continued or halted. Its **Margin** chart draws `committedMarginSeries`, and its **Funding** chart draws `fundingPaidSeries` with the sign flipped, so paying walks down. ## Long and short splits `long` and `short` carry `netProfit`, `totalTrades` and `winRatePct` per direction. A "market-neutral" idea whose entire profit sits in `long` during a bull window is a long-only idea with extra steps. ## Simulation-quality stats These describe how the result was produced, not how the strategy performed: <!-- wrun:cards group="stats" --> | Stat | Definition | | --- | --- | | `ambiguousFillCount` | Intrabar ordering decisions settled by the declared `fillModel` rather than data; see [fill simulation](fill-simulation.md). | | `fineResolvedCount`, `fineFillCoveragePct`, `fillResolutionByLane` | Ordering questions settled by walking finer bars. The chart attaches no finer-interval data to a strategy run, so `fineResolvedCount` is `0` and `fillResolutionByLane` is `{}`; `fineFillCoveragePct` is `null` while no fill was contested and `0` once one was. | | `bookSlippageFillCount`, `bookSlippageUnavailableCount`, `bookSlippageAvgBps` | The book-estimate slippage model's counters: `0`, `0` and `null`, because the chart refuses that model by name. | In the Tester: `ambiguousFillCount` is **Model-settled fills** in the Performance tab, with a **± fill** badge on each such trade, and the fine-fill fields become the **Run details** precision line: "Fill precision: exact" while `fineFillCoveragePct` is `null`, "Fill precision: bar resolution" once a contested fill was settled by the model. ## Equity accounting, precisely Equity on every bar `i` is cash plus the open position marked at `close[i]`, fees already paid, recorded on flat bars too. Per-bar returns for Sharpe and Sortino are `r_i = equity_i / equity_{i-1} - 1` over confirmed bars with positive prior equity. On a live chart the forming bar is marked, never filled, so pending-order and stat values can shift once between the first render and the first live update, and settle when the bar confirms; [fill simulation](fill-simulation.md#the-newest-bar) says which bars count as confirmed. <!-- source: https://openmarket.xyz/wrun/strategies/examples --> # Examples Complete strategy files to start from: the five perps scenarios behind the engine's acceptance battery and a Hyperliquid perp walkthrough, each with a line on what it shows. The three spot examples live on the pages that teach them, listed first. To run one, replace the starter in a **New indicator** draft with the file and press **Backtest**; the [overview](overview.md#what-a-strategy-file-is) has the steps. The strategy trades the chart's own market, and the Strategy Tester docks under the chart with the trades and the stats ([Reading the Strategy Tester](reading-the-tester.md)). ## Spot examples | File | What it shows | Page | | --- | --- | --- | | Moving average cross | The smallest useful strategy: one entry rule, one exit rule. `percentOfEquity` sizing keeps the position proportional as equity compounds, and `commissionPercent` plus `slippageBps` make every fill pay realistic costs. | [Strategies overview](overview.md#what-a-strategy-file-is) | | RSI reversion with a protective stop | Buys oversold dips and arms a stop under every entry, so a dip that keeps dipping is cut instead of riding to the bottom. `strategy.exit(...).from("Dip")` scopes the stop to the named entry, and the stop follows the close at placement time. Built up line by line on its page. | [Build your first strategy](first-strategy.md) | | Trend entries with bracket exits | A crossover entry bracketed by a stop and a take-profit limit on one exit id, a one-cancels-all pair: whichever the market touches first closes the trade and cancels the other. `fillModel: "pathHeuristic"` decides a bar that touches both ([fill simulation](fill-simulation.md#intrabar-ordering-and-fill-models)). | [Writing strategies](writing-strategies.md#the-order-api) | ## The five perps scenarios The five canonical perps scenarios behind the engine's acceptance battery: liquidation on both sides, fee classification, funding erosion and bankruptcy accounting. They are reproduction scripts: each lands on the number the engine's battery pins for bars priced near 100, which is what makes it verifiable. On a chart they run against that chart's own prices, where a scenario that waits for a price near 100 may never trade, so read them for the mechanics rather than to match the numbers. [Perps](perps.md#liquidation) has the formulas. A first-bar gate is a row counter the file keeps itself. ### 1. Long liquidation What it shows: a 10x long entered at 100 with 0.5% maintenance margin must liquidate at exactly `(100 - 10) / 0.995 = 90.45226130653266`. ```typescript // Scenario 1, long liquidation: a 10x long at 100 with 0.5% maintenance margin must liquidate at (100 - 10) / 0.995. strategy({ initialCapital: 10000, instrument: "perps", leverage: 10, maintenanceMarginPercent: 0.5, qtyType: "fixed", qtyValue: 1, slippageBps: 0, makerFeePercent: 0, takerFeePercent: 0, funding: "off" }); output("close", line, overlay, { description: "Close price" }); let bars: i32 = 0; function onBar(): void { bars += 1; if (bars == 1) strategy.long("L").send(); out_close(bar.close()); } ``` ### 2. Short liquidation with taker fees What it shows: the short side of the same formula, `(100 + 10) / 1.005 = 109.45273631840797`, with a market entry so both the entry fill and the liquidation fill pay `takerFeePercent`. ```typescript // Scenario 2, short liquidation with taker fees: the short formula (100 + 10) / 1.005, both fills paying the taker rate. strategy({ initialCapital: 10000, qtyType: "fixed", qtyValue: 1, instrument: "perps", leverage: 10, maintenanceMarginPercent: 0.5, takerFeePercent: 0.05, funding: "off" }); output("close", line, overlay, { description: "Close price" }); function onBar(): void { const close = bar.close(); if (strategy.positionSize() == 0 && close < 100.5) strategy.short("S").send(); out_close(close); } ``` ### 3. Maker and taker split What it shows: two trades, four fills, four fee classifications. A limit entry and its take-profit limit pay maker; a market entry and its protective stop pay taker. Also a guard-design lesson: the file runs at the bar's close, after fills, so entry zones stay disjoint from exit prices or the flat-position guard re-arms on the very bar an exit filled. ```typescript // Scenario 3, maker and taker split: a limit entry and its take-profit pay maker, a market entry and its stop pay taker. strategy({ initialCapital: 10000, qtyType: "fixed", qtyValue: 1, instrument: "perps", leverage: 5, makerFeePercent: 0.01, takerFeePercent: 0.05, funding: "off" }); output("close", line, overlay, { description: "Close price" }); function onBar(): void { const close = bar.close(); const held = strategy.positionSize(); if (held == 0 && close > 100.5 && close < 103.0) strategy.long("LimitIn").limit(95.0).send(); if (held == 0 && close > 110.5) strategy.long("MarketIn").send(); if (held > 0) { strategy.exit("TP").from("LimitIn").limit(105.0).send(); strategy.exit("SL").from("MarketIn").stop(92.0).send(); } out_close(close); } ``` ### 4. Funding erosion What it shows: funding settlements debit cash and committed margin together, so the liquidation price tightens as margin erodes, and a settlement that depletes the margin liquidates the position at that bar's open with zero price PnL. The chart attaches no funding data to a strategy run, so on the chart this file charges nothing and counts every unsettled open bar in `fundingUnavailableCount`, and the Strategy Tester's **Run details** say "Funding data did not cover N bars" ([Funding](perps.md#funding)). ```typescript // Scenario 4, funding erosion: settlements debit cash and committed margin together, and a depleting settlement liquidates at that bar's open. strategy({ initialCapital: 10000, qtyType: "fixed", qtyValue: 1, instrument: "perps", leverage: 10, maintenanceMarginPercent: 0.5, funding: "data" }); output("close", line, overlay, { description: "Close price" }); function onBar(): void { const close = bar.close(); if (strategy.positionSize() == 0 && close > 99.5) strategy.long("L").send(); out_close(close); } ``` ### 5. Bankruptcy gap What it shows: price gaps straight through the liquidation level. The fill is the bar price because it is worse, the loss beyond committed margin is recorded as `bankruptcyDeficit`, and equity floors at exactly zero. ```typescript // Scenario 5, bankruptcy gap: a gap through the liquidation level fills at the worse bar price, the loss past the margin is the bankruptcy deficit, and equity floors at zero. strategy({ initialCapital: 10, instrument: "perps", leverage: 10, maintenanceMarginPercent: 0.5, qtyType: "fixed", qtyValue: 1, slippageBps: 0, makerFeePercent: 0, takerFeePercent: 0, funding: "off" }); output("equity", line, lower, { description: "Strategy equity" }); let bars: i32 = 0; function onBar(): void { bars += 1; if (bars == 1) strategy.long("L").send(); out_equity(strategy.equity()); } ``` ## Backtest a Hyperliquid perp strategy What it shows: a trend strategy under perps accounting on a venue that is leveraged, funded and liquidatable, with isolated margin at 10x, maker and taker fees, recorded funding and a protective stop off the average entry. A backtest that ignores those three venue facts will happily approve a strategy the venue would have destroyed. ```typescript // A funding-aware trend strategy at 10x: isolated margin, maker and taker rates, recorded funding, and a stop 3% under the average entry. strategy({ initialCapital: 10000, instrument: "perps", leverage: 10, qtyType: "percentOfEquity", qtyValue: 10, makerFeePercent: 0.015, takerFeePercent: 0.045, funding: "data", slippageBps: 2 }); output("fast", line, overlay, { description: "21-period EMA of close" }); output("slow", line, overlay, { description: "55-period EMA of close" }); const fastEma = new Ema(21); const slowEma = new Ema(55); const cross = new Cross(); function onBar(): void { const fast = fastEma.update(bar.close()); const slow = slowEma.update(bar.close()); if (isNaN(fast) || isNaN(slow)) return; const crossed = cross.update(fast, slow); if (crossed == 1) strategy.long("Trend").send(); if (crossed == -1) strategy.closeAll(); if (strategy.positionSize() > 0) strategy.exit("Protect").from("Trend").stop(strategy.positionAvgPrice() * 0.97).send(); out_fast(fast); out_slow(slow); } ``` Put the chart on a Hyperliquid perpetual (BTC, for example) before you press **Backtest**: the file reads the chart's own close through `bar.close()` and pins no market, so the chart you run it on is the market it tests. Reading the declaration, which does most of the perps work: - `instrument: "perps"` with `leverage: 10`: sizing commits **margin**, not notional. `qtyValue: 10` means each entry commits 10% of equity as isolated margin; the notional is that margin times leverage. The broker tracks the liquidation price from your leverage and maintenance margin and closes you there if a bar proves or assumes the level traded. - `makerFeePercent: 0.015, takerFeePercent: 0.045`: fees route by fill type, so market entries, stops and `closeAll` pay taker while limit-bound fills pay maker. Set your own tier's numbers; they travel inside the indicator when you publish it. - `funding: "data"`: the broker settles recorded funding against the open position when funding data is attached. The chart attaches none to a strategy run, so the run counts every unsettled open bar in `fundingUnavailableCount` and charges nothing; the count is the disclosure, and `funding: "off"` declares a strategy that should not depend on funding. - `strategy.positionAvgPrice() * 0.97`: the stop tracks the average entry, re-armed every held bar. ### Reading the result Three lines to check before believing the equity curve: 1. **Funding.** "Funding data did not cover N bars" in **Run details** (`fundingUnavailableCount`) is how many held bars went unsettled, and the Overview's **Funding** chart says no funding was applied. A long that looks fine gross can bleed through settlements while it holds, and this run has not charged them. 2. **Liquidations.** Trades that read **Liquidated** under **Exit via** (`liquidationCount` counts them), and the Overview's line saying when the first one happened. At 10x, a 3% protective stop and the liquidation level are uncomfortably close neighbors. If liquidations show up, the venue closed you before your stop did. 3. **Model-settled fills.** The Performance tab's count (`ambiguousFillCount`) and the **± fill** badge in the Trades tab: fills the declared fill model settled where one bar touched the stop and a level; **Run details** read "Fill precision: bar resolution" once one has. ### Tune it like it is real - **Leverage down first.** At 5x the liquidation level sits twice as far; watch the **Liquidated** exits disappear before you tune anything else. - **Widen the stop or drop the interval.** Brackets arm on the bar after entry, so a tight stop on a coarse chart is exposed for one full bar; [fill simulation](fill-simulation.md#choosing-an-interval-to-trust) covers the trade-off. - **Run the spot twin.** Copy the file into a second **New indicator** draft, drop the perps settings, and press **Backtest** on both: with two strategies on the chart, the Strategy Tester's **Compare** shows what leverage and fees cost you. That difference is the part most backtests never model. <!-- source: https://openmarket.xyz/wrun/reference/quick-reference --> # Quick reference The wrun kit on one screen, then the parts of it that are easiest to forget: the two hooks, the generated accessors (the `p_` / `in_` / `out_` families plus the cell and string families and the bar's own fields), every declaration signature, the fifteen setting kinds and the block kit, the sources the chart serves and their fields, the TA classes, and the kit modules. This page lists what a wrun indicator declares, because an indicator says what it reads and writes and the chart does the calling. ## One screen ### The hooks A file is its declarations at the top, then module state, then one or two plain functions (no `export`, nothing to import): | Hook | Runs | Do this here | Never here | | --- | --- | --- | --- | | `onStart(): void` (optional) | once per evaluation, before any bar | read params through `p_<param>()`, size buffers, construct TA objects | read an input (every `in_` reader and `bar.*()` return NaN before the first bar) or write an output (a write here never reaches a row) | | `onBar(): void` (required) | once per bar, oldest first | read inputs through `in_<input>()`, `bar.*()` and the cell readers, update module state, write outputs through `out_<output>(value)`, string slots, frames, handles and orders; the row is emitted when it returns | allocate (size buffers at module start or in `onStart()`); `emitRow()`: the build sends the row once, after `onBar()` returns, and a call of your own does nothing | Every bar emits a row: an output `onBar()` does not write is NaN on that bar and draws nothing there, so warm-up is a `return` before the writes, or a NaN written as it is. There is no fourth function to write: the chart replays the forming bar from a snapshot of the module's memory. ### Accessors (generated from your declarations each time the editor compiles, in scope with nothing to import) | Accessor | Phase | Meaning | | --- | --- | --- | | `p_<param>(): f64` | `onStart()` on | the param's value: its default, or the setting the chart user chose | | `pb_<param>(): bool`, `p_<range>_lo()` / `_hi()`, `p_<list>(): f64[]`, `p_<session>_start()` / `_end()` / `_tz()`, `p_<param>_unit()`, `pt_<param>(): string` | `onStart()` on | a typed setting's readers: a toggle, a range's ends, a list's filled slots, a session's parts, a unit menu's code, a text setting's words ([Setting kinds](../settings/kinds.md)) | | `in_<input>(): f64` | `onBar()` | this bar's scalar input; NaN on a celled input's slot | | `bar.open()`, `bar.high()`, `bar.low()`, `bar.close()`, `bar.volume()`, `bar.time()`, `bar.isLast()`, `bar.count()` | `onBar()` | the chart's own candle, its open in epoch seconds, whether this is the newest bar, and how many bars the run holds; no `input` line needed, reading a field adds its input to the sheet | | `out_<output>(value: f64): void` | `onBar()` | writes one output; the row is emitted when `onBar()` returns, NaN in every output it did not write | | `in_<input>_cells(): i32` | `onBar()` | f64 cells in this bar's block: `0` for a present empty block, `-1` when the bar carries no block (and before the first bar) | | `in_<input>_view(): StaticArray<f64>` | `onBar()` | the build's own buffer holding this bar's block, no copy; only the first `in_<input>_cells()` values belong to this bar | | `in_<input>_read(ptr: i32): i32` | `onBar()` | copies the block into module memory at `ptr`, for code that owns its own buffer; returns bytes written, `0`, or `-1` | | `in_<input>_max_cells: i32` | constant | the declared `max_cells`, in source tuples | | `in_<input>_capacity: i32` | constant | the f64 count of the build's buffer (`max_cells` x the class's tuple width) | | `sb_clear()`, `sb_text(s)`, `sb_int(n)`, `sb_f64(x, decimals)` | `onBar()` | build one UTF-8 line into a shared buffer, allocation-free | | `str_<slot>(s: string)`, `str_<slot>_sb()` | `onBar()` | send a whole string, or the built line, to one slot | ### Declarations (top-level statements of your file) | Declaration | Signature | | --- | --- | | Param | `param(name, default, { required?, min?, max?, description? })`, a number field | | Typed setting | `param.int`, `param.number`, `param.bool`, `param.choice`, `param.color`, `param.time`, `param.price`, `param.range`, `param.multi`, `param.list`, `param.source`, `param.timeframe`, `param.symbol`, `param.session` `(name, default, { required?, min?, max?, description?, label?, step?, group?, row?, hint?, when?, hide?, slider?, unit?, unit_default?, confirm? })`, `tz?` on a session; `market.tick_size()`, `market.price_precision()`, `market.kind()`, `market.point_value()`, `market.zone()`, `market.quote_is_usd()` | | Settings layout | `page(title)`, `section(title, { toggle?, collapsed?, when? })`, `divider()`, `note(text)`, `presets({ Name: { param: value } })`, `legend({ title })` | | Setting bindings | `"@<param>"` as `color`, a `colors` entry or `line_style` on an output or renderer (a `param.color`, a line-style `param.choice`), as `interval` or `symbol` on an input (a `param.timeframe`, a `param.symbol`) | | Input | `input(name, <source>.<field>, { symbol?, exchange?, interval?, bars?, view?, views?, offset?, side?, tenor?, delta?, fund?, asset?, publisher?, series?, venue?, token?, outcome?, missing?, description? })` | | Celled input | `input(name, <class>.cells, { max_cells, interval?, bars?, symbol?, exchange?, block_size?, max_depth?, venue?, expiries?, description? })`; `max_cells` may be left out on `candles` | | Output | `output(name, plot?, panel?, { description?, unit?, color?, colors?, width?, opacity?, line_style?, glow?, corner?, color_by?, shape_where?, displacement_bars?, width_by?, widths?, label?, format?, legend?, visible?, price_line?, axis_label?, tooltip?, hover?, badges?, hint? })`, returns an `OutputHandle` | | Hover card | `hover(handle, [block.value(label, output, { format?, delta?, tooltip?, hint? }), block.spark(label, output, { bars?, color? }), block.gauge(label, output, { min, max, format? }), block.pill(label, slot, { color_by?, colors? }), block.rows(label?, [[label, output or slot, format?]]), block.meter(label, output, { min, max, marks? }), block.chips(slot or ladder)])`; the same list inline as an output's `hover` | | Range | `range(upper, lower, { color?, colors?, color_by?, edge_width?, edge_line_style?, smooth?, gradient?, gradientMode?, legend?, label?, z? })`; `edge_width: 0` keeps the band and drops the edge lines | | Fill | `fill(upper, lower, { color?, opacity?, opacity_by?, color_by?, colors?, color_packed_by?, z? })`, the interior only between two drawn outputs on one pane; `opacity_by` an output whose value is each bar's opacity | | Pane | `pane(name, { title?, place?, height?, scale?, invert?, padding?, min?, max?, format?, decimals?, signed?, unit? })`, at most 4; an output names it with `pane` | | Box | `box(name, { top, bottom, from?, to?, when?, panel?, color?, borderColor?, opacity?, borderWidth?, borderStyle?, z?, colorBy?, colors?, colorPackedBy?, borderColorBy?, borderColors?, borderColorPackedBy? })` | | Segment | `segment(name, { yFrom, yTo, from?, to?, when?, panel?, color?, width?, lineStyle? })` | | String slot | `string(name, { max_bytes, description? })` | | Renderers | `render.text(name, { y, text, color?, size?, style?, align?, valign?, label_position?, background_color?, background_color_by?, background_colors?, border_color?, corner_radius?, font_weight?, font_family?, emblem_shape?, emblem_color?, size_by?, color_by?, colors?, color_packed_by?, panel?, tooltip?, hover?, badges? })`, `render.label(name, { x, y, text, color?, size?, style?, position?, offset?, align?, valign?, background_color?, border_color?, corner_radius?, padding?, font_weight?, font_family?, emblem_shape?, emblem_color?, size_by?, tooltip?, hover?, badges? })` (a corner label takes `position` and `offset` and no point), `render.table(name, { rows, cols, cells, position?, ...look, styles? })` ([Styled tables](../presentation/cards-frames-panels.md#styled-tables)), `render.shape(name, { output, shape, where?, color?, color_by?, colors?, width?, location?, glow?, fill?, fill_opacity?, char?, font_family?, tooltip? })`, `render.stats_row(name, { output, title?, format?, polarity?, color?, colors?, color_by?, color_packed_by?, priority?, visible? })`, `render.bgcolor(name, { where, color?, color_by?, colors?, width?, line_style? })`, `render.barcolor(name, { where, color?, color_by?, colors? })` | | Legend and HUD | `render.legend(name, { text?, value?, format?, color?, color_by?, colors? })`, `render.hud(name, { position, title?, columns?, tiles: [tile.value(...), tile.spark(...), tile.gauge(...), tile.pill(...), tile.rows(...), tile.meter(...), tile.chips(...), tile.rings(...)], look?, width?, offset?, z?, chrome?, texture?, text_glow?, title_case?, label_case?, accent_color?, accent_color_by?, accent_colors?, background_color?, background_opacity?, background_gradient?, gradient_direction?, border_color?, border_width?, border_style?, corner_radius?, padding?, shadow?, opacity?, text_color?, title_text_color?, font_family?, safe_area?, mobile? })` | | Drawings | `draw.line(name, { x1, y1, x2, y2, color?, width?, line_style?, extend?, arrow?, glow?, glow_color?, sticky_right?, axis_label?, opacity?, tooltip? })`, `draw.box(name, { left, top, right, bottom, color?, border_color?, opacity?, border_width?, border_style?, corner_radius?, extend?, shape?, gradient?, gradient_direction?, glow?, glow_color?, text?, text_color?, font_size?, font_weight?, font_family?, align?, valign?, padding?, tooltip? })`, `draw.polyline(name, { points, color?, width?, line_style?, opacity?, fill_color?, closed?, smooth?, arrow?, glow?, glow_color?, tooltip? })`, `draw.label(name, { x, y, text, color?, style?, align?, valign?, background_color?, border_color?, border_width?, corner_radius?, font_weight?, font_family?, padding?, max_width?, angle?, glow?, glow_color?, emblem_shape?, emblem_color?, sticky_right?, axis_label?, opacity?, tooltip? })` | | Widgets | `draw.card(name, { title, anchor?, offset?, z?, state_by?, rows, headline?, rule?, rule_color?, show_state?, state_colors?, ...look })`, `draw.feed(name, { frame, anchor?, offset?, z?, title?, time_format?, ...look })`, `draw.meter(name, { label, fraction, ramp, text?, anchor?, offset?, z?, title?, bar_height?, track_color?, ...look })`, `draw.ladder(name, { frame, side, divider?, title?, width_frac?, offset?, opacity?, color?, labels?, format?, decimals?, signed?, unit?, text_color?, font_size?, font_family?, font_weight?, divider_color?, panel? })`; the look is `chrome`, `stripe`, `controls`, `accent_color`, `title_text_color`, `title_font_size`, `title_font_weight`, `background_color`, `background_opacity`, `background_gradient`, `gradient_direction`, `border_color`, `border_width`, `border_style`, `corner_radius`, `padding`, `width`, `opacity`, `font_size`, `font_family`, `font_weight`, `text_color`, `label_text_color`, `above_drawings`, `safe_area`, `panel` | | Frames and panels | `frame(name, { max_bytes? })`; `plot.levels({ name, frame, dock, width_frac?, poc?, labels?, color?, span?, ...style })`; `panel.bars` / `line` / `scatter` / `histogram` / `pie` / `heatmap` / `table` / `tiles({ name, title, x, place, frame, series?, ...style })`; `plot.matrix({ name, frame, dock?, columns?, ...style })`; `out.inset(name, { dock, height_px?, shape?, color?, colors?, color_by?, opacity? })` | | Price canvases | `plot.heatmap({ name, cells | grid, value?, price_low?, price_step?, ...style })`, `out.grid(name, { rows })`, `plot.footprint({ name, cells, ...style })`, `plot.tpo({ name, cells?, period?, letter_minutes?, ...style })`, `plot.profile({ name, cells, span?, session?, start?, end?, ...style })` | | Handles | `handles.line`, `handles.box`, `handles.label`, `handles.polyline({ anchor?, safe_area?, ...the drawing's keys })`, the defaults for handles the module creates in `onBar()` | | Chart | `chart.contrast_guard(false)` (keep the author's colours on a light chart), `chart.stack_handles(true)` (keep clear of another indicator's anchored handles), `chart.up_color()`, `chart.down_color()`, `chart.grid_color()`, `chart.bg_color()` (the chart's colours as packed numbers, read in `onStart()`) | | Alert | `alert(name, { when, message?, text?, every_bar?, description? })`, `when` an output handle bound by a top-level `const`, `text` a string slot ([Alerts](../functions/alerts.md)) | Vocabulary the declarations take: | Slot | Values | | --- | --- | | `plot` | `line`, `bar`, `area` (filled down to zero), `histogram`, `candle` (four in a row draw one candle series: open, high, low, close), `shape`, `scatter` (one mark per value), `none` (data-only: computed, never drawn) | | `panel` | `overlay` (the price pane), `lower` (a pane below the chart) | | `line_style` / `lineStyle` / `edge_line_style` | `solid`, `dashed`, `dotted` | | `format` (outputs, panes, panels, levels, cards, ladders, canvases, blocks, `{{name:format}}`) | `price` (the chart's price digits), `%`, `si`, `int`, `0`, `0.0`, `0.00`, `0.000`, `usd` (`$1.2M`), `auto` (six significant digits with thousands separators); a card row or headline also takes `pct`; `decimals` (0..8), `signed` and `unit` (up to 8 characters on the new surfaces; unbounded on an output, as it always was) refine a declared format | | `style` (labels) | `plain`, `price_label`, `pill`, `callout`, `badge`, `box`, `knockout`, `emblem`, on `render.text`, `render.label`, `draw.label` and `handles.label`; a drawing or handle label takes `box` for the tag look, never `price_label`, and a corner label takes neither `price_label` nor `callout` | | `position` (tables, HUD, corner labels) and `anchor` (cards, feeds, meters, anchored handles) | `top_left`, `top_center`, `top_right`, `middle_left`, `middle_center`, `middle_right`, `bottom_left`, `bottom_center`, `bottom_right` | | `font_family`, `font_weight`, `valign` | `ui`, `mono`, `serif`, `rounded` (system stacks, nothing downloads); `normal`, `medium`, `bold`; `top`, `middle`, `bottom` | | `gradient_direction`, `corner_radius`, `opacity` | `vertical`, `horizontal`; 0..32 px; 0..1, multiplying every colour's own alpha | | `location` (marks) | `absolute`, `above_bar`, `below_bar`, `top`, `bottom` | | `chrome` | a panel: `box`, `grid`, `none`; a card, feed or meter: `state`, `plain`; a HUD frame: `card`, `none`, `brackets`, `rules`, `tag`, `title_bar`, `window`, `cover` | | `look` (HUD) | `default`, `glass`, `stage`, `signal`, `terminal`, `broadsheet`, `chart_desk`, `dial`, `grid`, `cockpit`, `phosphor`, `classic`; a word declared beside it wins | | `param.timeframe` default | `chart`, `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, `1w` | | `param.source` default | `ohlcv.open`, `.high`, `.low`, `.close`, `.volume`, and the blends `.hl2`, `.hlc3`, `.ohlc4`, `.hlcc4` | | `market.kind()` | `p_market_kind()`: 1 crypto, 2 stock or ETF, 3 forex, 4 metal, 5 index, 6 economic series, 0 unknown | | `market.quote_is_usd()` | `p_market_quote_is_usd()`: 1 US dollars, 2 another currency or coin, 0 unknown | | `market.zone()` | `p_market_zone()`: the market's zone as its `tz` index below (0 UTC, also unknown) | | `unit` | `price`, `ticks`, `%`, `atr` (codes 0, 1, 2, 3 in `p_<param>_unit()`) | | `tz` (a session) | `UTC`, `America/New_York`, `America/Chicago`, `Europe/London`, `Europe/Berlin`, `Asia/Tokyo`, `Asia/Hong_Kong`, `Asia/Singapore`, `Australia/Sydney`, `Asia/Kolkata`, `America/Los_Angeles`, `America/Toronto`, `America/Mexico_City`, `America/Sao_Paulo`, `America/Argentina/Buenos_Aires`, `Europe/Paris`, `Europe/Amsterdam`, `Europe/Zurich`, `Europe/Madrid`, `Europe/Rome`, `Europe/Stockholm`, `Europe/Oslo`, `Europe/Copenhagen`, `Europe/Warsaw`, `Europe/Helsinki`, `Europe/Athens`, `Europe/Istanbul`, `Europe/Moscow`, `Asia/Jerusalem`, `Asia/Riyadh`, `Asia/Dubai`, `Africa/Johannesburg`, `Asia/Karachi`, `Asia/Bangkok`, `Asia/Jakarta`, `Asia/Ho_Chi_Minh`, `Asia/Kuala_Lumpur`, `Asia/Shanghai`, `Asia/Taipei`, `Asia/Manila`, `Asia/Seoul`, `Pacific/Auckland` (42 zones; each with its offset and daylight rule on [Sessions and units](../settings/sessions-and-units.md)) | | `shape` (renderer) | `circle`, `cross`, `triangle_up`, `triangle_down`, `diamond`, `arrow_up`, `arrow_down`, `flag`, `square` | | `missing` | `carry` (the default: the latest value carries forward), `nan`, `zero`: what a bar with no observation of its own reads | | `side` | `BUY`, `SELL` | | `tenor` | `ONE_W`, `ONE_M`, `TWO_M`, `THREE_M`, `SIX_M`; required on `implied_volatility` and `skew` | | `delta` | `5`, `15`, `25` (the default), `35`; `skew` only | | `fund` | an ETF ticker (`IBIT`, `FBTC`, `ETHA`, ...) or `all` (the sum over every fund listed for the chart's coin); required on `etf_flow`, `etf_holdings` and `etf_premium`, and `etf_premium` takes one ticker, never `all` | | `venue` | on `options_chain`: `auto` (the default), `deribit`, `cme`, `binance`, `okx`, `bybit`, `bullish`, `derive`; on `options_oi` and `options_volume`: `deribit` (the default), `binance` | | `publisher`, `series` | the publisher's upper-case id (`FRED`, `US_TREASURY`, `ECB`, ...) and that publisher's series id (`DGS10`); `economic` only, both required | | `asset` | an upper-case ticker (`BTC`); `treasury_balance` only, the chart's coin when absent | | `token` | the token's display name (`Bitcoin`, `Ethereum`, `Solana`), never its ticker; `token_supply` only, required | | `outcome` | `YES`, `NO` (`NO` on the `close` field only) | | `interval` (a pin) | `MINUTE`, `FIVE_MINUTES`, `FIFTEEN_MINUTES`, `THIRTY_MINUTES`, `HOUR`, `FOUR_HOURS`, `DAY`, `WEEK` (or `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, `1w`); chart only, the custom timeframes `TWO_MINUTES`, `THREE_MINUTES`, `TEN_MINUTES`, `FORTY_FIVE_MINUTES`, `TWO_HOURS`, `SIX_HOURS`, `EIGHT_HOURS`, `TWELVE_HOURS`, `THREE_DAYS` (or `2m`, `3m`, `10m`, `45m`, `2h`, `6h`, `8h`, `12h`, `3d`) | | `view` (with an interval pin) | `confirmed` (the default), `forming`, `is_new_period`; `views` lists the extra ones as `"forming,is_new_period"`; `offset: N` writes the `offset` view for you | | `offset` (with an interval pin) | `1` to `500`: the pinned candle N candles before the confirmed one, `NaN` until N + 1 have closed | | `bars` (an interval pin or `candles`) | `1` to `5000`: how many of the pin's own candles to reach back from the newest; it only deepens the fetch | | Box and segment offsets | literals in -500..500, whole or fractional (a fraction places the edge inside its bar), or an output handle whose per-bar value truncates to the offset | | Colors | any string on outputs, segments, renderers and drawings; a box fill needs hex, `rgb()`, or `hsl()`; every style word this reference lists on panels, levels, cards, feeds, meters, ladders, HUDs and canvases takes `#rrggbb`, `#rrggbbaa` or a theme token; the seven tokens `theme.up`, `theme.down`, `theme.text`, `theme.muted`, `theme.bg`, `theme.grid`, `theme.accent` pass on every colour key and frame colour (a colour param's default and presets excepted) and follow the chart's theme at paint time | ### Settings Fifteen kinds, each a top-level `param.<kind>(name, default, options?)`: the control the dialog draws for it, and the reader `onStart()` calls (it answers from then on) ([Setting kinds](../settings/kinds.md)). | Kind | The dialog draws | `onStart()` reads | | --- | --- | --- | | `param.int("length", 20, { min: 2, max: 500 })` | a number field that steps by 1 | `p_length()`, a whole number | | `param.number("mult", 2.0, { min: 0.5, max: 5, step: 0.1 })` | a number field that steps by `step` | `p_mult()` | | `param.bool("show_bands", true)` | an on/off toggle | `pb_show_bands()`, a `bool` | | `param.choice("kind", ["Simple", "Exponential"], "Simple")` | a menu of the labels | `p_kind()`, the picked index (0 for the first label) | | `param.color("basis_color", "#2962ff")` | a color picker | `p_basis_color()`, the color packed into one number; most files bind it to an output with `color: "@basis_color"` instead | | `param.time("since", "2024-01-01 00:00")` | a date and time field with **Pick**: the next click on the chart sets it | `p_since()`, epoch seconds | | `param.price("floor", 0.0)` | a number field with **Pick**: the next click on the chart sets the price | `p_floor()` | | `param.range("band_pct", [0.5, 2.0], { min: 0, max: 10 })` | one slider with two handles | `p_band_pct_lo()` and `p_band_pct_hi()` | | `param.multi("days", ["Mon", "Tue", "Wed"], ["Mon", "Wed"])` | a chip per option up to four options, a multi-select menu past that | `p_days()`, a bitmask (bit 0 is the first option); `multiHas(mask, i)` tests one | | `param.list("lookbacks", [5.0, 20.0], { max: 4 })` | an editable list of numbers with **Add**, up to `max` long | `p_lookbacks()`, an `f64[]` of the filled slots | | `param.source("src", ohlcv.close)` | a menu of `open`, `high`, `low`, `close`, `hl2`, `hlc3`, `ohlc4`, `volume`, `hlcc4` | nothing: it declares the input too, and `in_src()` reads the picked field per bar | | `param.timeframe("htf", "chart")` | a menu of `chart`, `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, `1w` | nothing: `{ interval: "@htf" }` on an input pins that input to the pick | | `param.symbol("pair", "BINANCE_FUTURES:ETHUSDT")` | a market button that opens the symbol search | nothing: `{ symbol: "@pair" }` on an input pins that input to the pick | | `param.session("rth", "09:30-16:00", { tz: "America/New_York" })` | a day strip with the window shaded, a start and an end on the 24-hour clock, and a zone menu | `p_rth_start()` and `p_rth_end()` (minutes from midnight), `p_rth_tz()` (the zone's index) | | `param.text("label", "Session average", { max_bytes: 64 })` | a text field (a text area for `param.text_area`) | `pt_label()`, the words as a `string` | Every kind takes one options object, every key optional ([Options on a setting](../settings/options.md)). The words that lay the dialog out are on [Pages, sections, dividers, notes](../settings/layout.md), the named sets of values on [Presets](../settings/presets.md), the per-output rows the dialog adds by itself on [The Style page](../settings/style-page.md), the session zones and the unit codes on [Sessions and units](../settings/sessions-and-units.md), where a pick applies and the 128-setting cap on [Picks, lanes, the cap](../settings/picks-and-lanes.md), and every refusal on [What the build checks](../settings/checks.md). ### Presentation One kit of seven blocks with two homes: the card the cursor opens (`block.<kind>`, on a line, its legend entry, a HUD tile, a text or label mark) and a HUD card at one of the nine anchors (`tile.<kind>`). Every block reads the newest row; outputs and slots are named by a bound handle or by their name as a string ([Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles)). | Block or tile | Renders in | Options | | --- | --- | --- | | `value(label, output)` | a hover card as `block.value`, a HUD card as `tile.value`: the output's newest value under its label, a signed delta from the `delta` output beside it | `format`, `delta`, `tooltip`, `hint`, `color` or `color_by` + `colors`, `headline` (one per card, spanning every column), `font_size` | | `spark(label, output)` | both: a sparkline of the output's last bars (64 at most) | `bars`, `color` or `color_by` + `colors`, `draw` (`line`, `area`, `dots`, `bars`, `text`), `height` (16..120) | | `gauge(label, output)` | both: a dial between `min` and `max` | `min` and `max` (required), `format`, `color` or `color_by` + `colors`, `draw` (`arc`, `ring`, `dial`, `ticks`, `bar`, `blocks`, `text`) | | `pill(label, slot)` | both: the slot's words as a chip, colored by a ladder | `color`, `color_by`, `colors`, `draw` (`capsule`, `dot`, `led`, `text`), `headline`, `font_size` | | `rows(label?, [[label, output or slot, format?], ...], options?)` | both: rows of label and value | a `format` per row; `color` or `color_by` + `colors`, `leader` (`none`, `dots`) | | `meter(label, output)` | both: a bar between `min` and `max` with marks | `min` and `max` (required), `marks`, `color` or `color_by` + `colors`, `draw` (`bar`, `split`, `blocks`, `segments`, `scale`, `text`), `height` | | `chips(slot or ladder output)` | both: the slot's words, or the ladder's labels, as chips | none | | `rings(label?, [[label, output, { min, max, color? }], ...])` | both: 1..3 concentric rings, outermost first, each a share of its own range, with a legend beside | `height` (16..120) | The hover card is `hover: [...]` on an output, a `render.text` or a `render.label`, or `hover(handle, [...])` at the top level ([Hover cards](../presentation/hud-and-hover-cards.md#hover-cards)); the HUD card is `render.hud(name, { position, title?, columns?, tiles })` ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards)). The third home, the legend, takes words rather than blocks: `legend({ title })` after the indicator's name and `render.legend(name, { text?, value?, format?, color?, color_by?, colors? })` per entry, with `label`, `format` and `legend: false` on an output shaping its own entry ([Legend](../presentation/legend.md)). The `tooltip` templates, the label `style` words and `badges` are on [Labels, tooltips, badges](../presentation/labels-and-tooltips.md); `glow`, `corner`, `opacity`, `width`, `line_style`, a range's gradient and the `color_by` and `width_by` ladders are on [Styling](../presentation/styling.md). ### Sources the chart serves | Source | Fields | Knobs and units | | --- | --- | --- | | `ohlcv` | `open`, `high`, `low`, `close`, `volume` | the chart's own candles; a secondary input may pin another market or a coarser interval | | `trades` | `volume` | `side` REQUIRED: that side's volume per bar, in the base asset; `currency` `"USD"` reads it in dollars, `"Coin"` in coins (one lane per unit) | | `funding` | `rate_close` | in percent, normalized to a one-hour rate; may pin a coarser interval | | `oi` | `open`, `high`, `low`, `close` | in USD; may pin a coarser interval | | `liquidations` | `liquidations` | `side` optional (absent = both sides summed); in USD; sparse, so an indicator usually declares `missing: "nan"` or `"zero"` | | `long_short_ratio` | `total_account`, `top_trader_account`, `top_trader_position` | plain ratios of longs over shorts (a value at or below 0 reads `NaN`); the chart must be 5 minutes or coarser | | `implied_volatility` | `implied_volatility` | `tenor` REQUIRED; Deribit's summary for the chart's coin | | `skew` | `skew` | `tenor` REQUIRED, `delta` optional (25 when absent); Deribit's skew for the chart's coin | | `volatility_index` | `open`, `high`, `low`, `close` | Deribit's volatility index (DVOL) for the chart's coin, in index points; BTC and ETH | | `options_oi` | `puts`, `calls` | `venue` optional; open interest in the venue's units (contracts on Deribit, the base coin on Binance); BTC and ETH | | `options_volume` | `puts`, `calls` | `venue` optional; volume in contracts; BTC and ETH | | `etf_flow` | `flow_usd` | `fund` REQUIRED; the chart's coin must be BTC, ETH or SOL; daily (on an intraday chart the day's value lands on the first bar of its day); served in the browser, and an alert on an indicator that reads it is refused | | `etf_holdings` | `holdings` | `fund` REQUIRED (a ticker or `all`); in coins of the fund's underlying; a coin with listed spot ETFs | | `etf_premium` | `premium_rate` | `fund` REQUIRED (one ticker); in percent; daily, read as the newest value at or before the bar's day | | `ethena_positions` | `collateral` | Ethena's collateral in the chart's coin; BTC and ETH | | `bitfinex_funding` | `funding_size`, `credit_size`, `active_credit_size`, `margin_rate` | the three sizes in the funding currency, `margin_rate` an APR in percent; the chart's coin | | `treasury_balance` | `balance` | `asset` optional; Binance's own balance in coins, published monthly | | `token_supply` | `marketcap`, `first_marketcap`, `marketcap_dominance_percent`, `circulating_supply`, `total_supply`, `max_supply`, `total_value_locked`, `fully_diluted_valuation`, `cg_marketcap_rank`, `total_volume`, `usd_price` | `token` REQUIRED; daily; a one-minute chart is refused | | `economic` | `value` | `publisher` and `series` REQUIRED; the publisher's unit; daily or slower | | `odds` | `open`, `high`, `low`, `close` (default), `volume` | the market's condition id (`0x` plus 64 hex characters) as `symbol`, plus `outcome`; `exchange` is implied and refused; the chart has no market picker, so `binding` is refused | | `time` | `bar_open_sec`, `trade_date`, `session` | the bar's open, its exchange trade date (epoch seconds at 00:00 UTC) and its session (`1` regular, `2` pre-market, `3` after-hours, `0` closed); no knobs, never the primary input | Ten feeds share the `etf_flow` rule (served in the browser, and an alert on an indicator that reads one is refused): `long_short_ratio`, `volatility_index`, `options_oi`, `options_volume`, `etf_holdings`, `etf_premium`, `ethena_positions`, `bitfinex_funding`, `treasury_balance` and `economic`. The kit also declares the `tape` cells, which the chart does not serve: a file that reads them is refused by name when it runs ("celled source class 'tape' is not served by the browser lane yet"). Pins: `symbol` and `exchange` go together (a lone half is refused), in the chart's own ids (`{ symbol: "ETHUSDT", exchange: "BINANCE_FUTURES" }`). The chart checks every pin before it fetches anything. A secondary `ohlcv` input may pin another market, read bar by bar at the chart's interval, and an interval coarser than the chart's that is a whole multiple of it, read as of the coarser candle's close; `funding` and `oi` may pin a coarser interval on the chart's own market. Everything else is refused by name: a pin on the primary input, a market pin on any other source, a finer interval, and an interval on any other feed (`trades`, `liquidations`, `implied_volatility`, `skew`, `etf_holdings`, `economic`, ...), on `time` or on a celled input. Two celled inputs are the exception: `intrabar` and `candles` require an `interval` and may pin another market. [Multi-timeframe](../core-concepts/multi-timeframe.md) has the views. ### Celled classes (`abi_version: "wrun-2"`) | Class | Tuple | Width | Knobs | On the chart | | --- | --- | --- | --- | --- | | `volume_profile` | `[low, high, buy, sell]` per price bucket, ascending by price | 4 f64 | `ticks_per_bar` (1 to 500 of the market's buckets merged into one), `currency` (`"USD"`, or `"Coin"` by default) | served, history and live, on the chart's own market | | `book` | `[price, size, side]` per level, side `+1` bid / `-1` ask | 3 f64 | `block_size` REQUIRED by the declaration | served, history and live: the chart's own order book at its own grouping, at most 500 levels a side, whatever `block_size` and `max_depth` say; bids come best first, then asks from the farthest to the best, so branch on `side`, never on position | | `intrabar` | `[offset_ms, open, high, low, close, volume]` per closed finer bar, ascending | 6 f64 | `interval` REQUIRED (finer than the chart's and dividing it evenly, `1m` at the finest); `max_cells` at least the finer bars in one chart bar; `symbol` + `exchange` for another market | served on the chart's own market or a pinned one | | `candles` | `[offset_ms, open, high, low, close, volume]` per closed candle of the stream's interval, oldest first | 6 f64 | `interval` REQUIRED (any word); `bars` 1 to 5000; `max_cells` filled when left out; `symbol` + `exchange` for another market; `view: "forming"` (the live candle on the live bar) or `"forming_open"` (on every bar the open of the candle holding it, `[offset_ms, open, NaN, NaN, NaN, NaN]`), each without `bars` | served: the backlog on the first bar, then each candle on the bar it closes with | | `trade_volume_by_size` | `[bucket, buy_usd, sell_usd, buy_count, sell_count]` per USD trade-size bucket that traded (each fill at its own size), ascending; `bucket` 1 (under 1K) to 7 (10M and up) | 5 f64 | none (`max_cells` 7 holds a full bar) | served on the chart's own market; a bar with no trades is an empty block | | `options_chain` | `[strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega]` per listed contract, side `+1` call / `-1` put | 10 f64 | `venue` (`"auto"` default, `"deribit"`, `"cme"`, `"binance"`, `"okx"`, `"bybit"`, `"bullish"`, `"derive"`), `expiries` (`"all"` default or `"nearest:N"`) | served on the LAST row only (history rows are empty blocks), a snapshot refreshed about every 30 seconds | | `tape` | | | | not served: refused by name | ### The TA classes (`./sdk/ta`) One class per calculation, each fed one bar at a time. Construct in `onStart()`, `.update(...)` once per bar in `onBar()` (it returns the primary value, `NaN` until warm). `update(x)` takes one value unless noted. | Group | Classes | Per bar | | --- | --- | --- | | Averages | `Sma`, `Ema`, `Rma`, `Wma`, `Hma`, `Alma`, `Swma`, `Linreg` | `update(x)`; `Vwma` is `update(x, volume)` | | Statistics and series | `Sum`, `Median`, `Percentile`, `Variance`, `Stdev`, `Zscore`, `Change`, `Mom`, `Roc`, `Cum`, `Fixnan` | `update(x)`; `Correlation` is `update(a, b)` | | Oscillators | `Rsi`, `Cmo`, `Tsi`, `Macd` | `update(x)` | | Oscillators over OHLC | `Cci`, `Wpr`, `Stoch`, `Stochastic` | `update(high, low, close)`; `Mfi` is `update(high, low, close, volume)`; `Obv` is `update(close, volume)` | | Ranges and bands | `Tr`, `Atr` | `update(high, low, close)`; `Bb` is `update(x)`, `Keltner` is `update(x, high, low, close)`, `Donchian` is `update(high, low)` | | Window extremes | `Highest`, `Lowest`, `HighestBars`, `LowestBars` | `update(x)` (pass the high for a highest high, the low for a lowest low) | | Trend systems | `Adx`, `Ichimoku`, `Psar`, `Supertrend` | `update(high, low, close)`; `Vwap` is `update(open, high, low, close, volume, tsMs)`, `tsMs` the bar's open time in milliseconds | | Events | `Rising`, `Falling`, `PivotHigh`, `PivotLow` | `update(x)`; `ValueWhen` is `update(condition, x)`, `BarsSince` is `update(condition)`, `Cross` is `update(a, b): i32` (`+1` up, `-1` down, `0` otherwise) | Multi-output classes return the primary line and carry the rest as fields: `Bb`, `Keltner`, `Donchian` (`basis`, `upper`, `lower`); `Macd` (`macd`, `signal`, `hist`); `Stoch`, `Stochastic` (`k`, `d`); `Supertrend` (`line`, `direction`); `Adx` (`adx`, `plusDi`, `minusDi`); `Ichimoku` (`tenkan`, `kijun`, `senkouA`, `senkouB`, `chikou`); `Highest`, `Lowest` (`bars`, how many bars ago); `HighestBars`, `LowestBars` (`value`, the matching extreme). Every class allocates in its constructor and restores its just-constructed state on `.reset()`. Two honest exceptions to bit-exactness: `Ichimoku`'s `chikou` is the current close (the engine reads a future bar), and `Vwap` anchors other than none, `"day"` and a millisecond bucket are unproven. The composite and every convention are in [TA library](../functions/ta-library.md). ### Kit modules (`./sdk/...`) Ten kits beside the TA classes, every name in scope with nothing to import. Construct their classes in `onStart()`; each page lists every export with its rules. | Kit | Main exports | Page | | --- | --- | --- | | `./sdk/ta-plus` | 26 more classes: `Dev`, `PercentRank`, `Bbw`, `Kcw`, `AccDist`, `Wad`, `Wvad`, `Nvi`, `Pvi`, `Pvt`, `RunningMax`, `RunningMin`, `Mode`, `Range`, `Cog`, `Iii`, `Dema`, `Tema`, `Zlema`, `Trix`, `Ultimate`, `Vortex`, `Aroon`, `Choppiness`, `Efficiency`, `Kama` | [Extra indicators](../functions/extra-indicators.md) | | `./sdk/stats` | `History` (`push(v)`, `ago(n)`, `max()`, `min()`, `mean()`, `sum()`); `stats.sum`, `mean`, `variance`, `stdev`, `min`, `max`, `argmin`, `argmax`, `slope`, `covariance`, `correlation`, `zscore`, `sortAscending`, `median`, `percentile` over a `StaticArray<f64>` and a count | [Stats, history and lists](../functions/stats-history-lists.md) | | `./sdk/fmt` | `TextBuilder` (`text`, `char`, `int`, `f64`, `auto`, `price`, `pct`, `signed`, `compact`, `time`, `duration`, `spaces`); the generated `sb_*` calls wrap one (`sb_price`, `sb_pct`, `sb_signed`, `sb_compact`, `sb_time`, `sb_duration` and the rest) | [Strings and text](../functions/text-formatting.md) | | `./sdk/clock` | `Clock` (`update(t)`, `hour`, `minute`, `weekday`, `dayOfMonth`, `month`, `year`, `isNewDay`, `isNewWeek`, `isNewMonth`, `index`, `intervalSec`, `barCloseSec`); `Session` (`update(t)`, `isIn`, `isFirst`, `closed`, `key`) | [Clock and sessions kit](../functions/time-and-sessions-kit.md) | | `./sdk/resample` | `Resampler` (`update`, `closed`, `newBucket`, `last`, `forming`, `source`, `confirmed`, `refused`); `ClosedWindow` (`push`, `mean`, `wma`, `stdev`, `highest`, `lowest`, `sum`, `vwma`, `back`); `Smoothed` (`commit`, `value`, `peek`); `tf.*`, `field.*` | [Higher-timeframe kit](../functions/higher-timeframe-kit.md#from-the-chart-bars-resampler) | | `./sdk/candles` | `Periods` over a `candles` stream (`load`, `confirmed(n)`, `developing`, `isNew`, `startSec`, `endSec`, `complete`), each period a `PeriodCandle` (`open`, `high`, `low`, `close`, `volume`, `vwap()`); `CandleList` (`load`, `count`, `openSec(i)`, `open(i)` ... `volume(i)`); `period.*` | [Higher-timeframe kit](../functions/higher-timeframe-kit.md#calendar-periods-and-candle-lists) | | `./sdk/color` | `ink.*` (twenty packed colors), `fromHex`, `alpha`, `mix`, `lighten`, `darken` for handle colors; `ColorScale`, `Thresholds` for a `color_by` index | [Colors](../functions/colors-kit.md) | | `./sdk/orderflow` | `delta`, `deltaPct`, `Cvd`, `VolumeProfile` (`poc`, `vah`, `val`), `BookImbalance`, `Absorption`, `LiquidationBurst` | [Order flow](../functions/order-flow-kit.md) | | `./sdk/levels` | `PeriodLevels`, `SessionLevels`, `PivotPoints`, `roundLevels`, `SupportResistance` | [Levels kit](../functions/levels-kit.md) | | `./sdk/structure` | `Swings`, `MarketStructure`, `FairValueGaps`, `OrderBlocks`, `Divergence`, `candles.*` | [Market structure kit](../functions/market-structure-kit.md) | ### Caps 16 boxes, 16 segments, 64 renderers, 64 drawings (8 cards among them, and 32 cards, feeds and meters per pane), 64 polyline points, 16 declared alerts, 64 string slots of at most 4096 bytes, 8 frames of at most 96 KiB, 4 docked profiles, 8 panels, 4 heatmaps, 4 footprints, letter and time-anchored profiles together, 4 matrices, 4 panes, 64 KiB of strings per row, 2 MiB of strings and frames per run, 8 MiB of expanded render result, 2,000 drawings per run, offsets in -500..500, 4 MiB of module memory, 20 seconds per run. Every number, with the refusal it produces, is in [Limits](limits.md). ## The four-function form The module the chart runs exports four functions, `init`, `state`, `finalize` and `reset`, with exact signatures that Run checks before anything reaches the chart. A file with `onBar()` gets them from the build; a file may still export the four itself, and it builds the same way ([Execution model](../core-concepts/execution-model.md#the-four-function-form)). How the hooks map onto them: | Export | The build's version, around a file with `onBar()` | In a file that exports it | | --- | --- | --- | | `init(): void` | reads every param once, then calls `onStart()` when the file declares one | runs once before any bar; the only place `p_<param>()` reads | | `state(): i32` | reads this bar's inputs and returns `1` on every bar | runs once per bar; the only place `in_<input>()` and the cell accessors read; `0` abstains the row | | `finalize(): void` | writes NaN to every output, calls `onBar()`, then emits the row | runs after each bar whose `state()` returned `1`: writes outputs and string slots, then `emitRow()` LAST | | `reset(): void` | calls `onReset()` when the file declares one; otherwise nothing is reset | required: reassigns every module-level variable, `.reset()` on every TA object | One `export` of `init`, `state`, `finalize` or `reset` makes the whole file the four-function form. The chart replays the live forming bar from a snapshot of the module's memory and globals taken after the last closed bar, so every replay of the forming bar starts from the state that bar left. ## Generated accessors The runtime contract is positional; the accessors are how your source stays name-attached. One function per declared name, regenerated from your declarations each time the editor compiles: - `p_<param>(): f64` (params, readable from `onStart()` on), plus `pb_<param>(): bool` on a toggle, `p_<range>_lo()` / `_hi()`, `p_<list>(): f64[]`, `p_<session>_start()` / `_end()` / `_tz()` and `p_<param>_unit()` on the composite settings, and `pt_<param>(): string` on a text setting - `in_<input>(): f64` (inputs, read in `onBar()`); the chart's own candle and bar time also read as `bar.open()`, `bar.high()`, `bar.low()`, `bar.close()`, `bar.volume()` and `bar.time()` with no declaration - `out_<output>(value: f64): void` (outputs, written in `onBar()`; the row is emitted when it returns) A declared name becomes its accessor by lowercasing it and collapsing every run of characters outside `[a-z0-9_]` to one `_`: param `fast.len` becomes `p_fast_len()`, input `btc-close` becomes `in_btc_close()`, output `BTC-Ratio` becomes `out_btc_ratio()`. Any name may be written with capitals. Param, input, string-slot and frame names are stored lowercase (`param("fastLen", 12)` is the setting `fastlen`), and so is every option that names one (`"@lineColor"`, `when: "showBands"`, a preset's keys) and the name inside a `{{fastLen}}` placeholder of a legend title, tooltip or HUD title; output names keep their capitals (`[A-Za-z0-9][A-Za-z0-9._-]*`). The accessor answers to the spelling your file uses: `p_fastLen()` and `p_fastlen()` are the same reader, `out_BTC_Ratio()` and `out_btc_ratio()` the same writer. Two names in one family that differ only by case (box, segment and alert names excepted: they keep their spelling), or that escape identically, are refused, and so are duplicate param names. Every accessor family gets the same treatment: a string slot `summary` sends through `str_summary()` and `str_summary_sb()`, a celled input `profile` reads through `in_profile_cells()` and `in_profile_read()`. Declarations whose names need escaping, and the source that reads them (the `close` line stays first because the first input sets the request grid, and `bar.close()` reads through it; the `btc-close` input pins BTCUSDT on Binance Futures, which the chart reads bar by bar at its own interval): ```typescript param("fast.len", 12, { min: 1, max: 200 }); param("slow.len", 26, { min: 2, max: 400 }); input("close", ohlcv.close); input("btc-close", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES", description: "Fixed BTC reference market" }); output("spread", line, lower); output("BTC-Ratio", line, lower); let fast = new Ema(12); let slow = new Ema(26); function onStart(): void { fast = new Ema(i32(p_fast_len())); slow = new Ema(i32(p_slow_len())); } function onBar(): void { const close = bar.close(); const btc = in_btc_close(); const spread = fast.update(close) - slow.update(close); const ratio = btc > 0.0 ? close / btc : NaN; if (isNaN(spread) || isNaN(ratio)) return; out_spread(spread); out_btc_ratio(ratio); } ``` Reordering declarations moves the SLOT inside each accessor while your source keeps the NAME, so reordering params or inputs never changes what the module computes; a renamed declaration renames its accessor, and the compiler then points at every stale reader or writer. Raw positional slot literals (`getFloat(0)`, `getInt(0)`, `setOutput(0, ...)`, `wrun_arg_f64(0)`, `wrun_output_f64(0, ...)`) are refused before the compiler runs ("Raw slot literal 0 passed to getFloat(): raw positional slots rebind silently when the sheet changes"), because slots silently rebind when declarations change. Variable indexes stay legal: `./sdk/sdk` wraps the raw host imports for genuinely dynamic access. ## Declarations, in one place The signatures in the table above are the whole grammar; the rules the editor enforces, each as a named error in the Console: - Names are string literals; defaults and option values are literals; the code never runs when the editor reads the declarations. - Names may carry capitals (`fastLen`). Params, inputs, string slots and frames are stored lowercase, and so is a `{{fastLen}}` placeholder that names one; outputs keep their spelling. Two names of one family that differ only by case are refused, naming both (boxes, segments and alerts keep their spelling and may differ by case). - Declarations are top-level statements of your file; one anywhere else is refused with its line. An indicator is one file. - Indexes follow declaration order: the first `input(...)` is slot 0, the primary input. The rows are always the chart's own bars and every input aligns to them; reordering declarations reorders slots while the generated accessors keep your source name-attached. A candle field read through `bar.*()` with no `input(...)` line is appended after the declared inputs, and a file with no `input(...)` line gets `close` as slot 0 whether or not it reads `bar.close()`; a declared input that reads exactly that feed serves the field instead. - Box and segment coordinates are output handles bound by a top-level `const` (`let`, `var`, and `export const` bind too; a handle may be bound below the shape that uses it); the sheet records the output's name, never the handle. - `color_by`, `width_by`, and `shape_where` name a DIFFERENT declared output; an output cannot color, widen, or gate itself. `colors` needs at least two entries beside `color_by`; `widths` and `width_by` go together. - A celled input (`<class>.cells`) requires `max_cells`; scalar-feed knobs (`side`, `tenor`, ...) are refused on it; `book` requires `block_size`. Run derives the contract from what you declare: a string slot, a renderer, a drawing, or a celled input stamps the derived sheet `abi_version: "wrun-2"`; a handle, `strategy(...)` or `bar.isLast()` stamps `"wrun-3"`; a frame, a level profile or a panel stamps `"wrun-4"`; a text setting (`param.text`) stamps `"wrun-5"`; a `bar.count()` read stamps `"wrun-6"`; scalar-only declarations keep the first contract. `range`, `box`, `segment` and `alert` are contract-neutral: they never flip the sheet. The derived sheet records `generated_from: "declarations"` plus a `source_digest`, and it is derived state: to change it, edit the declaration. The editor has no hand-written sheet to fall back to, so a file that declares nothing is refused ("This indicator declares nothing. Add param(...), input(...), and output(...) statements ..."). A file with `onBar()` needs at least one `output(...)`; it needs no `input(...)` line, because the chart's own candle reads through `bar.*()`. The sheet's fields are on [Declarations and the sheet](declarations.md#the-sheet-run-derives), its pins on [Multi-timeframe](../core-concepts/multi-timeframe.md) and [Multi-source](../core-concepts/multi-source.md), odds modes on [Data sources](../core-concepts/data-sources.md#beyond-the-charts-venue); the styling ladders are in [Styling](../presentation/styling.md). ## The cell channel (`wrun-2`) Modules on the second contract or later may read celled inputs: a variable-length block of f64 cells per bar (a volume profile's per-price rows, a book snapshot's levels) beside the scalar argument block. A celled input is declared in the source, `input(name, <class>.cells, { max_cells })` (the derived sheet records it as `cellType: "array"` with `max_cells`), reads a celled source class ([Data sources](../core-concepts/data-sources.md)), and receives cells in fixed-width TUPLES per class: `volume_profile` = `[low, high, buy, sell]` (4 f64s), `book` = `[price, size, side]` (3 f64s), `intrabar` = `[offset_ms, open, high, low, close, volume]` (6 f64s), `trade_volume_by_size` = `[bucket, buy_usd, sell_usd, buy_count, sell_count]` (5 f64s), `options_chain` = 10 f64s per contract. `max_cells` counts tuples. The editor generates one accessor family per celled input (no scalar accessor: the input's slot in the scalar block holds NaN), read in `onBar()`: - `in_<input>_cells(): i32`: f64 cells in this bar's block (tuples x tuple width); `0` for a present, empty block; `-1` when the bar carries no block, and before the first bar. - `in_<input>_view(): StaticArray<f64>`: the build's own buffer, holding this bar's block with no copy. Only the first `in_<input>_cells()` values belong to this bar: on an absent or empty bar the buffer still holds the previous block, so bound every loop by the count. - `in_<input>_read(ptr: i32): i32`: copies the block into the module's exported memory at `ptr`, for code that owns its own buffer; returns bytes written, `0` for a zero-cell block, `-1` for a missing block. - `in_<input>_max_cells: i32`: the declared `max_cells` (source tuples). - `in_<input>_capacity: i32`: the f64 count of the build's buffer (`max_cells` x the class's tuple width); generated when the class's width is known. Cells are IEEE 754 f64, little-endian, 8 bytes each, contiguous in block order. `max_cells` is a contract, not a hint: the build allocates one buffer of `max_cells` tuples per celled input when the module starts (`in_<input>_capacity` f64s), reads each bar's block into it once, and a bar whose block exceeds the cap refuses the WHOLE run by name ("... has 1200 cells; max_cells is 1000, so the evaluation is refused (a block is never truncated)"). An out-of-bounds `ptr` is refused naming the sizes, and a read of an input not declared as celled is refused naming the declared set. Underneath the accessors sit two host imports in the `wrun` namespace, `wrun_arg_len(index)` and `wrun_arg_bytes(index, ptr)`; positional literals on them are refused exactly like `getFloat(0)`, so source goes through the accessors. A second-contract module that reads no celled input is legal: such packages may be scalar-only, and scalar behavior is bit-identical across every contract. Alignment is an exact join, never a fill: each chart bar gets the celled observation with the same bar open, a bar with no observation gets a PRESENT EMPTY block (the module sees `0` cells, not `-1`), and celled values are never carried forward (a replayed block would double-count volume). Celled inputs never abstain a row: on a bar with no block, leave the output unwritten or `return` from `onBar()`. A celled class can never be the primary input (so a scalar `input(...)` line stays in front of it) and follows the chart's own market (a market pin on it is refused); only `intrabar` takes an `interval`, the finer bars it folds. Summing a bar's volume profile (total volume = buy + sell in every `[low, high, buy, sell]` tuple): ```typescript input("close", ohlcv.close); // One tuple per price level: an hour of BTCUSDT carries several hundred, so leave room. input("profile", volume_profile.cells, { max_cells: 8192 }); output("total", line, lower); function onBar(): void { const n = in_profile_cells(); if (n < 0) return; // this bar carries no block let total = 0.0; if (n > 0) { const cells = in_profile_view(); for (let i = 0; i + 3 < n; i += 4) { total += cells[i + 2] + cells[i + 3]; // buy + sell per [low, high, buy, sell] tuple } } out_total(total); } ``` The first contract is frozen: its import allowlist never grows, and a module on it that imports the cell functions is refused naming the way in (a celled input moves the derived sheet to `wrun-2`). Which celled classes the chart serves is in the table above; an alert on an indicator that reads a source the alerts engine cannot evaluate is refused when you save it ([Alerts](../functions/alerts.md)). ## The string channel (`wrun-2`) From the second contract on, a file may declare string slots: numbered, named, byte-capped text channels written from `onBar()`. Slots are NOT outputs: outputs stay numeric; strings exist so renderers and drawing labels can carry text, and they are never outputs. When the file declares `string(...)`, the editor generates the string family, in scope with nothing to import: - `sb_clear()`, `sb_text(s)`, `sb_int(value)`, `sb_f64(value, decimals)` build one UTF-8 line allocation-free into a shared buffer sized to the largest declared `max_bytes` (allocated once at module start, so per-bar string work allocates nothing). - `str_<slot>(s: string)` encodes and sends `s` to that slot; `str_<slot>_sb()` sends the built line. Both pass the REQUIRED byte count, so a line over the slot's `max_bytes` refuses by name; strings are never truncated. A slot named `debug` is also the editor's debug log: each non-empty line prints in the Console as `<ISO bar time> <text>`, the newest 400 lines kept. A slot not written that bar is ABSENT, which is distinct from a written empty string: `render.text` draws every present slot (empty included), `render.label` keeps the last present NONEMPTY one, and `render.table` waits for a row where every cell is present. Writing the same slot twice in one `onBar()` replaces its bytes. The channel belongs to the row being committed, the bar `onBar()` runs on: the underlying import (`wrun_output_str(slot, ptr, len)`, `wrun` namespace) traps by phase anywhere else, and positional literals on it are refused like the cell imports (use the generated senders). Caps the chart enforces, each a named refusal: `max_bytes` per slot (at most 4096), 64 slots per indicator, 64 KiB of string bytes per row, 2 MiB of strings and frames together per run (a live session counts as one run). Bytes must be valid UTF-8; a broken sequence refuses the run instead of landing as a replacement character. An RSI with two slots: a readout the label renderer keeps on the newest bar, and a zone word written only on bars that are in a zone, so the text mark stays absent everywhere else: ```typescript param("period", 14, { min: 2, max: 200 }); output("rsi", line, lower, { color: "#38bdf8" }); output("tag_x", none, lower, { description: "Bar time in epoch seconds, the label's x" }); string("readout", { max_bytes: 32 }); string("zone", { max_bytes: 16 }); // One label, riding the newest bar whose readout slot was written. render.label("rsi_tag", { x: "tag_x", y: "rsi", text: "readout", color: "#38bdf8", size: 11 }); // One text mark per bar whose zone slot was written; quiet bars leave it absent. render.text("rsi_zone", { y: "rsi", text: "zone", color: "#f59e0b", size: 10 }); let rsi = new Rsi(14); let period: i32 = 14; function onStart(): void { period = i32(p_period()); rsi = new Rsi(period); } function onBar(): void { const value = rsi.update(bar.close()); if (isNaN(value)) return; out_rsi(value); out_tag_x(bar.time()); sb_clear(); sb_text("RSI "); sb_int(period); sb_text(": "); sb_f64(value, 1); str_readout_sb(); // "RSI 14: 63.2", at most 32 bytes if (value >= 70.0) str_zone("overbought"); else if (value <= 30.0) str_zone("oversold"); } ``` Renderers and drawings are selected after the run ([Plotting](../presentation/plotting.md) and [Drawing objects](../presentation/drawing-objects.md)); the worked table example in [Declarations and the sheet](declarations.md#string-slots-renderers-drawings) writes four slots per bar through this module surface. ## Language cheat The file is AssemblyScript: TypeScript syntax over fixed-width numbers, compiled to a module with no filesystem, no network, and no allocation after `onStart()`. Data types, math, strings, control flow, and user functions take these forms: | Need | Form | | --- | --- | | Numbers | `f64` for every param, input, and output; `i32` for counts, periods, and loop bounds; `bool` for flags. Casts are explicit: `i32(p_period())`, `f64(count)`. | | Missing value | `NaN`, tested with `isNaN(x)`; write NaN to an output for "nothing here", or `return` from `onBar()` before the writes: an output left unwritten is NaN on that bar. `Infinity` in an output is refused by name. | | Windows and history | `History` from `./sdk/stats`: `push(x)` once per bar, then `ago(1)` for the previous bar's value and `max()`, `min()`, `mean()`, `sum()` over the window ([Stats, history and lists](../functions/stats-history-lists.md#history-xn-from-pine)). There is no `close[1]`. By hand: yesterday's value in a module-level variable, or a `StaticArray<f64>` sized once (from a param's `max`) and written as a ring buffer. | | Collections | `StaticArray<T>` and `Array<T>` allocated at module start or in `onStart()`, never in `onBar()`; `Map<K, V>` for keyed state, sized up front. | | Math | `Math.abs`, `Math.max`, `Math.min`, `Math.sqrt`, `Math.pow`, `Math.exp`, `Math.log`, `Math.floor`, `Math.ceil`, `Math.round`, `Math.trunc`, `Math.sign`, the trigonometric set, `Math.PI`, `Math.E`, all over `f64`. | | Strings | only in string slots; build with `sb_text` / `sb_int` / `sb_f64`, never `+` on a string per bar (that allocates). | | Control flow | `if` / `else`, `for`, `while`, `break`, `continue`, `switch` on integers; a number is not a truth value (`if (value > 0.0)`, not `if (value)`). | | Functions | `function name(x: f64, n: i32): f64 { ... }` at module scope, typed params and return; closures cannot capture locals, so a reducer is a named function over a buffer. | | Types | `class Zone { top: f64 = NaN; bottom: f64 = NaN; alive: bool = false; }`, constructed in `onStart()`; a `type` alias for readability. | | Time | `bar.time()` gives the bar's open in epoch seconds. Feed it to a `Clock` (the hour, the weekday, new-day tests, in a named zone) or a `Session` (an `"0930-1600"` window on the weekdays you list) from `./sdk/clock` ([Clock and sessions kit](../functions/time-and-sessions-kit.md)). By hand, sessions are integer math on it (UTC). | | Colors | never computed: declared per output, box, segment, or renderer (hex, `rgb()`, `hsl()`, or a named color where a fill is not involved, or a `theme.` token the chart resolves at paint time), or chosen per bar through a `color_by` ladder. A handle's colour is a packed number from `./sdk/color`, where `theme.UP` and its six siblings are the same tokens. | A rolling highest-high that shows the shapes above (a buffer sized once, a typed helper function, explicit casts; the `high` line is kept as the first input, which sets the request grid, and `bar.high()` reads through it): ```typescript param("bars", 20, { min: 1, max: 500, description: "Lookback in bars" }); input("high", ohlcv.high); output("highest", line, overlay, { color: "#38bdf8" }); const MAX_BARS: i32 = 500; // the param's max: the buffer is sized once, never per bar const highs = new StaticArray<f64>(MAX_BARS); let n: i32 = 20; let cursor: i32 = 0; let count: i32 = 0; // A plain function: typed params, a typed return, no closure over locals. function maxOf(values: StaticArray<f64>, len: i32): f64 { let best = -Infinity; for (let i = 0; i < len; i++) { if (values[i] > best) best = values[i]; } return best; } function onStart(): void { n = i32(p_bars()); } function onBar(): void { highs[cursor] = bar.high(); cursor = (cursor + 1) % n; if (count < n) count += 1; if (count < n) return; out_highest(maxOf(highs, n)); } ``` ## The sheet Run derives Run turns your declarations into a sheet, the metadata a published indicator carries; you never write it. Where each of its top-level fields is explained: | Field | Meaning | Page | | --- | --- | --- | | `id`, `name`, `description` | the sheet's short name and display strings | [Declarations and the sheet](declarations.md#the-sheet-run-derives) | | `abi_version` | `wrun-1` (absent means this) through `wrun-5`, derived from what the declarations use | [Declarations and the sheet](declarations.md#abi-versions-the-five-contracts) | | `wasm_sha256` | the compiled module's digest, stamped by Run | [Declarations and the sheet](declarations.md#the-sheet-run-derives) | | `params[]` | `{name, default, required?, min?, max?, description?}` | [Declarations and the sheet](declarations.md#the-sheet-run-derives) | | `inputSources{}`, `inputs[]` | sources keyed by input name; `{index, name, description?, cellType?, max_cells?}` | [Data sources](../core-concepts/data-sources.md) | | `outputs[]` | `{index, name, plot?, panel?, unit?, displacement_bars?, ...styling}` | [Plotting](../presentation/plotting.md) | | `ranges[]` | a band between two outputs, with edges, a ladder or a gradient | [Styling](../presentation/styling.md) | | `boxes[]`, `segments[]` | per-bar shapes over outputs (16 each) | [Drawing objects](../presentation/drawing-objects.md) | | `string_slots[]`, `renderers[]`, `drawings[]` | the second contract's text and decoration vocabulary | [Declarations and the sheet](declarations.md#string-slots-renderers-drawings) | | `handles`, `strategy` | the third contract's drawing handles and strategy settings | [Drawing objects](../presentation/drawing-objects.md), [Writing strategies](../strategies/writing-strategies.md) | | `frames[]`, `levels[]`, `panels[]`, `matrices[]` | the fourth contract's snapshots, docked profiles, panels and strike matrices | [Cards, frames and panels](../presentation/cards-frames-panels.md#frames-panels-and-compact-widgets) | | `heatmaps[]`, `profiles[]` | the canvases drawn from cells or a grid of outputs, on any contract | [Price canvases](../presentation/price-canvases.md) | | `panes[]`, `fills[]`, `contrast_guard`, `stack_handles` | named lower panes, interior fills between two lines, and the two chart flags | [Styling](../presentation/styling.md) | | `alerts[]` | the declared signals the chart's alert dialog offers | [Alerts](../functions/alerts.md) | | `generated_from`, `source_digest` | provenance of the sheet derived from declarations | this page | The full validation list, every refusal by path, is at the end of [Declarations and the sheet](declarations.md#validation-in-one-list); the messages you will actually meet, with their fixes, are in [Common errors](../faq/common-errors.md). <!-- source: https://openmarket.xyz/wrun/reference/declarations --> # Declarations and the sheet A wrun indicator declares what it reads, writes and draws as top-level statements at the top of its file, and **Run** derives the sheet from them: the description of the indicator the chart draws from and a published indicator carries. This page is the grammar of those statements, the rules the editor enforces, the sheet's fields and provenance, and the runtime contract the sheet records. What each declaration does on the chart is on the page that owns it, and every signature with every option is one table on [Quick reference](quick-reference.md). ## param `param(name, default, { required?, min?, max?, description? })` is a number field, read from `onStart()` on through `p_<name>()`. `param.<kind>(name, default, options?)` is a typed setting (`int`, `number`, `bool`, `choice`, `color`, `time`, `price`, `range`, `multi`, `list`, `source`, `timeframe`, `symbol`, `session`, `text`), each naming the control the dialog draws, its literal default and its reader: [Setting kinds](../settings/kinds.md). `param.text` (one line) and `param.text_area` (several) take words, read once in `onStart()` through `pt_<name>()` ([Text settings](../settings/kinds.md#text-settings)). The options, and which kind takes each: [Options on a setting](../settings/options.md). `market.tick_size()`, `market.price_precision()`, `market.kind()`, `market.point_value()`, `market.zone()`, `market.quote_is_usd()`, `chart.interval_sec()`, `chart.bg_color()`, `chart.fg_color()`, `chart.up_color()`, `chart.down_color()` and `chart.grid_color()` declare hidden settings the host writes before `onStart()`, 0 where it cannot know one ([Chart context](../settings/sessions-and-units.md#chart-context)). ## input `input(name, source.field, options?)` declares one per-bar number, read in `onBar()` through `in_<name>()`; the chart's own candle needs no line (`bar.close()` and the other `bar.*()` readers), and the first input is the bar grid. The sources, their fields and knobs: [Data sources](../core-concepts/data-sources.md). The pins: `symbol` with `exchange` for another market ([Multi-source](../core-concepts/multi-source.md)); `interval` with `view`, `views`, `offset` and `bars` for a coarser leg ([Multi-timeframe](../core-concepts/multi-timeframe.md)); `missing` for what a bar with no observation reads. `input(name, <class>.cells, { max_cells, ... })` declares a celled input, a block of tuples per bar. No input reads another indicator's outputs. ## output `output(name, plot?, panel?, options?)` declares a per-bar number, written in `onBar()` with `out_<name>(value)`; an output not written that bar stays `NaN` and draws nothing there. `plot` is `line`, `bar`, `area`, `histogram`, `candle`, `shape`, `scatter` or `none` (data-only: computed, never drawn); `panel` is `overlay` (the price pane) or `lower` (a pane below it), per output. The options are style keys and presentation keys: [Plotting](../presentation/plotting.md), [Styling](../presentation/styling.md). The styling round's words ride the same literal: the plot-kind looks (`step`, `smooth`, `gradient`, `split` with `up_color`, `down_color`, `split_level`, `split_fill` and `split_base`, `fill_color`, `fill_opacity`, `fill_gradient`, `gradient_mode`, `fill_color_by` plus `fill_colors`, `fill_color_packed_by`, `base`, `grading`, `stack`, `candle_style`, `border_colors`, `wick_colors`, `border_width`, `shape`, `location`, `char`, `font_family`, `fill`, `role`, `align`, `pill_style`, `font_size`, each refused by name off the plot kinds that take it), `z` (-10..10, the paint order), `pane` (a declared pane's name or `"lower"`), `color_packed_by` (a packed rgba output per bar), `displacement_bars_by` (`{ param, scale?, offset? }`), and the presentation keys `decimals` (0..8) and `signed` (both need `format`, whose words are `price`, `%`, `si`, `int`, `0`, `0.0`, `0.00`, `0.000`, `usd` and `auto`; `unit` prints beside one) and `axis_name`; every one reaches the sheet only ([Plotting](../presentation/plotting.md#lines-areas-columns-dots-marks)). Four more ride the same way: `show_price_display` (the output's price-axis tag under `display({ price_display: "per_output" })`), `show_price_display_by` (an output whose value switches that tag per run), `omit_if_empty` (no legend row or tag when no bar drew) and `barcolor: true` on a `none` output (its value picks the candle tint from `colors`, or `color_packed_by` names it) ([Styling](../presentation/styling.md#tags-the-legend-and-the-candle-tint)). `output(...)` returns a handle; bind it with a top-level `const` when a `box`, `segment`, `hover` or `alert` needs to name it. ## string `string(name, { max_bytes, description? })` declares a text slot, written per bar in `onBar()` through `str_<name>(text)` or the `sb_*` builder and `str_<name>_sb()`; a slot not written that bar is absent. Slots feed renderers, drawings, blocks and the Console, never a plot ([Strings and text](../functions/text-formatting.md)). A slot named exactly `debug` is the indicator's debug log in the editor's Console ([Debugging](../faq/debugging.md)). ## render and draw `render.text`, `render.label`, `render.table`, `render.shape`, `render.stats_row`, `render.bgcolor` and `render.barcolor` place text, marks and tints over outputs and slots; `render.legend` adds a legend entry and `render.hud` a card. `draw.line`, `draw.box`, `draw.polyline` and `draw.label` with a name as the first argument each declare one drawing, placed from the newest bar. Numeric references name outputs; `text` and table `cells` name string slots. `render.table` also takes the look keys and per-cell `styles` ([Styled tables](../presentation/cards-frames-panels.md#styled-tables)), and `render.shape` takes `width`, the mark's size in pixels ([Mark size](../presentation/plotting.md#mark-size)). Renderers: [Plotting](../presentation/plotting.md); legend and cards: [Legend](../presentation/legend.md), [HUD and hover cards](../presentation/hud-and-hover-cards.md); drawings and handles: [Drawing objects](../presentation/drawing-objects.md). Since the styling round, `render.text` and `render.label` take `style` from eight words (`plain`, `price_label`, `pill`, `callout`, `badge`, `box`, `knockout`, `emblem`) and the look keys: `align` and `valign` on plain text; `label_position`, `background_color`, `border_color` and `corner_radius` on a tag (a tag key on plain text, or `align` on a tag, is refused); `font_weight`, `font_family`, `emblem_shape`, `emblem_color` and `size_by` on both; a text renderer's `background_color_by` plus `background_colors`, `color_by` plus `colors` or `color_packed_by`, and `panel`; a label's `padding`. A `render.label` pins to a corner with `position` (one of the nine anchors) and `offset` (`[x, y]`, each -200..200 px) instead of `x` and `y` (declaring both is refused; `price_label` and `callout` are refused there). `render.shape` adds `location` (`absolute`, `above_bar`, `below_bar`, `top`, `bottom`), `glow`, `char` (one character, with and only with `shape: "char"`), `font_family`, `fill`, `fill_opacity` and `tooltip`; `render.stats_row` adds `color`, `colors`, `color_by`, `color_packed_by` and `priority` (1..3); `render.bgcolor` adds `width` (0.5..10, a vertical line per gated bar) and `line_style`; `render.hud` takes its surface, type and look words. Every `draw.*` declaration takes its handle kind's look words in snake_case: `extend`, `arrow`, `glow`, `glow_color`, `sticky_right`, `axis_label`, `opacity` and `tooltip` on a line; `border_color`, `border_width`, `border_style`, `corner_radius`, `shape`, `gradient`, `gradient_direction`, `text` (a string slot), `text_color`, `font_size`, `font_weight`, `font_family`, `align`, `valign` and `padding` on a box, whose `color` is the fill; `fill_color`, `closed` and `smooth` on a polyline; `style`, `background_color`, `max_width`, `angle`, `emblem_shape` and `emblem_color` on a label. Every one is a presentation key, stripped before the compiler, and since the styling round every `render.text`, `render.label`, `render.shape` and `draw.*` declaration is erased whole ([Plotting](../presentation/plotting.md), [Drawing objects](../presentation/drawing-objects.md)). ## box, segment and range `box(name, { top, bottom, ... })` and `segment(name, { yFrom, yTo, ... })` declare per-bar shapes over output HANDLES; `range(upper, lower, options?)` declares a band between two rendered outputs (ranges never dedupe: repeat the declaration for several bands); its `edge_width` is 0..10 (`0` for no edge lines) and it also takes `legend` (false drops the band's legend row), `label`, `z`, `fill` (false draws the two edge lines with no interior), `show_price_display` (the band's tags) and `omit_if_empty` (no legend row or tag when no bar had both edges). `fill(a, b, options?)` shades the interior between two drawn outputs on one pane with no edge lines (`color`, `opacity` 0..1 with default 0.25, `color_by` plus `colors`, `color_packed_by`, `z`; only a top-level `fill(` statement is read, a `.fill(` method on a handle is something else), and `pane(name, options?)` declares a pane of the indicator's own, at most four (`title`, `place` `"below"` or `"price"`, `height_frac` 0.05..0.6, `scale` `"linear"`, `"log"`, `"percent"` or `"indexed"`, `invert`, `padding` `[top, bottom]`, `min`, `max`, `format`, `decimals`, `signed`, `unit`), joined by an output's `pane: "<name>"`. A box also takes `borderStyle`, `z` and the colour ladders `colorBy` plus `colors`, `colorPackedBy`, `borderColorBy` plus `borderColors` and `borderColorPackedBy` (output handles for the `By` keys; a ladder half without the other is refused). All of these are erased before the compiler. What the chart draws: [Drawing objects](../presentation/drawing-objects.md), [Styling](../presentation/styling.md). ## alert `alert(name, { when, message?, description?, text?, every_bar?, title? })` declares a signal the chart's alert dialog offers: `when` is the handle of the output whose false-to-true edge fires it (a data-only `none` output works, so no 0/1 line has to be drawn), `message` the fire text with placeholders such as `{{symbol}}`, `{{close}}` and `{{<output>}}` (a declared output's value on the fired bar), `description` the picker's sub-line, both at most 200 characters. `text` names a string slot: the words the file writes into it on the fired bar are the message, sent as one plain line of at most 200 characters. `every_bar: true` fires on every bar `when` holds, once per bar at most; the default fires on the false-to-true edge only ([Alerts](../functions/alerts.md#alert-words-and-every-bar)). `title` is the words the dialog, an armed alert and its notification show for the signal, any characters, 1 to 120 of them; the name keeps letters, digits, `.`, `_` and `-`. The declaration adds nothing to the module. A worked signal: [Alerts](../functions/alerts.md). ## Layout words `page(title)`, `section(title, { toggle?, collapsed?, when? })`, `divider()` and `note(text)` lay the settings dialog out in source order; `presets({ Name: { param: value } })` ships named settings sets; `legend({ title })` sets the legend's title template. Sheet-only, compiled to nothing: [Pages, sections, dividers, notes](../settings/layout.md), [Presets](../settings/presets.md), [Legend](../presentation/legend.md). `display({ axis?, price_display?, overlay?, mount_order? })`, at most once, sets the indicator's look on the chart as a whole: `axis: false` gives it no price axis of its own (an indicator on the price pane rides the chart's), `price_display: "per_output"` tags each plot on the price axis by its own `show_price_display`, `overlay: "offchart"` homes it in its own pane below the chart, and `mount_order: "first_value"` lists its plots in the order they first draw ([Styling](../presentation/styling.md#tags-the-legend-and-the-candle-tint)). ## Other words `hover(handle, [block.*])` is an output's hover card ([HUD and hover cards](../presentation/hud-and-hover-cards.md)); `handles.*` sets handle defaults ([Drawing objects](../presentation/drawing-objects.md)); `frame`, `panel.*`, `plot.levels`, `draw.ladder`, `draw.feed`, `draw.meter` and `out.inset` declare JSON snapshots and the panels and widgets drawn from them ([Cards, frames and panels](../presentation/cards-frames-panels.md)); `strategy({ ... })` places orders through the engine's broker ([Strategies overview](../strategies/overview.md)). `plot.heatmap`, `plot.footprint`, `plot.tpo`, `plot.profile` and `plot.matrix` declare the price canvases and `out.grid` a block of data-only outputs a heatmap reads ([Price canvases](../presentation/price-canvases.md)); `chart.contrast_guard(false)` and `chart.stack_handles(true)` are top-level flags with one boolean literal each (a binding is refused): the first keeps the author's colours as written on a light chart, the second stacks the indicator's corner-anchored handle groups below other indicators' groups ([Colors](../functions/colors-kit.md#theme-tokens)). ## A file that uses them Two settings, two inputs, three outputs and the debug slot, with the hooks that read and write them (the bar loop in full: [Execution model](../core-concepts/execution-model.md)): ```typescript // One output in its own pane (lower), labelled with a unit. param("period", 14, { min: 2, max: 200, description: "RSI Period" }); // An on/off setting is a 0/1 param. param("show_raw", 1, { min: 0, max: 1, description: "Write the raw RSI as well as the smoothed one" }); // The first input is the bar grid the funding input is aligned to; bar.close() reads through it. input("close", ohlcv.close); // A funding input, aligned to the close's rows; the chart serves it as a percent. input("funding", funding.rate_close, { description: "Funding rate in percent, as of the bar" }); output("rsi", line, lower, { unit: "%", color: "#7c3aed", width: 2, description: "RSI (14)" }); output("smoothed", line, lower, { unit: "%", color: "#38bdf8", width: 1, description: "RSI smoothed by a 5-bar EMA" }); output("funding_pct", line, overlay, { unit: "%", color: "#f59e0b", description: "Funding in percent, on the price pane" }); // The debug log: a string slot named debug, printed in the editor's Console. string("debug", { max_bytes: 32 }); let rsi = new Rsi(14); const ema = new Ema(5); let showRaw: bool = true; function onStart(): void { rsi = new Rsi(i32(p_period())); showRaw = p_show_raw() > 0.5; } function onBar(): void { const value = rsi.update(bar.close()); const fundingRate = in_funding(); if (isNaN(value)) return; const smoothed = ema.update(value); out_rsi(showRaw ? value : NaN); out_smoothed(smoothed); out_funding_pct(fundingRate); sb_clear(); sb_text("rsi "); sb_f64(value, 2); str_debug_sb(); } ``` A `0` in `show_raw` writes `NaN` to the raw line, which draws nothing: that is how a boolean toggle hides a plot. ## The declaration grammar A declaration is a top-level statement of your file: a call to one of the words above with a string-literal name, a literal default and a literal options object, nothing imported. The build reads them without running the code, derives the sheet, and generates the readers and writers (`p_*`, `pb_*`, `in_*`, `out_*`, `str_*`, `sb_*`, `fb_*`) from the same object, so the two cannot disagree. A file with `onBar()` and no `output(...)` is refused at Run. A file that exports `init`, `state`, `finalize` or `reset` is the four-function form; both forms derive the same sheet ([The four-function form](../core-concepts/execution-model.md#the-four-function-form)). ```typescript param("period", 14, { min: 2, max: 200, description: "Lookback window" }); param.bool("show_raw", true, { label: "Raw line" }); input("close", ohlcv.close); input("btc_close", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES" }); output("value", line, lower, { unit: "score", label: "Score", format: "0.00" }); ``` ### Bindings `"@<param>"` as the value of `color`, an entry of `colors` or `line_style` on an output or a text, label or legend renderer binds that spot to a `param.color` (for `line_style`, a `param.choice` over `solid`, `dashed`, `dotted`); as `interval` or `symbol` on an input it binds the pin to a `param.timeframe` or a `param.symbol`; a `param.source` declares the input it reads. The build writes the setting's default in the reference's place before the compiler runs and records the binding on the sheet (`style_targets`, `interval_param`, `symbol_param`, `field_param`), so the module's bytes never depend on a setting ([The Style page](../settings/style-page.md), [Picks, lanes, the cap](../settings/picks-and-lanes.md)). The same reference works on a `range`, a `fill`, a `box` and a `segment` (their colours, palette entries, gradient stops and line or border styles), on an output's other colour words (`fill_color`, `fill_colors`, `fill_gradient`, `gradient`, `up_color`, `down_color`, `border_colors`, `wick_colors`), and on the style keys of a `plot.levels` profile, where a word key takes a `param.choice` over that key's words, a number key a `param.number` or `param.int` whose range lies inside the key's, and a boolean key a `param.bool`. A data-only output refuses a paint binding (a `barcolor` output, which paints the candles, takes one), and so do a `draw.*` object, a `handles.*` default, a panel series and a tile. Every colour reference also takes `"@<param>/<alpha>"`, a panel series colour excepted: the setting's default at that alpha (a decimal from 0 to 1, replacing the colour's own) lands in the reference's place, as `rgba(r, g, b, a)` where the colour takes `rgba()` (an output's `color` and `colors`, a renderer's colours but a stats row's, a legend entry, a range's colours and gradient, a fill's `color`, a box's `color` and `borderColor`, a segment's `color`, a mini-chart grid's colours) and as `#rrggbbaa` where it takes a colour word, and the binding records the alpha, so a recolour keeps the translucency. `ticks_per_bar: "@<param>"` on a `volume_profile.cells` input binds the profile's bucket size to a `param.int` within 1..500: the sheet carries its default and `ticks_per_bar_param`, and the chart fetches again when it changes. ### Sheet-only keys An output's presentation keys, a renderer's `style`, `tooltip`, `hover` and `badges`, the plot-kind looks, `z` and `pane`, an input's `ticks_per_bar`, `currency` and a profile's `missing`, and the whole of `hover`, `range`, `fill`, `pane`, `box`, `segment`, `alert`, `display`, the chart flags, every `plot.*` canvas, the layout words, `render.legend`, `render.hud` and (since the styling round) every `render.text`, `render.label`, `render.shape` and `draw.*` declaration reach the sheet only: the build strips them before the compiler runs, so a declaration with them compiles to the same bytes as one without. A `const` bound to a `string(...)` or `output(...)` is a handle for naming, dropped the same way. ### Rules every declaration follows Each is a named build error on the declaration's line: - Names are string literals; defaults and option values are literals; the code never runs at build time. - Names may carry capitals. Params, inputs, string slots and frames are stored lowercase, with every option and `{{placeholder}}` that names one; outputs keep their spelling; each accessor also answers to the file's spelling. Two names of one family that differ only by case are refused; boxes, segments and alerts keep their spelling, so `Zone` and `zone` are two boxes. - Declarations are top-level statements; one anywhere else names the file and line. - Indexes follow declaration order: the first `input(...)` is slot 0, the primary input, and reordering declarations reorders slots while the accessors stay name-attached. A candle field read through `bar.<field>()` is served by a declared input of exactly that feed, else appended after the declared inputs under its own name, in the order `open`, `high`, `low`, `close`, `volume`, `bar_t`; a file with no `input(...)` line gets `close` as slot 0. - Box and segment coordinates, a section's `toggle` and `when`, a `hover` target and an alert's `when` are output handles bound by a top-level `const` (`let`, `var` and `export const` bind too); the sheet records the output's name, never the handle. A string literal where a handle goes, or an unbound handle, is refused. - `color_by`, `width_by` and `shape_where` name a DIFFERENT declared output; `colors` goes with `color_by` and `widths` with `width_by`, each half without the other refused. - A typed setting's options belong to its kind; `when` names a declared `param.bool`; a `"@<param>"` reference names a setting of the kind the spot needs; a preset sets sheet params only. A setting cannot take a name the chart keeps (`symbol`, `exchange`, `interval`, `transformations`, `ticksPerBar`, `currency`, `runMode`, `devViewerTier` and the overlay's own keys), start with `__style__`, or collide with a name another setting derives (`band_lo`, `rth_tz`, `offset_unit`, `market_tick_size`). - The expanded sheet holds at most 128 params (a `range` counts two, a `session` three, a `list` its `max` + 1, a `unit` list one more); every other cap is on [Limits](limits.md). - Names share one namespace across outputs, boxes, segments, renderers, drawings and alerts. - A celled input requires `max_cells` (`candles` gets the larger of `bars` and 2 when it is left out) and `book` requires `block_size`; scalar-feed knobs are refused on a celled input. ## The sheet Run derives When you press **Run**, the editor derives the sheet from the declarations, checks it against the schema, and compiles; a refusal prints in the Console on the declaration's line, and Run stops there. There is no hand-written sheet. Read it at the Console prompt (`sheet`) and in **Copy for LLM**; a published indicator carries it. The sheets on this page omit the provenance fields every derived sheet carries (`generated_from`, `source_digest`, and `wasm_sha256` once the module compiles). These declarations, in a tab titled "Context Skeleton": ```typescript param("period", 14, { min: 2, max: 200, description: "Lookback bars" }); input("close", ohlcv.close); input("btc_close", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES", description: "Fixed BTC reference market" }); input("funding_rate", funding.rate_close); output("value", line, lower, { unit: "score" }); output("raw", none); output("lagging", line, lower, { displacement_bars: -26 }); ``` derive every top-level field a scalar sheet has: ```json { "id": "context-skeleton", "name": "Context Skeleton", "params": [ { "name": "period", "default": 14, "min": 2, "max": 200, "description": "Lookback bars" } ], "inputSources": { "close": { "source": "ohlcv", "field": "close" }, "btc_close": { "source": "ohlcv", "field": "close", "exchange": "BINANCE_FUTURES", "symbol": "BTCUSDT" }, "funding_rate": { "source": "funding", "field": "rate_close" } }, "inputs": [ { "index": 0, "name": "close" }, { "index": 1, "name": "btc_close", "description": "Fixed BTC reference market" }, { "index": 2, "name": "funding_rate" } ], "outputs": [ { "index": 0, "name": "value", "plot": "line", "panel": "lower", "unit": "score" }, { "index": 1, "name": "raw", "plot": "" }, { "index": 2, "name": "lagging", "plot": "line", "panel": "lower", "displacement_bars": -26 } ] } ``` | Field | What Run writes | | --- | --- | | `id`, `name` | from the editor tab's title (`Context Skeleton` derives `context-skeleton` and `Context Skeleton`); `name` is shown in lists, and publishing names the package `@yourname/<name>` ([Publishing](../functions/publishing.md)) | | `abi_version` | the runtime contract, picked from the declarations you use; ABSENT means `"wrun-1"` (below) | | `generated_from`, `source_digest` | `"declarations"`, and the sha256 of the source the sheet came from | | `wasm_sha256` | the compiled module's sha256, stamped when Run compiles it | | `params` | `{name, default, required?, min?, max?, description?}` with unique names, in declaration order; values reach the module positionally in that order | | `inputSources` | keyed BY INPUT NAME: the source, the field and the options the declaration set, an interval always as the long word (`FOUR_HOURS`) | | `inputs` | `{index, name, description?}`, indexes contiguous from 0 in declaration order; index 0 is the PRIMARY input, which follows the chart's market and interval (a pin on it is refused; an `odds` input is the exception); a `time` source cannot be primary | | `outputs` | `{index, name, description?, plot?, panel?, unit?, displacement_bars?, ...style, ...presentation}`, indexes contiguous from 0, unique names; `plot: ""` is a data-only output (declared `none`); `displacement_bars` draws the value written at bar i at bar `i + displacement_bars`, the module never shifting a row | The chart reads no warm-up field: the bars where `onBar()` writes nothing are the warm-up. The sheet is DERIVED state, serialized canonically (an unchanged source rewrites nothing): edit the declaration, never the sheet. ## Typed settings on the sheet A typed setting adds its `type` (`int`, `number`, `boolean`, `select`, `color`, `time`, `price`, `multi`, `symbol`, `text`) and the dialog's keys (`label`, `step`, `group`, `row`, `hint`, `when`, `hide`, `slider`, `confirm`; `options` and `option_labels` on a select, `multi_bits` on a multi, `unit` and `unit_default` beside a unit menu); a composite expands to its members (`range` / `range_end`, `list` / `list_index` / `list_end`, `session` / `session_end`, `unit_for`); a host-applied setting carries `host` and a symbol its `symbol_default`; a binding records `style_targets` (and `style_values` on a line-style choice); a `text` setting carries `max_bytes`, `multiline` and its words as `text_default` (its `default` is 0); `market.*` and `chart.*` carry `host_fill` and `hidden`. None of these change what reaches the module: one number per sheet param, a text setting's number being its words' byte count, with the words on the text channel. The declarations below, in a tab titled "Typed Skeleton": ```typescript page("Signal"); param.int("period", 14, { min: 2, max: 200, label: "Length" }); param.choice("kind", ["Simple", "Exponential"], "Simple"); const show = param.bool("show_band", true); section("Band", { toggle: show }); param.range("band_pct", [0.5, 2.0], { min: 0, max: 10, step: 0.1, label: "Band width %" }); param.color("band_color", "#94a3b8"); input("close", ohlcv.close); output("value", line, overlay, { label: "Average", format: "price" }); output("band_hi", line, overlay, { color: "@band_color", label: "Band", legend: false }); presets({ Tight: { band_pct_lo: 0.2, band_pct_hi: 1 } }); ``` derive six sheet params from five settings (the range is two), the color's default written onto the output it paints plus the binding on the param, the layout, and the preset: ```json { "id": "typed-skeleton", "name": "Typed Skeleton", "params": [ { "name": "period", "default": 14, "min": 2, "max": 200, "type": "int", "step": 1, "label": "Length" }, { "name": "kind", "default": 0, "type": "select", "options": [0, 1], "option_labels": ["Simple", "Exponential"] }, { "name": "show_band", "default": 1, "min": 0, "max": 1, "type": "boolean" }, { "name": "band_pct_lo", "default": 0.5, "min": 0, "max": 10, "type": "number", "step": 0.1, "label": "Band width %", "range": "band_pct", "range_end": "lo" }, { "name": "band_pct_hi", "default": 2, "min": 0, "max": 10, "type": "number", "step": 0.1, "label": "Band width %", "range": "band_pct", "range_end": "hi" }, { "name": "band_color", "default": 9975030760, "type": "color", "style_targets": [{ "output": "band_hi", "property": "color" }] } ], "inputSources": { "close": { "source": "ohlcv", "field": "close" } }, "inputs": [{ "index": 0, "name": "close" }], "outputs": [ { "index": 0, "name": "value", "plot": "line", "panel": "overlay", "label": "Average", "format": "price" }, { "index": 1, "name": "band_hi", "plot": "line", "panel": "overlay", "color": "#94a3b8", "label": "Band", "legend": false } ], "layout": [ { "kind": "page", "title": "Signal" }, { "kind": "param", "name": "period" }, { "kind": "param", "name": "kind" }, { "kind": "param", "name": "show_band" }, { "kind": "section", "title": "Band", "toggle": "show_band" }, { "kind": "param", "name": "band_pct_lo" }, { "kind": "param", "name": "band_color" } ], "presets": [ { "name": "Tight", "values": { "band_pct_lo": 0.2, "band_pct_hi": 1 } } ] } ``` Underneath, the module still receives six numbers in this order: the length, the choice's index, 1 or 0 for the toggle, the two ends of the range, and the packed color. ## Pins on the sheet A pin lands on the input's `inputSources` entry as declared; one the user can change carries the setting's default plus `interval_param` or `symbol_param`, a profile's bucket size `ticks_per_bar_param`, and a `param.source` input `field_param`. A volume profile declared `missing: "empty"` carries the word too, and so does a `trades` input's `currency`. The chart checks every pin before anything is fetched and refuses one it cannot serve by name. What each source may pin, and what a leg, a view, an offset or `bars` reads: [Multi-timeframe](../core-concepts/multi-timeframe.md); another market and the `missing` policy: [Multi-source](../core-concepts/multi-source.md). Liquidations first, read as 0 on quiet bars, with the close carried beside them (the schema refuses `missing: "carry"` on the first input): `input("liqs", liquidations.liquidations, { missing: "zero", description: "Total liquidations, 0 on quiet bars" })`, `input("close", ohlcv.close, { description: "Close, carried" })` and `output("liq_ratio", histogram, lower)`, in a tab titled "Dense Liquidations", derive: ```json { "id": "dense-liquidations", "name": "Dense Liquidations", "params": [], "inputSources": { "liqs": { "source": "liquidations", "field": "liquidations", "missing": "zero" }, "close": { "source": "ohlcv", "field": "close" } }, "inputs": [ { "index": 0, "name": "liqs", "description": "Total liquidations, 0 on quiet bars" }, { "index": 1, "name": "close", "description": "Close, carried" } ], "outputs": [ { "index": 0, "name": "liq_ratio", "plot": "histogram", "panel": "lower" } ] } ``` A fixed BTC reference on its own 4h candles, as of close: `input("btc_4h", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES", interval: "4h", description: "BTC on its own 4h grid, as of close" })` beside `input("close", ohlcv.close)` and `output("ratio", line, lower)`, in a tab titled "BTC 4h Context", derive the sheet below, the interval spelled `FOUR_HOURS`. On a 4h chart the pin equals the chart's interval and reads BTC row by row; on a coarser chart it is finer than the chart, and Run refuses it. ```json { "id": "btc-4h-context", "name": "BTC 4h Context", "params": [], "inputSources": { "close": { "source": "ohlcv", "field": "close" }, "btc_4h": { "source": "ohlcv", "field": "close", "exchange": "BINANCE_FUTURES", "symbol": "BTCUSDT", "interval": "FOUR_HOURS" } }, "inputs": [ { "index": 0, "name": "close" }, { "index": 1, "name": "btc_4h", "description": "BTC on its own 4h grid, as of close" } ], "outputs": [{ "index": 0, "name": "ratio", "plot": "line", "panel": "lower" }] } ``` An `odds` input names its own Polymarket market by condition id (`0x` + 64 hex) in `symbol` and may be the first input ([Data sources](../core-concepts/data-sources.md)). This sample pins the placeholder market the chart's prediction starters ship, so Run refuses it until you paste your market's condition id into `symbol`: ```typescript param("period", 5, { min: 1, max: 200, description: "Momentum lookback, bars" }); // The market is its condition id (0x + 64 hex): paste yours over this placeholder. input("yes_odds", odds.close, { symbol: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", outcome: "YES", description: "Polymarket YES probability" }); output("momentum", line, lower, { description: "Rate of change of the YES probability" }); let roc = new Roc(5); function onStart(): void { roc = new Roc(i32(p_period())); } function onBar(): void { out_momentum(roc.update(in_yes_odds())); } ``` ## Boxes and segments (per-bar shapes) Two top-level arrays, derived from `box(...)` and `segment(...)`, legal on every contract: they add nothing to the module, and the chart evaluates them on EVERY bar row. The sheet records output NAMES under snake_case fields: `boxes` (at most 16), each `{name, top, bottom, x_from?, x_to?, when?, panel?, color?, border_color?, opacity?, border_width?, border_style?, z?, color_by?, colors?, color_packed_by?, border_color_by?, border_colors?, border_color_packed_by?}` (the `_by` keys name declared outputs whose per-bar value picks an entry of `colors` or `border_colors`, 1..64 colours each; the `_packed_by` keys an output carrying a packed colour per bar), and `segments` (at most 16), each `{name, y_from, y_to, x_from?, x_to?, when?, panel?, color?, width?, line_style?}`. The coordinates name declared outputs (data-only allowed); `x_from` / `x_to` are bar offsets, a literal (whole or fractional: a fraction places the edge inside its bar, at that share of the bar's interval) or the name of an output whose truncated per-bar value is the offset; `when` names a gate output (the bar is skipped unless its value is finite and nonzero). Every refusal names the field (`boxes.0.top`). The defaults and what the chart draws: [Drawing objects](../presentation/drawing-objects.md). ## Declared alerts `alerts` (at most 64), derived from `alert(name, { when, message?, description?, text?, every_bar?, title? })`, each `{name, when, message?, description?, text?, every_bar?, title?}`: `when` names a declared output (any plot, data-only `""` included), and the alert fires on the bar that output turns from false to true (true is finite and nonzero; a `NaN` row is false and re-arms it), or on every bar it holds when `every_bar` is `true`; `text` records the slot's name. Legal on every contract; nothing new reaches the module. The golden-cross declarations on [Alerts](../functions/alerts.md), in a tab titled "Golden Cross", derive: ```json { "id": "golden-cross", "name": "Golden Cross", "params": [ { "name": "fast", "default": 21, "min": 1, "max": 200, "description": "Fast EMA length" }, { "name": "slow", "default": 55, "min": 2, "max": 400, "description": "Slow EMA length" } ], "inputSources": { "close": { "source": "ohlcv", "field": "close" } }, "inputs": [{ "index": 0, "name": "close" }], "outputs": [ { "index": 0, "name": "fast", "description": "Fast EMA of the close", "plot": "line", "panel": "overlay", "color": "#2563eb" }, { "index": 1, "name": "slow", "description": "Slow EMA of the close", "plot": "line", "panel": "overlay", "color": "#f97316" }, { "index": 2, "name": "golden_cross", "plot": "" } ], "alerts": [ { "name": "golden_cross_up", "when": "golden_cross", "message": "{{symbol}} golden cross at {{close}}", "description": "Fast EMA crosses above slow EMA" } ] } ``` ## Settings layout, presets, presentation Four more top-level keys, written only when the file declares them and read by the chart alone: `layout`, `presets`, `legend_title` and `presentation`. Legal on every contract; none reaches the module. | Key | Shape | | --- | --- | | `layout` | the dialog in source order: `{kind: "page", title}`, `{kind: "section", title, toggle?, collapsed?, when?}`, `{kind: "divider"}`, `{kind: "note", text}`, `{kind: "param", name}` (a composite once, under its first member's name; a hidden `market.*` param takes no entry); absent when the file uses no layout word and no `group` option, and the dialog keeps one flat list | | `presets` | `[{name, values: {param: number | boolean | string}}]` as declared; the chart turns each value into the setting's own form when a preset is picked | | `legend_title` | the `legend({ title })` template | | `presentation` | `{legend?: [...], hud?: [...]}` from `render.legend` and `render.hud`, each entry with the keys of its declaration, the tiles in the block shape an output's `hover` uses | A `presentation.hud` entry carries its surface, type and look words as declared (`width`, `offset`, `z`, `background_color`, `corner_radius`, `font_family`, `look`, `accent_color`, `chrome`, `safe_area`, `mobile` and the rest). Two more top-level arrays and two flags travel the same way: `panes` (from `pane(...)`, each `{name, title?, place?, height_frac?, scale?, invert?, padding?, min?, max?, format?, decimals?, signed?, unit?}`, at most 4), `fills` (from `fill(...)`, each `{between: [a, b], color?, opacity?, color_by?, colors?, color_packed_by?, z?}`), `contrast_guard` (boolean, default true) and `stack_handles` (boolean, default false). What the dialog draws from each: [Pages, sections, dividers, notes](../settings/layout.md), [Presets](../settings/presets.md), [Legend](../presentation/legend.md), [HUD and hover cards](../presentation/hud-and-hover-cards.md); the Style page is derived from the outputs, with no key of its own ([The Style page](../settings/style-page.md)). ## ABI versions: the five contracts Run writes `abi_version` from the declarations you use, and the module is validated and run under that contract and no other. The first contract is FROZEN: published indicators on it keep running bit-identically. Each later contract is ADDITIVE over the one before, the same entry points and scalar block plus one channel family, and freezes in turn. `range`, `box`, `segment` and `alert` are contract-neutral: declaring one never flips the sheet; so are `fill`, `pane`, the chart flags and the cell-fed canvases (`plot.heatmap`, `plot.footprint`, `plot.tpo`, `plot.profile`), which ride whichever contract their input already needs. | Contract | Stamped by | Adds | | --- | --- | --- | | `"wrun-1"` (the field absent) | scalar-only declarations | params, inputs, outputs | | `"wrun-2"` | a celled input, a string slot, a renderer or a declared drawing | the cell and string channels, the renderer and drawing vocabulary (below) | | `"wrun-3"` | a handle, `strategy(...)` or a `bar.isLast()` call | the handle-keyed draw channel, the strategy channel, the last-bar flag ([Drawing objects](../presentation/drawing-objects.md), [Strategies overview](../strategies/overview.md)) | | `"wrun-4"` | a frame (`frame(...)`, `panel.*`, `plot.levels`, `plot.matrix`, a `draw.ladder` or `draw.feed` HUD) | the frame channel ([Cards, frames and panels](../presentation/cards-frames-panels.md)) | | `"wrun-5"` | a text setting (`param.text`, `param.text_area`) | the text channel (`wrun_param_len`, `wrun_param_bytes`): a text setting's words, handed over before the first bar ([Text settings](../settings/kinds.md#text-settings)) | | `"wrun-6"` | a `bar.count()` read | the bar count (`wrun_bar_count`): how many bars the run holds, so a bar can tell whether a later bar exists ([Execution model](../core-concepts/execution-model.md#how-many-bars-the-run-holds)) | The second contract's sheet additions, as Run derives them from `input("profile", volume_profile.cells, { max_cells: 256 })` and `input("book", book.cells, { max_cells: 1000, block_size: 10 })` in a tab titled "Orderflow Context": ```json { "id": "orderflow-context", "name": "Orderflow Context", "abi_version": "wrun-2", "params": [], "inputSources": { "close": { "source": "ohlcv", "field": "close" }, "profile": { "source": "volume_profile" }, "book": { "source": "book", "block_size": 10 } }, "inputs": [ { "index": 0, "name": "close" }, { "index": 1, "name": "profile", "cellType": "array", "max_cells": 256 }, { "index": 2, "name": "book", "cellType": "array", "max_cells": 1000 } ], "outputs": [ { "index": 0, "name": "imbalance", "plot": "line", "panel": "lower" } ] } ``` `inputs[].cellType: "array"` marks a celled input, and `max_cells` beside it counts the class's TUPLES (the cap the chart enforces: an oversize bar refuses the run, never truncates); `block_size` and `max_depth` are recorded, and the chart serves the book it has stored as it is. A celled input keeps its positional slot in the scalar block (reading `NaN`), can never be the primary input, and joins the chart's rows exactly: a bar with no observation is a PRESENT EMPTY block (`0` cells, not `-1`), never a carried value and never a withheld row. The classes and each block's rules: [Data sources](../core-concepts/data-sources.md#celled-sources), [Multi-timeframe](../core-concepts/multi-timeframe.md). ## String slots, renderers, drawings Outputs stay numbers; the second contract adds three top-level arrays, derived from `string(...)`, `render.*` and `draw.*`, that turn numbers and per-bar text into chart decorations. Numeric coordinates name declared OUTPUTS, text fields name declared STRING SLOTS, and the two namespaces never substitute for each other. | Array | Entries | | --- | --- | | `string_slots` (at most 64) | `{index, name, max_bytes, description?}`, indexes contiguous from 0, unique names; a slot not written that bar is ABSENT, distinct from a written empty string | | `renderers` (at most 64) | `{kind, name, ...}` with the keys of the matching `render.*` declaration, `kind` one of `text`, `label`, `table`, `shape`, `stats_row`, `bgcolor`, `barcolor` | | `stats_strip` (beside a `stats_row` renderer) | `{grading?, label_side?, theme?}`, the strip's look in its words ([Plotting](../presentation/plotting.md)); a `param.choice` paints a key it holds through `style_targets` and `style_values` | | `drawings` (at most 64) | `{kind, name, ...}` with the coordinate outputs of the matching `draw.*` declaration, `kind` one of `line`, `box`, `polyline`, `label`; x values are epoch SECONDS | What each renderer draws and which row wins: [Plotting](../presentation/plotting.md); a declared drawing is evaluated on the LAST row only: [Drawing objects](../presentation/drawing-objects.md). The expanded render selection must stay under 2 MiB per run, refused as `wrun_render_result_too_large`. A 2x2 session-stats table plus a box around the session's price range. The box's top and bottom are drawn as lines on the price pane: a package with a drawn `overlay` output is homed there, and a declared drawing follows the pane of its y output (`top` for a box), so the box frames the candles while `range_pct` keeps its own pane below. Data-only outputs never decide a pane, so the two x coordinates, bar times, stay `none`. The four cells are rewritten every bar so the table shows the last complete row: ```typescript output("range_pct", line, lower, { unit: "%", description: "Session range as a percent of its low" }); // The box's coordinates, read by name. Its top and bottom are drawn on the price pane: // a drawn overlay output homes the package there, and the box follows its `top` output's // pane, so it frames the candles rather than the percent line. The two times stay data-only. output("left", none); output("top", line, overlay, { color: "#f59e0b", width: 1, description: "Session high so far, the box top" }); output("right", none); output("bottom", line, overlay, { color: "#f59e0b", width: 1, description: "Session low so far, the box bottom" }); // The table's four cells: byte-capped string slots rewritten every bar. string("close_label", { max_bytes: 16 }); string("close_text", { max_bytes: 32 }); string("range_label", { max_bytes: 16 }); string("range_text", { max_bytes: 32 }); render.table("session_stats", { rows: 2, cols: 2, cells: ["close_label", "close_text", "range_label", "range_text"], position: "top_right" }); draw.box("session_zone", { left: "left", top: "top", right: "right", bottom: "bottom", color: "#f59e0b" }); let high: f64 = NaN; let low: f64 = NaN; let left: f64 = NaN; let right: f64 = NaN; function onBar(): void { const close = bar.close(); if (isNaN(high) || close > high) high = close; if (isNaN(low) || close < low) low = close; const t = bar.time(); if (isNaN(left)) left = t; right = t; if (isNaN(low) || low <= 0.0) return; const range = (100.0 * (high - low)) / low; out_range_pct(range); out_left(left); out_top(high); out_right(right); out_bottom(low); str_close_label("close"); sb_clear(); sb_f64(close, 2); str_close_text_sb(); str_range_label("range"); sb_clear(); sb_f64(range, 2); sb_text("%"); str_range_text_sb(); } ``` Because every coordinate output is finite on the last bar, the box exists with `createdBar` = that bar; a module that wants a drawing REMOVED writes `NaN` to a coordinate output on the newest bar. Decorations never change the numbers the outputs carry. ## Validation, in one list When Run checks the derived sheet, the schema refuses, naming the path (the Console puts a param, input, output or string-slot refusal on the declaration it came from). The caps, each with its message, are on [Limits](limits.md); the messages with their fixes are in [Common errors](../faq/common-errors.md). Beyond the caps and the rules above, the schema refuses: | Family | Refused | | --- | --- | | Params | duplicate names; a `type` outside the nine words; a composite with a member missing or doubled; `hide` without `when`; `unit_default` outside `unit`; a `multi` default past its bits; a `when` or `unit_for` naming no param; a `style_targets` output that is not drawn; `unset_at_default` on a target of a setting that is not a `param.color`, or on a non-colour target; a `stats_strip` target the strip does not hold, or painted by anything but a choice over the key's words; a preset or presentation name used twice | | Inputs | non-contiguous or duplicate indexes and names; an input without a source entry, or the reverse; a `time` primary; a lone pin half; `outcome` or `binding` outside odds; a condition id that is not `0x` + 64 hex; a required knob missing (`tenor`, `side`, `token`, `fund`, `publisher`, `series`) or a knob on a source that does not take it; a `delta` other than 5, 15, 25 or 35; `fund: "all"` on `etf_premium`; a `missing` value outside `carry`, `nan` and `zero`, `missing: "carry"` on the primary input, or any `missing` on a celled or `time` source; unknown fields per source | | Celled inputs | `cellType` without the second contract or without `max_cells`; `max_cells` without `cellType`; a celled class under a scalar input, as the primary input, or with a `field` or an `interval` pin (`intrabar` and `candles` excepted); `block_size` or `max_depth` anywhere but `book` | | Outputs and ranges | non-contiguous or duplicate indexes and names; a `format` outside the ten, `decimals` or `signed` without `format`; a `line_style` or `edge_line_style` outside solid, dashed and dotted; a block whose output or slot does not exist; an output badging itself; a style reference to an output that does not exist; range sides equal or not rendered; a range `color_by` without `colors` (bare `colors` is legal: the band sign palette) | | Boxes, segments, alerts | a coordinate, offset or `when` gate naming no declared output; a `panel` outside overlay and lower | | String slots, renderers, drawings | any of the three under the first contract; non-contiguous or duplicate slot indexes and names; a reference to an output or string slot that does not exist; a bgcolor, barcolor or shape ladder half without the other; a bgcolor or barcolor `where` naming no declared output; a text or label `style` outside the eight, a tag key on plain text or `align` on a tag, a corner label with `x` and `y` beside `offset`; a HUD `position` outside the nine anchors or its `columns` outside 1 and 2, a HUD `look`, `chrome` or tile `draw` outside its list | | Styling words | a `theme.` word outside the seven; a `unit` past 8 characters beside a `format` on a pane, panel, level, card, ladder or canvas (an output's `unit` stays unbounded); a `corner_radius` outside 0..32, a `font_weight` or `font_family` outside their words; a look word on a plot kind that does not take it (`step` on a bar, `gradient` beside `color_by`, `split` without both colours, `stack` beside `base`, `char` without `shape: "char"`); a `pane` naming no declared pane, a declared pane with nothing drawn in it or more than 4, a `fill` whose sides sit on two panes, a `z` outside -10..10; a box or fill ladder half without the other, or a packed key beside its `_by` key | <!-- source: https://openmarket.xyz/wrun/reference/limits --> # Limits reference Every ceiling a wrun indicator runs under on the chart, in one place: the declaration caps Run checks when it derives the sheet, the runtime ceilings the browser sandbox enforces on every run, the data bounds, and the publish bounds. Each row gives the exact number, what it protects, the shape that hits it, and the message it produces. Most of them are counts of declarations, because a wrun indicator declares almost everything once. ## Plans Free and Plus differ in how much you keep on your charts and in your account: | | Free | Plus | | --- | --- | --- | | Indicators | 3 across your charts | 10 per chart | | Indicator alerts | 2 | 25 | | Saved scripts | 100 | 500 | At the saved-script limit, saving a new script stops with "Saved-script limit reached (100 on your plan). Delete a script or upgrade to save new ones." ## Declaration caps Checked by Run in your browser when it derives the sheet from your declarations, before anything reaches the chart. A declaration over a cap is refused in the editor's Console, anchored on the declaration that breaks it and naming the field (`boxes.0.top`, `renderers.2.size`). | Limit | Value | What it caps | When you hit it | Refusal | | --- | ---: | --- | --- | --- | | Boxes | 16 per indicator | `box(...)` declarations, each drawn once per bar | one declaration per occurrence instead of one gated declaration | `boxes must declare at most 16 entries` | | Segments | 16 per indicator | `segment(...)` declarations, each drawn once per bar | the same shape | `segments must declare at most 16 entries` | | Renderers | 64 per indicator | `render.text`, `render.label`, `render.table`, `render.shape`, `render.stats_row`, `render.bgcolor`, `render.barcolor` | a footprint-style file with a text tile per price level | `renderers must declare at most 64 entries` | | Drawings | 64 per indicator | `draw.line`, `draw.box`, `draw.polyline`, `draw.label` and the widgets, placed from the newest bar | many run-level objects; a growing list is handles instead | `drawings must declare at most 64 entries` | | Polyline points | 64 pairs | `points` on one declared `draw.polyline` (a polyline handle takes 100,000) | a curve traced point by point in a declaration | `polyline points must be <= 64 pairs` | | Declared alerts | 64 per indicator; `message` and `description` at most 200 characters each; `title` 1 to 120 characters on one line, without control or invisible characters; `text` names a declared string slot, whose words are cut at 200 characters when the alert fires | `alert(...)` declarations | a signal per level instead of one gate output | `alerts must declare at most 64 entries`, `message must be at most 200 characters`, `alert 'cross' title is 121 characters; it takes at most 120`, `alert 'cross' text names 'ghost', which is not a declared string slot` | | Handle defaults | `width` 0.5..20, `border_width` 0..10, `opacity` 0..1, `size` and `font_size` 6..64, `line_style` and `border_style` solid/dashed/dotted, `extend` none/left/right/both, `align` left/center/right, `valign` top/middle/bottom, `padding` 0..64, `corner_radius` 0..32, `glow` 0..32, `max_width` 0..2000 (0 = no wrap), `angle` -90..90, `gradient` 2..8 colours, `arrow` none/start/end/both, `axis_label` none/price (text on a label), `panel` overlay/lower; the legacy `borderWidth`, `borderColor` and `lineStyle` spellings still read, never beside their snake_case twins | the defaults one `handles.<kind>(...)` declaration sets for `line`, `box`, `label` or `polyline`, and the same keys on a declared `draw.line`, `draw.box`, `draw.polyline` or `draw.label` | a label handle no longer needs a string slot: `.mark()` draws an emblem with no text | `handles.line.width: width must be <= 20`, `a line has no text; use price` | | Table geometry | 32 rows x 8 cols | one `render.table`; `cells` must list exactly `rows * cols` slot names; `rows_by` paints the first rows only (a data-only output's value on the table's bar, clamped to 0..`rows`) | a dashboard dump | `table rows must be <= 32`, `table cols must be <= 8`, `table 'stats' declares 2x2 = 4 cells but lists 3`, `render.table 'stats' rows_by names 'v', which is a drawn output; rows_by takes a data-only output (plot none) holding how many rows paint` | | Table look | `width` and each of `column_widths` 0..4096 px (0 = measured, one per column), `cell_padding` 0..64, `offset` within [-200, 200], `border_width` and `grid_width` 0..10, `corner_radius` 0..32, `font_size` 6..64, `opacity` 0..1, a gradient 2..8 colours, `header_rows` at most `rows`, a `colspan` or `rowspan` inside the grid at every cell carrying the slot, every colour ladder in a pair (`<key>_by` + `<key>s`, or `<key>_packed_by`, never both) | the look keys and `styles` entries on one `render.table` ([Styled tables](../presentation/cards-frames-panels.md#styled-tables)) | a span past the last column, a widths list shorter than the columns | `table 'board' lists 1 column widths for 2 columns`, `table 'board' cell 'b' spans 2 columns from column 2 of 2`, `table 'board' declares text_color_by without text_colors (both or neither)` | | String slots | 64 per indicator | `string(...)` declarations | one slot per table cell on a big table | `string_slots must declare at most 64 slots` | | Slot bytes | 1..4096 per slot | `max_bytes` on one slot | a long per-bar readout | `max_bytes must be <= 4096 (the per-slot byte cap)` | | Frames | 8 per indicator, each at most 96 KiB (98,304 bytes) | `frame(...)` declarations and their `max_bytes` | a snapshot per widget instead of one frame per payload | named by field | | Mini-chart grid | 1..12 panels of 0..100 candles; labels 1..24 characters, the title at most 40 | the frame of one `draw.minicharts` | a grid of more than 12 markets or timeframes | `a grid holds at most 12 panels`, `a panel holds at most 100 candles` | | Mini-chart look | `columns` 1..4, `panel_width` 72..320, `panel_height` 48..220, `gap` 0..48, `x` and `y` -2000..2000, `ma_width` 0.5..5 | the options of one `draw.minicharts` | | `columns must be within 1..4` | | Docked profiles | 4 per indicator; a frame of 1..512 rows; `width_frac` 0.05..0.5 or `width_px` 16..600 (never both); `offset` x 0..4096; `thickness_px` 2..40; `opacity`, `outside_opacity` 0..1; `series` 1..8 with unique names; `gradient` 2..8 colours; `border_width`, `value_area_width`, `poc_width` 0..10 (the POC at least 1); `font_size` 6..64; `step`, `scale_max` above 0; `labels_text` 0..24 characters a row, `tooltips` 0..64, `poc_label_text` 1..24 | `plot.levels(...)` and its frame ([Docked profiles](../presentation/cards-frames-panels.md#docked-profiles)) | a level `beside` itself or on the other dock, a `behind_candles` profile on the lower pane, a `scale: "fixed"` with no `scale_max` | `width_px is refused beside width_frac`, `scale_max needs scale 'fixed'`, `wrun_frame_invalid` for a frame whose series count or shape disagrees with the declaration | | Level spans | 64 spans per frame, 1..512 prices each, 4096 rows across every span | a `plot.levels` frame under `span: "time"` | one span per bar instead of one per session | `wrun_frame_invalid: <frame>.spans` | | Panels | 8 per indicator; `series` 1..8 (a table 1..12); frame rows 2000 (a table 128, a pie or tiles 24); `markers` and `guides` 0..8; `summary` 0..5 rows; `height_frac` 0.05..0.9; `title` 1..40, `caption` 1..64; `x_title`, `y_title` 1..40; `width` 0..4096, `column_widths` 0..4096 each (one per column), `cell_padding` 0..64, `offset` -200..200, `font_size` 6..64, `border_width` and `grid_width` 0..10, `background_gradient` 2..8, `palette` 2..8, `bins` 2..200, `columns` 1..8, `slice_gap` 0..8, `trail_width` 0.5..6, a series `width` 0.5..20, marker and guide `width` 0.5..4, labels 1..24 | `panel.<kind>(...)` and its frame ([Frames, panels and compact widgets](../presentation/cards-frames-panels.md#frames-panels-and-compact-widgets)) | a 9th marker, a word on a kind that does not take it, a rich-line word on a boxed line | `markers allows at most 8`, `table allows at most 128 rows`, `series allows at most 12 columns on a table`, `<word> is only valid for <kinds>`, `<word> needs chrome 'grid' or 'none' on a time, category or stacked line` | | Side strip | `width_px` 160..480, `height_px` 80..800, both with `place: "side"` only; the strip takes at most 40% of the chart and folds away under 480 px | a panel placed beside the chart or a matrix with `dock: "side"` | a side panel on a narrow layout | `width_px is only valid with place: 'side'` | | Heatmaps | 4 per indicator; `palette` 2..8 colours; `auto_quantile` 0.5..1; `cell_gap` 0..4; `opacity` 0..1; `tooltip` 1..200 characters, `label` 1..40; `out.grid` rows 2..128, and the grid's outputs count toward the 256 | `plot.heatmap(...)` and `out.grid(...)` ([Price canvases](../presentation/price-canvases.md)) | a grid with a row per tick, `cells` beside `grid` | `heatmaps must declare at most 4 entries`, `out.grid 'heat' rows must be between 2 and 128`, `heatmap 'h' takes cells or grid, not both` | | Profiles | 4 per indicator across footprints, TPOs and time-anchored profiles; `imbalance_ratio` 1.1..20; `stacked_imbalances` 0..10; `value_area` 0.5..0.95; `poc_width` 1..10; `letter_minutes` 1..240; a TPO `palette` 2..26; `width_frac` 0.05..1; `outside_va_opacity`, `background_opacity` 0..1 | `plot.footprint(...)`, `plot.tpo(...)`, `plot.profile(...)` | a footprint over book cells, a TPO colour mode that needs profile cells | `footprint 'fp' does not take period`, `tpo 't' color_mode 'volume' needs cells from a volume_profile input`, `profile 'p' behind_candles needs span 'session' and a filled mode` | | Matrices | 4 per indicator, `wrun-4` only; a frame of 1..128 prices, 1..12 columns and at most 1536 cells; cell text and column labels 1..16 characters; `column_width` 24..160; `row_max_px` 8..64; `cell_padding` 0..12; `tooltip` 1..200, `label` 1..40 | `plot.matrix(...)` and its frame | a matrix on a `wrun-3` sheet, a 13th column | `matrices needs abi_version "wrun-4" (wrun-3 has no frame channel)`, `wrun_frame_invalid: <frame>.cells` | | Widgets | 8 cards per indicator and 32 cards, feeds and meters per pane; a card's `rows` 1..12 (0..12 with a headline); `title` 1..40 (80 with a template), a row `label` 1..24 (64 with a template), a literal value 0..64; `offset` -4096..4096; `width` 80..1200; `corner_radius` 0..32; `padding` 0..24; `border_width` 0..10; `font_size` and `title_font_size` 6..64; `background_gradient` 2..8; `state_colors` exactly 4; a ladder frame 1..256 rows, `width_frac` 0.02..0.5, `offset` x 0..4096 with y 0; a feed 1..50 lines of 1..80 characters; a meter `ramp` 2..5, `bar_height` 2..40 | `draw.card`, `draw.feed`, `draw.meter`, `draw.ladder` ([Status cards](../presentation/cards-frames-panels.md#status-cards)) | a card with neither rows nor a headline, `accent_color` beside `state_colors` | `card 'x' needs a row or a headline`, `title takes 1 to 40 characters, 80 with a {{template}}`, `offset entries must be integers from -4096 to 4096`, `accent_color and state_colors are exclusive` | | HUD cards | `width` 80..1200 (300, or 180 at one column); `offset` -4096..4096; `corner_radius` 0..32; `padding` 0..24; `border_width` 0..10; `text_glow` 0..8; `accent_colors` 2..8; one headline tile per card; `rings` 1..3; a tile `font_size` 6..64, a tile `height` 16..120 | `render.hud(...)` and its tiles ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards)) | two headline tiles, a fourth ring | `a HUD takes at most one headline tile`, `look must be one of 'default', 'glass', ...` | | Number format | `decimals` 0..8; `unit` at most 8 characters on panes, panels, levels, cards, ladders and canvases; `decimals` and `signed` only beside `format` (an output's `unit` stays unbounded with or without `format`, as it always was; without `format` it is never printed) | every `format` word (outputs, panes, panels, levels, cards, ladders, canvases) | a unit longer than 8 characters on a pane, panel, level, card, ladder or canvas | `decimals must be an integer from 0 to 8`, `unit takes at most 8 characters`, `decimals needs format` | | Text size | 6..64 px | `size` on `text` and `label` renderers, and every `font_size` | | `size must be >= 6`, `size must be <= 64`, `font_size must be an integer from 6 to 64` | | Type words | `font_family` ui/mono/serif/rounded, `font_weight` normal/medium/bold, `valign` top/middle/bottom, `gradient_direction` vertical/horizontal, `corner_radius` 0..32 | every surface that takes them (panels, levels, cards, feeds, meters, ladders, HUDs, handles, drawings, renderers, canvases) | a CSS family name, a numeric weight | `font_family must be one of 'ui', 'mono', 'serif', 'rounded'`, `font_weight must be one of 'normal', 'medium', 'bold'`, `corner_radius must be between 0 and 32` | | Display offset | -500..500 bars, integer | `displacement_bars` on an output | a lagging span longer than 500 bars | `displacement_bars must be >= -500`, `displacement_bars must be <= 500`, `displacement_bars must be an integer count of bars` | | Shape offset | -500..500 bars, a whole or fractional literal | `from` / `to` on a box or segment | a zone anchored further back than 500 bars | `a literal bar offset must be within -500..500 bars of the current bar` | | Width ladder | 1..10 entries, each 0.5..20 (0.05..20 on bar and histogram) | `widths` beside `width_by` | | `widths must list at most 10 entries`, `widths entries must be <= 20` | | Line width | 0.5..20 | `width` on an output or a segment | | `width must be <= 20` | | Border width | 0..10 | `borderWidth` on a box | | `border_width must be <= 10` | | Edge width | 1..10, integer | `edge_width` on a range | | `edge_width must be <= 10` | | Gradients | 2..8 colors | `gradient` on a range, a line or a docked profile, `fill_gradient` on an area, `background_gradient` on a widget, a HUD or a table, a box's `gradient` | | named by field, `<key> must list 2 to 8 colours` | | Opacity | 0..1 | `opacity` on an output or a box (box default 0.2) | | named by field | | Color string | 1..64 chars, or a theme token | any `color` on a box, segment, renderer, or drawing; `theme.up`, `theme.down`, `theme.text`, `theme.muted`, `theme.bg`, `theme.grid` and `theme.accent` pass everywhere a colour goes (a colour param's default and presets excepted), and any other `theme.` word is refused | a `theme.` word outside the seven | `color must be a non-empty string of at most 64 chars`, `colour token must be one of theme.up, theme.down, theme.text, theme.muted, theme.bg, theme.grid, theme.accent`, `a colour param takes #rrggbb or #rrggbbaa; put the token on the colour key` | | Colour word | `#rrggbb`, `#rrggbbaa` or a theme token | every colour key this round added (panels, levels, cards, feeds, meters, ladders, HUDs, canvases, and the frames they read) | `rgba(...)` or a named colour on a panel series | `a colour must be #rrggbb, #rrggbbaa or one of theme.up, theme.down, theme.text, theme.muted, theme.bg, theme.grid, theme.accent` | | Box fill color | hex, `rgb()`, or `hsl()` | a box `color` (the fill takes the opacity) | a named color such as `"red"` | `color must be a hex, rgb() or hsl() color (the fill takes the opacity)` | | Palette | at least 2 entries | `colors` beside `color_by` | | `color_by needs 'colors' with at least 2 entries` | | Celled cap | required, positive integer, counts TUPLES | `max_cells` on a celled input | a celled input declared without it | `celled input 'profile' needs max_cells, e.g. input("profile", volume_profile.cells, { max_cells: 512 }) ...` | | Bucket size from a setting | a `param.int` whose `min..max` lies inside 1..500 | `ticks_per_bar: "@<param>"` on a `volume_profile.cells` input | an open-ended setting, or one that reaches past 500 | `input 'profile' ticks_per_bar references "@ticks", whose min..max (open..open) must lie inside ticks_per_bar's range 1..500` | | Colour alpha | a decimal from 0 to 1 after the setting's name, `"@<param>/0.4"` | every colour bound to a setting: the default lands as `rgba()` where the colour takes it, as `#rrggbbaa` where it takes a colour word | a panel series colour, which binds without an alpha | `whose alpha is not a decimal from 0 to 1 ("@bull/0.4")`, `a panel series colour binds without an alpha` | | Outputs | 1..256 | output declarations; the sandbox tracks 256 output slots per run (a file whose visuals are all frames may declare none) | a file past 256 outputs, or a raw write past the tracked slots | `WRUN output index 300 out of bounds for 256 tracked outputs` | | Settings | 128 per indicator, after expansion: most kinds count 1, a `unit` list on a number 1 more, a `range` 2, a `session` 3, a `list` its `max` + 1, a `draw.minicharts` grid that binds no setting 9 to 16 (its look's own settings) | `param.<kind>` declarations and each hidden `market.*` setting; OpenMarket's cloud accepts at most 128 settings per indicator, so the build refuses the declaration that crosses the line ([Picks, lanes, the cap](../settings/picks-and-lanes.md)) | a list whose `max` alone crosses 128 (a list's max counts its slots and the count, so `max` is at most 127), or many composites | `the sheet would carry 129 params after expansion (a range derives two, a list max + 1, a session three, a unit list one more); hosted lanes cap params at 128` | | Text settings | `max_bytes` 1..4096 UTF-8 bytes per setting, 256 when left out; the default within it and free of invisible and control characters | `param.text` / `param.text_area` declarations ([Setting kinds](../settings/kinds.md)) | a default longer than its cap, a pasted name carrying a bidi control | `param.text 'label' default is 12 UTF-8 bytes, over its max_bytes 4`, `param.text 'label' default carries a newline, a control or an invisible character` | ## What the chart trims when it draws These limits never refuse a run. The chart applies them when it draws, so one indicator can't bury another, hide the chart's own buttons, or draw more rows than a screen can show. | Limit | Value | What happens past it | | --- | --- | --- | | Drawing and label text | 256 characters | the text is cut and ends with an ellipsis | | Drawing order | `zorder` -100..100 | a higher value draws as 100, a lower one as -100 | | Drawings per run | 2,000, counting every bar a segment draws, every declared drawing and every live handle | the newest 2,000 are drawn and the oldest are left out; the indicator's legend row says "Drawings capped", and its card says how many of the total were drawn | | Widgets | 16 cards, feeds, meters and ladders per indicator per pane | the ones past 16 are not drawn | | Legend entries | 32 per indicator, each 64 characters | entries past 32 are not shown; longer text ends with an ellipsis | | HUD cards | inside the price pane: below the symbol row and the legend, left of the price axis | an `offset` that would move a card out is clamped; a card under 0.15 opacity lets clicks through | | Card controls | `controls: "none"` | the glyphs stay hidden at rest; hovering always shows hide and collapse | | TPO rows per period | 512 per day, week or month | the chart doubles the row height until every period fits, at most 6 times, for `"auto"` and a number alike; every run says so with a warning row in the Console and the warning on the legend chip: `tpo 'tpo': row_height 1 coarsened to 8 (512 rows per day is the chart's limit)` ([Row height](../presentation/price-canvases.md#row-height)) | | TPO rows on screen | 3 px | thinner rows merge on the price grid: a merged row's letters are the union of its rows' letters, its counts and volumes add up, and the POC and the value area are computed on the merged rows | | TPO blocks | 262,144 per TPO | the blocks past it are not drawn, and the Console says so | | Heatmap cells per indicator | the chart's budget, across all of an indicator's heatmaps, the loaded history and the live bars together (one cell per book level, grid row or profile bucket on each bar) | the oldest bars are dropped and the newest are drawn whole; the indicator's legend row says "Cells capped", and its card says how many bars are drawn | Names share one namespace across outputs, boxes, segments, renderers, and drawings; `color_by`, `width_by`, and `shape_where` cannot name their own output; each ladder half without the other is refused by name. The full validation list is at the end of [Declarations and the sheet](declarations.md#validation-in-one-list). ## Runtime ceilings Enforced on every run by the sandbox your browser runs the module in. A breach refuses the WHOLE run by name and the editor's Console says why; nothing is truncated, clamped, or silently dropped. An indicator that already drew keeps its last good render, and its legend offers a retry. | Limit | Value | What it protects | When you hit it | Refusal | | --- | ---: | --- | --- | --- | | Module memory | 4 MiB (4,194,304 bytes), fixed | the linear memory a module uses | a buffer sized far past any param's `max` | `WRUN memory 8388608 bytes exceeds limit 4194304 bytes` | | No growth after `onStart()` | 0 bytes of growth | flat per-bar memory across a long history and the forming bar's replays | an array that grows per bar, string concatenation in `onBar()` | "The Indicator allocated memory after init() (65536 to 131072 bytes): the sandbox forbids growth once the bars start." | | Output values | finite or NaN | a chart and an alert that mean something | a division by zero written as `Infinity` | `WRUN output 0 is Infinity; guard the calculation and emit NaN when the value is undefined` | | Run deadline | 20 s per run (the compile has its own 15 s) | one runaway module never holds the sandbox: the worker is stopped and replaced | unbounded loops, deep recursion | "The run did not finish within 20 s." | | Cell block | `max_cells` tuples per bar, per celled input | the one buffer of `max_cells` tuples the build allocates per celled input when the module starts; each bar's block is read into it once, and `in_<input>_view()` reads it in place | a deeper book than the cap; a profile with more price buckets than the cap | `... input 1 bar at ts 1725580800000 has 1200 cells; max_cells is 1000, so the evaluation is refused (a block is never truncated)`; a volume profile declared `missing: "empty"` is not refused: that bar reads an empty block (`in_<input>_cells()` is `0`) and the run goes on | | TPO rows per bar | 4,096 | the rows one bar expands into at a TPO's row height | a declared `row_height` far below one bar's range (`"auto"` stays under it) | `tpo 'tpo': row_height 0.05 expands one bar into 5001 price rows, above the 4096-row limit (declare a larger row_height)` | | Cell read bounds | inside exported memory | a copy that would overrun the module's memory | a pointer outside the preallocated buffer | `WRUN wrun_arg_bytes(1, 65536): 1536 bytes do not fit the module's exported memory of 65536 bytes; reserve max_cells * 8 bytes per celled input and pass a pointer inside that buffer` | | Cell phase | `onBar()` | the cell readers answer for the bar being evaluated; before the first bar (in `onStart()`, at module start) the count reads `-1` and there is no block | the host's cell import called in another phase, which the generated readers never do | `WRUN wrun_arg_len is callable during state() only (called during finalize); read cell blocks inside state() and carry what finalize() needs in module state` | | String phase | `onBar()` | slots belong to the row being committed | a string sent outside `onBar()` | `WRUN wrun_output_str is callable during finalize() only (called during state); ...` | | String slot bytes | `max_bytes` per slot (at most 4096) | the buffer reserved per slot | a line longer than the slot | `WRUN string slot 0 write of 40 bytes exceeds the slot's max_bytes 32; strings are never truncated; ...` | | String bytes per row | 65,536 (64 KiB) | one bar's text across all slots | many slots near their cap on one bar | `WRUN string writes for one row total 70000 bytes, over the per-row limit 65536; emit less text per bar` | | Strings and frames per run | 2,097,152 (2 MiB), together; a live session counts as one run | one run's text and frame snapshots across all bars | long readouts on every bar of a long history | `WRUN string and frame writes for this run total 2100000 bytes, over the per-run limit 2097152; emit less text or fewer rows` | | UTF-8 | valid sequences only | a slot never carries a replacement character silently | raw bytes sent through the host import | `WRUN string slot 0 write of 5 bytes is not valid UTF-8; wrun_output_str carries UTF-8 text only (encode before writing)` | | Expanded render result | 8 MiB (8,388,608 bytes) | the selection the chart receives after the run: 16 bytes per selected entry, 8 per carried number, the UTF-8 bytes of every carried string (table cells charge 8 more per cell; handles and a strategy's result count too) | many renderers over a long history with long strings; hundreds of live polylines | `WRUN render result exceeds 8388608 bytes expanded (wrun_render_result_too_large): fewer rows, shorter strings, or fewer renderers/drawings` | | Live handles per kind | 500 | the handles of one kind alive at once, counted on every creation | a zone tracker that never deletes | `wrun_draw_kind_limit`: `500 live box handles already exist; the cap is 500 per kind ...` | | Live handles in total | 1500 | every kind together | the same shape across kinds | `wrun_draw_total_limit`: `1500 live handles already exist; the cap is 1500 in total ...` | | Polyline handle points | 1..100,000 per call | the points one `setPoints` sends; the buffer must fit the module's memory | a path that never trims its oldest points | `wrun_draw_polyline_too_large`, `wrun_draw_polyline_empty`, `wrun_draw_polyline_out_of_bounds` | | Polyline points per run | 524,288 across the live polylines | the points every live polyline holds, checked on each `setPoints`: a re-sent path gives its old points back first and a deleted one frees its own | hundreds of long paths kept alive at once | `wrun_draw_polyline_budget` | | Draw calls per bar | 4096 | every handle call on one bar | re-sending every live handle on every bar | `wrun_draw_calls_per_row`: `more than 4096 draw calls on one row ...` | | Draw phase | `onBar()` | handle ops belong to the row being committed | a handle operation outside `onBar()` | `wrun_draw_phase` | | Handle identity | ids `>= 0`, one space across kinds, kind declared in `handles`, coordinates finite | the ledger the chart mirrors, kind by kind | a negative id; a label on a box's id; a kind the file does not declare; a `NaN` corner; a setter on an id nobody holds | `wrun_draw_id_negative`, `wrun_draw_kind_mismatch`, `wrun_draw_kind_undeclared`, `wrun_draw_non_finite`, `wrun_draw_handle_missing` | | Handle style values | the prop's range, integer where required, legal on the kind | the same setter table the engine enforces | `size` on a line; `width` of 30; `style` of `7` | `wrun_draw_prop_unsupported`, `wrun_draw_style_out_of_range`, `wrun_draw_prop_unknown` | | Handle label text | a declared slot, written on the bar of the draw call | the text a label carries is the slot's bytes at call time | `text(...)` before the slot's sender ran that bar | `wrun_draw_label_slot_absent`, `wrun_draw_label_slot_undeclared` | The module contract is checked by Run before any of this: the four engine functions the build adds around `onBar()` with exact signatures (a file that exports them itself is checked the same way, [The four-function form](../core-concepts/execution-model.md#the-four-function-form)), exported `memory` when the cell, string, or polyline channel is imported, every present draw import with its exact signature, and an import set limited to the contract's allowlist. Before a module runs, the chart checks its bytes against the sheet's `wasm_sha256` and refuses a mismatch. Those messages are in [Common errors](../faq/common-errors.md). ## Data bounds | Limit | Value | Where | | --- | ---: | --- | | History | the bars the chart has loaded | every source is fetched over the chart's loaded window, and a pan back runs again over the extended window; warm-up is the file's decision: an output `onBar()` leaves unwritten is NaN and draws nothing | | Live updates | the forming bar, about once a second at most | ticks in between are coalesced and the last one is never dropped; only the forming bar is replayed; a live gap wider than 250 bars rebuilds the run | | Source deadline | 45 s per source | a source that does not answer in time fails the run with its name | | Empty source | refused | a required source that answers empty fails the run: "<Label> data is unavailable for '<title>' (the <type> source lane answered empty or was declined), so the indicator cannot compute." | | Coarse pins | as of the coarser candle's close | a pinned `interval` contributes to a row only once its candle has closed; that market's candles are fetched from two leg spans before the chart's first bar | | Pinned intervals | coarser than the chart's and a whole multiple of it (`WEEK` counts 7 days) | any other interval pin is refused by name before any fetch | | Pinned venues | crypto, `POLYGON`, `FX_OTC` | stocks and ETFs on `POLYGON`, forex, gold and silver on `FX_OTC` are served like a crypto pin; an index pin (`POLYGON_INDICES`) reads its `missing` fill with a warning row; a CME Group pin is refused by name ([Multi-source](../core-concepts/multi-source.md#stocks-forex-and-gold)) | | Multi-day pins on session venues | `3d` refused | a `3d` candle on a stock, forex or gold market groups trading days, so the pin is refused before any fetch (`wrun_pin_session_interval_unserved`); pin `1d` or `1w` | | Intrabar | finer than the chart's interval and dividing it evenly, `1m` at the finest | so the chart must be 2 minutes or coarser; `max_cells` at least the finer bars in one chart bar; closed finer bars only | | Book depth | at most 500 levels each side | the chart's own order book at its own grouping, whatever `block_size` and `max_depth` say, so `max_cells: 1000` covers any bar | | Options chain | the live row only | the chain arrives on the last row; size `max_cells` for the chain (a BTC chain is about 1,550 contracts) | | Cell memory, per celled input | `max_cells` x tuple width x 8 bytes | 4 f64 per `volume_profile` tuple, 3 per `book` tuple, 6 per `intrabar` bar, 10 per `options_chain` contract, all inside the 4 MiB of module memory; the build allocates this buffer once per celled input when the module starts, reads each bar's block into it once, and `in_<input>_view()` reads it in place (a copy through `in_<input>_read(ptr)` holds the block twice) | | Alerts | 600 bars of the chart's interval, at most | the window is the larger of the file's `warmup()` and its largest setting maximum (up to 500); an indicator that reads a coarser pin gets all 600 unless it also reads `book` or `volume_profile` cells, and those bars hold 600 / (leg / chart) candles of the pin (a 4h pin: 150 on 1h, 37 on 15m, 12 on 5m), so a longer higher-timeframe average arms but stays empty; another market's candles on a secondary `ohlcv` input and a coarser `1m` to `1w` pin that is a whole multiple of the chart's interval are served; a pin on the first input, a finer pin, a custom timeframe and a source alerts cannot evaluate are refused when you save the alert ([Alerts](../functions/alerts.md)) | | Strategy order ids | 64 distinct per run (`closeAll` counts as one) | the 65th distinct id refuses the run (`wrun_strategy_id_limit`); an id is a slot, re-issuing one replaces its pending order | | Strategy id text | 64 bytes of UTF-8 per id, `from` name or OCA name | longer text refuses by name (`wrun_strategy_id_too_long`), never truncated; invalid UTF-8 refuses (`wrun_strategy_id_invalid_utf8`) | | Strategy calls per bar | 4096 order calls and getter reads per bar | over it refuses (`wrun_strategy_calls_per_row`); an infinite price refuses (`wrun_strategy_non_finite`), NaN is absent | | Closed trades per run | 10000 | the 10001st closed trade refuses on the bar it closes (`wrun_strategy_trade_limit`); every engine rejection (pyramiding, legs, sizing, margin, the forming bar) is counted in `rejectedOrders`, never a refusal | | Strategy result bytes | inside the 8 MiB expanded render result | the trades, orders and equity together with the drawings (`wrun_render_result_too_large`) | | Strategy feed | the price input reads `ohlcv` on the chart's own market, no symbol or exchange pin | refused by name (`wrun_strategy_feed_not_ohlcv`); `slippageModel: "bookEstimate"` refused (`wrun_strategy_slippage_model_unsupported`); `strategy.position` and `strategy.equity` are reserved output names (`wrun_strategy_reserved_output`) | ## Publish bounds | Limit | Value | | --- | ---: | | Open source code | 256 KB ("The source is {size} KB; the limit is {limit} KB.") | | What changed | one line of up to 280 characters, from the second version | | Name | lowercase letters, digits, and dashes after your `@yourname/` scope; fixed after the first publish | The module runs sandboxed: no filesystem, no network, no order capability. Each wrun indicator on a chart, a draft from Run included, counts against the chart's indicator limit. [Publishing](../functions/publishing.md) has the dialog. ## Other bounds Bounds that are not a number in the tables above. | Shape | In wrun | | --- | --- | | Sources per indicator | No per-indicator source budget. On the chart, a wrun indicator counts against your plan's per-chart indicator limit once per distinct source it reads ([Data sources](../core-concepts/data-sources.md)). Each source is fetched once per run over the chart's loaded bars, however many inputs read it; a pinned market or interval on `ohlcv` adds its own fetch, a `funding` or `oi` interval pin adds none. | | Output objects per run | No object count. Every output is one number per bar, in at most 256 slots; decorations are declared once. Every bar emits a row, with NaN in each output `onBar()` did not write, so the leading NaN rows of a warm-up count toward the per-run budgets above. What reaches the chart is capped by the 8 MiB expanded render result, and past 2,000 drawings in a run the chart draws the newest 2,000. | | Table cells | 32 x 8 = 256 cells per table renderer, each a string slot; 64 slots per indicator. A `panel.table` over a frame takes 128 rows by 12 columns instead, and a `plot.matrix` 128 prices by 12 columns within 1536 cells. | | Collection size | No element cap. Collections live inside the 4 MiB module memory and are allocated before the first bar, at module start or in `onStart()`. | | Nesting and recursion depth | The compiler's own rules apply; the module's stack lives inside its memory, and the 20-second run deadline stops runaway recursion. | | Backtest bars and runs per day | No grant to spend: the chart's Strategy Tester runs a wrun strategy in your browser over the bars the chart has loaded and recomputes as you pan ("This strategy runs locally over the chart window and recomputes as you pan."), [Reading the Strategy Tester](../strategies/reading-the-tester.md). | | Strategy declaration bounds (`leverage`, fees, funding) | Checked by Run with the engine's rules: `initialCapital` and `leverage` more than 0, `pyramiding` an integer of at least 1, `maintenanceMarginPercent` at least 0 and under 100, the fee and slippage rates at least 0, enum fields the engine's literals. A setting linked to a param is checked once before the first bar and falls back to the default when out of range, reported. [Writing strategies](../strategies/writing-strategies.md) has the table. | ## Notes that save debugging time - **`max_cells` is a contract, not a hint.** The build sizes one buffer per celled input from it (`in_<input>_capacity` f64s, tuples x width) when the module starts, and `in_<input>_view()` reads it in place; size a `book` input for the chart's depth: at most 500 levels a side, so 1000 tuples. - **Strings never truncate.** Pick `max_bytes` for the longest line the slot will ever carry; the generated builder counts the bytes a line REQUIRES, so an oversize line refuses by name instead of clipping. - **Most caps are counts of declarations.** You meet them when you press **Run**, in the editor's Console, anchored on the declaration. A repeating shape is one declaration gated per bar with `when`, never one declaration per occurrence. - **Segments count as drawings.** A segment drawn on every bar of a long window is the shape that reaches 2,000 drawings per run; gate it with `when` or narrow its span. - **The render cap counts expanded bytes.** Sixty-four text renderers over a long history with long strings is the shape that reaches 8 MiB; leave slots unwritten on quiet bars so those rows select nothing. - **Delete what you no longer draw.** A handle stays alive until `delete()`; the per-kind cap is met by the tracker that creates a zone on every pivot and never frees one. Keep a bounded pool of ids and delete the oldest before reusing its id, and re-send only the handles that changed on a bar (the calls-per-bar cap counts every setter). - **Allocate once.** Buffers are sized at module start or in `onStart()` from a param's `max`; per-bar allocation is the one pattern that both slows a long history and trips the no-growth rule. ## Example: the caps as contracts A book-imbalance indicator that sizes everything from what the chart delivers: the chart's order book carries at most 500 levels a side, so `max_cells` is 1000; the cell buffer is the build's own, sized from that cap and read in place; and the readout slot is sized for its longest line: ```typescript input("close", ohlcv.close); // The chart's order book carries at most 500 levels a side, so a bar never holds more than 1000 tuples: // max_cells is a contract, and a bigger block would refuse the whole run. // block_size is required by the declaration; the chart keeps its own price grouping. input("book", book.cells, { max_cells: 1000, block_size: 10 }); output("imbalance", line, lower, { unit: "ratio" }); // The longest line is "500 bids / 500 asks" (19 bytes); 32 leaves room and stays under the 4096 cap. string("depth", { max_bytes: 32 }); render.text("depth_mark", { y: "imbalance", text: "depth", size: 10 }); function onBar(): void { const n = in_book_cells(); if (n <= 0) return; // The build's own buffer, sized once at module start: max_cells x 3 cells per [price, size, side] tuple. const cells = in_book_view(); let bidSize = 0.0; let askSize = 0.0; let bids: i32 = 0; let asks: i32 = 0; for (let i = 0; i + 2 < n; i += 3) { if (cells[i + 2] > 0.0) { bidSize += cells[i + 1]; bids += 1; } else { askSize += cells[i + 1]; asks += 1; } } const total = bidSize + askSize; const imbalance = total > 0.0 ? (bidSize - askSize) / total : NaN; if (isNaN(imbalance)) return; out_imbalance(imbalance); sb_clear(); sb_int(bids); sb_text(" bids / "); sb_int(asks); sb_text(" asks"); str_depth_sb(); } ``` The scan branches on each tuple's `side`, never on its position: the chart lists the bids best first, then the asks from the farthest to the best. <!-- source: https://openmarket.xyz/wrun/reference/limitations --> # Limitations What a wrun indicator does not do in the chart, and where to find the numbers. An indicator is a sandboxed, declared computation over market data; a few things are deliberately out of scope, and knowing them up front saves you from designing around a feature that is not there. ## Not supported today <!-- wrun:cards --> **Numbers only in outputs.** Every output is a 64-bit float, and `NaN` is the one "nothing here" value. Text reaches the chart only through string slots and renderers (the second runtime contract, which **Run** derives for you when you declare one). Decisions are numbers too: `1` or `0` in a data-only output, turned into a look by a declaration (`shape_where`, `color_by`, a box's `when`). **Settings are typed, text included.** A setting is `param.<kind>(...)`: a whole number, a number, a toggle, a menu, a color, a time or a price picked on the chart, a range, a multi-select, a list, a price field, a timeframe, a market, a session or words, each drawn by the overlay's settings dialog as its own control and laid out in pages and sections ([The settings dialog](../settings/overview.md)). A `param.text` or `param.text_area` takes plain words within its `max_bytes` ([Setting kinds](../settings/kinds.md#text-settings)). A sheet holds at most 128 settings after a range (2), a session (3), a list (`max` + 1) and a unit menu (1 more) expand. A source, timeframe or symbol pick is applied by the chart when the Indicator runs in the browser; an Indicator that runs on OpenMarket's servers keeps the declared default for those three, and an alert refuses to arm while one is off its default. Changing a setting runs the module again over the loaded bars without recompiling. **Pins are served with restrictions.** An unpinned input follows the chart's own market and interval. The chart checks every pin before it fetches anything and refuses the ones it cannot serve by name: - a market pin (`symbol` and `exchange` together) only on a secondary `ohlcv` input; every other source and every celled input follows the chart's own market, except `odds`, which names its Polymarket market by condition id; - an interval pin only coarser than the chart and a whole multiple of it, on a secondary `ohlcv` input, an `odds` input, or `funding` and `oi`; - no market or interval pin on the first input: it follows the chart (an `odds` input may come first and still names its market); - a `forming` view up to `WEEK`, and never a view without an interval pin. Pins on stocks and ETFs (`POLYGON`), forex, gold and silver (`FX_OTC`) are served like crypto pins, on any chart. An index pin reads `NaN` with a warning row, and a CME Group pin or a `3d` pin on a stock, forex or gold market is refused by name. [Multi-timeframe](../core-concepts/multi-timeframe.md) and [Multi-source](../core-concepts/multi-source.md) have the rules in full. **No market picker.** The chart has no picker for a market chosen per use: an `odds` input declared with `binding` is refused ("the chart has no market picker yet; pin the market's condition id (0x…) in the input's symbol"). Pin the Polymarket market by its condition id instead. **No live prints.** The chart does not serve `tape` (individual prints); it is refused by name ("celled source class 'tape' is not served by the browser lane yet"). Side-split volume per bar is the `trades` source with `side: "BUY"` or `"SELL"`, a CVD is that pair accumulated in the module, and the split by trade size is `trade_volume_by_size` ([Data sources](../core-concepts/data-sources.md)). **No other indicator's output.** Reading another indicator's output as an input has no declaration at all, and neither does a `series` source, so a file in the editor cannot ask for either. **Some feeds are the chart's alone.** The option summaries beyond implied volatility and skew (`volatility_index`, `options_oi`, `options_volume`), `long_short_ratio`, the ETF feeds, `ethena_positions`, `bitfinex_funding`, `treasury_balance` and `economic` are served when the indicator runs in the browser, like `etf_flow`. An alert on an indicator that reads one is refused. **The options chain is the live row's only.** `options_chain` fills the newest row with the current chain (a 30-second snapshot, polled live) and leaves every history row an empty block, so a gamma map over past chains has no form yet. `venue` picks the chain: `auto` (the chart's own options venue, else the coin's Deribit chain), or one venue's (`deribit`, `cme`, `binance`, `okx`, `bybit`, `bullish`, `derive`), refused by name on a chart that venue cannot serve. **The order book is the chart's.** `book` cells come from the book the chart loads for its own market, up to 500 price levels a side, so a full book is up to 1,000 tuples; size `max_cells` for that. The declaration requires `block_size`, but the chart does not read it or `max_depth`, so neither changes what it fetches ([Order flow](../functions/order-flow-kit.md)). **Drawings have fixed budgets.** Boxes and segments are declared once and evaluated on every bar, 16 of each. Drawing handles (line, box, label, and polyline) are objects the module creates, moves, and deletes across bars, capped at 500 live per kind, 1,500 in all, 100,000 points per polyline and 524,288 across the live polylines, and 4,096 draw calls per row; there is no table handle (a table is `render.table`). One run draws at most 2,000 drawings on the chart; past that the chart draws the newest 2,000, says "Drawings capped" on the indicator's legend row, and its card says how many were drawn ([Drawing objects](../presentation/drawing-objects.md)). **No history array.** `onBar()` sees one bar, which is also why an indicator cannot look ahead by accident ([Execution model](../core-concepts/execution-model.md)). There is no `[]` operator on a series, and `History` from `./sdk/stats` keeps the window for you: `push()` the value once per bar and `ago(n)` reads it `n` bars back ([Stats, history and lists](../functions/stats-history-lists.md)). **No persistence across runs.** Module state lives for one run over the loaded bars. Nothing survives a reload, a new **Run**, or a pan that loads older bars (the chart runs the module again over the wider window); anything you need is recomputed from the loaded history. A draft's overlay lasts for the session and is not saved with the layout: **Run** it from the editor again to bring it back. **No cross-indicator communication.** Each overlay runs its own module. Two wrun indicators on the same chart share no state, and one cannot read another's outputs; combine the logic into one file. **No orders from the file.** The module has no filesystem, no network, no clock, and no order capability. A strategy's orders fill only in the Strategy Tester's simulated broker, against the chart's own candles; nothing reaches an exchange ([Strategies overview](../strategies/overview.md)). **One file per indicator.** Helpers, classes, and constants live in the one editor tab beside `onBar()`. The kit and the accessors generated from your declarations are the only names there are, so there are no library imports, and a `//@file=` line is refused ([Publishing](../functions/publishing.md)): ```text One file per indicator for now. Move the code from <file> into this tab and remove the //@file line. ``` **History is what the chart loaded.** The module sees exactly the candles the chart has loaded, and every source is fetched over that window; there is no separate warm-up fetch, so a long lookback needs the chart to load more bars. An indicator runs at any chart interval from 1s to 1Y, but on a 1s chart the sources not served at 1s are declined, prediction-market odds need a one-minute chart or coarser, and `intrabar` cells come from bars no finer than 1m, so they need a chart of 2m or coarser. **Alerts read pins within 600 bars.** An alert needs a published version and runs in OpenMarket's cloud on the chart's market and interval with the overlay's settings, over at most 600 bars of that interval. It serves another market's candles on a secondary `ohlcv` input and a coarser pin that is a whole multiple of the chart's interval, read once its candle has closed; 600 chart bars hold 600 / (leg / chart) candles of a pin, so a higher-timeframe average longer than that arms but stays empty, and belongs on a coarser chart. It refuses a pin on the first input, a finer pin, a custom timeframe, a source alerts cannot evaluate yet (the feeds above among them), and a source, timeframe or symbol setting off its default. An indicator that builds a higher timeframe from the chart's own bars must declare `warmup()`, or its alert can arm and stay empty. A data-only output is not offered as a condition target ([Alerts](../functions/alerts.md)). **CME data is first-party only.** **Run** is paused while the chart is on a CME market, and drafts and community indicators cannot read CME data; OpenMarket's official wrun indicators can. On CME markets only price alerts are available. **Where it runs is fixed at the first publish.** A name first published as Compiled or Open source runs in each reader's browser, and one first published as Protected runs on OpenMarket's servers; a later version cannot switch sides. The Publish dialog has no price, and paid indicators cannot be added from the chart yet ([Publishing](../functions/publishing.md)). ## What AssemblyScript and the sandbox leave out The file is AssemblyScript: TypeScript syntax over fixed-width numbers, compiled in your browser. The editor's compile hints link here when you reach for something it does not have: <!-- wrun:cards --> | You wrote | Write instead | | --- | --- | | a closure over a local variable | keep the value at module scope, or pass it as a parameter | | a union type | one concrete type (`f64`, `i32`, `string`), or a nullable class | | `any` | the concrete type: `f64`, `i32`, `string`, or a class | | a bare object literal | a class with those fields, built with `new` | | the spread operator | a counting loop, or `Array.slice()` | | destructuring | read the fields or indexes one at a time (`const a = pair[0]`) | | optional chaining (`?.`) or nullish coalescing (`??`) | test for `null` with `if` or a ternary | | `for...of` | a counting `for` loop over the indexes | | the `in` operator | `Map.has()`, or read the field directly | | a regular expression | the string methods: `includes`, `startsWith`, `indexOf`, `split` | | `try` / `catch` | test the condition before the call; a throw ends the run | | `async` / `await` | nothing: an indicator runs synchronously, one bar at a time | | `console.log(...)` | the debug log: `string("debug", { max_bytes: 256 })` and `str_debug(text)` in `onBar()` ([Debugging](../faq/debugging.md)) | | `Date.now()` or `new Date()` | `bar.time()`, the bar's open time in epoch seconds | | `Math.random()` | a variation derived from the bar data: a run is reproducible bar for bar | | `performance.now()` | nothing: the sandbox has no clock | ## Named refusals Each gap above is refused by name, never worked around silently, and the message names the way out: [Common errors](../faq/common-errors.md) lists every refusal with its cause and fix. ## Sandbox limits Every wrun indicator runs under fixed ceilings (declaration caps, text and frame budgets, memory, and a deadline per run) so one module cannot exhaust the chart; every number is in the [Limits reference](limits.md). ## Build and runtime errors For the messages you meet while writing an indicator (a coordinate passed as a string instead of a handle, a param option that does not exist, a missing cast, a sheet field out of range, an `onBar()` written with the wrong signature, a run that drew nothing), see [Common errors](../faq/common-errors.md). Each one is listed by its exact text with a cause and a fix. <!-- source: https://openmarket.xyz/wrun/reference/symbol-format --> # Exchange and symbol format The exchange ids and the venue-native symbol spellings a wrun indicator uses where the file names a market: a pinned input's `symbol` and `exchange`. The vocabulary is the chart's own: an exchange id in uppercase, and the symbol as that venue spells it. ## Where the vocabulary is used A market is the pair `exchange` + `symbol`, and in the file the two travel together: - **Pinned inputs.** `input("btc", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES" })`. The chart passes both as written, in its own ids, and serves a market pin on a secondary `ohlcv` input: it fetches that market's candles at the chart's interval (or at a coarser pinned `interval`) and joins them to the chart's bars by timestamp. A lone `symbol` or a lone `exchange` is refused at the sheet check, because half a pin names a market that does not exist. - **Polymarket odds.** An `odds` input names its market by condition id in `symbol` alone; the exchange is implied, and writing one is refused. - **Everything else follows the chart.** An unpinned input reads the chart's own market, and the module never sees a market name: the chart supplies it. Trades, funding, open interest, liquidations, order books, and volume profiles always read the chart's own market. ## Example: one market on three venues Three pinned closes of the same asset, spelled the way each venue spells it, and two spreads between them. The first input stays unpinned, so the indicator follows whatever market the chart shows: ```typescript input("close", ohlcv.close); // Every pin names the venue's own spelling of the market, and symbol and exchange always pin together. input("binance_spot", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE", description: "Binance spot, joined form" }); input("binance_perp", ohlcv.close, { symbol: "BTCUSDT", exchange: "BINANCE_FUTURES", description: "Binance perpetual, joined form" }); input("coinbase", ohlcv.close, { symbol: "BTC-USD", exchange: "COINBASE", description: "Coinbase spot, hyphen form" }); output("basis_pct", line, lower, { unit: "%", description: "Binance perp premium over Binance spot" }); output("coinbase_gap_pct", line, lower, { unit: "%", description: "Coinbase spot over Binance spot" }); function onBar(): void { const spot = in_binance_spot(); if (isNaN(spot) || spot <= 0.0) return; out_basis_pct(((in_binance_perp() - spot) / spot) * 100.0); out_coinbase_gap_pct(((in_coinbase() - spot) / spot) * 100.0); } ``` The unpinned `close` is never read by the code; it is the grid every pinned input aligns to. To try it, press **New indicator** in the editor's Explorer, keep the `//@lang=wrun-ts` line, replace the starter under it with this file, and press **Run**: the two spreads draw in their own pane below the chart, at the chart's interval, on a crypto chart (a stock or forex chart's session bars open at other times: [Stocks, forex and gold](../core-concepts/multi-source.md#stocks-forex-and-gold)). An alert evaluates the pinned markets too ([Alerts](../functions/alerts.md)). ## Exchange reference table Exchange ids are the chart's own spelling, always uppercase. Symbols are venue-native: each venue's own separator, or none. The format column shows the venue's convention with an example market. | Exchange | Category | Symbol format | Example | | --- | --- | --- | --- | | `BINANCE` | SPOT | `BTCUSDT` | Bitcoin to Tether (spot) | | `BINANCE_FUTURES` | PERPETUAL | `BTCUSDT` | BTC/USDT perpetual | | `BINANCE_DELIVERY` | dated futures | venue-native | BTC dated contracts | | `BITFINEX` | SPOT | `BTCUST` or `BTC:USD` | Bitcoin to USD (spot) | | `BITFINEX_DERIVATIVES` | PERPETUAL | `BTCF0:USTF0` | BTC perpetual | | `BITGET` | PERPETUAL | `BTCUSDT` | BTC/USDT perpetual | | `BITMEX` | PERPETUAL | `XBTUSD` | Bitcoin to USD | | `BITSTAMP` | SPOT | `BTCUSD` | Bitcoin to USD (spot) | | `BYBIT` | PERPETUAL | `BTCUSDT` | BTC/USDT perpetual | | `BYBIT_SPOT` | SPOT | `BTCUSDT` | Bitcoin to USDT (spot) | | `COINBASE` | SPOT | `BTC-USD` | Bitcoin to USD (spot) | | `DERIBIT` | PERPETUAL | `BTC-PERPETUAL`, `BTC_USDC-PERPETUAL` | BTC/USD and BTC/USDC perpetuals | | `GATE_IO` | SPOT | `BTC_USDC` | Bitcoin to USDC (spot) | | `GATE_IO_FUTURES` | PERPETUAL | `BTC_USDT` | BTC/USDT perpetual | | `HUOBI` | SPOT | venue-native | spot markets | | `HUOBI_DM_SWAP`, `HUOBI_DM_LINEAR_SWAP` | PERPETUAL | venue-native | coin- and USDT-margined swaps | | `HYPERLIQUID` | SPOT | `HYPE-USDC` | HYPE to USDC (spot) | | `HYPERLIQUID_FUTURES` | PERPETUAL | `BTC` | BTC perpetual (the bare base asset) | | `HYPERLIQUID_HIP3` | PERPETUAL | venue-native | HIP-3 perpetuals | | `OKEX` | SPOT | `BTC-USDT` | Bitcoin to USDT (spot) | | `OKEX_FUTURES` | dated futures | `BTC-USDT-251226` | BTC future (Dec 26, 2025) | | `OKEX_SWAP` | PERPETUAL | `BTC-USDT-SWAP` | BTC/USDT perpetual swap | | `UPBIT` | SPOT | `KRW-USDT` | Korean won to USDT | | `CME` | dated futures | venue-native | listed futures; drafts and community indicators cannot read CME data, and no script may pin a CME Group market (`CME`, `CBOT`, `NYMEX`, `COMEX`, `GLOBEX`) as another market's input: the pin is refused by name | | `POLYGON` | US stocks and ETFs | `AAPL/USD`, `SPY/USD` | a stock or ETF pinned as a reference market | | `FX_OTC` | forex, gold and silver | `EUR/USD`, `XAU/USD`, `XAG/USD` | quote candles, `volume` 0; served on the chart and in alerts | | `LBMA`, `LME`, `ICE`, `PLATTS` | retired | none | no longer served: pin gold and silver on `FX_OTC` (`XAU/USD`, `XAG/USD`) | | `POLYMARKET` | prediction markets | the 66-character condition id | never written in a pin: an `odds` input's `symbol` is the condition id, and the exchange is implied | A venue with both a spot book and a derivatives book has two ids (`BINANCE` and `BINANCE_FUTURES`, `BYBIT_SPOT` and `BYBIT`, `OKEX` and `OKEX_SWAP`, `GATE_IO` and `GATE_IO_FUTURES`, `HYPERLIQUID` and `HYPERLIQUID_FUTURES`), and the same symbol can exist on both: pin the id of the book you mean. ## A misspelled pin is refused A guessed id is a pin that reads nothing, and the chart does not draw a flat line in its place: it asks for the market as written, and when nothing comes back it refuses the run and names the market it could not load, in a toast and on the overlay's **Could not load** chip: ```text Pinned market candle (BINANCE_FUTURES/BTCUSDTT) data is unavailable for 'Venue spreads' (the ohlcv source lane answered empty or was declined), so the indicator cannot compute. ``` Check the spelling against the table: the exchange id in uppercase, and the symbol the way that venue writes it. ## Notes **Case sensitivity.** Exchange ids are uppercase (`BINANCE`, never `binance`), and symbols are matched as the venue spells them. An interval pin takes the vocabulary's words or their short forms, and nothing else: `MINUTE`, `FIVE_MINUTES`, `FIFTEEN_MINUTES`, `THIRTY_MINUTES`, `HOUR`, `FOUR_HOURS`, `DAY`, `WEEK`, or `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, `1w`, and on the chart only its custom timeframes, `TWO_MINUTES` to `THREE_DAYS` or `2m`, `3m`, `10m`, `45m`, `2h`, `6h`, `8h`, `12h`, `3d` ([The chart's custom timeframes](../core-concepts/multi-timeframe.md#the-charts-custom-timeframes)). **Separators vary.** Hyphens (`BTC-USD`), underscores (`BTC_USDT`), colons (`BTC:USD`), or nothing (`BTCUSDT`), per venue. The same asset is a different string on every venue, so an indicator that pins several venues carries one spelling per pin; there is no normalized symbol in a pin. **Data availability.** Not every source exists for every venue and symbol: funding, open interest, and liquidations are perpetual-venue feeds, and the implied volatility and skew tenors are Deribit's. On the chart those sources always read the chart's own market. When that market does not serve one, the volume profile, implied volatility, skew, the options chain, ETF flows, and funding (unless its input declares `missing: "nan"` or `"zero"`) refuse the run by name, while open interest, liquidations, and trades read their missing fill. **Dated futures and options.** Use the format the venue publishes; `YYMMDD` in an OKX future is the expiry. **Polymarket.** An `odds` input names one outcome market by its condition id (`0x` plus 64 hex characters), never the market slug or the event, with `outcome` `YES` or `NO`; the exchange is implicit and refused if written. The chart has no market picker, so the condition id in `symbol` is the only way to name the market ([Data sources](../core-concepts/data-sources.md)). **CME.** **Run** is paused while the chart is on a CME market, and drafts and community indicators cannot read CME data; OpenMarket's official wrun indicators can. <!-- source: https://openmarket.xyz/wrun/reference/versions --> # Versions and contracts How a wrun indicator changes over time for someone who writes it in the chart: the contract a package runs under, the compiler the editor pins, and what a new version of your indicator does and does not change. A wrun indicator has no language versions, only a contract that each published package carries and the versions you publish. ## What moves, and what never does ```text The contract abi_version in the sheet Run derives from your declarations Your versions 0.1.0 on the first publish, then Patch, Minor or Major The compiler AssemblyScript 0.27.37, loaded by the editor's Run ``` <!-- wrun:cards --> | Contract | What it adds | Your file is on it when it declares | | --- | --- | --- | | `wrun-1` | the first contract, frozen forever | nothing the rows below name | | `wrun-2` | celled inputs, text slots, renderers and declared drawings | a celled input, a text slot, a renderer or a declared drawing | | `wrun-3` | drawing handles, strategies and the last-bar flag | a handle, a `strategy(...)` or `bar.isLast()` | | `wrun-4` | frames: level profiles, panels and widgets | a frame | | `wrun-5` | text settings: the typed words, read by the file | a text setting | | `wrun-6` | the bar count: how many bars the run holds | a `bar.count()` read | - **The contract.** You never pick it: Run derives it from what your declarations use (reading `bar.count()` makes the sheet `wrun-6`; a text setting makes it `wrun-5`; a frame makes it `wrun-4`; a handle, a `strategy(...)` or `bar.isLast()` makes it `wrun-3`; a celled input, a text slot, a renderer or a declared drawing makes it `wrun-2`; anything else stays on `wrun-1`). The first contract is frozen: its behavior never changes, and every package written under it keeps computing bit-identically. Each later contract adds to the one before and freezes in turn, so an indicator written today keeps computing the same way. - **Your versions.** The first publish is 0.1.0, and every later publish is a new version you pick as **Patch**, **Minor** or **Major**, with a one-line "What changed" ([Publishing](../functions/publishing.md)). A published version never changes. A chart that added your indicator keeps the exact version it added and reloads that version, so a new release reaches a chart when its owner adds the new version. Where an indicator runs (the browser, or OpenMarket's servers for Protected code) is fixed by its first version. - **The compiler.** Run compiles in your browser with the AssemblyScript version the kit pins, so the same source always builds the same module. ## When the chart updates The chart's editor and OpenMarket's alerts engine ship on their own schedule, and an update can let the editor accept new declarations, draw new kinds of shapes, serve new sources, or accept a new file shape (a file with `onBar()` beside the four-function form). It never rewrites a published version: the module a chart loads is the one you published, under its frozen contract, and a package that does not use a new feature is unaffected by it. ## What we publish | Page | Purpose | | --- | --- | | [What is wrun](../getting-started/introduction.md) | What wrun is, how a file runs and where, in one read. | | [Publishing](../functions/publishing.md) | Versions, sharing and where a published indicator runs. |