Custom chart indicators for /trading
Build an indicator for the charting terminal. It becomes a picker entry with a
generated settings dialog, a legend row and saved-layout persistence, with no
build step and no restart.
This is the chart (JavaScript) path. It has nothing to do with the Python
openalgo.ta indicators used from strategies, scanners and backtests: different
language, different runtime, different API. If the request is for a Python
indicator, this skill is the wrong one.
The one rule
Never write a file into strategies/indicators/ directly. That folder is
imported by the live chart, and the runtime fails silently in the ways that
matter most: a column that is one element short, or a plot key that does not
match what calc returns, draws nothing at all and raises nothing anywhere.
Always: write to a scratch path, validate, install on a pass.
# 1. draft to a scratch file (never the indicators folder)
# e.g. <scratchpad>/my_indicator.js
# 2. validate against the real openalgo-charts build
node .claude/skills/chart-indicator/validate.mjs <scratch>/my_indicator.js
# 3. only on PASSED, install it
node .claude/skills/chart-indicator/validate.mjs <scratch>/my_indicator.js --install
--install copies into strategies/indicators/ only when there are zero
errors, and exits 1 otherwise. If validation fails, fix the draft and re-run.
Do not install a failing indicator, and do not weaken the validator to get a
pass. Report warnings to the user rather than silently accepting them.
Never run npm install for this. The full frontend tree is 560 MB across
521 packages; the validator needs two ES modules totalling 368 KB. It finds them
itself, in this order: frontend/node_modules/openalgo-charts if a React
developer already has it, then its own .cache/, then it fetches just that one
package at the version pinned in frontend/package.json. openalgo-charts has
zero dependencies, so that is one small download, about a second, cached after.
If the fetch fails (no network, npm unavailable), say so and offer the choice:
fix connectivity, or install without the pre-flight check and rely on the
chart's own validation, which reports the same structural problems as toasts
when the indicator loads. Do not silently skip validation.
Recent changes worth knowing
The descriptor contract has not changed since this skill was written, so an
existing indicator keeps working on the pinned build. What changed around it,
newest first:
- 2.2.0: hosts can offer 85 drawing tools. The draw tier adds channels,
pitchforks, Fibonacci and Gann geometry, wavefronts and manual patterns.
ADVANCED_LINE_TOOLS, ADVANCED_GEOMETRY_TOOLS and PATTERN_DRAWING_TOOLS
belong to openalgo-charts/draw; they are not part of the custom indicator's
API object. The indicator descriptor contract and its 102 built-ins are
unchanged. Drawing documents retain version 2 and existing tool IDs.
- 2.1.9: chart hosts gain built-in branding and
an optional text watermark.
ChartOptions.branding defaults to the
OpenAlgo mark, while ChartOptions.watermark defaults off. Hosts can update
them with setBranding and setWatermarkOptions, inspect them with
brandingOptions and watermarkOptions, and follow branding changes through
branding:changed. Blank watermark text follows the symbol and interval from
setDataContext. The public types are LogoWatermarkOptions,
ChartWatermarkOptions, and BrandingChangedEvent. These are host APIs and
do not change or belong inside an indicator descriptor.
- 2.1.8: navigation can ease automatic price ranges as it reveals new
extrema.
animAutoscale follows animZoom by default, while a manual scale
and a descriptor's fixed range() remain authoritative. Normalized wheel and
trackpad gestures and the packaged widget's responsive controls are host
features; they do not change a descriptor. OpenAlgo /trading constructs a
bare Chart, so it receives the engine gestures but keeps its own toolbar,
rails and panels rather than receiving WidgetOptions.mobile controls.
- 2.1.7: hidden indicators remain hidden through layout restoration and style
edits. Reference levels now follow the instance's visibility along with its
plots and other visuals. The new
ChartObjects inventory also exposes an
indicator's visibility and Tier-2 data status to host and widget object
panels, but it does not change the descriptor contract or add work to a
custom indicator.
- 2.1.6: Tier-2 studies follow the chart's data context and loaded source
range.
createTier2Indicator receives dataContext with the host's symbol,
exchange and interval, cancels obsolete fetches, extends history when older
bars arrive and refreshes when the host changes instrument. A descriptor can
use supports(ctx) to report that its provider cannot serve a context. The
managed lifecycle publishes loading, ready, empty, unsupported and error
states with an explicit retry action, so provider failure is visible without
putting network state into calc. Existing Tier-2 descriptors get the range,
cancellation and status behavior through the wrapper without changing shape.
- 2.1.2: a Tier-2 study's data requests are keyed by data setting. Changing
the symbol or any other data input clears the previous values immediately, and
a response that arrives for the setting you just left cannot land on the new
one. A style-only change reuses the history already in flight instead of
refetching, and a live observation wins over a historical point for the same
time. An
attach that used to guard against its own stale responses no longer
has to.
- 1.8.9: precision is keyed on the pane, not the descriptor. An
onchart
plot prints at the instrument's tick; a plot on its own pane prints at that
pane's span with a floor of two decimals. A study pane is no longer formatted
in the instrument's tick, which is why an RSI reads 70.00 rather than 70.0.
Custom descriptors get this with nothing to declare, and a precision input is
still the wrong answer. See Do not, below.
- 1.8.4:
calc runs once per animation frame, not once per tick. A data
update marks the indicators stale and the flush happens before the paint, so a
burst of ticks collapses into one call. calc must therefore be a pure
function of (bars, settings). It always had to be, but running per tick used
to hide an indicator that counted its own calls or accumulated into store.
Reading chart.indicators() or an instance's values() flushes first, so a
read-after-update in the same turn still sees fresh numbers.
- 1.8.4:
calcTail is rarely worth it now. The tick-rate problem it existed
to solve is gone. It only pays when one pass over the loaded history is itself
slow, which means deep history, not a fast feed.
- 1.8.3: the catalogue went from 91 to 102 built-ins, so a file written
earlier can shadow an id that did not exist when it was named. The new ids are
listed in
reference/pitfalls.md under the collision entry. That release also
corrected nine built-ins and moved ten defaults, so an indicator that compares
itself against a built-in may need its expectations re-derived rather than
assumed unchanged.
Workflow
- Read the request. If it is a study from another platform, read it fully
and identify:
what is plotted, what is a signal, what state carries across bars, and what
resets per day or per session.
- Before writing a formula, check
reference/cookbook.md. Every
author-facing call is demonstrated there, and the first section is the one
that saves the most work: the 102 built-ins are descriptors, so
getIndicator('macd').calc(bars, settings, {}) gives you MACD's own columns
rather than a reimplementation that can drift from the chart's.
- Load the context you need.
reference/contract.md for the descriptor
shape and the runtime's exact behaviour, reference/api.md for what is
available inside the module, reference/pitfalls.md for the traps. Read
reference/pitfalls.md before writing anything; most first drafts fail on
something in it.
- Pick the closest example in
examples/ and work from it:
simple_zscore.js — one pane, one plot, rolling window, levels, range
intermediate_keltner_squeeze.js — several plots, fills, colorBy, a
second price scale, a boolean that hides part of the drawing
shaded_trend_zone.js — shading between two series, where the ribbon
flips sides and recolours with the trend
complex_session_vwap.js — per-session state, markers with a signal
latch, table, calcTail, zone-aware day boundaries
regime_shading.js — background(), barColors(), declared alerts
and a data-derived levels(ctx)
zones_with_draws.js — draws() with all four kinds, driven by
pivotHigh / pivotLow. The pattern behind structure studies
heikin_ashi_candles.js — a plot fed by four columns via ohlc
session_range_modern.js — parseSessionSpec, inSessionAt and the calc
context, replacing a hand-rolled session parser
tier2_external_data.js — createTier2Indicator and the manual attach
lifecycle, for data the chart does not have
- Draft to scratch. Validate. Iterate until it passes.
- Install, then tell the user to reopen the indicator picker on
/trading.
No page reload is needed: the catalogue re-reads the folder every time the
picker opens, and an edited file is re-imported because the URL carries the
file's modification time. A reload is only needed for a chart that was
already open before the app itself changed.
Migrating a study, construct by construct
Work through the source in this order. Each row is a mechanical translation;
the judgement is in the last two.
| In the source |
Here |
overlay=true / false |
placement: 'onchart' / 'pane' |
every input.* |
one inputs[] entry, matching type |
every plot() |
a plot key plus that column from calc |
plotshape / plotchar / plotarrow |
markers() |
hline |
levels(ctx) |
fill() |
fills, or background() if it shades the whole pane |
bgcolor() |
background() |
barcolor() |
barColors() |
plotcandle / plotbar |
a plot with ohlc: { open, high, low, close } |
line.new / box.new / label.new / polyline.new |
draws() |
alertcondition() |
an alerts[] entry |
var state across bars |
a variable outside the calc loop |
x[1], x[n] |
arr[i - 1], arr[i - n] |
na |
null, and guard every comparison |
barstate.* |
ctx.barState on the 4th calc argument |
| session strings |
parseSessionSpec + inSessionAt |
ta.* |
the exported helper of the same job, see reference/api.md |
Then the two that need thought:
A higher-timeframe request. There is no request.security. Either fold the
chart's own bars up to the higher timeframe, or fetch with
createTier2Indicator. Folding is usually more correct: a request against a
60-minute bar returns that whole bar's high, which is lookahead if your window
is shorter than the bar.
Anything drawn at a future bar. Not expressible: a column is one value per
bar and there is no bar yet. Shift the meaning back onto existing bars, or drop
it. This is the one thing that can make a study genuinely unportable today.
Two layers of validation
validate.mjs is a pre-flight check, and it is the one that can refuse to
install. The chart validates again at load time, in the browser, where the
library already is: it checks the descriptor before it reaches the catalogue,
and wraps calc so its first result is measured against the bars. Anything
wrong surfaces as a toast naming the file.
That second layer is why a trader with no Node.js at all still gets told what is
wrong instead of an indicator that quietly draws nothing.
What the file has to look like
Plain JavaScript. Nothing compiles it: no TypeScript, no JSX, no imports. The
module default-exports one function and is handed the whole charting API.
export default function ({ registerIndicator, sourceValues, sma, nulls }) {
registerIndicator({
id: 'my-thing', // unique slug; prefix your own to avoid overriding a built-in
name: 'My Thing', // picker and legend
category: 'Custom', // groups it in the picker rail
placement: 'onchart', // 'onchart' overlays price, 'pane' gets its own pane
inputs: [ ... ], // becomes the settings dialog
plots: [ ... ], // each key must appear in what calc returns
calc(bars, settings, store) {
return { /* one array per plot key, exactly bars.length long */ }
},
})
}
A bar is { time, open, high, low, close, volume } with time in UTC
seconds.
What the library gives you
The descriptor is much wider than the plot-plus-calc it started as. Before
hand-rolling anything, check whether one of these already covers it:
| Want |
Use |
| A trendline, zone, box or free label |
draws(ctx) |
| Shade the pane by state |
background(ctx) |
| Repaint the price candles |
barColors(ctx) |
| A horizontal level from the data |
levels(ctx), which receives bars and values |
| A condition the chart watches |
alerts[] with a when(ctx) predicate |
| One plot on price from a pane study |
plot.overlay: true |
| Candles or bars as a plot |
plot.ohlc: { open, high, low, close } |
| Know the bar state, symbol, interval, clock |
the 4th calc argument |
| The instrument's tick size |
ctx.tickSize, never an input for it |
| The decimals your plots print at |
Nothing: it follows the pane, see below |
| Parse a session window |
parseSessionSpec, inSessionAt, sessionFlags |
| Reason about the timeframe |
intervalParts, isIntradayInterval, ... |
| A colour ramp or alpha |
fromGradient, withAlpha |
| Pivots, rank, correlation, linreg |
pivotHigh, pivotLow, percentRank, correlation, linreg, ... |
| Any built-in's maths |
getIndicator(id).calc(bars, settings, {}), never a reimplementation |
Full list in reference/api.md, which is generated from the installed build.
The four things that go wrong most
Full list in reference/pitfalls.md. These four account for most failures:
- Column length. Every array must be exactly
bars.length. Short arrays do
not error, they just stop drawing partway.
- Warmup. Use
null (or nulls(...) on a helper's NaN output). A 0 puts
a spike at the bottom of the pane and wrecks autoscale.
na semantics. Script languages with a not-available value treat every
comparison against it as false. In
JavaScript 5 > null is true. Guard with x != null or signals fire
through the warmup gap.
- Marker anchoring.
aboveBar / belowBar anchor to this indicator's own
plot line, not to the candle. To place a label relative to a bar, use
position: 'atPrice' with an explicit price.
Do not
- Add colour or line-width inputs. The chart generates colour, opacity,
thickness, line style and plot style per plot automatically, seeded from each
plot's
style. Your own width input becomes a second control that disagrees.
- Reuse a built-in id unless overriding it is the actual intent. Custom modules
register last, so they win. The validator warns on this.
- Add a precision or decimals input. Precision follows the pane, not the
descriptor, so there is nothing to declare and an override would only let a
plot disagree with the axis it is drawn against. An
onchart plot is a price
and prints at the instrument's tick (Supertrend on a 0.05 tick reads
1339.70); a plot on its own pane prints at that pane's own span with a floor
of two decimals (an RSI reads 70.00, a percentage study 0.61). A study pane
is not quoted in the instrument's tick, because an RSI is a dimensionless
0..100 band. If a plot of yours really is a price, put it on the candles with
overlay: true rather than reaching for a precision knob.
- Add an input for the tick size.
ctx.tickSize carries it, and an
input is a second source of truth that disagrees with the axis. Point value is
the exception: the chart does not know it, so that one is an input at 1.
- Assume the browser's local time. Use
zonedDayIndex /
utcSecondsToZonedParts with a zone, defaulting to DEFAULT_TIMEZONE.
Where things live
| Path |
|
strategies/indicators/*.js |
installed indicators, gitignored, never pushed |
.claude/skills/chart-indicator/validate.mjs |
the gate |
.claude/skills/chart-indicator/examples/ |
ten validated worked examples |
.claude/skills/chart-indicator/reference/ |
contract, API surface, pitfalls, cookbook |
.claude/skills/chart-indicator/coverage.mjs |
fails if an API or capability is documented but never demonstrated |
.claude/skills/chart-indicator/generate-api-index.mjs |
regenerates the export index in reference/api.md; --check fails when it is stale |
docs/custom-indicators.md |
the user-facing guide |
blueprints/custom_indicators.py |
serves the folder to the chart |
frontend/src/lib/trading/customIndicators.ts |
the loader |
Indicators are loaded over HTTP at runtime, not bundled, so they survive
git pull and need no rebuild. They run with full access to the logged-in
session: treat an indicator file from an untrusted source as you would any
script you are about to run.
1---2name: chart-indicator3description: Build a custom indicator for the OpenAlgo /trading charting terminal (openalgo-charts). Use when asked to create, port, or debug a chart indicator, overlay, oscillator, band, or on-chart signal, including porting a study written for another charting platform. Writes a plain-JS descriptor into strategies/indicators/, but only after it validates against the real library. This is the chart path, not the Python openalgo.ta path used from strategies and scanners.4---56# Custom chart indicators for `/trading`78Build an indicator for the charting terminal. It becomes a picker entry with a9generated settings dialog, a legend row and saved-layout persistence, with no10build step and no restart.1112**This is the chart (JavaScript) path.** It has nothing to do with the Python13`openalgo.ta` indicators used from strategies, scanners and backtests: different14language, different runtime, different API. If the request is for a Python15indicator, this skill is the wrong one.1617## The one rule1819**Never write a file into `strategies/indicators/` directly.** That folder is20imported by the live chart, and the runtime fails silently in the ways that21matter most: a column that is one element short, or a plot key that does not22match what `calc` returns, draws nothing at all and raises nothing anywhere.2324Always: write to a scratch path, validate, install on a pass.2526```bash27# 1. draft to a scratch file (never the indicators folder)28# e.g. <scratchpad>/my_indicator.js2930# 2. validate against the real openalgo-charts build31node .claude/skills/chart-indicator/validate.mjs <scratch>/my_indicator.js3233# 3. only on PASSED, install it34node .claude/skills/chart-indicator/validate.mjs <scratch>/my_indicator.js --install35```3637`--install` copies into `strategies/indicators/` **only** when there are zero38errors, and exits 1 otherwise. If validation fails, fix the draft and re-run.39Do not install a failing indicator, and do not weaken the validator to get a40pass. Report warnings to the user rather than silently accepting them.4142**Never run `npm install` for this.** The full frontend tree is 560 MB across43521 packages; the validator needs two ES modules totalling 368 KB. It finds them44itself, in this order: `frontend/node_modules/openalgo-charts` if a React45developer already has it, then its own `.cache/`, then it fetches just that one46package at the version pinned in `frontend/package.json`. `openalgo-charts` has47zero dependencies, so that is one small download, about a second, cached after.4849If the fetch fails (no network, npm unavailable), say so and offer the choice:50fix connectivity, or install without the pre-flight check and rely on the51chart's own validation, which reports the same structural problems as toasts52when the indicator loads. Do not silently skip validation.5354## Recent changes worth knowing5556The descriptor contract has not changed since this skill was written, so an57existing indicator keeps working on the pinned build. What changed around it,58newest first:5960- **2.2.0: hosts can offer 85 drawing tools.** The draw tier adds channels,61 pitchforks, Fibonacci and Gann geometry, wavefronts and manual patterns.62 `ADVANCED_LINE_TOOLS`, `ADVANCED_GEOMETRY_TOOLS` and `PATTERN_DRAWING_TOOLS`63 belong to `openalgo-charts/draw`; they are not part of the custom indicator's64 API object. The indicator descriptor contract and its 102 built-ins are65 unchanged. Drawing documents retain version 2 and existing tool IDs.66- **2.1.9: chart hosts gain built-in branding and67 an optional text watermark.** `ChartOptions.branding` defaults to the68 OpenAlgo mark, while `ChartOptions.watermark` defaults off. Hosts can update69 them with `setBranding` and `setWatermarkOptions`, inspect them with70 `brandingOptions` and `watermarkOptions`, and follow branding changes through71 `branding:changed`. Blank watermark text follows the symbol and interval from72 `setDataContext`. The public types are `LogoWatermarkOptions`,73 `ChartWatermarkOptions`, and `BrandingChangedEvent`. These are host APIs and74 do not change or belong inside an indicator descriptor.75- **2.1.8: navigation can ease automatic price ranges as it reveals new76 extrema.** `animAutoscale` follows `animZoom` by default, while a manual scale77 and a descriptor's fixed `range()` remain authoritative. Normalized wheel and78 trackpad gestures and the packaged widget's responsive controls are host79 features; they do not change a descriptor. OpenAlgo `/trading` constructs a80 bare `Chart`, so it receives the engine gestures but keeps its own toolbar,81 rails and panels rather than receiving `WidgetOptions.mobile` controls.82- **2.1.7: hidden indicators remain hidden through layout restoration and style83 edits.** Reference levels now follow the instance's visibility along with its84 plots and other visuals. The new `ChartObjects` inventory also exposes an85 indicator's visibility and Tier-2 data status to host and widget object86 panels, but it does not change the descriptor contract or add work to a87 custom indicator.88- **2.1.6: Tier-2 studies follow the chart's data context and loaded source89 range.** `createTier2Indicator` receives `dataContext` with the host's symbol,90 exchange and interval, cancels obsolete fetches, extends history when older91 bars arrive and refreshes when the host changes instrument. A descriptor can92 use `supports(ctx)` to report that its provider cannot serve a context. The93 managed lifecycle publishes loading, ready, empty, unsupported and error94 states with an explicit retry action, so provider failure is visible without95 putting network state into `calc`. Existing Tier-2 descriptors get the range,96 cancellation and status behavior through the wrapper without changing shape.97- **2.1.2: a Tier-2 study's data requests are keyed by data setting.** Changing98 the symbol or any other data input clears the previous values immediately, and99 a response that arrives for the setting you just left cannot land on the new100 one. A style-only change reuses the history already in flight instead of101 refetching, and a live observation wins over a historical point for the same102 time. An `attach` that used to guard against its own stale responses no longer103 has to.104- **1.8.9: precision is keyed on the pane, not the descriptor.** An `onchart`105 plot prints at the instrument's tick; a plot on its own pane prints at that106 pane's span with a floor of two decimals. A study pane is no longer formatted107 in the instrument's tick, which is why an RSI reads `70.00` rather than `70.0`.108 Custom descriptors get this with nothing to declare, and a precision input is109 still the wrong answer. See **Do not**, below.110- **1.8.4: `calc` runs once per animation frame, not once per tick.** A data111 update marks the indicators stale and the flush happens before the paint, so a112 burst of ticks collapses into one call. `calc` must therefore be a pure113 function of `(bars, settings)`. It always had to be, but running per tick used114 to hide an indicator that counted its own calls or accumulated into `store`.115 Reading `chart.indicators()` or an instance's `values()` flushes first, so a116 read-after-update in the same turn still sees fresh numbers.117- **1.8.4: `calcTail` is rarely worth it now.** The tick-rate problem it existed118 to solve is gone. It only pays when one pass over the loaded history is itself119 slow, which means deep history, not a fast feed.120- **1.8.3: the catalogue went from 91 to 102 built-ins**, so a file written121 earlier can shadow an id that did not exist when it was named. The new ids are122 listed in `reference/pitfalls.md` under the collision entry. That release also123 corrected nine built-ins and moved ten defaults, so an indicator that compares124 itself against a built-in may need its expectations re-derived rather than125 assumed unchanged.126127## Workflow1281291. **Read the request.** If it is a study from another platform, read it fully130 and identify:131 what is plotted, what is a signal, what state carries across bars, and what132 resets per day or per session.1332. **Before writing a formula, check `reference/cookbook.md`.** Every134 author-facing call is demonstrated there, and the first section is the one135 that saves the most work: the 102 built-ins are descriptors, so136 `getIndicator('macd').calc(bars, settings, {})` gives you MACD's own columns137 rather than a reimplementation that can drift from the chart's.1383. **Load the context you need.** `reference/contract.md` for the descriptor139 shape and the runtime's exact behaviour, `reference/api.md` for what is140 available inside the module, `reference/pitfalls.md` for the traps. Read141 `reference/pitfalls.md` before writing anything; most first drafts fail on142 something in it.1434. **Pick the closest example** in `examples/` and work from it:144 - `simple_zscore.js` — one pane, one plot, rolling window, levels, range145 - `intermediate_keltner_squeeze.js` — several plots, `fills`, `colorBy`, a146 second price scale, a boolean that hides part of the drawing147 - `shaded_trend_zone.js` — shading between two series, where the ribbon148 flips sides and recolours with the trend149 - `complex_session_vwap.js` — per-session state, `markers` with a signal150 latch, `table`, `calcTail`, zone-aware day boundaries151 - `regime_shading.js` — `background()`, `barColors()`, declared `alerts`152 and a data-derived `levels(ctx)`153 - `zones_with_draws.js` — `draws()` with all four kinds, driven by154 `pivotHigh` / `pivotLow`. The pattern behind structure studies155 - `heikin_ashi_candles.js` — a plot fed by four columns via `ohlc`156 - `session_range_modern.js` — `parseSessionSpec`, `inSessionAt` and the calc157 context, replacing a hand-rolled session parser158 - `tier2_external_data.js` — `createTier2Indicator` and the manual `attach`159 lifecycle, for data the chart does not have1605. **Draft to scratch. Validate. Iterate until it passes.**1616. **Install**, then tell the user to reopen the indicator picker on `/trading`.162 No page reload is needed: the catalogue re-reads the folder every time the163 picker opens, and an edited file is re-imported because the URL carries the164 file's modification time. A reload is only needed for a chart that was165 already open before the app itself changed.166167## Migrating a study, construct by construct168169Work through the source in this order. Each row is a mechanical translation;170the judgement is in the last two.171172| In the source | Here |173| --- | --- |174| `overlay=true` / `false` | `placement: 'onchart'` / `'pane'` |175| every `input.*` | one `inputs[]` entry, matching type |176| every `plot()` | a plot key plus that column from `calc` |177| `plotshape` / `plotchar` / `plotarrow` | `markers()` |178| `hline` | `levels(ctx)` |179| `fill()` | `fills`, or `background()` if it shades the whole pane |180| `bgcolor()` | `background()` |181| `barcolor()` | `barColors()` |182| `plotcandle` / `plotbar` | a plot with `ohlc: { open, high, low, close }` |183| `line.new` / `box.new` / `label.new` / `polyline.new` | `draws()` |184| `alertcondition()` | an `alerts[]` entry |185| `var` state across bars | a variable outside the `calc` loop |186| `x[1]`, `x[n]` | `arr[i - 1]`, `arr[i - n]` |187| `na` | `null`, and guard every comparison |188| `barstate.*` | `ctx.barState` on the 4th `calc` argument |189| session strings | `parseSessionSpec` + `inSessionAt` |190| `ta.*` | the exported helper of the same job, see `reference/api.md` |191192Then the two that need thought:193194**A higher-timeframe request.** There is no `request.security`. Either fold the195chart's own bars up to the higher timeframe, or fetch with196`createTier2Indicator`. Folding is usually more correct: a request against a19760-minute bar returns that whole bar's high, which is lookahead if your window198is shorter than the bar.199200**Anything drawn at a future bar.** Not expressible: a column is one value per201bar and there is no bar yet. Shift the meaning back onto existing bars, or drop202it. This is the one thing that can make a study genuinely unportable today.203204## Two layers of validation205206`validate.mjs` is a pre-flight check, and it is the one that can refuse to207install. The chart validates again at load time, in the browser, where the208library already is: it checks the descriptor before it reaches the catalogue,209and wraps `calc` so its first result is measured against the bars. Anything210wrong surfaces as a toast naming the file.211212That second layer is why a trader with no Node.js at all still gets told what is213wrong instead of an indicator that quietly draws nothing.214215## What the file has to look like216217Plain JavaScript. Nothing compiles it: no TypeScript, no JSX, no imports. The218module default-exports one function and is handed the whole charting API.219220```js221export default function ({ registerIndicator, sourceValues, sma, nulls }) {222 registerIndicator({223 id: 'my-thing', // unique slug; prefix your own to avoid overriding a built-in224 name: 'My Thing', // picker and legend225 category: 'Custom', // groups it in the picker rail226 placement: 'onchart', // 'onchart' overlays price, 'pane' gets its own pane227 inputs: [ ... ], // becomes the settings dialog228 plots: [ ... ], // each key must appear in what calc returns229 calc(bars, settings, store) {230 return { /* one array per plot key, exactly bars.length long */ }231 },232 })233}234```235236A `bar` is `{ time, open, high, low, close, volume }` with `time` in **UTC237seconds**.238239## What the library gives you240241The descriptor is much wider than the plot-plus-calc it started as. Before242hand-rolling anything, check whether one of these already covers it:243244| Want | Use |245| --- | --- |246| A trendline, zone, box or free label | `draws(ctx)` |247| Shade the pane by state | `background(ctx)` |248| Repaint the price candles | `barColors(ctx)` |249| A horizontal level from the data | `levels(ctx)`, which receives `bars` and `values` |250| A condition the chart watches | `alerts[]` with a `when(ctx)` predicate |251| One plot on price from a pane study | `plot.overlay: true` |252| Candles or bars as a plot | `plot.ohlc: { open, high, low, close }` |253| Know the bar state, symbol, interval, clock | the 4th `calc` argument |254| The instrument's tick size | `ctx.tickSize`, never an input for it |255| The decimals your plots print at | Nothing: it follows the pane, see below |256| Parse a session window | `parseSessionSpec`, `inSessionAt`, `sessionFlags` |257| Reason about the timeframe | `intervalParts`, `isIntradayInterval`, ... |258| A colour ramp or alpha | `fromGradient`, `withAlpha` |259| Pivots, rank, correlation, linreg | `pivotHigh`, `pivotLow`, `percentRank`, `correlation`, `linreg`, ... |260| **Any built-in's maths** | `getIndicator(id).calc(bars, settings, {})`, never a reimplementation |261262Full list in `reference/api.md`, which is generated from the installed build.263264## The four things that go wrong most265266Full list in `reference/pitfalls.md`. These four account for most failures:2672681. **Column length.** Every array must be exactly `bars.length`. Short arrays do269 not error, they just stop drawing partway.2702. **Warmup.** Use `null` (or `nulls(...)` on a helper's NaN output). A `0` puts271 a spike at the bottom of the pane and wrecks autoscale.2723. **`na` semantics.** Script languages with a not-available value treat every273 comparison against it as false. In274 JavaScript `5 > null` is **true**. Guard with `x != null` or signals fire275 through the warmup gap.2764. **Marker anchoring.** `aboveBar` / `belowBar` anchor to *this indicator's own277 plot line*, not to the candle. To place a label relative to a bar, use278 `position: 'atPrice'` with an explicit price.279280## Do not281282- Add colour or line-width inputs. The chart generates colour, opacity,283 thickness, line style and plot style per plot automatically, seeded from each284 plot's `style`. Your own width input becomes a second control that disagrees.285- Reuse a built-in id unless overriding it is the actual intent. Custom modules286 register last, so they win. The validator warns on this.287- Add a precision or decimals input. Precision follows the pane, not the288 descriptor, so there is nothing to declare and an override would only let a289 plot disagree with the axis it is drawn against. An `onchart` plot is a price290 and prints at the instrument's tick (Supertrend on a 0.05 tick reads291 `1339.70`); a plot on its own pane prints at that pane's own span with a floor292 of two decimals (an RSI reads `70.00`, a percentage study `0.61`). A study pane293 is not quoted in the instrument's tick, because an RSI is a dimensionless294 0..100 band. If a plot of yours really is a price, put it on the candles with295 `overlay: true` rather than reaching for a precision knob.296- Add an input for the tick size. `ctx.tickSize` carries it, and an297 input is a second source of truth that disagrees with the axis. Point value is298 the exception: the chart does not know it, so that one is an input at 1.299- Assume the browser's local time. Use `zonedDayIndex` /300 `utcSecondsToZonedParts` with a zone, defaulting to `DEFAULT_TIMEZONE`.301302## Where things live303304| Path | |305| --- | --- |306| `strategies/indicators/*.js` | installed indicators, gitignored, never pushed |307| `.claude/skills/chart-indicator/validate.mjs` | the gate |308| `.claude/skills/chart-indicator/examples/` | ten validated worked examples |309| `.claude/skills/chart-indicator/reference/` | contract, API surface, pitfalls, cookbook |310| `.claude/skills/chart-indicator/coverage.mjs` | fails if an API or capability is documented but never demonstrated |311| `.claude/skills/chart-indicator/generate-api-index.mjs` | regenerates the export index in `reference/api.md`; `--check` fails when it is stale |312| `docs/custom-indicators.md` | the user-facing guide |313| `blueprints/custom_indicators.py` | serves the folder to the chart |314| `frontend/src/lib/trading/customIndicators.ts` | the loader |315316Indicators are loaded over HTTP at runtime, not bundled, so they survive317`git pull` and need no rebuild. They run with full access to the logged-in318session: treat an indicator file from an untrusted source as you would any319script you are about to run.