acttrader-charts 1.3.0-beta.9 → 1.4.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,15 +58,18 @@ chart.on('durationChange', ({ duration, timeframe }) => {
58
58
  ```ts
59
59
  const chart = new ChartEngine({
60
60
  container,
61
- dataLoader: async ({ start, end, interval }) => {
62
- const res = await fetch(`/api/bars?from=${start.getTime()}&to=${end.getTime()}&tf=${interval}`);
61
+ dataLoader: async ({ start, end, interval, priceSource }) => {
62
+ // `priceSource` ('bid' | 'ask' | 'ltp') is the candle source the chart is showing —
63
+ // pass it to your history API so the bars match (dealing feeds: LTP vs BID candles).
64
+ const res = await fetch(`/api/bars?from=${start.getTime()}&to=${end.getTime()}&tf=${interval}&source=${priceSource}`);
63
65
  return res.json(); // OHLCVBar[]
64
66
  },
65
67
  });
66
- // No manual loadData() needed — the engine calls dataLoader on start and on timeframe/duration changes.
68
+ // No manual loadData() needed — the engine calls dataLoader on start, on timeframe/duration
69
+ // changes, and (unless `reloadOnPriceSourceChange: false`) whenever the price source changes.
67
70
  ```
68
71
 
69
- If the first fetch returns fewer than `minInitialBars` (default `10`), the engine automatically widens the lookback window and calls `dataLoader` again — up to the `maxLookbackMs` ceiling (default 365 days). This keeps the chart useful on weekends, holidays, or for instruments that just listed. If retries still yield zero bars, the engine shows a **"No data available"** overlay (customise the text via `labels.chart.noData`).
72
+ If the first fetch returns fewer than `minInitialBars` (default `10`), the engine automatically widens the lookback window and calls `dataLoader` again — up to the `maxLookbackMs` ceiling (default 365 days). That ceiling limits only the retries: with no duration selected, the first fetch always asks for `maxCandles` bars, so a 1W chart loads about four years rather than one. This keeps the chart useful on weekends, holidays, or for instruments that just listed. The engine keeps the retry that returned the most bars and stops widening once a wider window returns fewer, because feeds often return nothing for very large ranges (17 days of 1m). A failed retry keeps the bars already received. If no retry returned any bar, or the first request failed, the engine shows a **"No data available"** overlay (customise the text via `labels.chart.noData`). Scrolling back into older history follows the same rule.
70
73
 
71
74
  ---
72
75
 
@@ -104,6 +107,12 @@ specific instance via its pill **×** or the settings dialog's Remove button.
104
107
  Each instance has a unique `instanceId` (e.g. `"EMA#3"`). Listen for it via the `indicatorAdded`
105
108
  event and pass it to `removeIndicator` to target one instance:
106
109
 
110
+ `shortName` is always the menu / catalog short name (`"HammingMA"`, `"ZigZag"`,
111
+ `"EMACross"`), in events, `getState()`, the menu ticks and the mobile catalog,
112
+ even for indicators whose display name differs ("Hamming MA"). States saved
113
+ under the old display names still restore, and `removeIndicator` /
114
+ `addIndicatorByName` accept them too.
115
+
107
116
  ```ts
108
117
  chart.on('indicatorAdded', ({ instanceId, shortName, params }) => { /* track it */ });
109
118
  chart.removeIndicator('EMA#3'); // remove that one instance
@@ -152,6 +161,9 @@ const dealingChart = new ChartEngine({
152
161
  container,
153
162
  // "Show chart by" toggle: 'ltp' | 'ask' | 'bid' (candle close/high/low source)
154
163
  tickClosePriceSource: 'ltp',
164
+ // Header dropdown so the user can switch the source on the chart itself
165
+ // (rendered next to the timeframe dropdown; omit to hide):
166
+ priceSourceSelector: ['ltp', 'bid'],
155
167
  // Chart line checkboxes — each price line is independent:
156
168
  showAskLine: true, // Show AskLine
157
169
  showBidLine: true, // Show BidLine
@@ -161,8 +173,15 @@ const dealingChart = new ChartEngine({
161
173
  dealingChart.setShowAskLine(false); // hide the ask line
162
174
  dealingChart.setShowBidLine(true); // show the bid line
163
175
  dealingChart.setShowLtpPrice(true); // show the LTP line
176
+ // Switch the candle source programmatically (e.g. from a trade-settings page);
177
+ // the header dropdown label follows automatically:
178
+ dealingChart.setTickClosePriceSource('bid');
179
+ // A user pick from the dropdown emits `priceSourceChange` — persist it:
180
+ dealingChart.on('priceSourceChange', ({ source }) => saveTradeSetting(source));
164
181
  ```
165
182
 
183
+ **History follows the source.** Switching the source (dropdown pick or `setTickClosePriceSource()`) does not just change how live ticks extend the last candle — the engine re-runs your `dataLoader` with `params.priceSource` set to the new value and replaces the loaded bars, keeping the visible time window. Forward `priceSource` to your charting API (e.g. as a query parameter) so it returns LTP-built or BID-built candles accordingly; without it the API keeps serving the old source and the chart cannot change. Set `reloadOnPriceSourceChange: false` to opt out of the re-fetch.
184
+
166
185
  ### Compare symbols
167
186
 
168
187
  Overlay one or more comparison instruments on the main chart, normalized to
@@ -193,6 +212,9 @@ const chart = new ChartEngine({
193
212
  await chart.addCompare('MSFT');
194
213
  chart.removeCompare('MSFT');
195
214
  chart.clearCompares();
215
+ // Open the built-in picker from your own header (the engine's Compare button
216
+ // lives in the advanced header only):
217
+ chart.openComparePicker();
196
218
  chart.getCompares(); // [{ symbol, color, status }]
197
219
 
198
220
  // Events
@@ -249,6 +271,12 @@ chart.setLevels(
249
271
  );
250
272
  ```
251
273
 
274
+ **Trailing stop:** a trade with a trail always has a working stop, so keep passing its price as `stopLossPrice` and set `stopLossTrailing: true`. The line renders as usual but its pill reads **TSL** instead of SL, and it is read-only on the chart — no drag handle, no × — because the trail is a pip distance edited from the host's own panel. The level's TP stays fully editable.
275
+
276
+ ```ts
277
+ { label: 'POS-2', price: 1.0850, side: 'buy', lots: 1, stopLossPrice: 1.0800, stopLossTrailing: true }
278
+ ```
279
+
252
280
  **Bracket orders via `ToClose`** — link a separate SL/TP pending order to a parent level:
253
281
 
254
282
  ```ts
@@ -312,6 +340,37 @@ chart.on('tradeLevelQtyChange', ({ label, type, newLots, previousLots }) => {
312
340
 
313
341
  When a trade level's entry, SL, or TP price lies outside the visible price range, a small pill (`▲ N` / `▼ N`) appears near the right edge of the chart showing the count of off-screen markers per direction. Clicking the pill smooth-scrolls the nearest off-viewport marker to the vertical center of the chart. This requires no configuration — it activates automatically whenever levels are present.
314
342
 
343
+ **Clustered price-axis tags and the trade list (TradingView-style):**
344
+
345
+ Every open position puts a price tag on the right-hand scale. Tags that would
346
+ overlap used to be pushed down the axis one after another, so a bulk fill of
347
+ fifteen positions became a column of tags covering the whole scale. With
348
+ `levelClusteringEnabled` (the default) tags closer than `clusterThresholdDistance`
349
+ now merge into **one tag at their average price with a count bubble** on its
350
+ left edge — buy-coloured when every member is a buy, sell-coloured when every
351
+ member is a sell, the neutral position colour for a mixed run.
352
+
353
+ Hovering a merged tag (tapping it on touch — a tap elsewhere dismisses it)
354
+ opens a trade list beside it, the way TradingView summarises a stack of
355
+ executions:
356
+
357
+ ```
358
+ ┃ (12) Sell
359
+ ┃ 130 @ 1.11848 avg price
360
+ ┃ TRADES
361
+ ┃ 10 @ 1.11811 Tue, Oct 13, 12:41
362
+ ┃ 10 @ 1.11828 Tue, Oct 13, 12:46
363
+ ┃ …
364
+ ```
365
+
366
+ The header counts the members and names the side (`8 Buy · 4 Sell` for a mixed
367
+ run); the second line is the summed `lots` at the lots-weighted average price
368
+ (plain mean, and no size, when a member has no `lots`); the rows are each
369
+ position's `lots @ price` and its `timestamp` in the chart's timezone (its
370
+ `pnlText` instead when there is no timestamp), oldest first, twelve rows then
371
+ `+N more`. The same list opens when hovering an on-chart cluster badge. With
372
+ `levelClusteringEnabled: false` the axis is drawn exactly as before.
373
+
315
374
  **Drag constraints (enforced live):**
316
375
 
317
376
  | Order side | Stop Loss | Take Profit |
@@ -333,13 +392,15 @@ When `enableTrading` is on and live BID/ASK data is streaming, hovering / activa
333
392
 
334
393
  ### Horizontal (time-axis) order-line dragging
335
394
 
336
- Opt-in via `features.orderLineTimeDrag` (off by default; enable it per broker from the host app — e.g. only for Hankotrade users). When enabled, any level flagged `timeDraggable: true` can have its **info-box badge** dragged left/right to re-anchor the order to a different candle. The **price line stays locked** — only the time anchor moves. Grab the badge for horizontal drag (`grab` / `grabbing` cursor); the price line itself still drags vertically as before.
395
+ Opt-in via `features.orderLineTimeDrag` (off by default; enable it per broker from the host app — e.g. only for Hankotrade users). When enabled, any level flagged `timeDraggable: true` — open positions **and pending orders** alike — can have its **info-box badge** dragged left/right to re-anchor the order to a different candle. During a horizontal drag the **price line stays locked** — only the time anchor moves. A fixed-price position's badge grabs horizontally at once (`grab` / `grabbing` cursor). A pending order (or entry-editable position) keeps its price drag: its badge shows a `move` cursor and the **first movement decides the axis** — sideways moves the time anchor, up/down moves the entry price exactly as before, and the full-width line still drags vertically.
337
396
 
338
397
  ```ts
339
398
  const chart = new ChartEngine({
340
399
  container,
341
400
  features: { orderLineTimeDrag: true }, // host enables this only for the gated broker
342
401
  orderLineDragSnap: true, // snap to nearest candle on release (default)
402
+ orderLineAnchorPersistence: true, // remember dropped positions in localStorage (default)
403
+ orderLineDefaultAnchor: 'center', // un-dragged badges start mid-chart (default: 'timestamp')
343
404
  });
344
405
 
345
406
  chart.setLevels(
@@ -354,13 +415,101 @@ chart.on('orderLineMoved', ({ label, fromTimestamp, toTimestamp, data }) => {
354
415
  ```
355
416
 
356
417
  - **Bounds:** the badge is clamped to the visible viewport — it can't be dragged past the left/right edge.
418
+ - **Pending orders:** flag them `timeDraggable: true` too. The pill follows the drag direction — a sideways pull re-anchors the order in time (price locked, `orderLineMoved` on release), a vertical pull moves the entry price and its SL/TP brackets as usual (`tradeLevelDrag` / `tradeLevelEdit`). The axis is fixed after about 6px of movement and does not change mid-drag. Touch follows the same rule, and a tap that never moves still opens the edit form.
357
419
  - **Snap:** controlled by `orderLineDragSnap` (default `true`); set `false` to keep the exact released time.
358
420
  - **Escape / touch-cancel:** reverts the badge to its starting candle without emitting `orderLineMoved`.
359
421
  - **Stays put after release:** the chart holds the dropped position across `setLevels` refreshes until you echo the new `timestamp` back (auto-released on match).
422
+ - **Survives reloads:** with `orderLineAnchorPersistence` (default `true`) the dropped candle time is saved in `localStorage` under `finchart.orderLineTimeAnchors`, keyed by the level's label. After a page reload the badge returns to that candle even if your `setLevels()` payload still carries the order's original `timestamp` — the persisted anchor wins until the badge is dragged again. Capped at the 500 most recently dragged labels; set `false` if you persist anchors server-side instead.
423
+ - **New orders at the center:** with `orderLineDefaultAnchor: 'center'` a `timeDraggable` level that has never been dragged ignores its `timestamp` and renders mid-chart — so a freshly filled market order arrives in the middle of the chart rather than pinned to the latest candle at the right edge. Levels that are not `timeDraggable` still honor `timestamp`. Default `'timestamp'` keeps the previous behavior.
360
424
  - A level **without** `timestamp` renders exactly as before (badge centered), so existing consumers are unaffected.
361
425
 
362
426
  ---
363
427
 
428
+ ### Trade action bar — `features.tradeActionBar`
429
+
430
+ With the flag on, the floating trade button opens a segmented bar at the clicked
431
+ price instead of the two-row Buy/Sell popup:
432
+
433
+ ```
434
+ Alert | Pending | SL | TP | TSL | ×
435
+ ```
436
+
437
+ - No price readout — the axis already shows the price at that point.
438
+ - **Alert** calls `onAddAlert(price)`. The chip is only rendered when that callback is set. The same callback also backs the **Alert** entry of the advanced header's ⋯ menu (`headerOverflowMenu`), which passes the current market price (live ask, else bid, else last close) so the host opens the same form.
439
+ - **Pending** opens a sub-menu just above the bar — an `Above · 1.17586` / `Below · …`
440
+ header, then the two orders that make sense at that price (`Buy Limit` / `Sell Stop`
441
+ below the market, `Sell Limit` / `Buy Stop` above), each with its `@ price`. Picking a
442
+ row creates the draft order exactly as before and closes both.
443
+ - **SL / TP / TSL** emit `bulkLevelsRequested` with `{ kind, price }` and close the
444
+ bar — the host opens its Bulk Levels dialog pre-focused on that kind. The chart
445
+ never calls a broker. The chips grey out, with a tooltip saying why, when the
446
+ symbol has no open positions or has buys and sells open at once — one stop or
447
+ target price cannot sit on the right side of the market for both directions.
448
+ - **×**, a click elsewhere, Escape, TFC off and `dismissAllUI()` all close it.
449
+
450
+ Chip labels and the × tooltip live under `labels.trade` (`actionAlert`, `actionPending`,
451
+ `actionSl`, `actionTp`, `actionTsl`, `actionCloseTitle`).
452
+
453
+ ### On-chart P/L pill — `features.pnlPill`
454
+
455
+ A floating summary of the open positions on the chart, for the trader who wants
456
+ the total without opening the positions panel:
457
+
458
+ ```
459
+ ⋮⋮ 3 POS +72.65 USD | ◎ ✕
460
+ ```
461
+
462
+ - **`3 POS`** — how many position levels are on the chart. Pending orders,
463
+ position-style entry orders (`entryPriceEditable`) and an unconfirmed draft
464
+ are not counted.
465
+ - **`+72.65 USD`** — the sum of the numeric `pnl` those levels carry, i.e. the
466
+ `pnlKey` column you pass to `setLevels`. The chart computes no money itself;
467
+ while no position has a `pnl` yet the pill shows `—`. Profit is drawn in the
468
+ buy colour, loss in the sell colour.
469
+ - **Click the body** (the grip, the count or the amount) to fan every position
470
+ badge out on the chart — clustered levels expand, and TFC is switched on if it
471
+ was off — with an accent ring on the pill while they are shown. Click again to
472
+ collapse.
473
+ - **◎** (the same target glyph as the Bulk Levels dialog header) emits
474
+ `bulkLevelsRequested` with `source: 'pill'` and a `null` price, so your Bulk Levels
475
+ dialog opens pre-scoped to every open position on the symbol. Tooltip:
476
+ "Set SL · TP · TSL on all N {symbol} positions". Greyed out while buys and sells are
477
+ both open ("Bulk levels need all N {symbol} positions in one direction").
478
+ - **✕** (amber) emits `pnlPillCloseAll` with every counted position. Your app
479
+ confirms and closes them; the chart never calls a broker.
480
+
481
+ The pill sits top-right of the plot, clear of the price axis. Drag it anywhere
482
+ inside the plot; double-click puts it back. It hides itself while the chart has
483
+ no positions and follows the chart theme. Every element sits on one shared row
484
+ height, and the grip and both buttons have fixed widths, so a long or negative
485
+ amount never shifts or squeezes them.
486
+
487
+ With the feature on, the header (advanced toolbar and default top bar) also gets a
488
+ **P/L** switch — wallet icon, `P/L`, the same 24px `rounded-sm` button tinted while the
489
+ pill is shown. It hides or shows the pill on the user's say-so, wins over the position
490
+ count, and emits `pnlPillToggle` so you can persist the choice; `pnlPill.visible` seeds
491
+ it and `setPnlPillVisible()` drives it from code.
492
+
493
+ ```ts
494
+ const chart = new ChartEngine({
495
+ container,
496
+ features: { pnlPill: true },
497
+ pnlPill: { currency: 'USD' }, // → "+72.65 USD"; or format: (pnl) => myMoney(pnl)
498
+ });
499
+
500
+ chart.setLevels(positions, 'TradeID', 'Price', 'position', 'pnl', 'pnlText');
501
+
502
+ chart.on('pnlPillCloseAll', async ({ count, pnl, data }) => {
503
+ if (!(await confirmDialog(`Close all ${count} positions?`))) return;
504
+ for (const row of data) closeTrade(row);
505
+ });
506
+ ```
507
+
508
+ Labels live under `labels.pnlPill`: `positionsAbbrev` (`'POS'`), `showPositionsTitle`,
509
+ `hidePositionsTitle`, `bulkLevelsTitle`, `closeAllTitle` — titles may
510
+ use `{symbol}` and `{count}`. The maths behind the pill is exported as
511
+ `summarisePositionLevels(levels, draftLabel?)` and `formatPnlPillValue(pnl, currency?)`.
512
+
364
513
  ## Default Configuration
365
514
 
366
515
  ### `ChartConfig`
@@ -375,9 +524,13 @@ chart.on('orderLineMoved', ({ label, fromTimestamp, toTimestamp, data }) => {
375
524
  | `hideHeader` | | `false` | Hide only the chart header (TopBar / AdvancedToolbar / CompactToolbar, per `headerLayout`). Bottom bar, left drawing tools, and on-canvas overlays remain on their own flags. Drive the chart from your own UI via `setTimeframe(tf)`, `setSeries(type)`, `addIndicatorByName(name)`, `removeIndicator(name)` |
376
525
  | `showDrawingTools` | | `true` | Show drawing toolbar and pencil button |
377
526
  | `showFullscreenButton` | | `true` | Show the fullscreen toggle button in the top bar. Set to `false` to hide it entirely. Mobile wrappers (Android / iOS) default this to `false` |
378
- | `timeframe` | | `"1D"` | Initial timeframe |
527
+ | `timeframe` | | `"1D"` | Initial timeframe. Values: `1m` `3m` `4m` `5m` `10m` `15m` `30m` `1h` `2h` `4h` `8h` `1D` `1W` `1M`; `3m` / `4m` / `10m` / `30m` are requested from `dataLoader` as `3min` / `4min` / `10min` / `30min`. If your API lacks them, point `aggregateFrom` at a finer interval it does serve, e.g. `{ '3m': '1m', '4m': '1m', '10m': '5m', '30m': '15m' }` — the chart merges the candles |
528
+ | `timeframes` | | classic set | Timeframes offered in the header, in order: the advanced row's pills, the classic dropdown, and the rest of the reference header's ⌄ menu after its six fixed pills. Omit to keep `1m 5m 15m 1h 4h 1D 1W` (row) / `… 30m 2h 8h 1M` (dropdown, ⌄), so the newer minute timeframes only appear for hosts that list them |
379
529
  | `duration` | | — | Initial active duration button |
380
530
  | `symbol` | | — | Symbol name shown in the top bar |
531
+ | `features` | | `{}` | Opt-in feature gates. See [Feature flags](#feature-flags) |
532
+ | `instrument` | | — | Contract specs for `symbol` — pip size, contract size, money conversion. See [`InstrumentSpec`](#instrumentspec) |
533
+ | `account` | | — | Account equity and per-trade risk used to size the position tools. See [`AccountSpec`](#accountspec) |
381
534
  | `isins` | | — | Symbol list for the picker modal |
382
535
  | `onIsinSelect` | | — | Called when user picks a symbol from the picker modal |
383
536
  | `padding` | | `{top:8,right:0,bottom:0,left:0}` | Canvas padding (px) |
@@ -385,7 +538,7 @@ chart.on('orderLineMoved', ({ label, fromTimestamp, toTimestamp, data }) => {
385
538
  | `tickActivityMs` | | `30000` | ms the stream dot stays green after last tick |
386
539
  | `maxCandles` | | `200` | Max bars fetched per data-load request |
387
540
  | `minInitialBars` | | `10` | If `dataLoader` returns fewer bars, the fetch window auto-widens and retries (handles weekends, market closures, and sparse symbols) |
388
- | `maxLookbackMs` | | `31_536_000_000` | Hard ceiling on auto-widening lookback (ms). Retries stop once the window reaches this. Default: 365 days |
541
+ | `maxLookbackMs` | | `31_536_000_000` | Hard ceiling on auto-widening lookback (ms). Retries stop once the window reaches this. Default: 365 days. The first request (no duration selected) always covers `maxCandles` bars even when that is longer, e.g. 200 × 1W ≈ 4 years, so long-period studies (TEMA, TRIX, KVO, CRSI) draw on weekly charts |
389
542
  | `prefetchThreshold` | | `80` | Bars from start of data at which historical fetch triggers (min 20) |
390
543
  | `mobileBarDivisor` | | `2` | Divide desktop visible bar count on touch devices (`2`, `3`, or `4`) |
391
544
  | `momentumScrollEnabled` | | `true` | Enable momentum (kinetic) scrolling — chart coasts after a fast flick |
@@ -394,7 +547,7 @@ chart.on('orderLineMoved', ({ label, fromTimestamp, toTimestamp, data }) => {
394
547
  | `momentumMaxVelocity` | | `6.0` | Max launch velocity (px/ms) — caps hard-flick speed |
395
548
  | `targetCandleWidth` | | `10` | Target px width per candle for auto-calculating initial bar count |
396
549
  | `durationTimeframeMap` | | *(see below)* | Override duration → timeframe pairings |
397
- | `dataLoader` | | — | `(params) => Promise<OHLCVBar[]>` auto-called on load / change |
550
+ | `dataLoader` | | — | `({ start, end, interval, priceSource }) => Promise<OHLCVBar[]>` auto-called on load, timeframe/duration change, and price-source change. `priceSource` is the candle source to fetch (`'bid'`, `'ask'` or `'ltp'`) |
398
551
  | `compareDataLoader` | | — | `({ symbol, start, end, interval }) => Promise<OHLCVBar[]>` — fetches bars for a compare symbol. Set this to enable the library-owned Compare flow. |
399
552
  | `initialCompares` | | — | Symbols to auto-add as compares once the initial primary range is loaded |
400
553
  | `maxCompares` | | `8` | Maximum concurrent compare symbols. Adding beyond emits `compareError` |
@@ -403,6 +556,8 @@ chart.on('orderLineMoved', ({ label, fromTimestamp, toTimestamp, data }) => {
403
556
  | `labels` | | `DEFAULT_LABELS` | Deep-partial string overrides for i18n/translation |
404
557
  | `tickClosePriceSource` | | `"bid"` | Which price drives live tick close/high/low: `"bid"`, `"ask"`, or `"ltp"` (build candles from the last traded price — exchange/dealing feeds; ticks without a valid LTP fall back to the bid) |
405
558
  | `showLtpPrice` | | unset | Show the LTP marker (dashed price line + axis tag). Unset: shown only in `"ltp"` mode. `true`: always shown when the feed supplies an LTP. `false`: hidden even in `"ltp"` mode (candles still build from the LTP). Toggle at runtime with `chart.setShowLtpPrice(show)` |
559
+ | `priceSourceSelector` | | unset | Show a price-source dropdown in the chart header listing the given sources, e.g. `["ltp", "bid"]` (dealing feeds). A user pick switches the candle source, re-fetches history via `dataLoader` (see `reloadOnPriceSourceChange`) and emits `priceSourceChange`; sync it programmatically with `chart.setTickClosePriceSource(source)`. Hidden when unset/empty. Default header layout only |
560
+ | `reloadOnPriceSourceChange` | | `true` | Re-run `dataLoader` with the new `params.priceSource` whenever the candle price source changes, so historical candles are rebuilt from the selected price (LTP vs BID) and not only the live one. The visible time window is preserved. Set `false` to keep only live ticks following the new source |
406
561
  | `showBidAskLines` | | `false` | **Deprecated** — show both bid and ask as dashed lines during a live stream. Prefer the per-line checks `showAskLine` / `showBidLine`, which override it when set |
407
562
  | `showAskLine` | | unset | Show the Ask price line (dashed line + axis tag) independently. Unset: legacy `showBidAskLines` behavior. Toggle at runtime with `chart.setShowAskLine(show)` |
408
563
  | `showBidLine` | | unset | Show the Bid price line (dashed line + axis tag) independently. Unset: legacy `showBidAskLines` behavior. Toggle at runtime with `chart.setShowBidLine(show)` |
@@ -412,31 +567,150 @@ chart.on('orderLineMoved', ({ label, fromTimestamp, toTimestamp, data }) => {
412
567
  | `showPriceAxisCountdown` | | `false` | Show candle countdown timer on the right price axis, just below the live price tag. Honours `candleCountdownTimeframes`. Toggleable from the Settings dialog (Appearance tab). |
413
568
  | `maxSubPanes` | | `3` | Max simultaneous oscillator sub-panes |
414
569
  | `tradeDisplayFilter` | | `"all"` | Which TFC levels are visible: `"all"` · `"positions"` · `"orders"` · `"none"` |
570
+ | `pnlPill` | | — | `{ currency?, format?, visible? }` for the on-chart P/L pill — currency code appended to the amount, or your own formatter. Only read when `features.pnlPill` is on |
415
571
  | `positionRenderStyle` | | auto | Force position render style: `"line"` or `"dot"` |
416
572
  | `hideLevelConfirmCancel` | | `false` | Hide on-canvas ✓/✗ confirm-cancel buttons for TFC level edits |
417
573
  | `deselectActiveOnOutsideClick` | | `false` | When `true`, clicking/tapping anywhere outside a selected trade level dismisses it (reverting any pending edits, mirroring ✗ Cancel). Default `false` keeps the level active so incidental clicks — price-axis resize, taps outside the QTY input — don't drop an in-progress edit. The level can still be dismissed via ✓/✗, tapping the level again, or `setLevels()` removing it |
418
574
  | `showTradeLevelsAlways` | | `false` | Always render SL/TP bracket lines + price pills, even when the parent level is not hovered or selected. The close (×) button stays hover-only so the chart isn't cluttered. Toggleable from the Settings dialog (Trading tab). Persisted in `localStorage`. |
575
+ | `revealNewBrackets` | | `false` | When a level gains a **new** SL/TP — placed from the host's order panel, so it arrives via `setLevels()`, `updateLevelBracket()` or `updateDraftOrderBracket()` — whose price sits outside the visible price range, widen (compress) the price axis so the new line comes into view together with the candles already on screen. Animated, and the trader can drag or reset the axis afterwards as usual. A bracket dragged on the chart is visible by construction and never triggers it. Inert in compare (percent) mode |
419
576
  | `tradeLevelButtonScale` | | `1` | Multiplier for trade-level Confirm/Cancel/Edit/Close button radii and gaps. Scales visuals **and** hit/drag areas together — raise it on touch devices for larger tap targets. Clamped to `[1, 3]`. Also settable at runtime via `chart.setTradeLevelButtonScale(scale)` |
577
+ | `tradeLevelBadgeScale` | | `1` | Shrinks the on-chart position / order tag — font, padding, grip, ✎ × buttons and their hit areas together. `0.6` is about half the default area. Clamped `[0.5, 1]` |
578
+ | `tradeLevelBadgeAnchor` | | `"auto"` | `"left"` homes the tag at the plot's left edge like an axis label, whatever the open price or time anchor, so it never covers candles until the trader drags it. With `features.orderLineTimeDrag` the tag stays draggable along the time axis and the drop is remembered as before |
420
579
  | `tfcEnabled` | | `true` | Enable the TFC toggle button in the top bar. When `false`, TFC is completely disabled — the toggle button is hidden and all trade levels, draft orders, and the floating trade button are suppressed |
421
- | `features.orderLineTimeDrag` | | `false` | Enable **horizontal (time-axis) order-line dragging**. A level with `timeDraggable: true` can have its info-box badge dragged left/right to re-anchor the order to a different candle — **price stays locked**. Emits `orderLineMoved` on release. Off by default; intended to be enabled per broker (e.g. Hankotrade) by the host app |
580
+ | `crosshairEnabled` | | `true` | Draw the mouse crosshair. `false` hides it **and** the floating "place order at this price" button that rides on its horizontal line, ignores mirrored `setCrosshair()` calls and keeps the mobile long-press crosshair from arming. Trade levels, drawings and TFC are unaffected. Runtime: `chart.setCrosshairEnabled(enabled)` |
581
+ | `enableCrossHairHeader` | | `false` | Crosshair on/off switch in the header (advanced toolbar, default top bar, the compact per-pane strip and the mobile header): the reference `rounded-sm` button. The crosshair is on by default, so the icon starts tinted; a click hides the crosshair (and the trade button riding on it) and drops the icon to its plain state, the next click brings both back. Calls `setCrosshairEnabled()` and emits `crosshairToggle` so the host can persist the choice — see [Header crosshair switch](#header-crosshair-switch--enablecrosshairheader) |
582
+ | `enableCrosshairToggle` | | `false` | **Deprecated** — alias of `enableCrossHairHeader`; either flag renders the switch |
583
+ | `headerOverflowMenu` | | `false` | Advanced header only: the reference Hanko header. A 32px bar with six lowercase timeframe pills (`1m 5m 15m 1h 4h 1d`) and a ⌄ for the rest, then crosshair · P/L · Layout (grid icon) · ⋯ · fullscreen at 24px. Draw, Chart type, Indicators, Compare, Snapshot and Chart settings fold into the ⋯ menu; each keeps its behaviour. With `onAddAlert` set the menu also offers **Alert** (own group, before Snapshot) at the current market price |
584
+ | `features.orderLineTimeDrag` | | `false` | Enable **horizontal (time-axis) order-line dragging**. A level with `timeDraggable: true` (position or pending order) can have its info-box badge dragged left/right to re-anchor the order to a different candle — **price stays locked** while dragging sideways. Pending orders keep their vertical price drag: the badge's first movement picks the axis. Emits `orderLineMoved` on release. Off by default; intended to be enabled per broker (e.g. Hankotrade) by the host app |
422
585
  | `orderLineDragSnap` | | `true` | When horizontal dragging is enabled, snap the badge to the nearest candle on release. Set `false` to keep the exact released time |
423
- | `levelClusteringEnabled` | | `true` | Enable trade-level fan-out clustering; overlapping levels group into expandable badges |
424
- | `clusterThresholdDistance` | | `20` | Pixel proximity threshold for clustering (only when `levelClusteringEnabled` is `true`) |
586
+ | `orderLineAnchorPersistence` | | `true` | When horizontal dragging is enabled, remember where each badge was dropped in `localStorage` (key `finchart.orderLineTimeAnchors`, keyed by level label) so it comes back to the same candle after a reload. A persisted anchor overrides the level's `timestamp` until the badge is dragged again. Set `false` to persist anchors yourself |
587
+ | `orderLineDefaultAnchor` | | `'timestamp'` | Where an un-dragged `timeDraggable` badge sits: `'timestamp'` = over the candle at the level's `timestamp`; `'center'` = the chart's horizontal center (ignores `timestamp`), so a new market order lands mid-chart. Non-draggable levels always honor `timestamp` |
588
+ | `levelClusteringEnabled` | | `true` | Enable trade-level clustering: overlapping levels group into expandable badges on the chart, and overlapping position tags on the price axis merge into one tag with a count whose hover shows the trade list |
589
+ | `clusterThresholdDistance` | | `20` | Pixel proximity threshold for clustering, on the chart and on the price axis (only when `levelClusteringEnabled` is `true`) |
425
590
  | `hideSymbolAndTick` | | `false` | Hide the symbol name and tick-activity (streaming) dot in the top-left overlay. Does **not** affect the OHLC(V) strip — use `hideOHLCV` for that |
426
591
  | `hideOHLCV` | | `false` | Hide the OHLC(V) data strip (`O: H: L: C: V:`) in the top-left overlay. Independent of `hideSymbolAndTick` — set both to `true` to hide the entire overlay |
427
592
  | `showBottomBar` | | `false` | Show the bottom duration-selector bar |
593
+ | `features.enableScaleControls` | | `false` | Bottom-bar **Go to date**, **timezone** and **%** / **log** / **auto** cluster, left of the duration buttons. Needs `showBottomBar: true`. Off by default — see [Bottom Bar & Price Scale](#bottom-bar--price-scale) |
594
+ | `features.enableSidePanels` | | `false` | Right-hand docked **Data Window** / **Objects** panel (232px out of the plot). Off by default; toggle at runtime with `setSidePanelVisible()` — see [Side Panels](#side-panels) |
428
595
  | `hideQtyButton` | | `false` | Hide the floating Qty input overlay on draft orders |
429
596
  | `showQuantityField` | | `false` | Render an editable QTY pill at the left of the draft order info box. Clicking the pill opens a flyout input to edit the quantity before submitting |
430
597
  | `quantityFieldConfig` | | — | Constraints for the draft order QTY field (only relevant when `showQuantityField` is `true`). Object: `{ minLots?: number, maxLots?: number }`. `minLots` also sets the initial quantity and the step value for the flyout input (default: `1`). `maxLots` caps the input (default: `100`) |
431
598
  | `tradesThresholdForHorizontalLine` | | `2` | Level count above which render auto-switches to `"dot"` mode |
432
599
  | `timezone` | | `"UTC"` | IANA timezone string for time-axis and crosshair labels. `"UTC"` (default), `"local"` (browser/device timezone), or any IANA string (`"America/New_York"`, `"Europe/London"`, etc.) |
433
- | `canvasColors` | | — | Per-theme canvas background color overrides (persisted from Settings dialog) |
600
+ | `canvasColors` | | — | Per-theme colour overrides for the **canvas only** (persisted from the Settings dialog; runtime: `chart.setCanvasColors()`). The surface picks — `background`, `grid`, `axisText`, `axisBorder`, `crosshair` — repaint the chart canvas and leave the chart chrome (top/bottom/left bars, dialogs, popovers) on the theme from `themeOverrides`. Series picks (`candleUp`/`candleDown`/`wickUp`/`wickDown`/`borderUp`/`borderDown`/`volumeUp`/`volumeDown`) apply everywhere, so legends and indicator pills keep tracking them |
434
601
  | `aggregateFrom` | | — | Fetch finer-grained data and aggregate client-side per timeframe |
435
602
  | `onOrderSubmit` | | — | Called when user submits a trade via the floating button |
436
603
  | `onLevelEdit` | | — | Called when user confirms a TFC level edit |
437
604
  | `onLotsChange` | | — | Called when user changes qty on a draft order |
438
605
  | `onLevelDragEnd` | | — | *(deprecated — use `onLevelEdit`)* |
439
606
 
607
+ ### `InstrumentSpec`
608
+
609
+ The chart reads prices, never contract specs — so a tool that reports a distance
610
+ in **pips**, or converts one to money, has to be told how. Pass `instrument` at
611
+ construction and swap it with `setInstrument()` whenever the symbol changes.
612
+
613
+ ```ts
614
+ const chart = new ChartEngine({
615
+ container,
616
+ symbol: 'EURUSD',
617
+ instrument: {
618
+ pipSize: 0.0001, // 0.01 for JPY crosses
619
+ contractSize: 100_000, // units per lot
620
+ valuePerPoint: 1, // account-currency value of one price unit per unit
621
+ currencySymbol: '$',
622
+ },
623
+ });
624
+
625
+ chart.setSymbol('USDJPY').setInstrument({ pipSize: 0.01, contractSize: 100_000 });
626
+ ```
627
+
628
+ | Field | Default | Description |
629
+ |---|---|---|
630
+ | `pipSize` | inferred | Price distance counted as one pip |
631
+ | `contractSize` | `1` | Units per lot (`100` for XAUUSD, `100000` for most FX pairs) |
632
+ | `valuePerPoint` | `1` | Account-currency value of one price unit per contract unit |
633
+ | `currencySymbol` | `'$'` | Prefixed to money figures |
634
+
635
+ **Every field is optional and the whole block can be omitted** — tools fall back
636
+ to price-only readouts, so existing integrations keep working untouched.
637
+
638
+ When `pipSize` is absent it is inferred from how many decimals the feed quotes:
639
+ five-decimal (`1.08531`) and three-decimal (`151.234`) feeds carry fractional
640
+ pips, so the pip is the second-to-last digit; two- and four-decimal feeds quote
641
+ whole pips. **That convention is wrong for metals, indices and crypto** — pass
642
+ `pipSize` explicitly if pips matter.
643
+
644
+ The same fallbacks are exported for hosts that want to compute alongside the
645
+ chart:
646
+
647
+ ```ts
648
+ import { resolveInstrument, inferPipSize, toPips, toMoney } from 'acttrader-charts';
649
+ ```
650
+
651
+ > Per-position values still travel on the individual `TradeLevel`
652
+ > (`contractSize` / `valuePerPoint`). `InstrumentSpec` is the chart-wide default
653
+ > for tools that aren't attached to a position.
654
+
655
+ ### Feature flags
656
+
657
+ Newer tools ship behind `ChartConfig.features` so each host opts in. **Every flag
658
+ defaults to `false`** — a chart that sets none behaves exactly as it did before
659
+ the flag existed, including its toolbar contents.
660
+
661
+ ```ts
662
+ const chart = new ChartEngine({
663
+ container,
664
+ features: { enableForecasting: true },
665
+ });
666
+ ```
667
+
668
+ | Flag | Default | What it changes |
669
+ |---|---|---|
670
+ | `enableForecasting` | `false` | The reworked drawing tools, as one switch — see below |
671
+ | `orderLineTimeDrag` | `false` | Horizontal (time-axis) order-line dragging for positions and pending orders — see [Horizontal order-line dragging](#horizontal-time-axis-order-line-dragging) |
672
+ | `pnlPill` | `false` | Floating on-chart P/L pill — count and summed P/L of the positions on the chart — see [On-chart P/L pill](#on-chart-pl-pill--featurespnlpill) |
673
+ | `tradeActionBar` | `false` | The trade button opens a segmented `Alert · Pending · SL · TP · TSL · ×` bar at the clicked price instead of the two-row Buy/Sell popup — see [Trade action bar](#trade-action-bar--featurestradeactionbar) |
674
+
675
+ `enableForecasting` turns on three things together:
676
+
677
+ - **Long Position** / **Short Position** in a new **Forecasting** group in the
678
+ drawing toolbar. With the flag off the group is not rendered at all.
679
+ - **Brush** and **Highlighter** draw freehand — press, drag, release. Off, they
680
+ keep the click-per-point gesture ended by a double-click.
681
+ - **Ruler** reports percent, pips, calendar duration and volume. Off, it reports
682
+ the bar count and raw price delta as before.
683
+
684
+ They share one flag because they ship and roll back together: a broker either has
685
+ the reworked drawing tools or it doesn't.
686
+
687
+ Related config, independent of the flag: the position tools show quantity and
688
+ money only when [`account`](#accountspec) is supplied, and pips on both the
689
+ position tools and the ruler come from [`instrument`](#instrumentspec) — without
690
+ it, pip size is inferred from the feed's decimal count.
691
+
692
+ ### `AccountSpec`
693
+
694
+ What the **Long / Short position** tools size themselves against.
695
+
696
+ TradingView asks the user to type these because it has no broker connection. We
697
+ do have one — so pass real equity and keep it current.
698
+
699
+ ```ts
700
+ chart.setAccount({ size: 10_000, riskPercent: 1 });
701
+ ```
702
+
703
+ | Field | Default | Description |
704
+ |---|---|---|
705
+ | `size` | — | Account equity in the account currency |
706
+ | `riskPercent` | `1` | Percent of the account risked per trade |
707
+
708
+ Omit the block and the position tools still draw — price, percent, pips and
709
+ risk/reward all render; only the quantity and money amounts are left out.
710
+
711
+ > A sketch drawn against a stale balance reports the wrong quantity rather than
712
+ > failing visibly, so push `setAccount()` whenever equity moves.
713
+
440
714
  ### `OHLCVBar`
441
715
 
442
716
  | Field | Required | Type | Description |
@@ -477,7 +751,7 @@ import { DEFAULT_UI_CONFIG } from 'acttrader-charts';
477
751
 
478
752
  | Component key | Configurable properties |
479
753
  |---|---|
480
- | `drawingToolbar` | `iconBtnSize`, `iconFontSize`, `iconFontFamily`, `flyoutLabelFontSize`, `flyoutLabelFontFamily`, `shortcutFontSize`, `soonBadgeFontSize`, `flyoutHeadingFontSize`, `flyoutHeadingLetterSpacing`, `scrollBtnHeight`, `scrollBtnFontSize`, `barPadding`, `btnGap` |
754
+ | `drawingToolbar` | `iconBtnSize`, `iconFontSize`, `iconFontFamily`, `flyoutLabelFontSize`, `flyoutLabelFontFamily`, `shortcutFontSize`, `soonBadgeFontSize`, `flyoutHeadingFontSize`, `flyoutHeadingLetterSpacing`, `scrollBtnHeight`, `scrollBtnFontSize`, `barPadding`, `btnGap`, `modernIcons`, `actionsButtonIcon` (`'default'` \| `'lock'` — lock glyph on the Actions button, for hosts that relabel Lock All as "Save Charts") |
481
755
  | `topBar` | `height`, `dropBtnFontSize`, `dropBtnFontFamily`, `drawBtnSize`, `drawBtnIconFontSize`, `iconBtnSize`, `mobileIconBtnSize`, `mobileDrawBtnIconSize`, `flyoutRowFontSize`, `flyoutRowFontFamily`, `flyoutCategoryFontSize`, `flyoutCategoryLetterSpacing`, `flyoutCheckFontSize`, `streamDotSize` |
482
756
  | `bottomBar` | `height`, `btnFontSize`, `btnFontFamily` |
483
757
  | `priceAxis` | `fontSize`, `fontFamily` |
@@ -555,6 +829,11 @@ new ChartEngine({
555
829
  | `topBar.series` | `area` | `'Area'` | |
556
830
  | `topBar` | `indicatorsBtn` | `'Indicators'` | Indicators button (no active) |
557
831
  | `topBar` | `indicatorsBtnActive` | `'Indicators ({count})'` | `{count}` replaced at runtime |
832
+ | `topBar` | `disableCrosshairTitle` | `'Disable crosshair'` | Header crosshair switch tooltip while on (`enableCrossHairHeader`) |
833
+ | `topBar` | `enableCrosshairTitle` | `'Enable crosshair'` | Header crosshair switch tooltip while off |
834
+ | `topBar` | `pnlPillBtn` | `'P/L'` | Header P/L pill switch label (`features.pnlPill`) |
835
+ | `topBar` | `hidePnlPillTitle` | `'Hide P/L pill for {symbol}'` | P/L switch tooltip while the pill is shown |
836
+ | `topBar` | `showPnlPillTitle` | `'Show P/L pill for {symbol}'` | P/L switch tooltip while the pill is hidden |
558
837
  | `topBar` | `toggleDrawingTitle` | `'Toggle Drawing Tools'` | Toolbar toggle tooltip |
559
838
  | `topBar` | `toggleFullscreenTitle` | `'Toggle Fullscreen'` | Fullscreen button tooltip |
560
839
  | `topBar` | `exitFullscreenTitle` | `'Exit Fullscreen'` | |
@@ -597,6 +876,16 @@ new ChartEngine({
597
876
  | `trade` | `market` | `'Market'` | |
598
877
  | `trade` | `qty` | `'Qty'` | Draft order qty input label |
599
878
  | `trade` | `placeOrderTitle` | `'Place order at this price'` | Trade button tooltip |
879
+ | `trade` | `actionAlert` … `actionTsl` | `'Alert'`, `'Pending'`, `'SL'`, `'TP'`, `'TSL'` | Trade action bar chips (`features.tradeActionBar`) |
880
+ | `trade` | `actionCloseTitle` | `'Close'` | Trade action bar × tooltip |
881
+ | `trade` | `directionAbove`, `directionBelow` | `'Above'`, `'Below'` | Pending sub-menu header — side of the market the clicked price is on |
882
+ | `trade` | `actionBulkNoPositionsTitle`, `actionBulkMixedTitle` | `'No open {symbol} positions'`, `'Bulk levels need all {symbol} positions in one direction'` | SL / TP / TSL chips greyed out — why |
883
+ | `pnlPill` | `positionsAbbrev` | `'POS'` | On-chart P/L pill — word after the count (`3 POS`), shown uppercase |
884
+ | `pnlPill` | `showPositionsTitle` | `'Show positions on chart · drag handle to move · double-click to reset'` | Pill tooltip while collapsed — a body click fans the positions out |
885
+ | `pnlPill` | `hidePositionsTitle` | `'Hide positions on chart · drag handle to move · double-click to reset'` | Pill tooltip while fanned out |
886
+ | `pnlPill` | `bulkLevelsTitle` | `'Set SL · TP · TSL on all {count} {symbol} positions'` | ◎ tooltip — opens the host's Bulk Levels dialog |
887
+ | `pnlPill` | `bulkLevelsMixedTitle` | `'Bulk levels need all {count} {symbol} positions in one direction'` | ◎ tooltip while greyed out for mixed sides |
888
+ | `pnlPill` | `closeAllTitle` | `'Close all {symbol} positions'` | Amber ✕ tooltip; `{symbol}` and `{count}` are filled in |
600
889
  | `dialogs.settings` | `title` | `'Chart Settings'` | Dialog title |
601
890
  | `dialogs.settings` | `tfcSection` | `'Trade from Charts'` | Section heading |
602
891
  | `dialogs.settings` | `showLabel` | `'Show'` | Radio group label |
@@ -637,8 +926,8 @@ chart.setTheme('light');
637
926
  |---|---|---|
638
927
  | `background` | `string` | Canvas background color |
639
928
  | `grid` | `string` | Grid line color |
640
- | `axisText` | `string` | Axis tick label color |
641
- | `axisBorder` | `string` | Axis border / separator color |
929
+ | `axisText` | `string` | Axis tick label color. Also the default label colour for the chart chrome (dialog text, toolbar labels) — set it via `themeOverrides` to restyle both, or via the Settings dialog to restyle the canvas axis alone |
930
+ | `axisBorder` | `string` | Axis border / separator color. Also the default border colour for the chart chrome (dialog frames, toolbar dividers) — same split as `axisText` |
642
931
  | `crosshair` | `string` | Crosshair line color |
643
932
  | `tooltip` | `{background, text, border}` | Floating label colors |
644
933
  | `candle` | `{up, down, wickUp, wickDown}` | Candlestick colors |
@@ -673,7 +962,7 @@ chart.resetData(): this
673
962
 
674
963
  **Symbol switch pattern:**
675
964
  ```ts
676
- chart.setSymbol('GBPUSD').resetData();
965
+ chart.setSymbol('GBPUSD').setInstrument({ pipSize: 0.0001 }).resetData();
677
966
  // … fetch new bars …
678
967
  chart.loadData(newBars);
679
968
  ```
@@ -682,12 +971,17 @@ chart.loadData(newBars);
682
971
 
683
972
  ```ts
684
973
  chart.setSeries(series: SeriesType): this
974
+ chart.setInstrument(spec: InstrumentSpec | undefined): this // contract specs for pips / money readouts
975
+ chart.getInstrument(): ResolvedInstrument // specs with fallbacks applied
976
+ chart.setAccount(account: AccountSpec | undefined): this // equity + risk for the position tools
977
+ chart.getAccount(): AccountSpec | undefined
685
978
  chart.setTheme('dark' | 'light'): this
686
979
  chart.setTimeframe(tf: Timeframe): this // change timeframe and reload data
687
980
  chart.setDuration(d: Duration, timeframe?: Timeframe): this // select a duration; pairs the timeframe and reloads
688
981
  chart.getDuration(): Duration | null // currently selected duration
689
982
  chart.setBracketLabelMode(mode, currencySymbol?): this // SL/TP pills: 'price' (default) or 'amount'
690
- chart.setThemeOverrides(overrides: ThemeOverrides): this // update per-theme color overrides at runtime
983
+ chart.setThemeOverrides(overrides: ThemeOverrides): this // update per-theme color overrides at runtime — canvas AND chrome
984
+ chart.setCanvasColors(colors: ChartConfig['canvasColors'] | null): this // recolour the canvas only at runtime (same picks as the Settings dialog); null clears
691
985
  chart.setTimezone(tz: string): this // change display timezone at runtime
692
986
  chart.setVolume(show: boolean): this
693
987
  ```
@@ -698,6 +992,7 @@ chart.setVolume(show: boolean): this
698
992
 
699
993
  ```ts
700
994
  chart.addIndicator(indicator: IIndicator, initialParams?: IndicatorParams): this
995
+ chart.addIndicatorByName(shortName: string, params?: IndicatorParams): void // params from a host are normalised to the study's schema first: "20" → 20 (clamped to min/max), "Close" → 'close', TradingView's `length` → `period` — so a native settings sheet draws the same line as the web
701
996
  chart.addIndicatorByName(shortName: string, params?: IndicatorParams): void
702
997
  chart.removeIndicator(idOrShortName: string): this
703
998
  ```
@@ -725,6 +1020,7 @@ chart.cancelCurrentEdit(): this // cancel the active draft order or any in-pro
725
1020
  chart.addLevelBracket(label: string, bracketType: 'sl' | 'tp'): this // auto-place a SL or TP bracket at a default offset; emits tradeLevelBracketActivated with the computed price
726
1021
  chart.setDraftBracketPnl(bracketType: 'sl' | 'tp', pnlText: string | null): this // set estimated P&L text on the active bracket host — the draft order while drafting, or the currently selected existing pending order / position while modifying; pass null to clear
727
1022
  chart.setTfcActive(enabled: boolean): this // toggle TFC on/off at runtime; hides/shows all trade levels, draft orders, and floating trade button; fires tfcToggle event
1023
+ chart.setPnlPillVisible(visible: boolean): this // show/hide the on-chart P/L pill (features.pnlPill) — what the header's P/L switch does; emits pnlPillToggle
728
1024
  ```
729
1025
 
730
1026
  > **Staging semantics for `updateLevelMainPrice` / `updateLevelBracket`.** Calls to these methods register the change in the chart's pending-edit buffer (the same buffer chart-initiated drags use), so the edit stays visible even when the host app keeps pushing fresh server state via `setLevels` (e.g. per-tick PnL refreshes). When the server echoes back the new price in a later `setLevels` call, the staged edit is auto-released (mobile, i.e. `hideLevelConfirmCancel: true`). If a panel closes without submitting, call `cancelLevelEdit(label)` or `cancelCurrentEdit()` to drop the staged edit — otherwise it will keep overriding server state on the chart.
@@ -733,8 +1029,39 @@ chart.setTfcActive(enabled: boolean): this // toggle TFC on/off at runtime; hi
733
1029
 
734
1030
  ```ts
735
1031
  chart.setOrderLots(lots: number): void // update default qty at runtime
1032
+ chart.setCrosshairEnabled(enabled: boolean): this // off hides the crosshair AND this button — it rides on the crosshair line, so it has no place once the line is gone
1033
+ ```
1034
+
1035
+ The button follows the crosshair's horizontal line, so it is only ever shown while the
1036
+ crosshair is (`crosshairEnabled`, default `true`). Hosts with a crosshair on/off control
1037
+ should call `setCrosshairEnabled()` rather than painting the crosshair transparent — the
1038
+ latter leaves the button riding an invisible line.
1039
+
1040
+ ### Header crosshair switch — `enableCrossHairHeader`
1041
+
1042
+ ```ts
1043
+ const chart = new ChartEngine({
1044
+ container,
1045
+ enableCrossHairHeader: true, // crosshair icon in the header; the crosshair itself starts on
1046
+ });
1047
+ chart.on('crosshairToggle', ({ enabled }) => saveUserPref('crosshair', enabled));
736
1048
  ```
737
1049
 
1050
+ With the flag on, a crosshair icon sits in the header's right cluster (advanced toolbar and
1051
+ default top bar) or at the end of the compact per-pane strip (`headerLayout: 'compact'`). The chart crosshair is shown as usual on load and the icon is tinted. Clicking
1052
+ the icon hides the crosshair — and the floating trade button that rides on it — and the icon
1053
+ drops to its plain state; clicking again brings both back. Each click goes through
1054
+ `setCrosshairEnabled()` and emits `crosshairToggle`, so a host can persist the choice and seed
1055
+ it on the next load with `crosshairEnabled: false`. `enableCrosshairToggle` is the deprecated
1056
+ name of the same flag and still works. Native wrappers get the same switch via their
1057
+ `enableCrossHairHeader` init option, the `onCrosshairToggle` callback and `setCrosshairEnabled()`.
1058
+
1059
+ ### Native UI catalog (webview bridge only)
1060
+
1061
+ The webview bridge used by the Android and iOS wrappers posts a **`catalog`** event right after every `init` command, and again on the **`getCatalog`** command (which also works before `init`). It lists everything a native app needs to build its own header, drawing-tools sheet, indicators list and chart-settings screen: timeframes, chart types, cursors, drawing tool groups (with point counts and style fields), drawing-toolbar options, indicators (with pane and editable settings fields), the Chart Settings dialog (tabs → sections → items with types, options and defaults), canvas colour keys, price sources and limits. Every entry carries `available` (`true` = works in this version, `false` = planned). `catalogVersion` is the library version, stamped at build time.
1062
+
1063
+ Source: `src/core/mobileCatalog.ts`. `src/__tests__/mobileCatalog.test.ts` fails the build if an `available: true` id does not exist in the engine, or an engine tool / indicator is missing from the catalog — update the catalog together with any new chart type, tool or indicator. The web `ChartEngine` API is unchanged.
1064
+
738
1065
  ### Viewport
739
1066
 
740
1067
  ```ts
@@ -885,7 +1212,7 @@ Sub-pane heights are user-resizable by dragging the separator between panes.
885
1212
  | `horizontalLine` | Horizontal Line | Single click |
886
1213
  | `verticalLine` | Vertical Line | Single click |
887
1214
  | `trendLine` | Trend Line | 2 clicks |
888
- | `trendAngle` | Trend Angle | 2 clicks (shows angle) |
1215
+ | `trendAngle` | Trend Angle | 2 clicks. The angle is the drawing's own property: measured when a point is placed or dragged, kept while zooming, and saved as `angle` on the serialized drawing — so a synced drawing shows the same degrees on web, Android and iOS. The line is drawn from the first point at that angle, as far as the second point projects; the stored points are never rewritten by rendering (before 1.4.0-beta.4 the second point was re-anchored onto the line on every render, which lengthened the line on every zoom or tick until it ran off the pane). |
889
1216
  | `crossLine` | Cross Line | Single click (H+V) |
890
1217
  | `parallelChannel` | Parallel Channel | 3 clicks |
891
1218
  | `flatChannel` | Flat Channel | 2 clicks |
@@ -893,9 +1220,11 @@ Sub-pane heights are user-resizable by dragging the separator between panes.
893
1220
 
894
1221
  ### SL/TP bracket labels — price or amount
895
1222
 
896
- By default an SL/TP pill reads `SL 4159.00`. With `bracketLabelMode: 'amount'`
897
- it reads `SL -$290.80` — the money the position gains or loses if that bracket
898
- is hit, currency symbol in front.
1223
+ By default an SL/TP pill reads `SL @4159.00` — the currency sign is never shown on
1224
+ a price. With `bracketLabelMode: 'amount'` it reads `SL -$290.80` — the money the
1225
+ position gains or loses if that bracket is hit, currency symbol in front and a
1226
+ minus only on a loss. `'priceAndAmount'` shows `SL @4159.00` at rest and
1227
+ `SL @4159.00 -$290.80` while the bracket is dragged.
899
1228
 
900
1229
  The chart has no access to contract specs or the account currency, so each level
901
1230
  supplies what the maths needs:
@@ -910,7 +1239,7 @@ chart.setLevels([{
910
1239
  valuePerPoint: 1, // account-currency value of one price unit
911
1240
  currencySymbol: '$', // optional per-level override
912
1241
  }], 'label', 'price', 'position');
913
- // pills render: SL -$290.80 TP +$189.20
1242
+ // pills render: SL -$290.80 TP $189.20
914
1243
  ```
915
1244
 
916
1245
  ```
@@ -954,10 +1283,78 @@ In a **per-drawing** style (`SerializedDrawing.style.levels`), a level may also
954
1283
 
955
1284
  `rectangle` · `rotatedRectangle` · `ellipse` · `triangle` · `circle` · `arc` · `polyline` · `path` · `brush` · `highlighter`
956
1285
 
1286
+ **`brush` and `highlighter` draw freehand** when `features.enableForecasting` is on
1287
+ (see [Feature flags](#feature-flags)): press, drag, release — the pointer
1288
+ path is sampled continuously and the finished stroke is thinned to the points
1289
+ that carry its shape. (`polyline` and `path` remain click-per-point, ended with a
1290
+ double-click.)
1291
+
957
1292
  ### Measurements & Annotations
958
1293
 
959
1294
  `priceRange` · `dateRange` · `datePriceRange` · `ruler` · `priceLabel` · `priceNote` · `priceProjection` · `projection` · `arrowUp` · `arrowDown` · `arrowMarker` · `text` · `callout` · `anchoredNote` · `flag` · `sineLine` · `regressionTrend` · `ghostFeed`
960
1295
 
1296
+ **`ruler`**, with `features.enableForecasting` on (see [Feature flags](#feature-flags)),
1297
+ draws a direction-tinted box over the span it measures and reports:
1298
+
1299
+ ```
1300
+ 0.00455 (0.80%) 45.5
1301
+ 43 bars, 9d 4h
1302
+ Vol 102.27K
1303
+ ```
1304
+
1305
+ Price delta, percent, and **pips**; bar count and calendar duration; volume
1306
+ traded across the span.
1307
+
1308
+ A pip figure is always shown — but it is only *correct* when the host supplies
1309
+ [`instrument.pipSize`](#instrumentspec). Without it the chart infers pip size
1310
+ from how many decimals the feed quotes, which follows the usual FX convention
1311
+ and is wrong for metals, indices and crypto.
1312
+
1313
+ Duration needs bar timestamps, and the volume line is omitted entirely when the
1314
+ bars carry no volume, rather than showing a `0` that would read as "no volume
1315
+ traded".
1316
+
1317
+ The box takes its colour from `candle.up` / `candle.down` so a measurement never
1318
+ disagrees with the candles underneath it; picking a colour in the style popover
1319
+ overrides that. All four corners resize.
1320
+
1321
+ With the flag off it reports the bar count and raw price delta, as it always has.
1322
+
1323
+ ### Forecasting
1324
+
1325
+ `longPosition` · `shortPosition`
1326
+
1327
+ > Requires `features.enableForecasting` — see [Feature flags](#feature-flags).
1328
+ > With the flag off the group is not rendered, on the desktop toolbar or in the
1329
+ > mobile tools sheet.
1330
+ >
1331
+ > `ghostFeed` and `projection` stay in **Advanced Tools** and are ungated — they
1332
+ > predate this group.
1333
+
1334
+ **`longPosition` / `shortPosition`** sketch a trade that hasn't been placed: a
1335
+ green profit zone from entry to target, a red risk zone from entry to stop, and
1336
+ live readouts.
1337
+
1338
+ ```
1339
+ Target: 0.00313 (0.549%) 31.3, Amount: 5622.13
1340
+ Open PnL: 0.00146, Qty: 11323
1341
+ Risk/reward ratio: 1.84
1342
+ Stop: 0.00170 (0.298%) 17.0, Amount: 2887.5
1343
+ ```
1344
+
1345
+ Two clicks place it — entry, then target — and the stop lands at a 2:1
1346
+ reward:risk for you to drag. All three prices have handles.
1347
+
1348
+ Quantity is sized so that hitting the stop costs exactly `riskPercent` of the
1349
+ account, which needs [`account`](#accountspec); the money amounts and pips also
1350
+ need [`instrument`](#instrumentspec). Without them the tool still draws and still
1351
+ reports price, percent and risk/reward.
1352
+
1353
+ > **This is a drawing, not an order.** Trade-From-Chart (`setLevels`) is what
1354
+ > puts real broker orders on the chart — see [Trade From Chart
1355
+ > (TFC)](#trade-from-chart-tfc--setlevels). Nothing on a position tool reaches
1356
+ > the broker.
1357
+
961
1358
  ### Volume Profile
962
1359
 
963
1360
  `volumeProfile` · `anchoredVP` · `fixedRangeVP`
@@ -966,6 +1363,10 @@ In a **per-drawing** style (`SerializedDrawing.style.levels`), a level may also
966
1363
 
967
1364
  `cyclicLines` · `timeCycles`
968
1365
 
1366
+ > `ghostFeed` and `projection` remain here and are ungated. TradingView files
1367
+ > them under Forecasting, but moving them would have reshuffled the toolbar for
1368
+ > hosts that never enabled the new group.
1369
+
969
1370
  ### Harmonic & Elliott Patterns
970
1371
 
971
1372
  `abcdPattern` · `headShoulders` · `trianglePattern` · `batPattern` · `butterflyPattern` · `crabPattern` · `gartleyPattern` · `cypherPattern` · `sharkPattern` · `threeDrives` · `elliottImpulse` · `elliottCorrective` · `elliottTriangle` · `elliottCombination` · `elliottWxy`
@@ -986,11 +1387,13 @@ chart.on('pan', ({ viewport }) => {});
986
1387
  chart.on('timeframeChange',({ timeframe }) => {});
987
1388
  chart.on('durationChange', ({ duration, timeframe }) => {});
988
1389
  chart.on('seriesChange', ({ series }) => {});
1390
+ chart.on('priceSourceChange', ({ source }) => {}); // header BID/ASK/LTP dropdown pick
989
1391
  chart.on('streamStatus', ({ status }) => {}); // 'connected' | 'reconnecting' | 'disconnected'
990
- chart.on('dataLoaded', ({ timeframe, interval, start, end }) => {});
1392
+ chart.on('dataLoaded', ({ timeframe, interval, start, end, priceSource }) => {});
991
1393
  chart.on('newBar', ({ completedBar, openingBar, intervalMs }) => {});
992
1394
  chart.on('indicatorAdded', ({ instanceId, shortName, params }) => {}); // a study instance was added — keep instanceId to remove it later
993
1395
  chart.on('indicatorRemoved', ({ instanceId, shortName }) => {});
1396
+ chart.on('indicatorLimitReached', ({ shortName, maxSubPanes }) => {});
994
1397
  chart.on('stateChange', ({ symbol, state }) => {
995
1398
  // Fires after every user-driven config change (timeframe, series, theme,
996
1399
  // indicators, drawings, volume). `state` is a full serializable snapshot.
@@ -1010,6 +1413,10 @@ chart.on('tradeLevelEditCancelled', ({ label, type, isFullscreen }) => {}); // E
1010
1413
  chart.on('draftInitiated', ({ side, price, orderType, isFullscreen }) => {}); // new draft order shown — open buy/sell form
1011
1414
  chart.on('draftCancelled', ({ label, isFullscreen }) => {}); // draft order dismissed without confirming
1012
1415
  chart.on('tfcToggle', ({ enabled }) => {}); // TFC toggled on or off via top bar button or setTfcActive()
1416
+ chart.on('crosshairToggle', ({ enabled }) => {}); // crosshair switched on or off — header switch (enableCrossHairHeader) or setCrosshairEnabled(); persist it here
1417
+ chart.on('pnlPillCloseAll', ({ count, pnl, labels, data, isFullscreen }) => {}); // the on-chart P/L pill's ✕ — close these positions (features.pnlPill)
1418
+ chart.on('pnlPillToggle', ({ visible }) => {}); // the header's P/L switch showed or hid the pill — persist it here (features.pnlPill)
1419
+ chart.on('bulkLevelsRequested', ({ kind, price, source, isFullscreen }) => {}); // action-bar chip (price) or the P/L pill's ◎ (price null) — open your Bulk Levels dialog for `kind`
1013
1420
  chart.on('tradeLevelDragEnd', ({ label, type, newPrice, data }) => {}); // deprecated — use tradeLevelEdit
1014
1421
  chart.on('tradeLevelBracketDrag', ({ label, bracketType, newPrice, data }) => {}); // deprecated
1015
1422
 
@@ -1018,8 +1425,8 @@ chart.on('tradeLevelBracketDrag', ({ label, bracketType, newPrice, data }) => {}
1018
1425
  chart.on('orderLineMoveStart', ({ label, fromTimestamp, fromBarIndex, isFullscreen }) => {});
1019
1426
  chart.on('orderLineMoving', ({ label, toTimestamp, toBarIndex, isFullscreen }) => {}); // fires on every move
1020
1427
  chart.on('orderLineMoved', ({ label, fromTimestamp, toTimestamp, fromBarIndex, toBarIndex, data, isFullscreen }) => {
1021
- // Persist the new time anchor for this order. Echo `timestamp: toTimestamp` back in your
1022
- // next setLevels() payload so the badge stays put across refreshes.
1428
+ // The chart already remembers the drop in localStorage (orderLineAnchorPersistence, default on).
1429
+ // Hook here only if you also want to persist the new time anchor server-side.
1023
1430
  });
1024
1431
 
1025
1432
  // Fires after addLevelBracket() auto-places a bracket, delivering the computed price back to the caller
@@ -1058,6 +1465,16 @@ chart.on('snapshot', ({ dataUrl, action }) => {
1058
1465
  });
1059
1466
  ```
1060
1467
 
1468
+ ### On mobile
1469
+
1470
+ The `'mobile'` header shows the same Layout button when `enableMultipleLayouts: true`, and
1471
+ opens the same preset popover. For apps that draw their own header, the native-UI catalog
1472
+ (`catalog` event / `getCatalog`) carries a `layouts` section — every preset above with its
1473
+ `id`, `label`, `count`, `cols`, `rows`, `areas` and `icon`, plus the five sync toggles with
1474
+ their defaults — and the `layouts` toolbar entry. The app sends `setLayoutPreset(presetId)`,
1475
+ receives `layoutChange`, and mounts that many chart views itself; see the Android and iOS
1476
+ READMEs.
1477
+
1061
1478
  ### Seeding & restoring the popover's sync toggles
1062
1479
 
1063
1480
  The layout popover's five sync toggles (`symbol` / `interval` / `crosshair` / `time` / `dateRange`) default to `DEFAULT_LAYOUT_SYNC`. To open the popover in a different state — e.g. restoring a user's saved preference — pass a **partial** `layoutSync` at construction (omitted keys keep their `DEFAULT_LAYOUT_SYNC` value):
@@ -1160,45 +1577,1584 @@ The composite excludes trade levels (positions, SL/TP) — same rule as `chart.t
1160
1577
 
1161
1578
  ---
1162
1579
 
1163
- ## Custom Indicator
1580
+ ## Icons & Drawing Toolbar Options
1164
1581
 
1165
- Implement `IIndicator` and pass it to `addIndicator()`:
1582
+ ### Icons, Emojis & Stickers — `'icon'`
1583
+
1584
+ A glyph placed at a bar and price, behind `features.enableIconTools`. **One tool,
1585
+ not three**: emojis, stickers and icons are the same drawing with a different
1586
+ character, so the picker chooses the glyph and the glyph lives in `text` like any
1587
+ other annotation's content. They serialise, re-anchor and re-style through
1588
+ machinery that already exists rather than needing three parallel implementations.
1166
1589
 
1167
1590
  ```ts
1168
- import type { IIndicator, OHLCVBar, IndicatorResult } from 'acttrader-charts';
1591
+ const chart = new ChartEngine({
1592
+ container,
1593
+ features: { enableIconTools: true },
1594
+ });
1595
+ chart.setDrawingTool('icon'); // uses the last glyph picked
1596
+ ```
1169
1597
 
1170
- class MyIndicator implements IIndicator {
1171
- name = 'MyInd';
1172
- color = '#e91e63';
1173
- pane = 'sub' as const; // 'main' = overlay, 'sub' = dedicated pane
1174
- paneHeight = 80;
1598
+ #### The picker
1175
1599
 
1176
- compute(data: OHLCVBar[]): IndicatorResult[] {
1177
- return data.map((bar, i) => ({ index: i, value: bar.close }));
1178
- }
1600
+ Opening the **Icons & Emojis** group gives a panel, not a list of rows: a row per
1601
+ glyph is unusable past a couple of dozen, and this is the one category whose
1602
+ items *are* the icon rather than being labelled by one.
1179
1603
 
1180
- render(ctx, results, scale, viewport, chartHeight) {
1181
- // draw with ctx (Canvas 2D)
1182
- }
1183
- }
1604
+ ```
1605
+ ┌────────────────────────────────┐
1606
+ │ 😊 🐻 🍔 🏀 🚀 💡 ❤️ 🏳️ │ category tabs
1607
+ ├────────────────────────────────┤
1608
+ │ 😀 😃 😄 😁 😆 😅 🤣 😂 │
1609
+ │ 🙂 🙃 😉 😊 😇 🥰 😍 🤩 │ scrolling grid
1610
+ ├────────────────────────────────┤
1611
+ │ Emojis Stickers Icons │ kind tabs
1612
+ └────────────────────────────────┘
1613
+ ```
1184
1614
 
1185
- chart.addIndicator(new MyIndicator());
1615
+ The three bottom tabs are not three copies of the same thing:
1616
+
1617
+ | Tab | Holds | Draws as |
1618
+ | --- | --- | --- |
1619
+ | **Emojis** | ~900 across the eight Unicode groups | System colour font, 22px |
1620
+ | **Stickers** | Trading-flavoured sets — bull & bear, reactions, money, warnings, awards | The same characters at 44px |
1621
+ | **Icons** | Monochrome arrows, shapes, marks, currency | Takes the drawing's colour |
1622
+
1623
+ That last row is why **Icons** is worth having at all. An emoji is a colour
1624
+ bitmap in the system font: it ignores `fillStyle`, so it can never be recoloured
1625
+ to match a palette or printed legibly in mono. Everything under Icons is a plain
1626
+ text character, and does tint.
1627
+
1628
+ The panel renders into the toolbar's existing flyout rather than adding a second
1629
+ floating layer, so it inherits that popover's positioning, outside-click
1630
+ dismissal and teardown. It remembers the tab and category per session — reopening
1631
+ a picker to find yourself back on the first page of smileys is the single most
1632
+ irritating thing a picker can do.
1633
+
1634
+ #### Resizing
1635
+
1636
+ A placed icon is selectable and **resizable by dragging any of its four corner
1637
+ handles**. Size is also in the style popover, which offers a colour swatch and a
1638
+ px picker rather than the default stroke/width/style — three controls that would
1639
+ do nothing to a glyph.
1640
+
1641
+ ```ts
1642
+ chart.getDrawings()
1643
+ .filter(d => d.type === 'icon')
1644
+ .forEach(d => { d.style = { ...d.style, fontSize: 64 }; });
1645
+ ```
1646
+
1647
+ **The size is in pixels, not price.** It does not rescale when the chart is
1648
+ zoomed. Deriving it from two data points would have fit the existing drag
1649
+ machinery with no new concepts, but a pin dropped on a bar would then become a
1650
+ billboard three zoom steps out and vanish three steps in — and the user would
1651
+ have to resize it again every time they changed the view. So `DrawingHandle`
1652
+ grew a `mode: 'size'`, and handles marked that way route to
1653
+ `IDrawing.resizeToPixel(px, py)` instead of the point writer.
1654
+
1655
+ `resizeToPixel` takes pixels because the size it sets is in pixels — converting
1656
+ through price and back would make the result depend on the zoom level it was
1657
+ dragged at. It measures against the anchor position cached during `render`,
1658
+ which is the only place a drawing is handed the scale.
1659
+
1660
+ The new size is the larger of the two axis distances, not the diagonal, so
1661
+ pulling straight out sideways grows the glyph as much as pulling corner to
1662
+ corner. A glyph is square; a user dragging one edge means "bigger", not "bigger
1663
+ by cos 45°". Clamped to 10–400px and rounded, since the value is shown in a px
1664
+ picker and round-trips through JSON.
1665
+
1666
+ Glyphs render through the system emoji font, so colour emoji stay colour on every
1667
+ platform that has them.
1668
+
1669
+ ### Drawing toolbar options
1670
+
1671
+ Five toggles in the toolbar's actions menu (⋯).
1672
+
1673
+ | Option | What it does |
1674
+ | --- | --- |
1675
+ | **Magnet Mode** | Drawing points snap to the nearest open/high/low/close of the bar under the cursor. |
1676
+ | **Keep Drawing Mode** | The tool stays armed after each drawing, so a series can be placed without returning to the toolbar. |
1677
+ | **Copy to All Charts** | New drawings are announced for replication across a layout. |
1678
+ | **Hide Drawings Toolbar** | Hides the toolbar at runtime; bring it back with `setDrawingToolbarVisible(true)`. |
1679
+ | **Zoom In** | In the Cursors group — drag a box and the chart zooms to it. |
1680
+
1681
+ ```ts
1682
+ const chart = new ChartEngine({
1683
+ container,
1684
+ magnetMode: true,
1685
+ keepDrawingMode: true,
1686
+ copyDrawingsToAllCharts: true,
1687
+ });
1688
+
1689
+ chart.setMagnetMode(false);
1690
+ chart.on('magnetModeChange', ({ enabled }) => persist(enabled));
1691
+ ```
1692
+
1693
+ **Magnet snaps within a pixel threshold, not always.** A trend line deliberately
1694
+ drawn through the middle of a range should stay there. The threshold is in
1695
+ pixels rather than price so it feels identical at every zoom — a price-space
1696
+ tolerance would snap from far away on a zoomed-out chart and never trigger on a
1697
+ zoomed-in one.
1698
+
1699
+ **Keep-drawing mode routes through one place.** There are four separate
1700
+ completion paths in the drawing manager; a mode that held for only some of them
1701
+ would look broken in exactly the cases a user reached for it.
1702
+
1703
+ **Copy-to-all is announced, not performed.** The chart emits `drawingCreated`
1704
+ with `copyToAll`, because only the host knows which other panes exist:
1705
+
1706
+ ```ts
1707
+ chart.on('drawingCreated', ({ drawing, copyToAll }) => {
1708
+ if (copyToAll) group.broadcastDrawing(drawing, thisPaneId);
1709
+ });
1186
1710
  ```
1187
1711
 
1712
+ `ChartGroup.broadcastDrawing(drawing, fromPaneId?)` hands the payload to every
1713
+ other pane's `ChartEngine.addDrawing(drawing)`. Unlike `broadcastSymbol` there
1714
+ is no `LayoutSyncState` flag gating it — the toggle already lives on the chart
1715
+ the user drew on, and that is what `copyToAll` reports.
1716
+
1717
+ `addDrawing` **appends and is undoable**, where `setState` would replace the
1718
+ list and flatten the target pane's history. Each pane's copy also gets a fresh
1719
+ id, so dragging the mirrored drawing in one pane leaves the others alone.
1720
+
1721
+ **Zoom In is a drawing internally.** It reuses the two-point press-preview-release
1722
+ gesture rather than duplicating it as a bespoke drag mode, then applies the zoom
1723
+ and removes itself — so nothing is left on the chart and it never reaches
1724
+ `ChartState`. It sets *both* axes: zooming only time would leave the chosen
1725
+ prices no larger than before, which is not what dragging a box asks for.
1726
+
1727
+ ### Native wrapper parity
1728
+
1729
+ `'icon'` and `'zoomIn'` are `DrawingToolType` strings and need no wrapper change.
1730
+ The toolbar options are new bridge commands — see
1731
+ [charts-android](../charts-android) and [charts-ios](../charts-ios).
1732
+
1188
1733
  ---
1189
1734
 
1190
- ## Build & Dev
1735
+ ## Notes & Text
1191
1736
 
1192
- ```bash
1193
- npm run dev # Vite dev server with demo chart
1194
- npm run build # ESM + CJS via tsup → dist/
1195
- npm run test # Vitest unit tests
1196
- npm run typecheck # tsc --noEmit
1197
- npm run lint # ESLint
1737
+ Six annotation tools, all single-click: place, then type.
1738
+
1739
+ | Tool | `DrawingToolType` | Form |
1740
+ | --- | --- | --- |
1741
+ | **Anchored Text** | `'anchoredText'` | Free text with a dot on the bar and price it refers to. |
1742
+ | **Note** | `'note'` | A compact page marker with the text beside it. |
1743
+ | **Pin** | `'pin'` | A teardrop whose **tip** sits exactly on the price. |
1744
+ | **Table** | `'table'` | Rows of text in a bordered grid. |
1745
+ | **Comment** | `'comment'` | A rounded speech bubble with its tail on the anchor. |
1746
+ | **Signpost** | `'signpost'` | A plaque on a post, rising clear of the candles. |
1747
+
1748
+ ```ts
1749
+ chart.setDrawingTool('signpost');
1750
+ ```
1751
+
1752
+ All six anchor to a bar and price like every other drawing, so they re-anchor
1753
+ correctly after a data reload and serialise into `ChartState` unchanged.
1754
+
1755
+ ### Editing
1756
+
1757
+ | | |
1758
+ | --- | --- |
1759
+ | **Place** | Click — the editor opens focused |
1760
+ | **Re-edit** | Select, then double-click |
1761
+ | **New line** | `Enter` (multi-line tools) |
1762
+ | **Commit** | `Ctrl`/`⌘`+`Enter`, or click away |
1763
+ | **Discard** | `Escape` |
1764
+
1765
+ **Anchored Text, Note, Table, Comment and Signpost get a multi-line editor**;
1766
+ Text, Callout, Anchored Note and Pin keep the single-line field where `Enter`
1767
+ commits, exactly as before. A Table is unusable without this — its rows *are*
1768
+ newlines, so `Enter` has to insert one.
1769
+
1770
+ The box grows with its content as you type, so adding rows is visible rather
1771
+ than feeling like it has a row limit.
1772
+
1773
+ ### Why these are separate tools rather than one with options
1774
+
1775
+ The shape *is* the choice. Someone reaching for a Pin wants a precise marker at
1776
+ a level; someone reaching for a Signpost wants a dated flag that does not cover
1777
+ the candles; someone reaching for a Table wants several values at once.
1778
+ Collapsing them into "text with a style dropdown" would make each one harder to
1779
+ reach than the thing it replaced.
1780
+
1781
+ ### Details worth knowing
1782
+
1783
+ **Table syntax** — newlines are rows and `|` separates columns, so a table is
1784
+ typed into the same single text field as everything else rather than needing a
1785
+ bespoke editor. The first row is tinted as a header; short rows are padded so the
1786
+ grid stays rectangular, and empty cells are kept (clearing a value is editing the
1787
+ table, not asking for the column to vanish).
1788
+
1789
+ **A new table arrives already a grid** — four rows by two columns, with the
1790
+ syntax visible in the editor:
1791
+
1792
+ ```
1793
+ Level | Price
1794
+ Entry |
1795
+ Stop |
1796
+ Target |
1198
1797
  ```
1199
1798
 
1799
+ That is deliberate. An empty table rendered a single placeholder row and opened a
1800
+ blank editor, so nothing told you rows are newlines and columns are `|` — the
1801
+ tool looked as though it had no rows or columns at all. Overtyping a grid is also
1802
+ faster than building one from nothing.
1803
+
1804
+ **Pin anchors by its tip, not its centre.** A marker whose middle sits on the
1805
+ level is ambiguous about which price it means.
1806
+
1807
+ **Anchored Text vs Text** — plain `text` floats with no indication of what it
1808
+ refers to. Anchored Text draws its anchor, so a note about one specific candle
1809
+ still points at that candle after panning and zooming.
1810
+
1811
+ **Comment vs Callout** — `callout` is a square-cornered box with a straight
1812
+ pointer; `comment` is the softer rounded bubble. Both are kept because the
1813
+ difference is exactly what the user is choosing between.
1814
+
1815
+ Empty tools render a placeholder (`Text`, `Note`, `Event`…) rather than nothing,
1816
+ so a drawing placed but not yet typed into is still visible and selectable.
1817
+
1818
+ ### Native wrapper parity
1819
+
1820
+ All six are `DrawingToolType` strings, so `setDrawingTool('pin')` works on
1821
+ [charts-android](../charts-android) and [charts-ios](../charts-ios) with no
1822
+ wrapper change.
1823
+
1200
1824
  ---
1201
1825
 
1202
- ## License
1826
+ ## Indicator Library
1203
1827
 
1204
- MIT
1828
+ **78 studies added**, taking the library from 30 to 108.
1829
+
1830
+ ```ts
1831
+ chart.addIndicatorByName('TRIX');
1832
+ chart.addIndicatorByName('Vortex');
1833
+ ```
1834
+
1835
+ They appear in the Indicators flyout alongside the originals, grouped by the
1836
+ same categories, and each is configurable through the settings dialog.
1837
+
1838
+ ### How they are registered
1839
+
1840
+ The original thirty are registered by hand in three places — a preset list, a
1841
+ param-schema list, and `indicators.json`. Repeating that seventy-eight times
1842
+ would be seventy-eight chances to register an indicator in two places and not
1843
+ the third, which fails *silently*: the tool appears in the menu and does
1844
+ nothing, or computes and never appears.
1845
+
1846
+ So each new indicator is declared once in `indicators/registry.ts` and the three
1847
+ views are derived:
1848
+
1849
+ ```ts
1850
+ {
1851
+ shortName: 'TRIX', name: 'TRIX', type: 'Momentum',
1852
+ fields: [periodField(18), colorField('#00BFA5'), sourceField()],
1853
+ factory: (p) => new SimpleOscillator('TRIX', p.color, …),
1854
+ }
1855
+ ```
1856
+
1857
+ ### Shared foundations
1858
+
1859
+ | Module | What it holds |
1860
+ | --- | --- |
1861
+ | `indicators/math.ts` | `sma`, `ema`, `rma`, `wma`, `hma`, `vwma`, `alma`, `dema`, `tema`, `mcginley`, `lsma`, `stdev`, `standardError`, `correlation`, `atr`, … |
1862
+ | `indicators/base.ts` | `LineIndicatorBase`, `BandIndicatorBase`, `HistogramIndicatorBase` |
1863
+
1864
+ All exported, so a host can compute the same series off-chart.
1865
+
1866
+ Two conventions the maths holds to throughout:
1867
+
1868
+ - **`null` means undefined and propagates.** An EMA of an RSI is null wherever
1869
+ the RSI is. Returning `0` instead would draw a line through a period where the
1870
+ indicator does not exist, which reads as data and is not.
1871
+ - **`stdev` is the population form**, because Bollinger Bands and everything
1872
+ derived from them are defined that way; using `n−1` shifts every band visibly
1873
+ on short periods.
1874
+
1875
+ ### The comparative studies
1876
+
1877
+ **Correlation Coefficient**, **Correlation – Log**, **Ratio**, **Spread** and
1878
+ **Advance/Decline** are undefined against a single series — they need a second
1879
+ instrument. `IIndicator.compute` gained an optional second argument for this:
1880
+
1881
+ ```ts
1882
+ compute(data: OHLCVBar[], context?: IndicatorComputeContext): IndicatorResult[]
1883
+ ```
1884
+
1885
+ `context.compares` carries the bars for each symbol added through the Compare
1886
+ feature. The parameter is optional, so every existing indicator is unaffected.
1887
+
1888
+ **With no compare symbol added, these draw nothing.** Substituting a stand-in
1889
+ series — correlating price against its own average, say — would produce a
1890
+ confident-looking line answering a different question than the indicator's name
1891
+ promises.
1892
+
1893
+ ### Two things worth knowing
1894
+
1895
+ **Volume Profile Visible Range** distributes each bar's real volume across the
1896
+ price levels that bar actually traded through, and marks the Point of Control.
1897
+ The existing `anchoredVP` *drawing tool* is a different thing: its bar heights
1898
+ come from a hardcoded bell curve, not from the feed.
1899
+
1900
+ **`maxSubPanes` caps concurrent sub-pane indicators.** Most of these 78 live in
1901
+ their own pane, so adding one past the cap is refused: the chart shows a short
1902
+ notice and emits `indicatorLimitReached` `{ shortName, maxSubPanes }`. Raise
1903
+ `maxSubPanes` if you need more on screen together.
1904
+
1905
+ ```ts
1906
+ chart.on('indicatorLimitReached', ({ shortName, maxSubPanes }) => {
1907
+ console.warn(`${shortName} not added: ${maxSubPanes} sub-panes in use`);
1908
+ });
1909
+ ```
1910
+
1911
+ **Compare-based studies say what they need.** Correlation Coefficient,
1912
+ Correlation - Log, Ratio and Spread need one Compare symbol and Advance/Decline
1913
+ needs two. Until then their pane shows *"Click Compare in the chart header to add a symbol for this
1914
+ indicator"* instead of staying blank, and they fill in as soon as the compare
1915
+ loads.
1916
+
1917
+ **No volume, no plot.** When no loaded bar has volume, volume studies are not
1918
+ plotted at all — no line, no pane, no pill — and the indicator menus leave them
1919
+ out (the Volume heading too, when it would be empty), in search as well. One
1920
+ added anyway (API, bridge, saved layout) is kept and shows a short notice; it
1921
+ appears by itself once data with volume loads. `dataLoaded` carries `hasVolume`
1922
+ and `barCount`; the WebView bridge also posts `volumeAvailability { hasVolume }`
1923
+ and marks catalog indicators `requiresVolume`.
1924
+
1925
+ **Bars without volume become gaps.** Volume studies (Volume, OBV, A/D, MFI, CMF,
1926
+ VWAP, EOM, EFI, Klinger, Volume Oscillator, Net Volume, PVT, Chaikin Oscillator)
1927
+ draw nothing on bars whose feed sent no volume (missing or 0). The line ends at
1928
+ the last bar with volume instead of going flat or reading MFI 100, and a pane with
1929
+ no volume in view says *"No volume data from the feed for these bars"*.
1930
+
1931
+ **52 Week High/Low** covers 52 weeks of time on every timeframe, or all the
1932
+ loaded history when that is shorter. **Zig Zag** always draws its latest leg,
1933
+ even when no swing has reached the deviation yet. Large sub-pane axis values are
1934
+ shortened (1.83M, -45.6B).
1935
+ **Chande Kroll Stop** follows the standard formula (stop short = highest of the
1936
+ high stop, stop long = lowest of the low stop), so both lines sit near price. The
1937
+ main pane's auto-scale includes the upper and lower lines of band studies (52W
1938
+ H/L, Chande Kroll, Bollinger, …), so those lines stay on screen.
1939
+
1940
+ ### Native wrapper parity
1941
+
1942
+ All 78 are reachable through the existing `addIndicator` bridge command by short
1943
+ name — no wrapper change:
1944
+
1945
+ ```kotlin
1946
+ chartView.addIndicatorByName("TRIX")
1947
+ ```
1948
+
1949
+ ### What was added
1950
+
1951
+ | Group | Studies |
1952
+ | --- | --- |
1953
+ | Moving averages (14) | ALMA, DEMA, TEMA, LSMA, McGinley, SMMA, VWMA, Hamming, Adaptive (KAMA), MA Channel, MA Double, MA Triple, MA Multiple, Guppy |
1954
+ | Momentum (23) | Accelerator, ASI, Balance of Power, CMO, Connors RSI, Coppock, DPO, Fisher, KST, Majority Rule, Momentum, Price Oscillator, Rank Correlation, Relative Vigor, SMI Ergodic, TRIX, TSI, Ultimate, Correlation, Correlation Log, + crosses |
1955
+ | Volatility (16) | %B, BB Width, Historical, Close-to-Close, Zero Trend, O-H-L-C, Volatility Index, StdDev, StdError, StdError Bands, Choppiness, Chop Zone, Chaikin Volatility, Mass Index, RVI, Envelopes |
1956
+ | Trend (12) | Aroon, DMI, Vortex, Trend Strength, LinReg Slope, Price Channel, Chande Kroll, 52W H/L, Alligator, Fractal, Zig Zag, MA/EMA crosses |
1957
+ | Volume (9) | Ease of Movement, Elder's Force, Klinger, Volume Oscillator, Net Volume, PVT, Chaikin Oscillator, Advance/Decline, VPVR |
1958
+ | Price transforms (4) | Average Price, Median Price, Typical Price, Ratio, Spread |
1959
+
1960
+ ---
1961
+
1962
+ ## Shapes & Arrows
1963
+
1964
+ ### Already shipped: the line arrow
1965
+
1966
+ **`'arrowMarker'`** is TradingView's Arrow — a line from tail to tip with a
1967
+ filled arrowhead. It was only ever *labelled* "Arrow Marker", which hid it. The
1968
+ label now reads **Arrow**.
1969
+
1970
+ The `DrawingToolType` string is unchanged, and labels are i18n-overridable
1971
+ through `labels.drawingToolbar.tools`, so nothing breaks — but it is a
1972
+ user-visible string change worth knowing about.
1973
+
1974
+ ### Arrow Mark Left / Right — `'arrowMarkLeft'`, `'arrowMarkRight'`
1975
+
1976
+ Single-click marks that point *at* a price level from the side — the horizontal
1977
+ siblings of `arrowUp` / `arrowDown`. Where those call out a move's direction,
1978
+ these call out a level: the arm runs along the price, so the mark reads as "this
1979
+ price, from the left" rather than "up from here".
1980
+
1981
+ Fixed pixel geometry, not price-space — a marker is chrome and should stay the
1982
+ same size whatever the axis is doing. The hit box covers the whole glyph, stem
1983
+ and head, so you grab what you can see.
1984
+
1985
+ ### Curve — `'curve'`
1986
+
1987
+ Two endpoints and a handle that bends the line between them: a **quadratic
1988
+ Bézier**, not a circular arc.
1989
+
1990
+ The existing `'arc'` tool fits a circle through three points, and a circle's
1991
+ curvature is constant — it cannot leave one endpoint steeply and arrive at the
1992
+ other gently. A Bézier can, which is what you want for sketching a rounding top
1993
+ or a decelerating trend.
1994
+
1995
+ One handle is exactly what a quadratic takes, so every position you can drag to
1996
+ maps to a curve, and every curve the tool can draw is reachable by dragging. A
1997
+ cubic would need a second handle to be fully controllable, or would hide half its
1998
+ shape. The handle and its guides are drawn only while selected.
1999
+
2000
+ ### Double Curve — `'doubleCurve'`
2001
+
2002
+ An S, from the same three clicks as a single Curve. The chord is split at its
2003
+ midpoint and each half gets its own quadratic, bent by equal and **opposite**
2004
+ offsets — so one handle controls both bends, and dragging across the chord
2005
+ inverts the S.
2006
+
2007
+ Deriving the second bend from the first, rather than asking for a fourth click,
2008
+ is the point: an S whose halves are set independently is just two curves. The
2009
+ shape only reads as one gesture if the halves stay symmetric, and making that
2010
+ automatic is cheaper for the user than making it their job.
2011
+
2012
+ ### Curve maths
2013
+
2014
+ Exported, so a host can reproduce the same geometry:
2015
+
2016
+ ```ts
2017
+ import { quadraticAt, controlFromOffset, offsetFromHandle } from 'acttrader-charts';
2018
+ ```
2019
+
2020
+ `controlFromOffset` and `offsetFromHandle` are **true inverses**, both speaking
2021
+ in *apex* distance — how far the curve itself lands from the chord, not how far
2022
+ the control point does. A quadratic's apex sits halfway between chord and
2023
+ control, so the conversion doubles in one direction and halves in the other.
2024
+
2025
+ ### Native wrapper parity
2026
+
2027
+ All four are `DrawingToolType` strings, so `setDrawingTool('doubleCurve')` works
2028
+ on [charts-android](../charts-android) and [charts-ios](../charts-ios) with no
2029
+ wrapper change.
2030
+
2031
+ ---
2032
+
2033
+ ## Projection & Measurement
2034
+
2035
+ ### Already shipped: Long Position / Short Position
2036
+
2037
+ `'longPosition'` and `'shortPosition'` exist today and are complete risk/reward
2038
+ tools: three prices (entry, target, stop) drawn as a green profit zone and a red
2039
+ risk zone, with the money and quantity the account's risk budget implies. You
2040
+ place entry and target; the stop is materialised from the default reward:risk, so
2041
+ the common case is two clicks rather than three.
2042
+
2043
+ They are gated behind `features.enableForecasting` and need `account` for money
2044
+ and quantity, and `instrument` for pips:
2045
+
2046
+ ```ts
2047
+ new ChartEngine({
2048
+ container,
2049
+ features: { enableForecasting: true },
2050
+ account: { equity: 50_000, riskPercent: 1 },
2051
+ });
2052
+ ```
2053
+
2054
+ Deliberately **not** Trade-From-Chart — `setLevels` draws real broker orders and
2055
+ committing one sends it. These are drawings; nothing here reaches the broker.
2056
+
2057
+ ### Anchored VWAP — `'anchoredVwap'`
2058
+
2059
+ Volume-weighted average price accumulated from a bar you pick: the average price
2060
+ everyone who traded since the anchor actually paid, which is what turns a swing
2061
+ high or an earnings gap into support.
2062
+
2063
+ **This is not the existing `'anchoredVP'`.** That one is Anchored Volume
2064
+ *Profile* — a horizontal histogram of volume by price level, and its bar heights
2065
+ come from a hardcoded bell curve rather than from the feed. Anchored VWAP is a
2066
+ single line computed from real volume.
2067
+
2068
+ Placed with one click, re-accumulating as data streams in. The series is
2069
+ exported:
2070
+
2071
+ ```ts
2072
+ import { anchoredVwapSeries } from 'acttrader-charts';
2073
+
2074
+ const vwap = anchoredVwapSeries(bars, anchorIndex); // number[] | null
2075
+ ```
2076
+
2077
+ Two deliberate behaviours:
2078
+
2079
+ - It uses the **(H+L+C)/3 typical price**, the convention VWAP is defined on —
2080
+ not the close, which would make it a volume-weighted close.
2081
+ - It returns **`null` when the span carries no volume**, and the chart draws no
2082
+ line. A VWAP without volume silently degrades into a running mean of typical
2083
+ prices: it looks like a VWAP and is not one. A volume-less feed gets no line
2084
+ rather than a plausible wrong one. The anchor dot is still drawn, so the
2085
+ drawing stays selectable and deletable.
2086
+
2087
+ ### Forecast — `'forecast'`
2088
+
2089
+ Three points: p1→p2 is what happened, p2→p3 is what you expect next. The base is
2090
+ solid, the forecast dashed, and a shaded cone opens from zero at p2 to half the
2091
+ base move's size at p3 — because a projection is exact where it starts and vague
2092
+ where it ends, and a bare line implies a precision no forecast has. A readout
2093
+ gives the projected change in price, percent and bars.
2094
+
2095
+ That cone is what separates it from the existing `'projection'` tool, which
2096
+ extrapolates a trend line to the right edge with no second leg you control and no
2097
+ statement of confidence.
2098
+
2099
+ ### Bars Pattern — `'barsPattern'`
2100
+
2101
+ Three points: p1 and p2 bracket the bars to copy, p3 anchors the copy. It draws
2102
+ real candles from the source bars, re-based by price **offset** rather than ratio
2103
+ — the tool is for asking "the same move, from here", not "a proportional move".
2104
+
2105
+ The copy is drawn at reduced alpha inside a dashed bounding box. Without that it
2106
+ renders in the chart's own candle colours and becomes indistinguishable from real
2107
+ data, which is worse than useless.
2108
+
2109
+ ### Native wrapper parity
2110
+
2111
+ All three new tools are `DrawingToolType` strings, so `setDrawingTool('forecast')`
2112
+ works on [charts-android](../charts-android) and [charts-ios](../charts-ios) with
2113
+ no wrapper change. Long/Short Position need `enableForecasting` in the native
2114
+ `init` payload, which both wrappers already expose.
2115
+
2116
+ ---
2117
+
2118
+ ## Patterns
2119
+
2120
+ ### XABCD Pattern — `'xabcdPattern'`
2121
+
2122
+ The free-form harmonic, and the only one that shows its working.
2123
+
2124
+ Every named harmonic in the library — Gartley, Bat, Butterfly, Crab, Shark,
2125
+ Cypher — draws the identical X→A→B→C→D zigzag and dashed XD line, differing only
2126
+ in colour. None of them measures or validates anything. XABCD assumes nothing
2127
+ instead: you place five pivots and it reports the ratios those pivots actually
2128
+ produce, so you can decide which harmonic the structure matches.
2129
+
2130
+ That measurement is the whole point. Without it this would be an existing tool
2131
+ under a new name.
2132
+
2133
+ | Ratio | Measured against | Why it matters |
2134
+ | --- | --- | --- |
2135
+ | `AB/XA` | the initial leg | the first retracement |
2136
+ | `BC/AB` | the AB leg | the counter-move |
2137
+ | `CD/BC` | the BC leg | the final leg's extension |
2138
+ | `AD/XA` | the whole XA move | what separates a Gartley (0.786) from a Butterfly (1.27) |
2139
+
2140
+ It also shades the XAB and BCD triangles, which is what makes the shape readable
2141
+ at a glance — the zigzag alone is just four lines.
2142
+
2143
+ The arithmetic is exported, so a host can auto-classify a pattern with the same
2144
+ numbers the chart draws:
2145
+
2146
+ ```ts
2147
+ import { xabcdRatios } from 'acttrader-charts';
2148
+
2149
+ const chips = xabcdRatios(drawing.points);
2150
+ // → [{ from: 1, to: 2, text: 'AB/XA 0.600' }, …]
2151
+ ```
2152
+
2153
+ A leg of zero length has no defined ratio against it, so that entry is **omitted**
2154
+ rather than printed as `Infinity` or `NaN`, and ratios for pivots you have not
2155
+ placed yet simply do not appear.
2156
+
2157
+ ### Elliott Triple Combo (WXYXZ) — `'elliottTripleCombo'`
2158
+
2159
+ The triple three: three corrective structures joined by two X waves, six points
2160
+ from an origin.
2161
+
2162
+ There are now three neighbouring tools and the distinction is exact:
2163
+
2164
+ | Tool | Points | Labels |
2165
+ | --- | --- | --- |
2166
+ | `'elliottWxy'` | 4 | the **double** three — W, X, Y |
2167
+ | `'elliottCombination'` | 7 | W, X, Y, X2, Z, X3, end |
2168
+ | `'elliottTripleCombo'` | 6 | 0, W, X, Y, X, Z |
2169
+
2170
+ You asked whether the existing Combination tool already covers this. It partly
2171
+ does — but it takes **seven** points where a triple three has six, and numbers its
2172
+ connectors `X2`/`X3` where Elliott notation leaves both as `X`. A trader counting
2173
+ a triple three places five waves after the origin, and a tool demanding six
2174
+ leaves them with a pivot to park somewhere meaningless. This tool matches the
2175
+ count and the notation; the Combination tool is unchanged.
2176
+
2177
+ Wave labels sit above a peak and below a trough, so they never land on the line
2178
+ that produced them.
2179
+
2180
+ ### Native wrapper parity
2181
+
2182
+ Both are new `DrawingToolType` strings, so `setDrawingTool('xabcdPattern')` works
2183
+ on [charts-android](../charts-android) and [charts-ios](../charts-ios) with no
2184
+ wrapper change.
2185
+
2186
+ ---
2187
+
2188
+ ## Fibonacci & Gann
2189
+
2190
+ Five tools added for TradingView parity. Each exists because it answers a
2191
+ question an existing tool in the same group cannot.
2192
+
2193
+ | Tool | `DrawingToolType` | Points | vs. its neighbour |
2194
+ | --- | --- | --- | --- |
2195
+ | **Trend-Based Fib Time** | `'trendBasedFibTime'` | 3 | Fib Timezone measures from its own origin. This projects one leg's *duration* onto a different starting bar. |
2196
+ | **Fib Speed Resistance Arcs** | `'fibSpeedResistanceArcs'` | 2 | The Fan projects straight lines, so implied support stays proportional forever. Arcs curve, so it decays with distance. |
2197
+ | **Fib Wedge** | `'fibWedge'` | 3 | Fib Circles sweep a full turn from one centre. A wedge is bounded by two user-drawn rays. |
2198
+ | **Pitchfan** | `'pitchfan'` | 3 | A Pitchfork's tines run parallel — a fixed channel. A Pitchfan's radiate, so they spread with distance. |
2199
+ | **Gann Square Fixed** | `'gannSquareFixed'` | 2 | The plain Gann Square stretches to the drag box, so its diagonal is only 45° at one zoom. |
2200
+
2201
+ ```ts
2202
+ chart.setDrawingTool('fibWedge');
2203
+ ```
2204
+
2205
+ ### Details that matter
2206
+
2207
+ **Trend-Based Fib Time** — p1→p2 is the base leg and p3 is where projection
2208
+ starts, so the verticals answer *"if this leg took N bars, when do the Fibonacci
2209
+ multiples of N fall from here?"*. Lines outside the viewport are skipped rather
2210
+ than stroked and clipped. It stays inert until the third point lands and when the
2211
+ base leg has zero duration — there is nothing to project in either case.
2212
+
2213
+ **Fib Speed Resistance Arcs** — drawn as **ellipses**, not circles, with the x and
2214
+ y radii taken separately from the trend's run and rise. A pixel-space circle
2215
+ would change shape the moment the price axis was rescaled, which makes the tool
2216
+ meaningless on a chart the user has zoomed. Only the half facing the trend is
2217
+ drawn; the half behind the origin would imply resistance before the move began.
2218
+
2219
+ **Fib Wedge** — the sweep always takes the short way round, so a wedge never
2220
+ flips to the reflex angle when an edge crosses the ±π seam. Hit testing checks
2221
+ the angular span first, so a click at a matching radius but outside the wedge
2222
+ correctly misses.
2223
+
2224
+ **Pitchfan** — rays are extended *forward* past the base, since that is the only
2225
+ part anyone trades off. Handle and base are dashed: construction, not signal.
2226
+
2227
+ **Gann Square Fixed** — takes the **longer** side of the drag and uses it for
2228
+ both, so the 45° diagonal really is 45°. That is the premise of Gann's angles and
2229
+ worthless if the shape skews with the axes. The cost is that the box no longer
2230
+ matches the drag exactly — "fixed" means fixed *proportion*, not fixed size.
2231
+
2232
+ ### Native wrapper parity
2233
+
2234
+ All five are new `DrawingToolType` strings, so `setDrawingTool('pitchfan')` works
2235
+ on [charts-android](../charts-android) and [charts-ios](../charts-ios) with no
2236
+ wrapper change.
2237
+
2238
+ ---
2239
+
2240
+ ## Cursors & Line Tools
2241
+
2242
+ ### Extending lines
2243
+
2244
+ Four tools that differ from a Trend Line only in where they stop being drawn —
2245
+ which is exactly why they exist as separate tools rather than a style option.
2246
+
2247
+ | Tool | `DrawingToolType` | What it does |
2248
+ | --- | --- | --- |
2249
+ | **Ray** | `'ray'` | Two anchors; continues past the second. |
2250
+ | **Extended Line** | `'extendedLine'` | Two anchors; continues past both. |
2251
+ | **Horizontal Ray** | `'horizontalRay'` | One click; a level that runs **forward only**. |
2252
+ | **Info Line** | `'infoLine'` | A trend line that permanently reports what it spans. |
2253
+
2254
+ ```ts
2255
+ chart.setDrawingTool('ray');
2256
+ ```
2257
+
2258
+ The extension is recomputed against the canvas every frame, so panning and
2259
+ zooming re-extend the line instead of dragging a stale endpoint around. Hit
2260
+ testing uses the *extended* segment, so the infinite part is clickable too — the
2261
+ user can grab what they can see. Anchor dots are drawn only at the anchors,
2262
+ never at the extended ends, because a dot at the canvas edge reads as a handle
2263
+ and there is nothing there to grab.
2264
+
2265
+ **Horizontal Ray vs Horizontal Line** is the one worth calling out: a level drawn
2266
+ from the swing high that formed it should not be painted back across the history
2267
+ that preceded it, because that implies support which did not yet exist.
2268
+
2269
+ **Info Line vs Ruler**: the Ruler is a transient measurement — drawn, read,
2270
+ discarded. An Info Line is a drawing you keep, so its readout travels with it and
2271
+ updates as either end is dragged. It reports price delta, percent, bar count,
2272
+ and — when `instrument` and bar timestamps are available — pips and calendar
2273
+ duration. Each is omitted rather than shown as a zero when it cannot be computed.
2274
+
2275
+ ### Cursors
2276
+
2277
+ Pointer behaviour over the plot, **independent of the drawing tool**: switching
2278
+ mode never cancels a drawing in progress, and arming a tool never changes the
2279
+ mode.
2280
+
2281
+ | `CursorMode` | Behaviour |
2282
+ | --- | --- |
2283
+ | `'cross'` | The default crosshair with its axis readouts. |
2284
+ | `'dot'` | A dot follows the pointer instead of two lines cutting across the candles. |
2285
+ | `'arrow'` | A plain pointer, no crosshair. |
2286
+ | `'demonstration'` | A laser trail that fades behind the pointer, for screen-sharing. Draws nothing permanent and creates no drawing. |
2287
+ | `'eraser'` | A click deletes the drawing under it. |
2288
+
2289
+ ```ts
2290
+ const chart = new ChartEngine({
2291
+ container,
2292
+ cursorMode: 'cross', // initial mode
2293
+ features: { enableCursorModes: true }, // adds the Cursors group to the toolbar
2294
+ });
2295
+
2296
+ chart.setCursorMode('eraser');
2297
+ chart.on('cursorModeChange', ({ mode }) => persist(mode));
2298
+ ```
2299
+
2300
+ `features.enableCursorModes` is a gate because it adds a **category** to a
2301
+ toolbar that hosts have laid out around its current contents. With it off the
2302
+ group is absent and the toolbar is exactly what it was — and the modes are still
2303
+ reachable from `setCursorMode()`, so a host can drive them from its own chrome
2304
+ without taking the group.
2305
+
2306
+ Two details worth knowing:
2307
+
2308
+ - **A drawing tool owns the cursor while armed.** It needs crosshair precision
2309
+ whatever the mode says; clearing the tool hands the cursor back to the mode.
2310
+ - **The eraser consumes every click**, hit or miss. A miss must not fall through
2311
+ to selecting or panning — that would be a surprising thing to do with an eraser
2312
+ in hand. It deletes via select-then-delete, so it shares the undo history with
2313
+ every other delete.
2314
+
2315
+ ### Touch gestures (iOS / Android)
2316
+
2317
+ | Gesture | Effect |
2318
+ | --- | --- |
2319
+ | One-finger drag on the chart | Scrolls through time (with momentum) |
2320
+ | Pinch | Zooms the time axis |
2321
+ | One-finger drag along the time (x) axis | Zooms the time axis the way TradingView does: the right edge (normally the latest bar) stays put, drag right to zoom in and left to zoom out. Zooming out stops when the oldest bar reaches the left edge, so the candles always fill the full width. The mouse drag on the time axis behaves the same way |
2322
+ | One-finger drag on the price (y) axis | Zooms the price axis |
2323
+ | One-finger **double tap** on the chart | Zooms the price axis in one step around the tapped price |
2324
+ | One-finger **double tap** on the price (y) axis | Resets the view, the same as the Reset view button |
2325
+ | One-finger **double tap, then drag** up/down | Zooms the price axis continuously: up zooms in, down zooms out (the same direction as the price-axis drag) |
2326
+ | Long press | Shows the crosshair |
2327
+
2328
+ Once the view has moved away from its default, a round **Reset view** button
2329
+ appears just above the time axis. It is part of the built-in UI, so it is not
2330
+ shown when `showUI: false` (the usual iOS / Android setup, where the app draws
2331
+ its own controls). On a bare chart, double tap the price axis or call
2332
+ `resetView()` to bring the view back.
2333
+
2334
+ ### Value tooltip on long press
2335
+
2336
+ ```ts
2337
+ const chart = new ChartEngine({ container, valueTooltip: true });
2338
+ ```
2339
+
2340
+ A floating OHLCV readout beside a long press, on top of the crosshair that
2341
+ gesture already arms. The OHLC strip at the top of the chart carries the same
2342
+ numbers, but on a phone it is at the opposite end of the screen from the finger —
2343
+ the user has to look away from the thing they are pointing at.
2344
+
2345
+ It flips to the other side of the touch rather than running off an edge, never
2346
+ takes pointer events (the drag is still in progress), and omits volume rather
2347
+ than printing a zero when the feed carries none. Default `false`.
2348
+
2349
+ The long press itself (500 ms, finger still within 6 px) arms the crosshair on
2350
+ every touch platform. The chart claims that touch with `preventDefault()` so a
2351
+ WebView's own long-press gesture — WebKit's text-selection / callout recogniser
2352
+ on iOS, which used to cancel the touch at the same moment and left iPhones with
2353
+ no crosshair at all — cannot take it over; a cancel that still arrives after
2354
+ 300 ms of holding counts as the long press.
2355
+
2356
+ ### Native wrapper parity
2357
+
2358
+ The four line tools are new `DrawingToolType` strings, so `setDrawingTool('ray')`
2359
+ works on [charts-android](../charts-android) and [charts-ios](../charts-ios)
2360
+ unchanged.
2361
+
2362
+ | JS | Android | iOS |
2363
+ | --- | --- | --- |
2364
+ | `setCursorMode(m)` | `setCursorMode(m)` | `setCursorMode(_:)` |
2365
+ | `cursorModeChange` | `BridgeEvent.CursorModeChange` | `.cursorModeChange` |
2366
+ | `cursorMode` at init | `Init(cursorMode = "…")` | `cursorMode:` argument |
2367
+ | `valueTooltip` | `Init(valueTooltip = true)` | `valueTooltip:` argument |
2368
+ | `features.enableCursorModes` | `Init(enableCursorModes = true)` | `enableCursorModes:` argument |
2369
+
2370
+ ---
2371
+
2372
+ ## Chart Types
2373
+
2374
+ Thirteen time-based types plus five added for TradingView parity. The five split
2375
+ into two kinds, and the difference matters more than the list suggests.
2376
+
2377
+ ### HLC bars — `'hlc'`
2378
+
2379
+ An ordinary per-bar series: the high-low range with a close tick and no open
2380
+ stub. Halves the ink of an OHLC bar, which is the point — on a dense chart the
2381
+ closes stay legible where full bars smear. Colour still comes from open vs close.
2382
+
2383
+ ### Price-transform types — `'renko'`, `'linebreak'`, `'kagi'`, `'pointfigure'`
2384
+
2385
+ These are **not drawn per time bar.** A brick, block, kagi segment or P&F column
2386
+ forms when *price* moves far enough, which may take fifty bars or none at all.
2387
+ 365 daily candles might be 24 Renko bricks or 15 P&F columns.
2388
+
2389
+ So they are not renderers — the bar array itself is rebuilt. Each transform turns
2390
+ raw OHLCV into **synthetic** bars, one per visual element, which the rest of the
2391
+ chart then treats as ordinary bars. That is what keeps the feature small:
2392
+ viewport, zoom, price range, crosshair, the time axis, indicators and drawings
2393
+ all keep working unchanged. It also means **an indicator on a Renko chart is
2394
+ computed over the bricks**, not the underlying candles — same as TradingView.
2395
+
2396
+ Three consequences worth knowing:
2397
+
2398
+ - **The time axis is non-linear.** Labels show when each element *completed*.
2399
+ Several elements completing on one bar get timestamps one millisecond apart so
2400
+ drawing anchors and viewport restore keep working.
2401
+ - **Switching in or out refits the view**, because the old viewport indexes a
2402
+ different number of elements.
2403
+ - **Market price still comes off the raw feed.** Trade levels and P&L price
2404
+ against the real last close, never a brick top.
2405
+ - **Live ticks move the element count both ways.** The forming bar completes a
2406
+ brick, line or column on one tick and takes it back on the next. The view
2407
+ keeps tracking the live edge either way — same width, same room past the
2408
+ last element — and a view scrolled into history stays put. Box and reversal
2409
+ sizes are measured over **closed** bars, so a tick never re-grids the series;
2410
+ an ATR box changes once per completed bar. (Before 1.4.0-beta.4 the view only
2411
+ ever moved forward, so these types went blank after a while on a live feed.)
2412
+
2413
+ ### Parameters
2414
+
2415
+ ```ts
2416
+ const chart = new ChartEngine({
2417
+ container,
2418
+ series: 'renko',
2419
+ seriesOptions: {
2420
+ renko: { boxSize: { kind: 'atr', length: 14 } }, // the default
2421
+ lineBreak: { lines: 3 },
2422
+ kagi: { reversal: { kind: 'percent', percent: 2 } },
2423
+ pointFigure: { boxSize: { kind: 'fixed', size: 0.5 }, reversal: 3 },
2424
+ },
2425
+ });
2426
+
2427
+ chart.setSeriesOptions({ renko: { boxSize: { kind: 'fixed', size: 5 } } });
2428
+ ```
2429
+
2430
+ Every box/reversal size defaults to **ATR(14)**, not a fixed price, because a
2431
+ fixed box is meaningless until you know the instrument: ten points is noise on an
2432
+ index and a lifetime on a forex pair. `setSeriesOptions` merges, so retuning
2433
+ Renko leaves Kagi and P&F alone.
2434
+
2435
+ | `BoxSizeMode` | Meaning |
2436
+ | --- | --- |
2437
+ | `{ kind: 'atr', length }` | Wilder ATR over the last `length` closed bars (the forming bar is excluded). Default `14`. |
2438
+ | `{ kind: 'fixed', size }` | A fixed price distance. |
2439
+ | `{ kind: 'percent', percent }` | A percentage of the last closed bar's close. |
2440
+
2441
+ When a box cannot be sized — fewer than `length + 1` closed bars for the ATR, or
2442
+ a non-positive size — the transform returns **no elements** rather than guessing. A
2443
+ wrong box size does not look wrong; it looks like a different market.
2444
+
2445
+ ### The rules each type implements
2446
+
2447
+ | Type | Rule |
2448
+ | --- | --- |
2449
+ | **Renko** | A brick prints when price closes a full box past the last brick's edge. Reversals need **two** boxes, not one — that two-box rule is what filters the noise. `source: 'highlow'` lets a bar's extremes print bricks instead of its close. |
2450
+ | **Line Break** | A block prints when the close clears the extreme of the last `lines` blocks (default 3). No box size at all — the reversal distance is whatever recent blocks span, so it widens automatically in volatile stretches. |
2451
+ | **Kagi** | **Direction** flips on a retrace of the reversal amount. **Thickness** flips on a break of the prior shoulder or waist. These are independent: a line can reverse repeatedly without ever changing thickness. |
2452
+ | **Point & Figure** | Columns of X's and O's on a box grid, built from each bar's **high and low** — high first in an X column, low first in an O column, so a bar that both extended and reversed is read in the order it happened. A new column needs `reversal` boxes (default 3). |
2453
+
2454
+ ### Using the transforms off-chart
2455
+
2456
+ They are exported as pure functions, so a backtest or a server-side render gets
2457
+ byte-identical elements:
2458
+
2459
+ ```ts
2460
+ import { toRenko, toKagi, atrValue } from 'acttrader-charts';
2461
+
2462
+ const { bars, meta } = toRenko(ohlcv, { boxSize: { kind: 'fixed', size: 5 } });
2463
+ ```
2464
+
2465
+ Each returns `{ bars, meta }`. `meta[i]` carries what cannot be read off the
2466
+ synthetic bar: Kagi's `thick`, P&F's `boxSize`, and `sourceBars` / `startTime`
2467
+ for every type.
2468
+
2469
+ ### Native wrapper parity
2470
+
2471
+ The five types are new values of the same `series` string, so `setSeries('renko')`
2472
+ works on [charts-android](../charts-android) and [charts-ios](../charts-ios)
2473
+ unchanged. Parameters travel as a JSON object:
2474
+
2475
+ | JS | Android | iOS |
2476
+ | --- | --- | --- |
2477
+ | `seriesOptions` at init | `Init(seriesOptionsJson = "…")` | `seriesOptionsJson:` init argument |
2478
+ | `setSeriesOptions(o)` | `setSeriesOptions(json)` | `setSeriesOptions(_:)` |
2479
+
2480
+ ---
2481
+
2482
+ ## Top-Toolbar Features
2483
+
2484
+ Four opt-in features that bring the header in line with TradingView's: **indicator
2485
+ templates**, **saved chart layouts**, **Quick Search**, and **chart-settings
2486
+ templates with "Apply to all charts"**. Compare and Snapshot already shipped — see
2487
+ [Multi-Pane Layouts](#multi-pane-layouts) and the Compare section.
2488
+
2489
+ Two rules apply to all of them:
2490
+
2491
+ - **Nothing moves.** Every gate defaults to `false`, and with them off the chart's
2492
+ DOM is unchanged. When enabled, each feature extends a flyout, popover or dialog
2493
+ that already exists — no feature adds a header button (Quick Search can, but only
2494
+ if you ask for one).
2495
+ - **The library persists nothing.** Saving emits an event carrying the complete
2496
+ object; your app stores it and seeds it back. This is the same contract
2497
+ `stateChange` follows, and it is what lets Android and iOS reuse the feature
2498
+ without reimplementing storage twice.
2499
+
2500
+ ### Indicator templates
2501
+
2502
+ Named, reusable indicator sets, listed in a **Templates** block at the foot of the
2503
+ indicators flyout.
2504
+
2505
+ ```ts
2506
+ const chart = new ChartEngine({
2507
+ container,
2508
+ enableIndicatorTemplates: true,
2509
+ indicatorTemplates: loadFromStorage(), // what you saved last session
2510
+ });
2511
+
2512
+ chart.on('indicatorTemplateSaved', ({ template }) => persist(template));
2513
+ chart.on('indicatorTemplateDeleted', ({ id }) => forget(id));
2514
+ ```
2515
+
2516
+ | API | Does |
2517
+ | --- | --- |
2518
+ | `captureIndicatorTemplate(name)` | Saves the active indicators; emits `indicatorTemplateSaved`. Returns the template or `null`. |
2519
+ | `applyIndicatorTemplate(idOrName)` | Replaces the active indicators with the template's. Returns `false` if nothing matched. |
2520
+ | `deleteIndicatorTemplate(idOrName)` | Removes it; emits `indicatorTemplateDeleted`. |
2521
+ | `setIndicatorTemplates(list)` | Replaces the list the flyout shows. |
2522
+ | `getIndicatorTemplates()` | Reads it back (copies). |
2523
+
2524
+ Saving twice under one name yields two templates — ids are de-duplicated
2525
+ (`my-set`, `my-set-2`), which is what users expect from "Save as".
2526
+
2527
+ Rendered by the default `TopBar` and by `AdvancedToolbar`. The compact per-pane
2528
+ strip and the mobile header have no full indicators flyout, so they skip it.
2529
+
2530
+ ### Saved chart layouts
2531
+
2532
+ A **saved layout** is a grid preset *plus* every pane's full `ChartState` — symbol,
2533
+ timeframe, series, indicators, drawings, viewport, compares. That is the difference
2534
+ from the preset grid already in the layout popover, which only picks a *shape*.
2535
+
2536
+ Requires `enableMultipleLayouts`, since the section renders inside that popover.
2537
+
2538
+ ```ts
2539
+ const chart = new ChartEngine({
2540
+ container,
2541
+ enableMultipleLayouts: true,
2542
+ enableSavedLayouts: true,
2543
+ savedLayouts: loadFromStorage(),
2544
+ });
2545
+
2546
+ chart.on('layoutSaved', ({ layout }) => persist(layout));
2547
+ chart.on('layoutApplied', ({ layout }) => mountPanes(layout)); // multi-pane hosts
2548
+ ```
2549
+
2550
+ | API | Does |
2551
+ | --- | --- |
2552
+ | `captureSavedLayout(name, paneId?)` | Saves the preset + this chart's state. Single-engine: one pane. |
2553
+ | `applySavedLayout(idOrName, paneId?)` | Applies the preset (emits `layoutChange`) then this pane's state. |
2554
+ | `deleteSavedLayout(idOrName)` | Removes it; emits `layoutDeleted`. |
2555
+ | `setSavedLayouts(list)` / `getSavedLayouts()` | Replace / read the list. |
2556
+
2557
+ For a real multi-pane grid use `ChartGroup`, which sees every pane:
2558
+
2559
+ ```ts
2560
+ const layout = group.captureLayout('My Desk', chart.getLayoutPreset());
2561
+ persist(layout);
2562
+ for (const [, engine] of group.entries()) engine.setSavedLayouts(allLayouts);
2563
+
2564
+ // restoring: mount the grid for layout.presetId yourself, then
2565
+ const { applied, missing } = group.applyLayout(layout);
2566
+ ```
2567
+
2568
+ `applyLayout` skips panes that aren't registered yet and returns them in `missing`,
2569
+ so a host that mounts lazily can create them and call again. Registered panes that
2570
+ the layout doesn't mention are left alone rather than cleared.
2571
+
2572
+ ### Quick Search
2573
+
2574
+ A command palette over everything the chart can do: series types, timeframes,
2575
+ durations, indicators, drawing tools, saved templates and layouts, and the header
2576
+ actions.
2577
+
2578
+ ```ts
2579
+ const chart = new ChartEngine({ container, enableQuickSearch: true });
2580
+ ```
2581
+
2582
+ Opened with **Ctrl/⌘+K**, with **`/`**, or by `chart.openQuickSearch()`. It is
2583
+ keyboard-first and **adds no header button** — pass
2584
+ `quickSearch: { showButton: true }` if you want one. A bare `/` is ignored while
2585
+ the user is typing in any input, textarea, select, or contenteditable.
2586
+
2587
+ Add your own commands beside the built-ins:
2588
+
2589
+ ```ts
2590
+ chart.registerCommand({
2591
+ id: 'myapp.openTicket',
2592
+ label: 'Open order ticket',
2593
+ group: 'My App',
2594
+ keywords: ['buy', 'sell', 'trade'],
2595
+ run: () => openTicket(),
2596
+ });
2597
+
2598
+ chart.on('quickSearchCommand', ({ id }) => track(id));
2599
+ ```
2600
+
2601
+ Namespace your ids so a future built-in can't collide. `matchCommands`,
2602
+ `scoreCommand` and `groupCommands` are exported if you want the same ranking in
2603
+ your own UI: exact label beats prefix beats word-start beats substring, with
2604
+ keyword and group hits last, and ties keep their original order so the list never
2605
+ reshuffles unpredictably as the user types.
2606
+
2607
+ ### Chart-settings templates + Apply to all charts
2608
+
2609
+ Named snapshots of everything the Chart Settings dialog edits, plus a switch to
2610
+ push the current settings to every other chart. Both render in the dialog's
2611
+ footer — no new button anywhere.
2612
+
2613
+ ```ts
2614
+ const chart = new ChartEngine({
2615
+ container,
2616
+ enableSettingsTemplates: true,
2617
+ settingsTemplates: loadFromStorage(),
2618
+ });
2619
+
2620
+ chart.on('settingsTemplateSaved', ({ template }) => persist(template));
2621
+
2622
+ chart.on('chartSettingsApplied', ({ settings, applyToAll }) => {
2623
+ // This chart already applied them. Only the fan-out is yours:
2624
+ if (applyToAll) group.applySettingsToAll(settings, thisPaneId);
2625
+ });
2626
+ ```
2627
+
2628
+ | API | Does |
2629
+ | --- | --- |
2630
+ | `getChartSettings()` / `applyChartSettings(s)` | Read / apply a settings snapshot. Every route — dialog, template, apply-to-all, bridge — goes through `applyChartSettings`. |
2631
+ | `captureSettingsTemplate(name)` | Saves the current settings; emits `settingsTemplateSaved`. |
2632
+ | `applySettingsTemplate(idOrName)` | Applies a saved one. |
2633
+ | `deleteSettingsTemplate(idOrName)` | Removes it. |
2634
+ | `setSettingsTemplates(list)` / `getSettingsTemplates()` | Replace / read the list. |
2635
+ | `ChartGroup.applySettingsToAll(settings, fromPaneId?)` | Applies to every other pane. Not gated on a `sync` toggle — it is an explicit one-shot action, not continuous mirroring. |
2636
+
2637
+ ### Native wrapper parity
2638
+
2639
+ All four features, plus snapshot capture, are exposed by
2640
+ [charts-android](../charts-android) and [charts-ios](../charts-ios). Templates and
2641
+ layouts cross the bridge as opaque JSON strings: the app stores what the chart
2642
+ handed it and sends the same string back, so neither Kotlin nor Swift has to mirror
2643
+ a `ChartState` that only the chart interprets.
2644
+
2645
+ | JS | Android | iOS |
2646
+ | --- | --- | --- |
2647
+ | `enableIndicatorTemplates` | `Init(enableIndicatorTemplates = true)` | `HeaderFeatures(enableIndicatorTemplates: true)` |
2648
+ | `captureIndicatorTemplate(name)` | `captureIndicatorTemplate(name)` | `captureIndicatorTemplate(_:)` |
2649
+ | `enableSavedLayouts` | `Init(enableSavedLayouts = true)` | `HeaderFeatures(enableSavedLayouts: true)` |
2650
+ | `enableQuickSearch` / `openQuickSearch()` | `openQuickSearch()` | `openQuickSearch()` |
2651
+ | `enableSettingsTemplates` | `Init(enableSettingsTemplates = true)` | `HeaderFeatures(enableSettingsTemplates: true)` |
2652
+ | `chartSettingsApplied` event | `BridgeEvent.ChartSettingsApplied` | `.chartSettingsApplied` |
2653
+ | `chart.toDataUrl()` | `requestSnapshot()` → `BridgeEvent.Snapshot` | `requestSnapshot()` → `.snapshot` |
2654
+
2655
+ On mobile there is no Ctrl/⌘+K, so Quick Search is opened with the
2656
+ `openQuickSearch` command from your own toolbar.
2657
+
2658
+ ---
2659
+
2660
+ ## Chart Settings
2661
+
2662
+ Six settings TradingView has and ActCharts did not, behind
2663
+ `features.enableChartSettings`. The gate adds three tabs — **Status Line**,
2664
+ **Scales**, **Canvas** — to the Chart Settings dialog and a bar-colouring choice
2665
+ to Appearance. Every one of them is also a `ChartConfig` option and a
2666
+ `ChartEngine` method, so a host can drive them from its own chrome and leave the
2667
+ dialog as it was.
2668
+
2669
+ ```ts
2670
+ const chart = new ChartEngine({
2671
+ container,
2672
+ features: { enableChartSettings: true },
2673
+ statusLine: { barChange: true },
2674
+ scales: { highLowLabels: true, pricePrecision: 4, timezone: 'Asia/Tokyo' },
2675
+ canvas: { gridVertical: false, watermarkVisible: true },
2676
+ barColorSource: 'previousClose',
2677
+ });
2678
+ ```
2679
+
2680
+ ### Status line
2681
+
2682
+ What the legend above the chart shows.
2683
+
2684
+ | Field | Default | Shows |
2685
+ | --- | --- | --- |
2686
+ | `symbol` | `true` | The instrument name and its stream dot |
2687
+ | `ohlc` | `true` | O / H / L / C of the hovered bar |
2688
+ | `barChange` | **`false`** | Absolute and percent change, e.g. `+1.68 (+0.96%)` |
2689
+ | `volume` | `true` | V, when the feed carries it |
2690
+
2691
+ `barChange` is the one default of `false`: it is a new readout, and turning it on
2692
+ by itself would change a legend hosts have laid out around. **Change is measured
2693
+ against the previous bar's close**, which is what "the change" means on every
2694
+ quote screen — against this bar's open it would only restate the candle's colour.
2695
+
2696
+ ### Scales and lines
2697
+
2698
+ ```ts
2699
+ chart.setScalesSettings({ lastPriceLabel: false, highLowLabels: true });
2700
+ ```
2701
+
2702
+ | Field | Default | Effect |
2703
+ | --- | --- | --- |
2704
+ | `lastPriceLabel` | `true` | The live price tag and its dashed line |
2705
+ | `bidLabel` / `askLabel` | follows config | The bid / ask tags |
2706
+ | `highLowLabels` | `false` | Tags at the extremes of the **visible** range |
2707
+ | `timeAxisCountdown` | follows `showCandleCountdown` | Countdown on the time axis |
2708
+ | `pricePrecision` | `null` | Decimal places; null infers from the feed |
2709
+ | `timezone` | `'UTC'` | IANA name for axis, crosshair and tooltips |
2710
+
2711
+ Bid and ask fall through to the `showBidLine` / `showAskLine` config they
2712
+ duplicate, so the dialog and the existing runtime setters stay **one switch**
2713
+ rather than two that can disagree.
2714
+
2715
+ High/low are measured over what is on screen, not the whole dataset: a tag for a
2716
+ high six months out of view marks a line the user cannot see and cannot scroll
2717
+ to without losing it.
2718
+
2719
+ ### Price precision
2720
+
2721
+ ```ts
2722
+ chart.setPricePrecision(4); // pin
2723
+ chart.setPricePrecision(null); // back to inferring from the feed
2724
+ ```
2725
+
2726
+ One accessor feeds all eleven places the chart writes a price — axis, legend,
2727
+ tooltips, crosshair, drawings, trade levels — so an override cannot apply to
2728
+ some and not others. A price axis reading `1.2345` beside a tooltip reading
2729
+ `1.23` is worse than either choice on its own.
2730
+
2731
+ **It deliberately does not move pip size.** A pip is a property of the contract;
2732
+ asking the axis for fewer decimals is a display choice, and the Ruler must keep
2733
+ saying what a move is actually worth. Pip size still comes from `InstrumentSpec`
2734
+ and, failing that, from the precision *inferred* from the data.
2735
+
2736
+ ### Timezone
2737
+
2738
+ `setTimezone(tz)` already existed; it is now in the dialog, carried in
2739
+ `ChartSettingsSnapshot`, and emits `timezoneChange`. `'local'` resolves to the
2740
+ device zone immediately, so `getTimezone()` always returns a real IANA name.
2741
+
2742
+ ### Canvas
2743
+
2744
+ ```ts
2745
+ chart.setCanvasOptions({ gridVertical: false, watermarkVisible: true, watermarkSize: 128 });
2746
+ ```
2747
+
2748
+ | Field | Default |
2749
+ | --- | --- |
2750
+ | `gridHorizontal` / `gridVertical` | `true` |
2751
+ | `watermarkVisible` | `false` |
2752
+ | `watermarkText` | `''` — empty uses the current symbol |
2753
+ | `watermarkSize` / `watermarkOpacity` | `72` / `0.06` |
2754
+ | `crosshairStyle` | `'dashed'` |
2755
+ | `crosshairWidth` | `1` |
2756
+
2757
+ Colours stay in `setCanvasColors`, which was already plumbed per theme;
2758
+ `gridVertical` and `watermark` are new entries in `CanvasColorSettings` and fall
2759
+ back to `grid` and `axisText`.
2760
+
2761
+ **The watermark draws above the grid and below everything else.** Painted after
2762
+ the series it would tint the candles, and the point of it is to sit behind them.
2763
+ Crosshair dash lengths scale with the width, or a 4px dash on a 4px line reads as
2764
+ a solid bar.
2765
+
2766
+ ### Colour bars based on previous close
2767
+
2768
+ ```ts
2769
+ chart.setBarColorSource('previousClose');
2770
+ ```
2771
+
2772
+ Whether a bar is "up" against its own open (the default) or against the previous
2773
+ bar's close. The two disagree constantly — a gap-up bar that sells off all
2774
+ session is up on one rule and down on the other — which is why it is a setting
2775
+ and not a fix.
2776
+
2777
+ Applies to candles, hollow candles, bars, HLC bars, columns **and the volume
2778
+ pane together**: they are one reading of the same market, and a volume pane
2779
+ disagreeing with the candles above it is a bug, not a setting. Heikin Ashi,
2780
+ Renko, Kagi and Point & Figure are unaffected — direction is intrinsic to what
2781
+ those transforms compute.
2782
+
2783
+ Eight series each carried their own `close >= open`; they now share
2784
+ `computeUpFlags()`, which the engine calls once per frame. It takes the bar
2785
+ *before* the visible slice, because the renderers only ever see what is on
2786
+ screen — without it the leftmost bar would have nothing to compare against and
2787
+ would flip colour as the user panned it into view. At the very start of the data,
2788
+ where no earlier bar exists, that one bar falls back to open-vs-close.
2789
+
2790
+ ### Persistence
2791
+
2792
+ All six ride in `ChartSettingsSnapshot`, so they are covered by settings
2793
+ templates and "Apply to all charts" for free. **Absent is meaningful**: a
2794
+ template saved before these fields existed applies as "change nothing", not as
2795
+ "reset everything to its default" — otherwise re-applying an old template would
2796
+ silently rearrange the chart.
2797
+
2798
+
2799
+ ## Bottom Bar & Price Scale
2800
+
2801
+ Five TradingView controls, behind `features.enableScaleControls`. The gate adds
2802
+ a cluster to the left of the duration buttons — **Go to date**, a **timezone**
2803
+ selector, and **%**, **log**, **auto** — and needs `showBottomBar: true`, since
2804
+ the duration bar is off by default. Each is also a `ChartEngine` method, so a
2805
+ host can drive them from its own chrome and leave the bar as it was.
2806
+
2807
+ ```
2808
+ ┌──────────────────────────────────────────────────────────────────────┐
2809
+ │ 📅 │ UTC ⌄ │ % log auto 1D 5D 1M 3M 6M 1Y 5Y All │
2810
+ └──────────────────────────────────────────────────────────────────────┘
2811
+ ```
2812
+
2813
+ All five are ordinary buttons styled like the duration buttons beside them, and
2814
+ the two that need input open a themed popover above the bar. **No native
2815
+ `<input type="date">` or `<select>`**: both take their colours from the
2816
+ operating system, so in a dark chart they rendered as pale boxes with a browser
2817
+ calendar glyph — the one part of the bar that could not be themed. The menus
2818
+ open *upward*, since the bar is the last row on screen.
2819
+
2820
+ ```ts
2821
+ const chart = new ChartEngine({
2822
+ container,
2823
+ showBottomBar: true,
2824
+ features: { enableScaleControls: true },
2825
+ priceScaleMode: 'log',
2826
+ autoScale: true,
2827
+ });
2828
+ ```
2829
+
2830
+ ### Go to a specific date
2831
+
2832
+ ```ts
2833
+ chart.goToDate('2024-03-01'); // ISO, a Date, or unix ms
2834
+ ```
2835
+
2836
+ The calendar button opens a date field and a **Go**; the method behind it
2837
+ centres the **nearest** bar and returns its index, or `null` when there is no
2838
+ data. Nearest rather than exact because the date a user types is rarely a bar —
2839
+ it falls on a weekend, a holiday, or between two intraday bars — and refusing to
2840
+ move is a worse answer than landing beside it. Binary search, so it costs
2841
+ nothing on a long history. Emits `goToDate` with the bar it settled on.
2842
+
2843
+ ### Timezone selector
2844
+
2845
+ The bottom-bar button shows the current zone's short name and opens a checked
2846
+ list. It writes through `setTimezone()`, the same path the Scales tab and the
2847
+ config option use, so all three stay in step. `'local'` resolves to
2848
+ the device zone immediately — `getTimezone()` always returns a real IANA name.
2849
+
2850
+ ### Price scale modes
2851
+
2852
+ ```ts
2853
+ chart.setPriceScaleMode('log'); // 'normal' | 'log' | 'percent'
2854
+ ```
2855
+
2856
+ **One choice, not three flags.** Log and percent are different answers to the
2857
+ same question, and a chart in both at once has no meaning. Clicking the active
2858
+ button returns to `'normal'`, so the pair works as a toggle rather than a trap.
2859
+
2860
+ #### Logarithmic
2861
+
2862
+ Equal ratios get equal height: a move from 10 to 20 occupies the same space as
2863
+ one from 100 to 200. On a series running 1 → 1000 the pixel gaps between the
2864
+ decade marks go from **49.8px and 497.3px** linear to **156.4px and 156.4px**
2865
+ log, and an exponential series renders as a straight line.
2866
+
2867
+ The mode is carried on the **`PriceRange`**, not on the `ScaleManager`:
2868
+
2869
+ ```ts
2870
+ interface PriceRange { min: number; max: number; log?: boolean }
2871
+ ```
2872
+
2873
+ One manager serves the price pane and every sub-pane, and an RSI drawn on a log
2874
+ axis would be nonsense. Every price→pixel call already passes a range, so putting
2875
+ the flag there made series, drawings, indicators, the crosshair and hit-testing
2876
+ all log-aware **without changing a single call site**.
2877
+
2878
+ **Log needs a positive range.** `isLogRange()` falls back to linear when `min <= 0`
2879
+ rather than refusing to draw, so an instrument that trades through zero quietly
2880
+ keeps working. A manual zoom-out that drags the displayed minimum below zero is
2881
+ clamped instead, because falling back there would make the chart snap between two
2882
+ mappings in the middle of a drag.
2883
+
2884
+ **Ticks are the 1/2/5 × 10ⁿ series per decade**, thinned by *pixel* distance
2885
+ rather than by value — a decade occupies the same height whether it runs 1→10 or
2886
+ 1000→10000. A range inside a single decade falls back to the linear generator,
2887
+ since over a span that narrow the two mappings are within a pixel of each other
2888
+ and linear ticks read better.
2889
+
2890
+ Label precision is computed **per tick** from its own neighbours. One precision
2891
+ taken from the first pair would render `500` as `500.0` and `0.2` as `0.2` in the
2892
+ same column.
2893
+
2894
+ #### Percent
2895
+
2896
+ Every price re-expressed as percent change from the first visible bar's close,
2897
+ with `+0.00%` emphasised on the axis. It reuses the transform compare mode
2898
+ already had, with no second series to combine against, so panning re-bases the
2899
+ way TradingView's percent scale does rather than pinning to the start of history.
2900
+
2901
+ ### Auto scale
2902
+
2903
+ ```ts
2904
+ chart.setAutoScale(false); // freeze the Y range
2905
+ ```
2906
+
2907
+ Switching it off freezes whatever is on screen *now*, so the chart does not jump
2908
+ at the moment the user asks it to stop moving. A manual axis drag still wins —
2909
+ turning auto off should not also take away the ability to move the axis by hand.
2910
+
2911
+ This is separate from the implicit manual mode a price-axis drag already
2912
+ produced: that one is entered by dragging and left by resetting, while this is a
2913
+ switch the user holds across pans and timeframe changes.
2914
+
2915
+
2916
+ ## Side Panels
2917
+
2918
+ A right-hand docked panel with two tabs, behind `features.enableSidePanels`.
2919
+
2920
+ ```
2921
+ ┌──────────────────────────┐
2922
+ │ Data Window │ Objects │✕ │
2923
+ ├──────────────────────────┤
2924
+ │ Date 12 Aug 2026│
2925
+ │ Open 149.73│
2926
+ │ Close 148.56│
2927
+ │ Change -1.17 (-0.78%) │
2928
+ │ MA(20) 150.56│
2929
+ │ MACD(12, 26, 9) │
2930
+ │ MACD 1.22│
2931
+ │ Signal 1.84│
2932
+ │ Histogram -0.62│
2933
+ └──────────────────────────┘
2934
+ ```
2935
+
2936
+ ```ts
2937
+ const chart = new ChartEngine({
2938
+ container,
2939
+ features: { enableSidePanels: true },
2940
+ });
2941
+
2942
+ chart.setSidePanelTab('objects');
2943
+ chart.setSidePanelVisible(false); // the ✕ does this too
2944
+ ```
2945
+
2946
+ The panel is **docked, not floating**: both tabs are read while looking at the
2947
+ chart, and a floating window would cover the candles the numbers describe.
2948
+
2949
+ **Closing it collapses it to an icon rail, it does not hide it.**
2950
+
2951
+ ```
2952
+ expanded (232px) collapsed (30px)
2953
+ ┌──────────────────┐ ┌──┐
2954
+ │ Data │ Objects │✕│ │ ▦ │ ← Data Window
2955
+ ├──────────────────┤ │ ◈ │ ← Objects
2956
+ │ … │ │ │
2957
+ └──────────────────┘ └──┘
2958
+ ```
2959
+
2960
+ A panel that vanished completely would leave nothing to click to get it back,
2961
+ stranding the feature behind a command palette. The rail keeps 30px and gives
2962
+ the chart the other 202px; the `ResizeObserver` already watching `canvasWrap`
2963
+ resizes the canvases for free. Each rail icon opens straight onto *its* tab
2964
+ rather than reopening whatever was last showing, and the last-opened one stays
2965
+ marked so reopening is a single click on the icon already highlighted.
2966
+
2967
+ `setSidePanelVisible(false)` is the same operation as the ✕ — expanded versus
2968
+ rail. `features.enableSidePanels` is what removes the panel entirely.
2969
+
2970
+ ### Data Window
2971
+
2972
+ The bar under the cursor — or the last bar when the cursor is off the plot —
2973
+ followed by one block per visible indicator, refreshed from the render pass so
2974
+ it coalesces with the animation frame instead of firing on every pointer event.
2975
+
2976
+ Values come from the engine's existing per-instance result cache, which is a
2977
+ full-length array indexed by bar, so nothing is recomputed to fill the panel.
2978
+ Only indicators that are actually being recomputed are listed: a hidden or
2979
+ timeframe-locked instance holds a stale cache and showing it would present old
2980
+ numbers as current.
2981
+
2982
+ **Indicator fields are named by what they are.** `IndicatorResult` has five
2983
+ fixed slots — `value`, `upper`, `lower`, `signal`, `histogram` — and indicators
2984
+ do not agree on what they mean. MACD puts its signal line in `upper` and its
2985
+ histogram in `lower`, where Bollinger puts bands in both, so labelling the slots
2986
+ generically would print `Upper 1.84` for a MACD signal. A small table names the
2987
+ ones that differ; everything else falls back.
2988
+
2989
+ A single-line indicator renders as one `MA(20): 150.56` row rather than a
2990
+ heading with one orphan beneath it. One still warming up shows a dash, because
2991
+ omitting the block would read as the indicator having been removed.
2992
+
2993
+ Rows use tabular figures so the value column does not jitter as digits change
2994
+ under a moving crosshair, and the panel **reconciles rather than rebuilds** —
2995
+ it keys on the row labels and, when they are unchanged, writes only the text.
2996
+
2997
+ ### Object Tree
2998
+
2999
+ Everything on the chart, grouped, with per-row show/hide, lock and remove.
3000
+ Clicking a drawing row selects it; clicking an indicator row opens its settings.
3001
+ Empty groups are omitted rather than shown as a zero.
3002
+
3003
+ Drawing names come from `labels.drawingToolbar.tools` — the toolbar's own label
3004
+ map, so the tree is localised by the same override a host already supplies. A
3005
+ text tool shows its first line beside the type name, clipped, since a Table or
3006
+ Note carries several and the rest would push every other row off the panel.
3007
+
3008
+ Indicators get **no lock control**: there is nothing to lock. The panel reads an
3009
+ absent `locked` as exactly that rather than needing a flag.
3010
+
3011
+ ### New per-object primitives
3012
+
3013
+ The manager only had bulk operations, so these are new:
3014
+
3015
+ ```ts
3016
+ chart.setDrawingVisible(id, false);
3017
+ chart.setDrawingLocked(id, true);
3018
+ chart.deleteDrawing(id); // not "select it, then delete"
3019
+ chart.selectDrawing(id); // null clears
3020
+ chart.toggleIndicatorVisibility(instanceId);
3021
+ ```
3022
+
3023
+ `deleteDrawing` is separate from `deleteSelected()` on purpose: selecting fires
3024
+ `onSelectionChange`, which opens the style popover on a drawing that is about to
3025
+ disappear. Hiding or locking the selected drawing deselects it, the way the bulk
3026
+ operations already did — a selection box floating over nothing, with a popover
3027
+ attached, is worse than losing the selection. All three take an undo snapshot.
3028
+
3029
+ ### Reopening
3030
+
3031
+ Click either rail icon. Three Quick Search commands do the same from the
3032
+ keyboard: **Open Data Window**, **Open Object Tree**, **Toggle side panel**.
3033
+
3034
+ ### Rendering the data yourself
3035
+
3036
+ `buildDataWindow()` and `buildObjectTree()` are exported as pure functions, and
3037
+ `SidePanel` takes their output. A host can render the same rows in its own
3038
+ chrome, or feed the panel from somewhere other than the chart.
3039
+
3040
+
3041
+ ## Custom Indicator
3042
+
3043
+ Implement `IIndicator` and pass it to `addIndicator()`:
3044
+
3045
+ ```ts
3046
+ import type { IIndicator, OHLCVBar, IndicatorResult } from 'acttrader-charts';
3047
+
3048
+ class MyIndicator implements IIndicator {
3049
+ name = 'MyInd';
3050
+ color = '#e91e63';
3051
+ pane = 'sub' as const; // 'main' = overlay, 'sub' = dedicated pane
3052
+ paneHeight = 80;
3053
+
3054
+ compute(data: OHLCVBar[]): IndicatorResult[] {
3055
+ return data.map((bar, i) => ({ index: i, value: bar.close }));
3056
+ }
3057
+
3058
+ render(ctx, results, scale, viewport, chartHeight) {
3059
+ // draw with ctx (Canvas 2D)
3060
+ }
3061
+ }
3062
+
3063
+ chart.addIndicator(new MyIndicator());
3064
+ ```
3065
+
3066
+ **Price fields.** A main-pane (`pane: 'main'`) indicator's `value`, `upper` and
3067
+ `lower` count as prices when the price scale auto-fits, so band lines stay in
3068
+ view. If your indicator stores something else in one of them, list only its
3069
+ price fields, or the scale stretches to that number and flattens the candles.
3070
+ Supertrend and Parabolic SAR keep the trend direction (+1 / -1) in `upper`:
3071
+
3072
+ ```ts
3073
+ readonly priceFields = ['value'] as const; // default: ['value', 'upper', 'lower']
3074
+ ```
3075
+
3076
+ ---
3077
+
3078
+ ## Build & Dev
3079
+
3080
+ ```bash
3081
+ npm run dev # Vite dev server with demo chart
3082
+ npm run build # ESM + CJS via tsup → dist/
3083
+ npm run test # Vitest unit tests
3084
+ npm run typecheck # tsc --noEmit
3085
+ npm run lint # ESLint
3086
+ ```
3087
+
3088
+ ---
3089
+
3090
+ ## License
3091
+
3092
+ MIT
3093
+
3094
+ ## Bar Replay
3095
+
3096
+ Replay history bar by bar, as TradingView does. `enableReplay: true` adds a
3097
+ **Replay** button to the header (simple and advanced layouts); it opens a
3098
+ control strip under the chart:
3099
+
3100
+ ```
3101
+ ⇤ Select bar ▾ │ ▷ ▷| 1x 1D │ ▷▷| ✕
3102
+ ```
3103
+
3104
+ - **Select bar** — a cut line with a scissors badge follows the pointer; click
3105
+ a bar and everything from it onward is hidden. The ▾ menu also offers
3106
+ **Select date…**, **First available date** and **Random bar**.
3107
+ - **▷ / ▷|** — play at the chosen speed (`0.1x` – `10x` bars per second) or
3108
+ step one bar forward. Keyboard: `Shift+→` step, `Shift+↓` play/pause,
3109
+ `Esc` leaves select mode.
3110
+ - **▷▷|** — jump to real time: every hidden bar comes back, including live
3111
+ bars built while the replay ran. **✕** closes the strip.
3112
+
3113
+ Live ticks keep updating the hidden series during a replay, so nothing is
3114
+ lost; a `loadData()` during a replay (timeframe or symbol change) keeps the
3115
+ replay date. Default `false`; without it nothing changes.
3116
+
3117
+ ```ts
3118
+ const chart = new ChartEngine({ container, enableReplay: true });
3119
+
3120
+ // Drive it from code (works without the header button too):
3121
+ chart.openReplay(); // show the strip, arm "Select bar"
3122
+ chart.startReplay('2026-06-01'); // hide bars after this date (number | Date | string)
3123
+ chart.setReplaySpeed(3); // bars per second
3124
+ chart.playReplay(); chart.pauseReplay(); chart.replayStepForward();
3125
+ chart.exitReplay(); // back to real time, strip stays open
3126
+ chart.closeReplay(); // back to real time and close the strip
3127
+ chart.isReplayActive();
3128
+
3129
+ chart.on('replayStart', ({ time }) => { /* bars after `time` are hidden */ });
3130
+ chart.on('replayStep', ({ time }) => { /* one more bar revealed */ });
3131
+ chart.on('replayEnd', ({ time }) => { /* last hidden bar revealed */ });
3132
+ chart.on('replayExit', () => { /* live chart is back */ });
3133
+ ```
3134
+
3135
+ On Android and iOS the same controls are bridge commands — `openReplay`,
3136
+ `closeReplay`, `startReplay { time }`, `playReplay`, `pauseReplay`,
3137
+ `replayStepForward`, `setReplaySpeed { barsPerSecond }`, `exitReplay` — with
3138
+ `enableReplay` on `init`; the four events above arrive through the bridge and the
3139
+ mobile header gets its own Replay button. See the `charts-android` and
3140
+ `charts-ios` READMEs for the Kotlin / Swift calls.
3141
+
3142
+ ## Two-finger measure (mobile)
3143
+
3144
+ Hold two fingers on the chart and it shows what the TradingView app shows: a
3145
+ pill over the range with the **close and date under each finger** and, between
3146
+ them, the **change in price and percent**, coloured by direction; a vertical
3147
+ line and a dot on the close mark each finger and the range is tinted. Moving
3148
+ the fingers moves the measurement; lifting either finger ends it. Pinch still
3149
+ zooms — the measure only starts once both fingers have rested for ~0.3 s.
3150
+
3151
+ ```ts
3152
+ new ChartEngine({ container, enableTwoFingerMeasure: true });
3153
+
3154
+ chart.on('twoFingerMeasure', ({ phase, startTime, endTime, startClose, endClose, change, changePercent, bars }) => {
3155
+ // phase: 'start' | 'update' | 'end'
3156
+ });
3157
+ ```
3158
+
3159
+ Touch-only, so desktop is unaffected. Default `false`. On Android / iOS set
3160
+ `enableTwoFingerMeasure` on `init`; the event arrives through the bridge.