acttrader-charts 1.3.0-beta.14 → 1.3.0-beta.16

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,12 +58,15 @@ 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
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). 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`).
@@ -171,6 +174,8 @@ dealingChart.setTickClosePriceSource('bid');
171
174
  dealingChart.on('priceSourceChange', ({ source }) => saveTradeSetting(source));
172
175
  ```
173
176
 
177
+ **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.
178
+
174
179
  ### Compare symbols
175
180
 
176
181
  Overlay one or more comparison instruments on the main chart, normalized to
@@ -348,6 +353,8 @@ const chart = new ChartEngine({
348
353
  container,
349
354
  features: { orderLineTimeDrag: true }, // host enables this only for the gated broker
350
355
  orderLineDragSnap: true, // snap to nearest candle on release (default)
356
+ orderLineAnchorPersistence: true, // remember dropped positions in localStorage (default)
357
+ orderLineDefaultAnchor: 'center', // un-dragged badges start mid-chart (default: 'timestamp')
351
358
  });
352
359
 
353
360
  chart.setLevels(
@@ -365,6 +372,8 @@ chart.on('orderLineMoved', ({ label, fromTimestamp, toTimestamp, data }) => {
365
372
  - **Snap:** controlled by `orderLineDragSnap` (default `true`); set `false` to keep the exact released time.
366
373
  - **Escape / touch-cancel:** reverts the badge to its starting candle without emitting `orderLineMoved`.
367
374
  - **Stays put after release:** the chart holds the dropped position across `setLevels` refreshes until you echo the new `timestamp` back (auto-released on match).
375
+ - **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.
376
+ - **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.
368
377
  - A level **without** `timestamp` renders exactly as before (badge centered), so existing consumers are unaffected.
369
378
 
370
379
  ---
@@ -405,7 +414,7 @@ chart.on('orderLineMoved', ({ label, fromTimestamp, toTimestamp, data }) => {
405
414
  | `momentumMaxVelocity` | | `6.0` | Max launch velocity (px/ms) — caps hard-flick speed |
406
415
  | `targetCandleWidth` | | `10` | Target px width per candle for auto-calculating initial bar count |
407
416
  | `durationTimeframeMap` | | *(see below)* | Override duration → timeframe pairings |
408
- | `dataLoader` | | — | `(params) => Promise<OHLCVBar[]>` auto-called on load / change |
417
+ | `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'`) |
409
418
  | `compareDataLoader` | | — | `({ symbol, start, end, interval }) => Promise<OHLCVBar[]>` — fetches bars for a compare symbol. Set this to enable the library-owned Compare flow. |
410
419
  | `initialCompares` | | — | Symbols to auto-add as compares once the initial primary range is loaded |
411
420
  | `maxCompares` | | `8` | Maximum concurrent compare symbols. Adding beyond emits `compareError` |
@@ -414,7 +423,8 @@ chart.on('orderLineMoved', ({ label, fromTimestamp, toTimestamp, data }) => {
414
423
  | `labels` | | `DEFAULT_LABELS` | Deep-partial string overrides for i18n/translation |
415
424
  | `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) |
416
425
  | `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)` |
417
- | `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 live candle source and emits `priceSourceChange`; sync it programmatically with `chart.setTickClosePriceSource(source)`. Hidden when unset/empty. Default header layout only |
426
+ | `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 |
427
+ | `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 |
418
428
  | `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 |
419
429
  | `showAskLine` | | unset | Show the Ask price line (dashed line + axis tag) independently. Unset: legacy `showBidAskLines` behavior. Toggle at runtime with `chart.setShowAskLine(show)` |
420
430
  | `showBidLine` | | unset | Show the Bid price line (dashed line + axis tag) independently. Unset: legacy `showBidAskLines` behavior. Toggle at runtime with `chart.setShowBidLine(show)` |
@@ -432,6 +442,8 @@ chart.on('orderLineMoved', ({ label, fromTimestamp, toTimestamp, data }) => {
432
442
  | `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 |
433
443
  | `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 |
434
444
  | `orderLineDragSnap` | | `true` | When horizontal dragging is enabled, snap the badge to the nearest candle on release. Set `false` to keep the exact released time |
445
+ | `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 |
446
+ | `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` |
435
447
  | `levelClusteringEnabled` | | `true` | Enable trade-level fan-out clustering; overlapping levels group into expandable badges |
436
448
  | `clusterThresholdDistance` | | `20` | Pixel proximity threshold for clustering (only when `levelClusteringEnabled` is `true`) |
437
449
  | `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 |
@@ -1181,7 +1193,7 @@ chart.on('durationChange', ({ duration, timeframe }) => {});
1181
1193
  chart.on('seriesChange', ({ series }) => {});
1182
1194
  chart.on('priceSourceChange', ({ source }) => {}); // header BID/ASK/LTP dropdown pick
1183
1195
  chart.on('streamStatus', ({ status }) => {}); // 'connected' | 'reconnecting' | 'disconnected'
1184
- chart.on('dataLoaded', ({ timeframe, interval, start, end }) => {});
1196
+ chart.on('dataLoaded', ({ timeframe, interval, start, end, priceSource }) => {});
1185
1197
  chart.on('newBar', ({ completedBar, openingBar, intervalMs }) => {});
1186
1198
  chart.on('indicatorAdded', ({ instanceId, shortName, params }) => {}); // a study instance was added — keep instanceId to remove it later
1187
1199
  chart.on('indicatorRemoved', ({ instanceId, shortName }) => {});
@@ -1212,8 +1224,8 @@ chart.on('tradeLevelBracketDrag', ({ label, bracketType, newPrice, data }) => {}
1212
1224
  chart.on('orderLineMoveStart', ({ label, fromTimestamp, fromBarIndex, isFullscreen }) => {});
1213
1225
  chart.on('orderLineMoving', ({ label, toTimestamp, toBarIndex, isFullscreen }) => {}); // fires on every move
1214
1226
  chart.on('orderLineMoved', ({ label, fromTimestamp, toTimestamp, fromBarIndex, toBarIndex, data, isFullscreen }) => {
1215
- // Persist the new time anchor for this order. Echo `timestamp: toTimestamp` back in your
1216
- // next setLevels() payload so the badge stays put across refreshes.
1227
+ // The chart already remembers the drop in localStorage (orderLineAnchorPersistence, default on).
1228
+ // Hook here only if you also want to persist the new time anchor server-side.
1217
1229
  });
1218
1230
 
1219
1231
  // Fires after addLevelBracket() auto-places a bracket, delivering the computed price back to the caller
@@ -603,6 +603,15 @@ interface DataLoaderParams {
603
603
  start: Date;
604
604
  end: Date;
605
605
  interval: string;
606
+ /**
607
+ * Which price the chart is currently building candles from
608
+ * (`tickClosePriceSource`: `'bid'`, `'ask'` or `'ltp'`). Forward it to your
609
+ * history API (e.g. as a query parameter) so the fetched bars match the
610
+ * source the user picked in the header dropdown — the engine re-runs the
611
+ * loader with the new value whenever the source changes. Always set by the
612
+ * engine; optional only so callers that build params by hand keep compiling.
613
+ */
614
+ priceSource?: 'bid' | 'ask' | 'ltp';
606
615
  }
607
616
  /**
608
617
  * Params passed to `compareDataLoader`. Mirrors `DataLoaderParams` but also
@@ -920,6 +929,14 @@ interface ChartConfig {
920
929
  * Rendered by the default header layout.
921
930
  */
922
931
  priceSourceSelector?: Array<'bid' | 'ask' | 'ltp'>;
932
+ /**
933
+ * Re-run `dataLoader` whenever the candle price source changes (a header
934
+ * dropdown pick or `setTickClosePriceSource()`), passing the new source as
935
+ * `params.priceSource`, so the historical candles are rebuilt from the
936
+ * selected price and not just the live one. Set `false` to keep the old
937
+ * behavior where only live ticks follow the new source. Default: `true`.
938
+ */
939
+ reloadOnPriceSourceChange?: boolean;
923
940
  /**
924
941
  * When the combined count of open positions and pending orders exceeds this
925
942
  * number, the position render style auto-defaults to `'dot'` (compact).
@@ -1295,6 +1312,26 @@ interface ChartConfig {
1295
1312
  * keeps the exact (interpolated) time it was released at. Default: `true`.
1296
1313
  */
1297
1314
  orderLineDragSnap?: boolean;
1315
+ /**
1316
+ * Remember where each horizontally-dragged order-line badge was dropped in
1317
+ * `localStorage` (keyed by the level's label, i.e. your order/trade id) so it comes back
1318
+ * to the same candle after a page reload — even when the next `setLevels()` payload
1319
+ * carries the order's original `timestamp`. A persisted anchor takes precedence over
1320
+ * the level's `timestamp` until the label is dragged again. Bounded to the 500 most
1321
+ * recently dragged labels. Only relevant when `features.orderLineTimeDrag` is enabled.
1322
+ * Default: `true`.
1323
+ */
1324
+ orderLineAnchorPersistence?: boolean;
1325
+ /**
1326
+ * Where a `timeDraggable` level's badge sits before the user has dragged it:
1327
+ * - `'timestamp'` (default): over the candle at the level's `timestamp`, as before.
1328
+ * - `'center'`: at the chart's horizontal center, ignoring the level's `timestamp`, so a
1329
+ * freshly placed market order arrives mid-chart instead of pinned to the latest candle.
1330
+ * Once the user drags the badge it is anchored to (and persisted at) the dropped candle.
1331
+ * Levels that are not `timeDraggable` always honor their `timestamp`.
1332
+ * Only relevant when `features.orderLineTimeDrag` is enabled.
1333
+ */
1334
+ orderLineDefaultAnchor?: 'timestamp' | 'center';
1298
1335
  }
1299
1336
  interface IndicatorResult {
1300
1337
  index: number;
@@ -1569,7 +1606,9 @@ type ChartEventMap = {
1569
1606
  };
1570
1607
  /** Emitted when the user picks a price source (BID / ASK / LTP) from the
1571
1608
  * header dropdown (`priceSourceSelector`). Not emitted for programmatic
1572
- * `setTickClosePriceSource()` calls. */
1609
+ * `setTickClosePriceSource()` calls. By the time this fires the engine has
1610
+ * already switched the live source and (unless `reloadOnPriceSourceChange`
1611
+ * is `false`) started re-fetching history via `dataLoader`. */
1573
1612
  priceSourceChange: {
1574
1613
  source: 'bid' | 'ask' | 'ltp';
1575
1614
  };
@@ -1583,6 +1622,8 @@ type ChartEventMap = {
1583
1622
  interval: string;
1584
1623
  start: Date;
1585
1624
  end: Date;
1625
+ /** Candle price source the bars were requested with (see `DataLoaderParams.priceSource`). */
1626
+ priceSource: 'bid' | 'ask' | 'ltp';
1586
1627
  };
1587
1628
  /** Emitted when the user clicks the × button on a trade, position, or bracket (SL/TP) level. */
1588
1629
  tradeLevelClose: {
@@ -603,6 +603,15 @@ interface DataLoaderParams {
603
603
  start: Date;
604
604
  end: Date;
605
605
  interval: string;
606
+ /**
607
+ * Which price the chart is currently building candles from
608
+ * (`tickClosePriceSource`: `'bid'`, `'ask'` or `'ltp'`). Forward it to your
609
+ * history API (e.g. as a query parameter) so the fetched bars match the
610
+ * source the user picked in the header dropdown — the engine re-runs the
611
+ * loader with the new value whenever the source changes. Always set by the
612
+ * engine; optional only so callers that build params by hand keep compiling.
613
+ */
614
+ priceSource?: 'bid' | 'ask' | 'ltp';
606
615
  }
607
616
  /**
608
617
  * Params passed to `compareDataLoader`. Mirrors `DataLoaderParams` but also
@@ -920,6 +929,14 @@ interface ChartConfig {
920
929
  * Rendered by the default header layout.
921
930
  */
922
931
  priceSourceSelector?: Array<'bid' | 'ask' | 'ltp'>;
932
+ /**
933
+ * Re-run `dataLoader` whenever the candle price source changes (a header
934
+ * dropdown pick or `setTickClosePriceSource()`), passing the new source as
935
+ * `params.priceSource`, so the historical candles are rebuilt from the
936
+ * selected price and not just the live one. Set `false` to keep the old
937
+ * behavior where only live ticks follow the new source. Default: `true`.
938
+ */
939
+ reloadOnPriceSourceChange?: boolean;
923
940
  /**
924
941
  * When the combined count of open positions and pending orders exceeds this
925
942
  * number, the position render style auto-defaults to `'dot'` (compact).
@@ -1295,6 +1312,26 @@ interface ChartConfig {
1295
1312
  * keeps the exact (interpolated) time it was released at. Default: `true`.
1296
1313
  */
1297
1314
  orderLineDragSnap?: boolean;
1315
+ /**
1316
+ * Remember where each horizontally-dragged order-line badge was dropped in
1317
+ * `localStorage` (keyed by the level's label, i.e. your order/trade id) so it comes back
1318
+ * to the same candle after a page reload — even when the next `setLevels()` payload
1319
+ * carries the order's original `timestamp`. A persisted anchor takes precedence over
1320
+ * the level's `timestamp` until the label is dragged again. Bounded to the 500 most
1321
+ * recently dragged labels. Only relevant when `features.orderLineTimeDrag` is enabled.
1322
+ * Default: `true`.
1323
+ */
1324
+ orderLineAnchorPersistence?: boolean;
1325
+ /**
1326
+ * Where a `timeDraggable` level's badge sits before the user has dragged it:
1327
+ * - `'timestamp'` (default): over the candle at the level's `timestamp`, as before.
1328
+ * - `'center'`: at the chart's horizontal center, ignoring the level's `timestamp`, so a
1329
+ * freshly placed market order arrives mid-chart instead of pinned to the latest candle.
1330
+ * Once the user drags the badge it is anchored to (and persisted at) the dropped candle.
1331
+ * Levels that are not `timeDraggable` always honor their `timestamp`.
1332
+ * Only relevant when `features.orderLineTimeDrag` is enabled.
1333
+ */
1334
+ orderLineDefaultAnchor?: 'timestamp' | 'center';
1298
1335
  }
1299
1336
  interface IndicatorResult {
1300
1337
  index: number;
@@ -1569,7 +1606,9 @@ type ChartEventMap = {
1569
1606
  };
1570
1607
  /** Emitted when the user picks a price source (BID / ASK / LTP) from the
1571
1608
  * header dropdown (`priceSourceSelector`). Not emitted for programmatic
1572
- * `setTickClosePriceSource()` calls. */
1609
+ * `setTickClosePriceSource()` calls. By the time this fires the engine has
1610
+ * already switched the live source and (unless `reloadOnPriceSourceChange`
1611
+ * is `false`) started re-fetching history via `dataLoader`. */
1573
1612
  priceSourceChange: {
1574
1613
  source: 'bid' | 'ask' | 'ltp';
1575
1614
  };
@@ -1583,6 +1622,8 @@ type ChartEventMap = {
1583
1622
  interval: string;
1584
1623
  start: Date;
1585
1624
  end: Date;
1625
+ /** Candle price source the bars were requested with (see `DataLoaderParams.priceSource`). */
1626
+ priceSource: 'bid' | 'ask' | 'ltp';
1586
1627
  };
1587
1628
  /** Emitted when the user clicks the × button on a trade, position, or bracket (SL/TP) level. */
1588
1629
  tradeLevelClose: {
package/dist/index.cjs CHANGED
@@ -18425,6 +18425,7 @@ var DrawingStylePopover = class {
18425
18425
  input.value = current;
18426
18426
  input.title = "Level ratio \u2014 press Enter to apply";
18427
18427
  input.enterKeyHint = "go";
18428
+ input.tabIndex = -1;
18428
18429
  Object.assign(input.style, {
18429
18430
  width: "58px",
18430
18431
  padding: "2px 4px",
@@ -18436,16 +18437,6 @@ var DrawingStylePopover = class {
18436
18437
  borderRadius: "3px",
18437
18438
  outline: "none"
18438
18439
  });
18439
- input.addEventListener("focus", () => {
18440
- input.style.borderColor = this.theme.ui.accent;
18441
- });
18442
- input.addEventListener("blur", () => {
18443
- input.style.borderColor = this.theme.axisBorder;
18444
- });
18445
- input.addEventListener("keydown", (e) => {
18446
- e.stopPropagation();
18447
- if (e.key === "Enter") input.blur();
18448
- });
18449
18440
  const commit = () => {
18450
18441
  const raw = Number(input.value);
18451
18442
  const next = Number.isFinite(raw) ? levelKey(raw) : null;
@@ -18461,6 +18452,17 @@ var DrawingStylePopover = class {
18461
18452
  input.value = next;
18462
18453
  onCommit(next);
18463
18454
  };
18455
+ input.addEventListener("focus", () => {
18456
+ input.style.borderColor = this.theme.ui.accent;
18457
+ });
18458
+ input.addEventListener("blur", () => {
18459
+ input.style.borderColor = this.theme.axisBorder;
18460
+ commit();
18461
+ });
18462
+ input.addEventListener("keydown", (e) => {
18463
+ e.stopPropagation();
18464
+ if (e.key === "Enter") input.blur();
18465
+ });
18464
18466
  input.addEventListener("change", commit);
18465
18467
  return input;
18466
18468
  }
@@ -18503,6 +18505,10 @@ var DrawingStylePopover = class {
18503
18505
  cb.addEventListener("change", () => {
18504
18506
  this.emit({ levels: { [key]: { visible: cb.checked, color } } });
18505
18507
  });
18508
+ cb.tabIndex = -1;
18509
+ swatch.querySelectorAll("input").forEach((control) => {
18510
+ control.tabIndex = -1;
18511
+ });
18506
18512
  panel.append(cb, editable ? this.buildLevelRatioInput(
18507
18513
  k,
18508
18514
  taken,
@@ -19788,6 +19794,101 @@ var OrderLineTimeDragHandler = class _OrderLineTimeDragHandler {
19788
19794
  }
19789
19795
  };
19790
19796
 
19797
+ // src/interaction/OrderLineAnchorStore.ts
19798
+ var ORDER_LINE_ANCHORS_LS_KEY = "finchart.orderLineTimeAnchors";
19799
+ var ORDER_LINE_ANCHORS_MAX_ENTRIES = 500;
19800
+ var OrderLineAnchorStore = class {
19801
+ constructor(_storage, _key = ORDER_LINE_ANCHORS_LS_KEY, _maxEntries = ORDER_LINE_ANCHORS_MAX_ENTRIES) {
19802
+ this._storage = _storage;
19803
+ this._key = _key;
19804
+ this._maxEntries = _maxEntries;
19805
+ this._entries = /* @__PURE__ */ new Map();
19806
+ this._load();
19807
+ }
19808
+ /** The page's `localStorage`, or `null` when it is absent or access throws. */
19809
+ static browserStorage() {
19810
+ try {
19811
+ if (typeof localStorage === "undefined") return null;
19812
+ return localStorage;
19813
+ } catch {
19814
+ return null;
19815
+ }
19816
+ }
19817
+ /** Persisted unix-ms anchor for `label`, or `undefined` when none was saved. */
19818
+ get(label) {
19819
+ return this._entries.get(label)?.ts;
19820
+ }
19821
+ has(label) {
19822
+ return this._entries.has(label);
19823
+ }
19824
+ /** Number of persisted anchors. */
19825
+ get size() {
19826
+ return this._entries.size;
19827
+ }
19828
+ /**
19829
+ * Records the candle time `label`'s badge was dropped on and writes it through to storage.
19830
+ * Non-finite timestamps are ignored. `now` is the save time used for eviction ordering.
19831
+ */
19832
+ set(label, ts, now = Date.now()) {
19833
+ if (!label || !Number.isFinite(ts)) return;
19834
+ this._entries.set(label, { ts, at: now });
19835
+ this._evict();
19836
+ this._save();
19837
+ }
19838
+ /** Forgets `label`'s anchor. Returns whether one existed. */
19839
+ delete(label) {
19840
+ const had = this._entries.delete(label);
19841
+ if (had) this._save();
19842
+ return had;
19843
+ }
19844
+ /** Drops every persisted anchor (memory + storage). */
19845
+ clear() {
19846
+ this._entries.clear();
19847
+ try {
19848
+ this._storage?.removeItem(this._key);
19849
+ } catch {
19850
+ }
19851
+ }
19852
+ _evict() {
19853
+ if (this._entries.size <= this._maxEntries) return;
19854
+ const byAge = [...this._entries.entries()].sort((a, b) => a[1].at - b[1].at);
19855
+ const excess = this._entries.size - this._maxEntries;
19856
+ for (let i = 0; i < excess; i++) {
19857
+ const victim = byAge[i];
19858
+ if (victim) this._entries.delete(victim[0]);
19859
+ }
19860
+ }
19861
+ _load() {
19862
+ if (!this._storage) return;
19863
+ try {
19864
+ const raw = this._storage.getItem(this._key);
19865
+ if (!raw) return;
19866
+ const parsed = JSON.parse(raw);
19867
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
19868
+ for (const [label, value] of Object.entries(parsed)) {
19869
+ if (!value || typeof value !== "object") continue;
19870
+ const { ts, at } = value;
19871
+ if (typeof ts !== "number" || !Number.isFinite(ts)) continue;
19872
+ const savedAt = typeof at === "number" && Number.isFinite(at) ? at : 0;
19873
+ this._entries.set(label, { ts, at: savedAt });
19874
+ }
19875
+ this._evict();
19876
+ } catch {
19877
+ }
19878
+ }
19879
+ _save() {
19880
+ if (!this._storage) return;
19881
+ try {
19882
+ if (this._entries.size === 0) {
19883
+ this._storage.removeItem(this._key);
19884
+ return;
19885
+ }
19886
+ this._storage.setItem(this._key, JSON.stringify(Object.fromEntries(this._entries)));
19887
+ } catch {
19888
+ }
19889
+ }
19890
+ };
19891
+
19791
19892
  // src/indicators/sourceField.ts
19792
19893
  function getSource(bar, source) {
19793
19894
  return bar[source];
@@ -23504,6 +23605,8 @@ var _ChartEngine = class _ChartEngine {
23504
23605
  /** Last traded price from the most recent tick that carried one ('ltp' source mode). */
23505
23606
  this.liveLtp = null;
23506
23607
  this.tickClosePriceSource = "bid";
23608
+ /** Re-fetch history through `dataLoader` when the price source changes (`reloadOnPriceSourceChange`). */
23609
+ this._reloadOnPriceSourceChange = true;
23507
23610
  /** True while an async data fetch is in-flight; suppresses tick processing to
23508
23611
  * prevent stale ticks from corrupting newly-loaded bars. */
23509
23612
  this._suppressTicks = false;
@@ -23550,6 +23653,15 @@ var _ChartEngine = class _ChartEngine {
23550
23653
  * `timestamp` back via `setLevels` (auto-released on match). Survives PnL-only refreshes.
23551
23654
  */
23552
23655
  this._timeAnchorOverride = /* @__PURE__ */ new Map();
23656
+ /**
23657
+ * Time anchors persisted in localStorage (`orderLineAnchorPersistence`), keyed by level
23658
+ * label — the candle each badge was last dropped on. Restored at construction so a
23659
+ * horizontally-dragged order line comes back where the user left it after a reload.
23660
+ * Null when persistence is disabled or time-drag is off.
23661
+ */
23662
+ this._orderLineAnchorStore = null;
23663
+ /** Where an un-dragged `timeDraggable` badge sits (`orderLineDefaultAnchor`). */
23664
+ this._orderLineDefaultAnchor = "timestamp";
23553
23665
  this.tradeDragNewPrice = null;
23554
23666
  /** Derived SL preview price during an entry drag in follow-brackets mode. */
23555
23667
  this.tradeDragNewSLPrice = null;
@@ -24786,6 +24898,7 @@ var _ChartEngine = class _ChartEngine {
24786
24898
  this._targetViewport = { ...this.viewport };
24787
24899
  this.maxSubPanes = config.maxSubPanes ?? 3;
24788
24900
  this.tickClosePriceSource = config.tickClosePriceSource ?? "bid";
24901
+ this._reloadOnPriceSourceChange = config.reloadOnPriceSourceChange !== false;
24789
24902
  this.showLtpPrice = config.showLtpPrice;
24790
24903
  this.tradesThresholdForHorizontalLine = config.tradesThresholdForHorizontalLine ?? 2;
24791
24904
  if (config.positionRenderStyle === "line" || config.positionRenderStyle === "dot") {
@@ -24815,6 +24928,12 @@ var _ChartEngine = class _ChartEngine {
24815
24928
  this._features = config.features;
24816
24929
  this.drawingManager.setFreehandEnabled(config.features?.enableForecasting === true);
24817
24930
  this._orderLineDragSnap = config.orderLineDragSnap !== false;
24931
+ this._orderLineDefaultAnchor = config.orderLineDefaultAnchor === "center" ? "center" : "timestamp";
24932
+ if (this._orderTimeDragEnabled && config.orderLineAnchorPersistence !== false) {
24933
+ this._orderLineAnchorStore = new OrderLineAnchorStore(
24934
+ OrderLineAnchorStore.browserStorage()
24935
+ );
24936
+ }
24818
24937
  this.tradeLevelButtonScale = Math.min(Math.max(config.tradeLevelButtonScale ?? 1, 1), 3);
24819
24938
  this._bracketLabel = {
24820
24939
  mode: config.bracketLabelMode ?? "price",
@@ -24994,11 +25113,7 @@ var _ChartEngine = class _ChartEngine {
24994
25113
  showSnapshotBtn,
24995
25114
  config.priceSourceSelector ?? null,
24996
25115
  this.tickClosePriceSource,
24997
- (source) => {
24998
- this.tickClosePriceSource = source;
24999
- this.scheduleRender();
25000
- this.emitter.emit("priceSourceChange", { source });
25001
- }
25116
+ (source) => this._applyPriceSource(source, true)
25002
25117
  );
25003
25118
  this.advancedToolbar.setIndicatorOptions(indicatorOptions);
25004
25119
  this.wrapper.appendChild(this.advancedToolbar.el);
@@ -25045,11 +25160,7 @@ var _ChartEngine = class _ChartEngine {
25045
25160
  resolvedOneClickTradeToggle ?? null,
25046
25161
  config.priceSourceSelector ?? null,
25047
25162
  this.tickClosePriceSource,
25048
- (source) => {
25049
- this.tickClosePriceSource = source;
25050
- this.scheduleRender();
25051
- this.emitter.emit("priceSourceChange", { source });
25052
- }
25163
+ (source) => this._applyPriceSource(source, true)
25053
25164
  );
25054
25165
  this.topBar.setIndicatorOptions(indicatorOptions);
25055
25166
  this.wrapper.appendChild(this.topBar.el);
@@ -25870,7 +25981,7 @@ var _ChartEngine = class _ChartEngine {
25870
25981
  const minBars = this.minInitialBars;
25871
25982
  const cap = this.maxLookbackMs;
25872
25983
  const attempt = (i, start) => {
25873
- loader({ start, end, interval }).then((bars) => {
25984
+ loader({ start, end, interval, priceSource: this.tickClosePriceSource }).then((bars) => {
25874
25985
  if (gen !== this._loadGeneration) return;
25875
25986
  const aggregated = this.maybeAggregate(bars);
25876
25987
  const reachedCap = end.getTime() - start.getTime() >= cap;
@@ -25882,7 +25993,8 @@ var _ChartEngine = class _ChartEngine {
25882
25993
  timeframe: this.timeframe,
25883
25994
  interval,
25884
25995
  start,
25885
- end
25996
+ end,
25997
+ priceSource: this.tickClosePriceSource
25886
25998
  });
25887
25999
  return;
25888
26000
  }
@@ -25917,7 +26029,7 @@ var _ChartEngine = class _ChartEngine {
25917
26029
  const minBars = this.minInitialBars;
25918
26030
  const cap = this.maxLookbackMs;
25919
26031
  const attempt = (i, start) => {
25920
- loader({ start, end, interval }).then((bars) => {
26032
+ loader({ start, end, interval, priceSource: this.tickClosePriceSource }).then((bars) => {
25921
26033
  if (gen !== this._loadGeneration) return;
25922
26034
  const aggregated = this.maybeAggregate(bars);
25923
26035
  const reachedCap = end.getTime() - start.getTime() >= cap;
@@ -27096,17 +27208,44 @@ var _ChartEngine = class _ChartEngine {
27096
27208
  this.streamAdapter = null;
27097
27209
  return this;
27098
27210
  }
27099
- /** Switch which price drives live candle close/high/low at runtime
27211
+ /** Switch which price drives candle close/high/low at runtime
27100
27212
  * (`'bid'`, `'ask'` or `'ltp'`). Also syncs the header price-source
27101
- * dropdown when `priceSourceSelector` is enabled. Programmatic calls do
27102
- * not emit `priceSourceChange` — that event is reserved for user picks
27103
- * from the dropdown. */
27213
+ * dropdown when `priceSourceSelector` is enabled, and — unless
27214
+ * `reloadOnPriceSourceChange` is `false` — re-runs `dataLoader` with the
27215
+ * new `params.priceSource` so the loaded history is rebuilt from that
27216
+ * price too. Programmatic calls do not emit `priceSourceChange` — that
27217
+ * event is reserved for user picks from the dropdown. */
27104
27218
  setTickClosePriceSource(source) {
27219
+ this._applyPriceSource(source, false);
27220
+ return this;
27221
+ }
27222
+ /**
27223
+ * Applies a new candle price source. Shared by the header dropdown pick and
27224
+ * `setTickClosePriceSource()`.
27225
+ *
27226
+ * Switching the field alone only changes how the NEXT live ticks extend the
27227
+ * last candle; every bar already on screen was fetched with the previous
27228
+ * source, so the chart kept showing e.g. bid history under an "LTP" label.
27229
+ * When the source actually changes and a `dataLoader` is configured, the
27230
+ * whole window is therefore re-fetched (`triggerLoad('priceSource')`) — the
27231
+ * loader receives the source as `params.priceSource` to forward to its API.
27232
+ * The visible time window is restored after the reload so the user does not
27233
+ * lose their scroll/zoom position.
27234
+ */
27235
+ _applyPriceSource(source, fromUserPick) {
27236
+ const changed = source !== this.tickClosePriceSource;
27105
27237
  this.tickClosePriceSource = source;
27106
27238
  this.topBar?.setPriceSource(source);
27107
27239
  this.advancedToolbar?.setPriceSource(source);
27108
27240
  this.scheduleRender();
27109
- return this;
27241
+ if (changed && this._reloadOnPriceSourceChange && this.dataLoader && !this._isDestroyed) {
27242
+ const viewport = this.getViewportTimestamps();
27243
+ if (viewport) {
27244
+ this._pendingViewportRestore = { ...this._pendingViewportRestore ?? {}, viewport };
27245
+ }
27246
+ this.triggerLoad("priceSource");
27247
+ }
27248
+ if (fromUserPick) this.emitter.emit("priceSourceChange", { source });
27110
27249
  }
27111
27250
  /** Show/hide the LTP (last traded price) marker — dashed line + axis tag.
27112
27251
  * `true` always shows it when the feed supplies an LTP, `false` hides it
@@ -27271,7 +27410,7 @@ var _ChartEngine = class _ChartEngine {
27271
27410
  }
27272
27411
  const incomingTs = lvl.timestamp;
27273
27412
  const overrideTs = this._timeAnchorOverride.get(label);
27274
- if (incomingTs !== void 0 && overrideTs !== void 0 && Math.round(this.timestampToBarIndex(incomingTs)) === Math.round(this.timestampToBarIndex(overrideTs))) {
27413
+ if (this._honorsLevelTimestamp(lvl) && incomingTs !== void 0 && overrideTs !== void 0 && Math.round(this.timestampToBarIndex(incomingTs)) === Math.round(this.timestampToBarIndex(overrideTs))) {
27275
27414
  this._timeAnchorOverride.delete(label);
27276
27415
  }
27277
27416
  }
@@ -28289,8 +28428,7 @@ var _ChartEngine = class _ChartEngine {
28289
28428
  * Resolves the horizontal badge center X (canvas px) for a level during render, or `null`
28290
28429
  * to keep the legacy chart-centered placement. Priority:
28291
28430
  * 1. the live drag bar index, for the level currently being time-dragged;
28292
- * 2. a staged drop override (set on the last drag release);
28293
- * 3. the level's own `timestamp`.
28431
+ * 2. the resolved time anchor — see `_resolvedTimeAnchor`.
28294
28432
  * Returns `null` when time-drag is disabled or the level has no time anchor.
28295
28433
  */
28296
28434
  _orderLineAnchorX(level, chartW) {
@@ -28298,10 +28436,34 @@ var _ChartEngine = class _ChartEngine {
28298
28436
  if (this.orderTimeDragHandler?.active && this.orderTimeDragHandler.dragLabel === level.label && this.tradeDragNewBarIndex !== null) {
28299
28437
  return this.scaleManager.xToPixel(this.tradeDragNewBarIndex, this.viewport, chartW);
28300
28438
  }
28301
- const ts = this._timeAnchorOverride.get(level.label) ?? level.timestamp;
28439
+ const ts = this._resolvedTimeAnchor(level);
28302
28440
  if (ts === void 0) return null;
28303
28441
  return this.scaleManager.xToPixel(this.timestampToBarIndex(ts), this.viewport, chartW);
28304
28442
  }
28443
+ /**
28444
+ * The unix-ms time anchor a level's badge should sit at (ignoring any live drag), or
28445
+ * `undefined` for the legacy chart-centered placement. Priority:
28446
+ * 1. a drop override staged this session (set on the last drag release);
28447
+ * 2. the anchor persisted in localStorage from an earlier session
28448
+ * (`orderLineAnchorPersistence`);
28449
+ * 3. the level's own `timestamp` — unless `orderLineDefaultAnchor: 'center'` and the
28450
+ * level is `timeDraggable`, in which case an un-dragged badge stays centered.
28451
+ */
28452
+ _resolvedTimeAnchor(level) {
28453
+ const override = this._timeAnchorOverride.get(level.label);
28454
+ if (override !== void 0) return override;
28455
+ const persisted = this._orderLineAnchorStore?.get(level.label);
28456
+ if (persisted !== void 0) return persisted;
28457
+ return this._honorsLevelTimestamp(level) ? level.timestamp : void 0;
28458
+ }
28459
+ /**
28460
+ * Whether a level's own `timestamp` positions its badge. False only for `timeDraggable`
28461
+ * levels under `orderLineDefaultAnchor: 'center'`, whose un-dragged badge is centered
28462
+ * regardless of `timestamp` (a dragged one is positioned by its override / persisted anchor).
28463
+ */
28464
+ _honorsLevelTimestamp(level) {
28465
+ return !(this._orderLineDefaultAnchor === "center" && level.timeDraggable === true);
28466
+ }
28305
28467
  /** Returns the effective entry price for a level, preferring any staged main drag. */
28306
28468
  _effectiveEntryPrice(label, fallback) {
28307
28469
  const changes = this.pendingLevelDrags.get(label);
@@ -28888,7 +29050,8 @@ var _ChartEngine = class _ChartEngine {
28888
29050
  const params = {
28889
29051
  start: new Date(allBars[0].time),
28890
29052
  end: new Date(allBars[allBars.length - 1].time),
28891
- interval: this.timeframe
29053
+ interval: this.timeframe,
29054
+ priceSource: this.tickClosePriceSource
28892
29055
  };
28893
29056
  if (this.compareManager.isActive()) {
28894
29057
  void this.compareManager.refetchAll(params).then(() => {
@@ -29850,7 +30013,7 @@ var _ChartEngine = class _ChartEngine {
29850
30013
  const rawAnchorX = this._orderLineAnchorX(level, chartW) ?? chartW / 2;
29851
30014
  const anchorX = Math.max(dragArea.x, Math.min(dragArea.x + dragArea.w, rawAnchorX));
29852
30015
  const startBarIndex = this.scaleManager.pixelToBarIndex(anchorX, this.viewport, chartW);
29853
- const startTimestamp = this._timeAnchorOverride.get(level.label) ?? level.timestamp ?? this.barIndexToTimestamp(startBarIndex) ?? 0;
30016
+ const startTimestamp = this._resolvedTimeAnchor(level) ?? this.barIndexToTimestamp(startBarIndex) ?? 0;
29854
30017
  this.orderTimeDragHandler.start(level, hitX ?? anchorX, anchorX, startBarIndex, startTimestamp);
29855
30018
  this.drawCanvas.style.cursor = "grabbing";
29856
30019
  this.emitter.emit("orderLineMoveStart", {
@@ -29953,6 +30116,7 @@ var _ChartEngine = class _ChartEngine {
29953
30116
  const fromBarIndex = Math.round(res.startBarIndex);
29954
30117
  if (toTs !== void 0 && res.newBarIndex !== fromBarIndex) {
29955
30118
  this._timeAnchorOverride.set(res.label, toTs);
30119
+ this._orderLineAnchorStore?.set(res.label, toTs);
29956
30120
  this.emitter.emit("orderLineMoved", {
29957
30121
  label: res.label,
29958
30122
  fromTimestamp: res.startTimestamp,
@@ -30076,7 +30240,12 @@ var _ChartEngine = class _ChartEngine {
30076
30240
  if (!this.dataLoader) return;
30077
30241
  if (this.isFetching) return;
30078
30242
  const interval = this.timeframeToInterval(this.getBaseInterval());
30079
- this.dataLoader({ start: new Date(fromTime), end: new Date(toTime), interval }).then((rawBars) => {
30243
+ this.dataLoader({
30244
+ start: new Date(fromTime),
30245
+ end: new Date(toTime),
30246
+ interval,
30247
+ priceSource: this.tickClosePriceSource
30248
+ }).then((rawBars) => {
30080
30249
  const bars = this.maybeAggregate(rawBars);
30081
30250
  if (bars.length === 0) return;
30082
30251
  this.backfillDrawingTimestamps();
@@ -30115,7 +30284,7 @@ var _ChartEngine = class _ChartEngine {
30115
30284
  const start = new Date(barTime);
30116
30285
  const end = new Date(barTime + intervalMs * 3);
30117
30286
  const interval = this.timeframeToInterval(this.getBaseInterval());
30118
- this.dataLoader({ start, end, interval }).then((rawBars) => {
30287
+ this.dataLoader({ start, end, interval, priceSource: this.tickClosePriceSource }).then((rawBars) => {
30119
30288
  const bars = this.maybeAggregate(rawBars);
30120
30289
  if (bars.length === 0) return;
30121
30290
  const all = this.dataStore.all;