dexbot 1.5.2 → 1.6.0

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.
Files changed (240) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +4 -3
  3. package/analysis/README.md +1 -2
  4. package/analysis/ama_fitting/fetch_lp_candles.ts +1 -9
  5. package/analysis/ama_fitting/package.json +1 -1
  6. package/analysis/analyze_derivatives.ts +1 -1
  7. package/analysis/bot_usage/discover_bot_accounts.ts +12 -6
  8. package/analysis/resolve_source.ts +11 -1
  9. package/analysis/results/ama_sweep_results_lp_pool_133_1h.json +2455 -0
  10. package/analysis/results/bot_fitting_results_lp_pool_133_1h.json +218 -0
  11. package/analysis/tradingview/README.md +32 -10
  12. package/analysis/tradingview/analyze_tradingview.ts +118 -3
  13. package/analysis/tradingview/h-bts_tradingview.html +1570 -0
  14. package/analysis/tradingview/t-bts_tradingview.html +1570 -0
  15. package/analysis/tradingview/tradingview_uplot_chart_generator.ts +1392 -125
  16. package/analysis/trend_detection/package.json +1 -1
  17. package/claw/docs/DEXBOT2_TUNING_CHEAT_SHEET.md +1 -1
  18. package/claw/modules/dexbot_profiles.ts +22 -2
  19. package/claw/package.json +1 -1
  20. package/claw/runtimes/openclaw-plugin/openclaw.plugin.json +1 -1
  21. package/claw/runtimes/openclaw-plugin/package.json +1 -1
  22. package/claw/skills/launcher-ops/references/launcher-workflow.md +1 -1
  23. package/claw/tests/test_claw_mcp_transport.ts +2 -2
  24. package/claw/tests/test_dexbot_profiles.ts +65 -0
  25. package/dist/analysis/ama_fitting/fetch_lp_candles.js +1 -9
  26. package/dist/analysis/ama_fitting/fetch_lp_candles.js.map +1 -1
  27. package/dist/analysis/analyze_derivatives.js +1 -1
  28. package/dist/analysis/bot_usage/discover_bot_accounts.js +11 -6
  29. package/dist/analysis/bot_usage/discover_bot_accounts.js.map +1 -1
  30. package/dist/analysis/bot_usage/kibana_bot_queries.d.ts +2 -0
  31. package/dist/analysis/bot_usage/kibana_bot_queries.d.ts.map +1 -1
  32. package/dist/analysis/resolve_source.d.ts +1 -0
  33. package/dist/analysis/resolve_source.d.ts.map +1 -1
  34. package/dist/analysis/resolve_source.js +8 -1
  35. package/dist/analysis/resolve_source.js.map +1 -1
  36. package/dist/analysis/tradingview/analyze_tradingview.d.ts +8 -0
  37. package/dist/analysis/tradingview/analyze_tradingview.d.ts.map +1 -1
  38. package/dist/analysis/tradingview/analyze_tradingview.js +137 -3
  39. package/dist/analysis/tradingview/analyze_tradingview.js.map +1 -1
  40. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.d.ts +5 -1
  41. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.d.ts.map +1 -1
  42. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js +1387 -125
  43. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js.map +1 -1
  44. package/dist/bot.js +1 -1
  45. package/dist/bot.js.map +1 -1
  46. package/dist/dexbot.js +3 -3
  47. package/dist/dexbot.js.map +1 -1
  48. package/dist/market_adapter/ama_signal_runner.js +2 -2
  49. package/dist/market_adapter/candle_utils.d.ts +6 -0
  50. package/dist/market_adapter/candle_utils.d.ts.map +1 -1
  51. package/dist/market_adapter/candle_utils.js +6 -0
  52. package/dist/market_adapter/candle_utils.js.map +1 -1
  53. package/dist/market_adapter/core/asymmetric_bounds.d.ts +33 -1
  54. package/dist/market_adapter/core/asymmetric_bounds.d.ts.map +1 -1
  55. package/dist/market_adapter/core/asymmetric_bounds.js +38 -1
  56. package/dist/market_adapter/core/asymmetric_bounds.js.map +1 -1
  57. package/dist/market_adapter/core/kibana_candles.d.ts +17 -1
  58. package/dist/market_adapter/core/kibana_candles.d.ts.map +1 -1
  59. package/dist/market_adapter/core/kibana_candles.js +101 -19
  60. package/dist/market_adapter/core/kibana_candles.js.map +1 -1
  61. package/dist/market_adapter/core/kibana_client.d.ts +2 -0
  62. package/dist/market_adapter/core/kibana_client.d.ts.map +1 -1
  63. package/dist/market_adapter/core/kibana_client.js +31 -4
  64. package/dist/market_adapter/core/kibana_client.js.map +1 -1
  65. package/dist/market_adapter/core/market_adapter_service.d.ts.map +1 -1
  66. package/dist/market_adapter/core/market_adapter_service.js +0 -6
  67. package/dist/market_adapter/core/market_adapter_service.js.map +1 -1
  68. package/dist/market_adapter/inputs/fetch_book_data.d.ts +14 -0
  69. package/dist/market_adapter/inputs/fetch_book_data.d.ts.map +1 -0
  70. package/dist/market_adapter/inputs/fetch_book_data.js +138 -0
  71. package/dist/market_adapter/inputs/fetch_book_data.js.map +1 -0
  72. package/dist/market_adapter/inputs/fetch_cex_synthetic_data.js +1 -1
  73. package/dist/market_adapter/inputs/fetch_lp_data.d.ts +16 -2
  74. package/dist/market_adapter/inputs/fetch_lp_data.d.ts.map +1 -1
  75. package/dist/market_adapter/inputs/fetch_lp_data.js +109 -281
  76. package/dist/market_adapter/inputs/fetch_lp_data.js.map +1 -1
  77. package/dist/market_adapter/inputs/kibana_feed_source.d.ts +87 -0
  78. package/dist/market_adapter/inputs/kibana_feed_source.d.ts.map +1 -0
  79. package/dist/market_adapter/inputs/kibana_feed_source.js +570 -0
  80. package/dist/market_adapter/inputs/kibana_feed_source.js.map +1 -0
  81. package/dist/market_adapter/inputs/kibana_source.d.ts.map +1 -1
  82. package/dist/market_adapter/inputs/kibana_source.js +10 -5
  83. package/dist/market_adapter/inputs/kibana_source.js.map +1 -1
  84. package/dist/market_adapter/inputs/window_cache.d.ts +166 -0
  85. package/dist/market_adapter/inputs/window_cache.d.ts.map +1 -0
  86. package/dist/market_adapter/inputs/window_cache.js +500 -0
  87. package/dist/market_adapter/inputs/window_cache.js.map +1 -0
  88. package/dist/market_adapter/interval_utils.d.ts +8 -1
  89. package/dist/market_adapter/interval_utils.d.ts.map +1 -1
  90. package/dist/market_adapter/interval_utils.js +14 -1
  91. package/dist/market_adapter/interval_utils.js.map +1 -1
  92. package/dist/modules/account_bots.d.ts +1 -0
  93. package/dist/modules/account_bots.d.ts.map +1 -1
  94. package/dist/modules/account_bots.js +21 -5
  95. package/dist/modules/account_bots.js.map +1 -1
  96. package/dist/modules/account_orders.d.ts +25 -4
  97. package/dist/modules/account_orders.d.ts.map +1 -1
  98. package/dist/modules/account_orders.js +67 -4
  99. package/dist/modules/account_orders.js.map +1 -1
  100. package/dist/modules/bitshares-native/chain_client.d.ts +2 -0
  101. package/dist/modules/bitshares-native/chain_client.d.ts.map +1 -1
  102. package/dist/modules/bitshares-native/chain_client.js +3 -1
  103. package/dist/modules/bitshares-native/chain_client.js.map +1 -1
  104. package/dist/modules/bitshares-native/subscriptions.d.ts.map +1 -1
  105. package/dist/modules/bitshares-native/subscriptions.js +13 -3
  106. package/dist/modules/bitshares-native/subscriptions.js.map +1 -1
  107. package/dist/modules/bitshares-native/transport.d.ts +2 -0
  108. package/dist/modules/bitshares-native/transport.d.ts.map +1 -1
  109. package/dist/modules/bitshares-native/transport.js +122 -28
  110. package/dist/modules/bitshares-native/transport.js.map +1 -1
  111. package/dist/modules/bitshares_client.d.ts.map +1 -1
  112. package/dist/modules/bitshares_client.js +59 -8
  113. package/dist/modules/bitshares_client.js.map +1 -1
  114. package/dist/modules/bot_settings.d.ts.map +1 -1
  115. package/dist/modules/bot_settings.js +11 -0
  116. package/dist/modules/bot_settings.js.map +1 -1
  117. package/dist/modules/constants.d.ts +13 -0
  118. package/dist/modules/constants.d.ts.map +1 -1
  119. package/dist/modules/constants.js +68 -4
  120. package/dist/modules/constants.js.map +1 -1
  121. package/dist/modules/dexbot_class.d.ts +10 -18
  122. package/dist/modules/dexbot_class.d.ts.map +1 -1
  123. package/dist/modules/dexbot_class.js +57 -2
  124. package/dist/modules/dexbot_class.js.map +1 -1
  125. package/dist/modules/dexbot_cow_runtime.d.ts +62 -37
  126. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  127. package/dist/modules/dexbot_cow_runtime.js +797 -569
  128. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  129. package/dist/modules/dexbot_fill_runtime.d.ts +41 -1
  130. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  131. package/dist/modules/dexbot_fill_runtime.js +135 -5
  132. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  133. package/dist/modules/dexbot_maintenance_runtime.d.ts +79 -12
  134. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  135. package/dist/modules/dexbot_maintenance_runtime.js +585 -97
  136. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  137. package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
  138. package/dist/modules/dexbot_startup_runtime.js +13 -0
  139. package/dist/modules/dexbot_startup_runtime.js.map +1 -1
  140. package/dist/modules/dexbot_state_recovery.d.ts +15 -1
  141. package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
  142. package/dist/modules/dexbot_state_recovery.js +36 -7
  143. package/dist/modules/dexbot_state_recovery.js.map +1 -1
  144. package/dist/modules/fund_registry.d.ts +1 -1
  145. package/dist/modules/fund_registry.js +1 -1
  146. package/dist/modules/node_connect_policy.d.ts +52 -0
  147. package/dist/modules/node_connect_policy.d.ts.map +1 -0
  148. package/dist/modules/node_connect_policy.js +35 -0
  149. package/dist/modules/node_connect_policy.js.map +1 -0
  150. package/dist/modules/node_manager.d.ts +32 -0
  151. package/dist/modules/node_manager.d.ts.map +1 -1
  152. package/dist/modules/node_manager.js +76 -9
  153. package/dist/modules/node_manager.js.map +1 -1
  154. package/dist/modules/order/accounting.d.ts +30 -0
  155. package/dist/modules/order/accounting.d.ts.map +1 -1
  156. package/dist/modules/order/accounting.js +176 -3
  157. package/dist/modules/order/accounting.js.map +1 -1
  158. package/dist/modules/order/export.d.ts.map +1 -1
  159. package/dist/modules/order/export.js +1 -0
  160. package/dist/modules/order/export.js.map +1 -1
  161. package/dist/modules/order/grid.d.ts.map +1 -1
  162. package/dist/modules/order/grid.js +170 -51
  163. package/dist/modules/order/grid.js.map +1 -1
  164. package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
  165. package/dist/modules/order/grid_reconcile.js +5 -3
  166. package/dist/modules/order/grid_reconcile.js.map +1 -1
  167. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  168. package/dist/modules/order/grid_reconcile_internal.js +182 -6
  169. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  170. package/dist/modules/order/manager.d.ts +66 -3
  171. package/dist/modules/order/manager.d.ts.map +1 -1
  172. package/dist/modules/order/manager.js +238 -12
  173. package/dist/modules/order/manager.js.map +1 -1
  174. package/dist/modules/order/strategy.d.ts +3 -0
  175. package/dist/modules/order/strategy.d.ts.map +1 -1
  176. package/dist/modules/order/strategy.js +79 -7
  177. package/dist/modules/order/strategy.js.map +1 -1
  178. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  179. package/dist/modules/order/sync_engine.js +90 -1
  180. package/dist/modules/order/sync_engine.js.map +1 -1
  181. package/dist/modules/order/utils/math.d.ts +20 -12
  182. package/dist/modules/order/utils/math.d.ts.map +1 -1
  183. package/dist/modules/order/utils/math.js +33 -12
  184. package/dist/modules/order/utils/math.js.map +1 -1
  185. package/dist/modules/order/utils/order.d.ts +308 -28
  186. package/dist/modules/order/utils/order.d.ts.map +1 -1
  187. package/dist/modules/order/utils/order.js +862 -38
  188. package/dist/modules/order/utils/order.js.map +1 -1
  189. package/dist/modules/order/utils/system.d.ts +37 -30
  190. package/dist/modules/order/utils/system.d.ts.map +1 -1
  191. package/dist/modules/order/utils/system.js +130 -101
  192. package/dist/modules/order/utils/system.js.map +1 -1
  193. package/dist/modules/order/utils/validate.d.ts +3 -2
  194. package/dist/modules/order/utils/validate.d.ts.map +1 -1
  195. package/dist/modules/order/utils/validate.js +11 -3
  196. package/dist/modules/order/utils/validate.js.map +1 -1
  197. package/dist/modules/paths.d.ts +2 -0
  198. package/dist/modules/paths.d.ts.map +1 -1
  199. package/dist/modules/paths.js +1 -0
  200. package/dist/modules/paths.js.map +1 -1
  201. package/dist/modules/runtime_settings.d.ts +13 -0
  202. package/dist/modules/runtime_settings.d.ts.map +1 -1
  203. package/dist/modules/runtime_settings.js +20 -0
  204. package/dist/modules/runtime_settings.js.map +1 -1
  205. package/dist/modules/utils/errors.d.ts +16 -0
  206. package/dist/modules/utils/errors.d.ts.map +1 -1
  207. package/dist/modules/utils/errors.js +31 -0
  208. package/dist/modules/utils/errors.js.map +1 -1
  209. package/dist/pm2.js +7 -7
  210. package/dist/scripts/analyze-credit.d.ts.map +1 -1
  211. package/dist/scripts/analyze-credit.js +13 -3
  212. package/dist/scripts/analyze-credit.js.map +1 -1
  213. package/dist/scripts/analyze-orders.js +2 -2
  214. package/dist/scripts/analyze-orders.js.map +1 -1
  215. package/dist/scripts/diagnose-kibana-candles.d.ts.map +1 -1
  216. package/dist/scripts/diagnose-kibana-candles.js +0 -1
  217. package/dist/scripts/diagnose-kibana-candles.js.map +1 -1
  218. package/dist/scripts/test-credit-renewal.js +1 -1
  219. package/dist/scripts/test-credit-renewal.js.map +1 -1
  220. package/dist/scripts/tv.d.ts +24 -2
  221. package/dist/scripts/tv.d.ts.map +1 -1
  222. package/dist/scripts/tv.js +190 -65
  223. package/dist/scripts/tv.js.map +1 -1
  224. package/docs/BITSHARES_ONBOARDING.md +9 -2
  225. package/docs/COW_INVARIANTS.md +18 -1
  226. package/docs/DEXBOT_COMPARISON.md +3 -3
  227. package/docs/EVOLUTION.md +9 -7
  228. package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +21 -9
  229. package/docs/GRID_RECALCULATION.md +5 -6
  230. package/docs/GRID_RECONCILE.md +12 -2
  231. package/docs/MPA_CREDIT_USAGE.md +2 -2
  232. package/docs/README.md +5 -6
  233. package/docs/architecture.md +0 -58
  234. package/docs/developer_guide.md +2 -1
  235. package/docs/docker.md +1 -1
  236. package/market_adapter/README.md +4 -0
  237. package/modules/README.md +1 -1
  238. package/package.json +1 -1
  239. package/scripts/README.md +5 -3
  240. package/tests/README.md +2 -2
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
  import fs from 'node:fs';
3
3
  import { MARKET_ADAPTER } from '../../modules/constants.js';
4
- import { escapeHtml, serializeJsonForScript, UPLOT_SHARED_SCRIPT } from '../chart_utils.js';
4
+ import { escapeHtml, serializeJsonForScript, UPLOT_SHARED_SCRIPT, embedFunctionSources } from '../chart_utils.js';
5
5
  import { cursorCSS, uplotBgCSS } from '../chart_css.js';
6
6
  import { zoomResetScript } from '../chart_ui.js';
7
7
  import { normalizeCandle } from '../math_utils.js';
@@ -9,6 +9,9 @@ import { PATHS } from '../../modules/paths.js';
9
9
  import { getStorage } from '../../modules/storage/index.js';
10
10
  const { readJSON } = getStorage();
11
11
  import { getErrorMessage } from '../../modules/utils/errors.js';
12
+ import { parseRelativeMultiplier } from '../../modules/order/utils/math.js';
13
+ import { computeAverageAmaSlopePct, computeAmaSlopeClipThreshold } from '../../market_adapter/core/strategies/dynamic_weight_series.js';
14
+ import { resolveBaseBounds, computeAsymmetricBoundsMetrics, applyAsymmetricBounds, applyNarrowingSideGuard, } from '../../market_adapter/core/asymmetric_bounds.js';
12
15
  function inferBaseIntervalSeconds(candles, fallback = 3600) {
13
16
  if (!Array.isArray(candles) || candles.length < 2)
14
17
  return fallback;
@@ -83,6 +86,25 @@ function resolveAmaDefaults({ meta, data, marketProfiles } = {}) {
83
86
  : Number(source.slowPeriod),
84
87
  };
85
88
  }
89
+ const TRADINGVIEW_PREFS_KEY_PREFIX = 'dexbot2-tradingview-uplot-v3';
90
+ const TRADINGVIEW_SYNC_KEY = 'dexbot2-tradingview-sync';
91
+ function sanitizeStorageComponent(value, fallback) {
92
+ // Objects (e.g. a meta asset node missing both id and symbol) stringify to
93
+ // "[object Object]", which would collapse distinct assets onto one key.
94
+ if (value == null || typeof value === 'object')
95
+ return fallback;
96
+ const raw = String(value).trim();
97
+ const cleaned = raw.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
98
+ return cleaned || fallback;
99
+ }
100
+ // Prefer asset ids over symbols: ids are immutable, symbols can be relabeled.
101
+ function resolveChartStorageKey(meta = {}, baseIntervalSeconds = 0) {
102
+ const pool = sanitizeStorageComponent(meta?.pool ?? meta?.poolId, 'nipool');
103
+ const assetA = sanitizeStorageComponent(meta?.assetA?.id ?? meta?.assetA?.symbol ?? meta?.assetA, 'assetA');
104
+ const assetB = sanitizeStorageComponent(meta?.assetB?.id ?? meta?.assetB?.symbol ?? meta?.assetB, 'assetB');
105
+ const interval = Number(baseIntervalSeconds) > 0 ? Math.round(Number(baseIntervalSeconds)) : 0;
106
+ return `${TRADINGVIEW_PREFS_KEY_PREFIX}:${pool}:${assetA}_${assetB}:${interval || 'base'}`;
107
+ }
86
108
  function generateHTML(data, title = 'TradingView Style Research') {
87
109
  const rawCandles = Array.isArray(data.candles) ? data.candles : [];
88
110
  const candles = rawCandles.map(normalizeCandle).filter(Boolean);
@@ -97,12 +119,38 @@ function generateHTML(data, title = 'TradingView Style Research') {
97
119
  { label: '4h', seconds: 14400 },
98
120
  { label: '1d', seconds: 86400 },
99
121
  { label: '1w', seconds: 604800 },
122
+ { label: '1M', seconds: 2592000, calendar: 'month' },
100
123
  ].map((item) => ({ ...item, enabled: item.seconds >= baseIntervalSeconds }));
101
124
  const defaultTimeframe = timeframes.find((item) => item.label === data.defaultTimeframe && item.enabled)
102
125
  || timeframes.find((item) => item.enabled)
103
126
  || timeframes[0];
104
127
  const marketProfiles = data.marketProfiles || loadMarketProfiles();
105
128
  const defaultAmaConfig = resolveAmaDefaults({ meta, data, marketProfiles });
129
+ const rangeSlope = {
130
+ lookbackBars: MARKET_ADAPTER.DYNAMIC_WEIGHT_AMA_LOOKBACK_BARS,
131
+ maxSlopePct: MARKET_ADAPTER.DYNAMIC_WEIGHT_AMA_MAX_SLOPE_PCT,
132
+ neutralZonePct: MARKET_ADAPTER.DYNAMIC_WEIGHT_AMA_NEUTRAL_ZONE_PCT,
133
+ maxSlopeOffset: MARKET_ADAPTER.DYNAMIC_WEIGHT_ASYMMETRIC_OFFSET_CLAMP,
134
+ maxAsymmetryFactor: MARKET_ADAPTER.ASYMMETRIC_BOUNDS_MAX_ASYMMETRY_FACTOR,
135
+ minScaleSlots: MARKET_ADAPTER.ASYMMETRIC_BOUNDS_MIN_SCALE_SLOTS,
136
+ clipPercentile: MARKET_ADAPTER.DYNAMIC_WEIGHT_CLIP_PERCENTILE,
137
+ };
138
+ function defaultRangeSpan(input) {
139
+ const cli = Number(input.rangeSpan);
140
+ if (Number.isFinite(cli))
141
+ return Math.min(2, Math.max(1.2, cli));
142
+ const down = parseRelativeMultiplier(input.grid?.minPrice);
143
+ const up = parseRelativeMultiplier(input.grid?.maxPrice);
144
+ if (down != null && down > 1 && up != null && up > 1)
145
+ return Math.min(2, Math.max(1.2, (down + up) / 2));
146
+ const vd = Number(input.grid?.minPrice);
147
+ const vu = Number(input.grid?.maxPrice);
148
+ if (Number.isFinite(vd) && Number.isFinite(vu) && vd > 0 && vu > vd) {
149
+ const ref = Math.sqrt(vd * vu);
150
+ return Math.min(2, Math.max(1.2, (ref / vd + vu / ref) / 2));
151
+ }
152
+ return 1.55;
153
+ }
106
154
  const defaults = {
107
155
  smaPeriod: Math.max(1, Math.round(data.smaPeriod ?? 500)),
108
156
  amaDefaults: defaultAmaConfig,
@@ -111,6 +159,21 @@ function generateHTML(data, title = 'TradingView Style Research') {
111
159
  vwapEnabled: data.vwapEnabled === true,
112
160
  vwapBars: Math.max(5, Math.round(data.vwapBars ?? 500)),
113
161
  priceScale: data.priceScale === 'linear' ? 'linear' : 'log',
162
+ rangeEnabled: data.rangeEnabled === true,
163
+ rangeScaleEnabled: data.rangeScaleEnabled === true,
164
+ rangeWidthPct: Number.isFinite(Number(data.rangeWidthPct)) && Number(data.rangeWidthPct) > 0
165
+ ? Number(data.rangeWidthPct)
166
+ : 2,
167
+ rangeSpan: defaultRangeSpan(data),
168
+ grid: data.grid && data.grid.minPrice != null && data.grid.maxPrice != null ? {
169
+ minPrice: data.grid.minPrice,
170
+ maxPrice: data.grid.maxPrice,
171
+ incrementPercent: Number(data.grid.incrementPercent) > 0 ? Number(data.grid.incrementPercent) : null,
172
+ maxAsymmetryFactor: Number.isFinite(Number(data.grid.maxAsymmetryFactor)) && Number(data.grid.maxAsymmetryFactor) > 0
173
+ ? Number(data.grid.maxAsymmetryFactor)
174
+ : rangeSlope.maxAsymmetryFactor,
175
+ } : null,
176
+ rangeSlope,
114
177
  };
115
178
  const assetLabelA = meta.assetA?.symbol || meta.assetA?.id || 'Asset A';
116
179
  const assetLabelB = meta.assetB?.symbol || meta.assetB?.id || 'Asset B';
@@ -121,7 +184,12 @@ function generateHTML(data, title = 'TradingView Style Research') {
121
184
  ? `${Math.round(baseIntervalSeconds / 86400)}d`
122
185
  : `${Math.round(baseIntervalSeconds / 3600)}h`;
123
186
  const defaultPairMode = data.defaultPairMode === 'inverse' ? 'inverse' : 'normal';
187
+ const storageKey = typeof data.storageKey === 'string' && data.storageKey.length > 0
188
+ ? data.storageKey
189
+ : resolveChartStorageKey(meta, baseIntervalSeconds);
124
190
  const payload = {
191
+ storageKey,
192
+ syncKey: TRADINGVIEW_SYNC_KEY,
125
193
  candles,
126
194
  timeframes,
127
195
  defaultTimeframe: defaultTimeframe.label,
@@ -132,6 +200,20 @@ function generateHTML(data, title = 'TradingView Style Research') {
132
200
  vwapEnabled: defaults.vwapEnabled,
133
201
  vwapBars: defaults.vwapBars,
134
202
  priceScale: defaults.priceScale,
203
+ rangeEnabled: defaults.rangeEnabled,
204
+ rangeScaleEnabled: defaults.rangeScaleEnabled,
205
+ rangeWidthPct: defaults.rangeWidthPct,
206
+ rangeSpan: defaults.rangeSpan,
207
+ grid: defaults.grid,
208
+ orderBuys: Array.isArray(data.orders?.buys) ? data.orders.buys.map(Number).filter(Number.isFinite) : [],
209
+ orderSells: Array.isArray(data.orders?.sells) ? data.orders.sells.map(Number).filter(Number.isFinite) : [],
210
+ gridBounds: {
211
+ low: Number.isFinite(Number(data.gridBounds?.low)) && Number(data.gridBounds.low) > 0 ? Number(data.gridBounds.low) : null,
212
+ high: Number.isFinite(Number(data.gridBounds?.high)) && Number(data.gridBounds.high) > 0 ? Number(data.gridBounds.high) : null,
213
+ },
214
+ updateMarkerTsSec: Number(data.updateMarkerTsSec) > 0 ? Number(data.updateMarkerTsSec) : null,
215
+ updateMarkerNewBars: Number(data.updateMarkerNewBars) || null,
216
+ rangeSlope,
135
217
  defaultPairMode,
136
218
  assetLabelA,
137
219
  assetLabelB,
@@ -140,6 +222,7 @@ function generateHTML(data, title = 'TradingView Style Research') {
140
222
  poolLabel,
141
223
  intervalLabel,
142
224
  amaDefaultsSource: marketProfiles ? 'market_profiles' : 'constants',
225
+ volumeIsCount: !!(meta?.feed),
143
226
  };
144
227
  return `<!doctype html>
145
228
  <html lang="en">
@@ -174,9 +257,11 @@ function generateHTML(data, title = 'TradingView Style Research') {
174
257
  #brand { display: flex; flex-direction: column; gap: 4px; min-width: 240px; }
175
258
  #title { font-size: 15px; font-weight: 700; color: #f5f8fb; }
176
259
  #subtitle { font-size: 11px; color: #b9c6d4; text-transform: uppercase; letter-spacing: 0.6px; }
177
- #toolbar { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px 12px; align-items: center; }
260
+ #toolbar { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
261
+ .toolbar-row { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px 12px; align-items: center; }
178
262
  .group { display: inline-flex; align-items: center; gap: 8px; padding-left: 12px; border-left: 1px solid #263241; }
179
263
  .group:first-child { padding-left: 0; border-left: 0; }
264
+ .primary-toolbar-row .group { padding-left: 0; border-left: 0; }
180
265
  :root {
181
266
  --control-height: 36px;
182
267
  }
@@ -199,6 +284,11 @@ function generateHTML(data, title = 'TradingView Style Research') {
199
284
  background: linear-gradient(180deg, rgba(35,53,79,0.95), rgba(22,32,47,0.98));
200
285
  }
201
286
  .time-btn:disabled { opacity: 0.35; cursor: not-allowed; text-decoration: line-through; }
287
+ .ama-preset-btn.active {
288
+ color: #fff;
289
+ border-color: rgba(250,204,21,0.85);
290
+ background: linear-gradient(180deg, rgba(79,66,22,0.95), rgba(47,40,18,0.98));
291
+ }
202
292
  .indicator {
203
293
  display: inline-flex;
204
294
  align-items: center;
@@ -384,11 +474,11 @@ function generateHTML(data, title = 'TradingView Style Research') {
384
474
  background: rgba(10,14,20,0.72);
385
475
  backdrop-filter: blur(10px);
386
476
  }
387
- .legend-line { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; font-size: 11px; }
477
+ .legend-line { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; font-size: 12px; }
388
478
  .legend-line + .legend-line { margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,0.06); }
389
479
  .legend-item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
390
480
  .legend-dot { width: 10px; height: 10px; border-radius: 999px; flex: 0 0 auto; }
391
- .legend-label { color: #8290a2; text-transform: uppercase; letter-spacing: 0.45px; font-size: 10px; }
481
+ .legend-label { color: #8290a2; text-transform: uppercase; letter-spacing: 0.45px; font-size: 12px; }
392
482
  .legend-value { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #eef4fb; font-weight: 700; }
393
483
  .status { display: inline-flex; align-items: center; gap: 8px; font-size: 10px; color: #8290a2; text-transform: uppercase; letter-spacing: 0.5px; }
394
484
  .pill { border: 1px solid rgba(255,255,255,0.06); border-radius: 999px; padding: 4px 8px; background: rgba(255,255,255,0.03); }
@@ -396,7 +486,8 @@ function generateHTML(data, title = 'TradingView Style Research') {
396
486
  ${cursorCSS()}
397
487
  @media (max-width: 980px) {
398
488
  #topbar { flex-direction: column; align-items: flex-start; }
399
- #toolbar { justify-content: flex-start; }
489
+ #toolbar { align-items: flex-start; }
490
+ .toolbar-row { justify-content: flex-start; }
400
491
  .group { padding-left: 0; border-left: 0; flex-wrap: wrap; }
401
492
  #legend { min-width: 0; max-width: calc(100vw - 24px); }
402
493
  }
@@ -410,9 +501,7 @@ function generateHTML(data, title = 'TradingView Style Research') {
410
501
  <div id="subtitle">${escapeHtml(poolLabel)} · ${escapeHtml(pairLabelNormal)} · ${escapeHtml(intervalLabel)} base · indicators from 1h · volume · uPlot</div>
411
502
  </div>
412
503
  <div id="toolbar">
413
- <div class="group" id="tf-group">
414
- ${timeframes.map((item) => `<button class="time-btn${item.label === defaultTimeframe.label ? ' active' : ''}" data-timeframe="${escapeHtml(item.label)}"${item.enabled ? '' : ' disabled'}>${escapeHtml(item.label)}</button>`).join('')}
415
- </div>
504
+ <div class="toolbar-row primary-toolbar-row">
416
505
  <div class="group">
417
506
  <div class="indicator">
418
507
  <span class="tag">pair</span>
@@ -426,9 +515,13 @@ function generateHTML(data, title = 'TradingView Style Research') {
426
515
  <span class="tag">scale</span>
427
516
  <button type="button" class="scale-toggle${defaults.priceScale === 'linear' ? ' is-linear' : ''}" id="scale-toggle" data-scale="${escapeHtml(defaults.priceScale)}" aria-label="Toggle price scale" title="Toggle price scale">${defaults.priceScale === 'linear' ? 'Linear' : 'Log'}</button>
428
517
  </div>
518
+ </div>
519
+ <div class="group" id="tf-group">
520
+ ${timeframes.map((item) => `<button class="time-btn${item.label === defaultTimeframe.label ? ' active' : ''}" data-timeframe="${escapeHtml(item.label)}"${item.enabled ? '' : ' disabled'}>${escapeHtml(item.label)}</button>`).join('')}
521
+ </div>
522
+ <div class="group">
429
523
  <div class="indicator">
430
524
  <label><input type="checkbox" id="sma-toggle"${defaults.smaEnabled ? ' checked' : ''}> SMA</label>
431
- <span class="tag">period</span>
432
525
  <input type="text" inputmode="numeric" id="sma-period" value="${defaults.smaPeriod}">
433
526
  <span class="step-stack">
434
527
  <button type="button" class="step-btn" id="sma-period-inc">▲</button>
@@ -437,13 +530,18 @@ function generateHTML(data, title = 'TradingView Style Research') {
437
530
  </div>
438
531
  <div class="indicator">
439
532
  <label><input type="checkbox" id="vwap-toggle"${defaults.vwapEnabled ? ' checked' : ''}> VWMA</label>
440
- <span class="tag">bars</span>
441
533
  <input type="text" inputmode="numeric" id="vwap-bars" value="${defaults.vwapBars}">
442
534
  <span class="step-stack">
443
535
  <button type="button" class="step-btn" id="vwap-bars-inc">▲</button>
444
536
  <button type="button" class="step-btn" id="vwap-bars-dec">▼</button>
445
537
  </span>
446
538
  </div>
539
+ <div class="indicator"><label><input type="checkbox" id="volume-toggle" checked> Volume</label> <button type="button" class="reset-btn" id="volume-unit-toggle" data-volume-mode="base" title="Volume units: base asset — click for quote asset">${escapeHtml(defaultPairMode === 'inverse' ? assetLabelB : assetLabelA)}</button></div>
540
+ ${(((payload.orderBuys?.length || 0) + (payload.orderSells?.length || 0) > 0 || payload.gridBounds?.low != null || payload.gridBounds?.high != null) ? '<div class="indicator"><label><input type="checkbox" id="orders-toggle" checked> Orders</label></div>' : '')}
541
+ </div>
542
+ </div>
543
+ <div class="toolbar-row">
544
+ <div class="group">
447
545
  <div class="indicator">
448
546
  <label><input type="checkbox" id="ama-toggle"${defaults.amaEnabled ? ' checked' : ''}> AMA</label>
449
547
  <span class="tag">er</span>
@@ -464,15 +562,26 @@ function generateHTML(data, title = 'TradingView Style Research') {
464
562
  <button type="button" class="step-btn" id="ama-slow-inc">▲</button>
465
563
  <button type="button" class="step-btn" id="ama-slow-dec">▼</button>
466
564
  </span>
467
- <button type="button" class="reset-btn" id="ama-reset">Reset</button>
565
+ <button type="button" class="reset-btn ama-preset-btn" data-ama-preset="AMA1" title="AMA1 (slow 62.1)">1</button>
566
+ <button type="button" class="reset-btn ama-preset-btn" data-ama-preset="AMA2" title="AMA2 (slow 72.0)">2</button>
567
+ <button type="button" class="reset-btn ama-preset-btn" data-ama-preset="AMA3" title="AMA3 (slow 83.6)">3</button>
568
+ <button type="button" class="reset-btn ama-preset-btn" data-ama-preset="AMA4" title="AMA4 (slow 96.9)">4</button>
468
569
  </div>
469
- <div class="indicator" style="margin-top:4px">
570
+ <div class="indicator" title="Range min/max built only from the live AMA price (red above, green below); Scale sizes it by AMA slope like the grid build">
571
+ <label><input type="checkbox" id="range-toggle"${defaults.rangeEnabled ? ' checked' : ''}> Range</label>
572
+ <label title="Range Scaling: size the band by AMA slope like the grid build (trend side widens, opposite tightens) and fit the price axis to it"><input type="checkbox" id="range-scale-toggle"${defaults.rangeScaleEnabled ? ' checked' : ''}> Scale</label>
573
+ <span id="range-grid-wrap" style="display:inline" title="x-range around AMA (1.2x–2.0x)">
574
+ <input type="range" id="range-span" min="1.2" max="2" step="0.05" value="${defaults.rangeSpan.toFixed(2)}" style="width:90px;vertical-align:middle">
575
+ <span id="range-span-val" style="font-size:11px;color:#8b949e;width:40px;display:inline-block;text-align:right">${defaults.rangeSpan.toFixed(2)}x</span>
576
+ </span>
577
+ </div>
578
+ <div class="indicator">
470
579
  <label><input type="checkbox" id="ama-init-offset-toggle"> Init Offset</label>
471
- <input type="range" id="ama-init-offset" min="-50" max="50" value="0" step="1" style="width:120px;vertical-align:middle" disabled>
580
+ <input type="range" id="ama-init-offset" min="-50" max="50" value="0" step="1" style="width:90px;vertical-align:middle" disabled>
472
581
  <span id="ama-init-offset-val" style="font-size:11px;color:#8b949e;width:32px;display:inline-block;text-align:right">0%</span>
473
582
  </div>
474
583
  </div>
475
- <div class="group"></div>
584
+ </div>
476
585
  </div>
477
586
  </div>
478
587
  <div id="chart-shell">
@@ -481,20 +590,16 @@ function generateHTML(data, title = 'TradingView Style Research') {
481
590
  <span class="legend-item"><span class="legend-label">Time</span> <span class="legend-value" id="legend-time">-</span></span>
482
591
  <span class="legend-item"><span class="legend-label">C</span> <span class="legend-value" id="legend-close">-</span></span>
483
592
  <span class="legend-item"><span class="legend-label">Delta</span> <span class="legend-value" id="legend-delta">-</span></span>
484
- <span class="legend-item"><span class="legend-label">Vol</span> <span class="legend-value" id="legend-volume">-</span></span>
485
- <span class="legend-item"><span class="legend-label">Scale</span> <span class="legend-value" id="legend-scale">-</span></span>
593
+ <span class="legend-item"><span class="legend-label">Vol</span> <span class="legend-value" id="legend-volume" title="${payload.volumeIsCount ? 'Volume: feed publish count per bucket' : 'Volume units — click to switch base/quote'}" style="cursor:${payload.volumeIsCount ? 'default' : 'pointer'}">-</span></span>
486
594
  </div>
487
595
  <div class="legend-line">
488
596
  <span class="legend-item"><span class="legend-dot" style="background:#f59e0b"></span><span class="legend-label">SMA</span> <span class="legend-value" id="legend-sma">-</span></span>
489
- <span class="legend-item"><span class="legend-dot" style="background:#2dd4bf"></span><span class="legend-label">AMA</span> <span class="legend-value" id="legend-ama">-</span></span>
490
- <span class="legend-item"><span class="legend-dot" style="background:#22c55e"></span><span class="legend-label">AMA Init</span> <span class="legend-value" id="legend-sma-init">-</span></span>
491
- <span class="legend-item"><span class="legend-dot" style="background:#a855f7"></span><span class="legend-label">Off Init</span> <span class="legend-value" id="legend-off-init">-</span></span>
492
- <span class="legend-item"><span class="legend-dot" style="background:#a855f7"></span><span class="legend-label">AMA Off</span> <span class="legend-value" id="legend-ama-off">-</span></span>
493
-
494
597
  <span class="legend-item"><span class="legend-dot" style="background:#93c5fd"></span><span class="legend-label">VWMA</span> <span class="legend-value" id="legend-vwap">-</span></span>
598
+ <span class="legend-item"><span class="legend-dot" style="background:#22c55e"></span><span class="legend-label">AMA Init</span> <span class="legend-value" id="legend-sma-init">-</span></span>
599
+ <span class="legend-item"><span class="legend-dot" style="background:#2dd4bf"></span><span class="legend-label">AMA</span> <span class="legend-value" id="legend-ama">-</span></span>
495
600
  </div>
496
601
  </div>
497
- <div id="price-chart" title="Wheel: zoom time (ulos = tyhjaa tilaa datan ymparilla) · Drag: siirra aika- ja hintanakymaa · Wheel/drag hinta-akselilla: zoomaa hintaa · Double-click hinta-akselia: autofit"></div>
602
+ <div id="price-chart" title="Wheel: zoom time (out = empty space around data) · Shift+wheel: zoom price · Drag: pan time + price (sets manual price scale) · Wheel/drag on price axis: zoom price · Drag on time axis: zoom time · Double-click price axis: autofit"></div>
498
603
  <div id="volume-chart"></div>
499
604
  </div>
500
605
  </div>
@@ -509,7 +614,12 @@ function generateHTML(data, title = 'TradingView Style Research') {
509
614
  const baseCloseValues = baseCandles.map((c) => c.close);
510
615
  const timeframes = Array.isArray(payload.timeframes) ? payload.timeframes.slice() : [];
511
616
  const timeframeMap = new Map(timeframes.map((item) => [item.label, item]));
512
- const STORAGE_KEY = 'dexbot2-tradingview-uplot-v2';
617
+ const STORAGE_KEY = typeof payload.storageKey === 'string' && payload.storageKey.length > 0
618
+ ? payload.storageKey
619
+ : 'dexbot2-tradingview-uplot-v3:default';
620
+ const SYNC_KEY = typeof payload.syncKey === 'string' && payload.syncKey.length > 0
621
+ ? payload.syncKey
622
+ : 'dexbot2-tradingview-sync';
513
623
 
514
624
  const state = loadState();
515
625
  let currentTimeframe = state.timeframe || payload.defaultTimeframe || '1h';
@@ -527,6 +637,25 @@ function generateHTML(data, title = 'TradingView Style Research') {
527
637
  : Number(payload.amaDefaults?.slowPeriod || ${MARKET_ADAPTER.AMAS.AMA3.slowPeriod});
528
638
  let currentVwapEnabled = state.vwapEnabled ?? !!payload.vwapEnabled;
529
639
  let currentVwapBars = Number.isFinite(state.vwapBars) ? state.vwapBars : Number(payload.vwapBars || 500);
640
+ let currentRangeEnabled = state.rangeEnabled ?? !!payload.rangeEnabled;
641
+ let currentRangeScaleEnabled = state.rangeScaleEnabled ?? !!payload.rangeScaleEnabled;
642
+ let currentRangeWidthPct = Number.isFinite(state.rangeWidthPct) && Number(state.rangeWidthPct) > 0
643
+ ? Number(state.rangeWidthPct)
644
+ : (Number.isFinite(Number(payload.rangeWidthPct)) && Number(payload.rangeWidthPct) > 0 ? Number(payload.rangeWidthPct) : 2);
645
+ let currentRangeSpan = Number.isFinite(state.rangeSpan) && Number(state.rangeSpan) > 0
646
+ ? Math.min(2, Math.max(1.2, Number(state.rangeSpan)))
647
+ : (Number.isFinite(Number(payload.rangeSpan)) && Number(payload.rangeSpan) > 0 ? Math.min(2, Math.max(1.2, Number(payload.rangeSpan))) : 1.55);
648
+ let currentOrdersVisible = state.ordersVisible ?? true;
649
+ let currentVolumeVisible = state.volumeVisible ?? true;
650
+ let currentVolumeMode = state.volumeMode === 'quote' ? 'quote' : 'base';
651
+ // Static per generation: the overlay (levels, reserve/ceiling lines,
652
+ // spread label) exists only when order data was embedded.
653
+ // --no-orders or a missing orders file therefore removes the whole
654
+ // overlay, including the reserve/ceiling lines.
655
+ const hasOrders = ((payload.orderBuys?.length || 0) + (payload.orderSells?.length || 0)) > 0;
656
+ // Bounds-only grids (virtual slots, no live levels yet) still show
657
+ // the reserve/ceiling lines; levels/spread/panel rows need live data.
658
+ const hasBounds = payload.gridBounds?.low != null || payload.gridBounds?.high != null;
530
659
  let currentAmaInitOffsetEnabled = false;
531
660
  let currentAmaInitOffset = Number.isFinite(state.amaInitOffset) ? state.amaInitOffset : 0;
532
661
  let currentPriceScale = state.priceScale || payload.priceScale || 'log';
@@ -548,6 +677,8 @@ function generateHTML(data, title = 'TradingView Style Research') {
548
677
  let currentSmaInit = [];
549
678
  let currentSmaInitOff = [];
550
679
  let currentAmaOff = [];
680
+ let currentRangeUpper = [];
681
+ let currentRangeLower = [];
551
682
  let priceChart = null;
552
683
  let volumeChart = null;
553
684
  let lastRenderedPriceScale = null;
@@ -557,6 +688,8 @@ function generateHTML(data, title = 'TradingView Style Research') {
557
688
  let lastCandleKey = null;
558
689
  let pendingRange = null;
559
690
  let pendingRangeRaf = 0;
691
+ let rangePanelRaf = 0;
692
+ let yRefitRaf = 0;
560
693
  let xMin = 0;
561
694
  let xMax = 0;
562
695
  let smaWorker = null;
@@ -575,27 +708,18 @@ function generateHTML(data, title = 'TradingView Style Research') {
575
708
  // TradingView-exporter interaction overrides (PR #3 / #11 feature):
576
709
  // redeclare after UPLOT_SHARED_SCRIPT so these hoisted declarations win
577
710
  // for this page only, without mutating the shared stack used by other charts.
578
- function clampXRange(min, max) {
579
- if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return { min: xMin, max: xMax };
580
- // Salli tyhjaa tilaa datan molemmin puolin (TradingView-tyyli):
581
- // enintaan 75 % datan pituudesta reunapuskurina kummallekin puolelle.
582
- const dataSpan = Math.max(1, xMax - xMin);
583
- const maxPad = dataSpan * 0.75;
584
- let lo = Math.max(xMin - maxPad, min);
585
- let hi = Math.min(xMax + maxPad, max);
586
- if (lo < xMin && hi <= lo) lo = xMin;
587
- if (hi > xMax + maxPad) hi = xMax + maxPad;
588
- if (hi <= lo) return { min: xMin, max: xMax };
589
- return { min: lo, max: hi };
590
- }
591
711
  function bindWheelZoom(chart) {
592
712
  chart.root.addEventListener('wheel', (e) => {
593
713
  if (e.ctrlKey || e.metaKey || e.altKey) return;
594
714
  e.preventDefault();
595
715
  const rect = chart.root.getBoundingClientRect();
596
- if (chart === priceChart && inYAxisZone(chart, e.clientX)) {
716
+ // Shift+wheel scales price (cursor-anchored) anywhere over the
717
+ // price pane — same as wheeling over the price axis. Volume has
718
+ // no manual Y lock, so it keeps the timeframe zoom.
719
+ if (chart === priceChart && (e.shiftKey || inYAxisZone(chart, e.clientX))) {
597
720
  const centerY = chart.posToVal(e.clientY - rect.top, 'y');
598
- zoomYAt(chart, centerY, e.deltaY < 0 ? 0.91 : 1.10);
721
+ const delta = e.deltaY !== 0 ? e.deltaY : e.deltaX;
722
+ zoomYAt(chart, centerY, delta < 0 ? 0.91 : 1.10);
599
723
  return;
600
724
  }
601
725
  e.stopPropagation();
@@ -614,12 +738,132 @@ function generateHTML(data, title = 'TradingView Style Research') {
614
738
  syncXRange(center - nextSpan * ratio, center - nextSpan * ratio + nextSpan);
615
739
  }, { passive: false });
616
740
  }
741
+ function clampRange(min, max) {
742
+ if (!Number.isFinite(xMin) || !Number.isFinite(xMax) || xMax <= xMin) return null;
743
+ if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return null;
744
+ // Allow empty space on both sides of the data (TradingView-style):
745
+ // enintaan 75 % datan pituudesta reunapuskurina kummallekin puolelle.
746
+ // Span-locked: pans slide the window and never squash it — only a
747
+ // deliberate over-wide zoom-out is centered down to the limit.
748
+ // Plot drags therefore move but never scale; scaling lives on
749
+ // the axis gutters alone.
750
+ const dataSpan = Math.max(1, xMax - xMin);
751
+ const maxPad = dataSpan * 0.75;
752
+ const lo = xMin - maxPad;
753
+ const hi = xMax + maxPad;
754
+ const span = max - min;
755
+ if (span > hi - lo) {
756
+ const center = (min + max) / 2;
757
+ const half = (hi - lo) / 2;
758
+ return { min: center - half, max: center + half };
759
+ }
760
+ let nMin = min;
761
+ if (nMin < lo) nMin = lo;
762
+ if (nMin + span > hi) nMin = hi - span;
763
+ return { min: nMin, max: nMin + span };
764
+ }
765
+ function syncXRange(min, max) {
766
+ pendingRange = clampRange(min, max);
767
+ if (pendingRangeRaf) return;
768
+ pendingRangeRaf = requestAnimationFrame(() => {
769
+ const next = pendingRange;
770
+ pendingRange = null;
771
+ pendingRangeRaf = 0;
772
+ if (!next || charts.length === 0) return;
773
+ charts.forEach((chart) => chart.batch(() => chart.setScale('x', next)));
774
+ });
775
+ }
776
+ function bindPan(chart) {
777
+ let dragging = false;
778
+ let startClientX = 0;
779
+ let startClientY = 0;
780
+ let startMin = 0;
781
+ let startMax = 0;
782
+ let startYRange = null;
783
+ let panRaf = 0;
784
+ let pendingPan = null;
785
+ const applyPendingPan = () => {
786
+ panRaf = 0;
787
+ const p = pendingPan;
788
+ pendingPan = null;
789
+ if (!dragging || !p) return;
790
+ // Plot drag pans time and price (TradingView-style): the price
791
+ // move sets a manual Y range; double-click the price axis to
792
+ // return to autofit. Axis gutters never pan — they scale.
793
+ if (chart === priceChart && startYRange && Number.isFinite(p.clientY)) {
794
+ const vStart = chart.posToVal(p.startY - p.rectTop, 'y');
795
+ const vCur = chart.posToVal(p.clientY - p.rectTop, 'y');
796
+ if (Number.isFinite(vStart) && Number.isFinite(vCur) && vCur !== vStart) {
797
+ // Rigid move only: the span is locked by construction so a
798
+ // plot drag can never rescale — Y scaling happens solely
799
+ // on the price-axis gutter (wheel/drag there).
800
+ if (currentPriceScale === 'log') {
801
+ const ratio = Math.max(1e-12, vStart) / Math.max(1e-12, vCur);
802
+ const spanRatio = startYRange.max / startYRange.min;
803
+ if (Number.isFinite(ratio) && ratio > 0 && Number.isFinite(spanRatio) && spanRatio > 0) {
804
+ const nextMin = startYRange.min * ratio;
805
+ applyYRange(chart, nextMin, nextMin * spanRatio);
806
+ }
807
+ } else {
808
+ const dy = vStart - vCur;
809
+ const span = startYRange.max - startYRange.min;
810
+ if (Number.isFinite(dy) && Number.isFinite(span) && span > 0) {
811
+ const nextMin = startYRange.min + dy;
812
+ applyYRange(chart, nextMin, nextMin + span);
813
+ }
814
+ }
815
+ }
816
+ }
817
+ const delta = chart.posToVal(p.clientX - p.rectLeft, 'x') - chart.posToVal(p.startX - p.rectLeft, 'x');
818
+ syncXRange(startMin - delta, startMax - delta);
819
+ };
820
+ const onMove = (e) => {
821
+ if (!dragging) return;
822
+ e.preventDefault();
823
+ const rect = chart.root.getBoundingClientRect();
824
+ pendingPan = {
825
+ clientX: e.clientX, clientY: e.clientY,
826
+ startX: startClientX, startY: startClientY,
827
+ rectLeft: rect.left, rectTop: rect.top,
828
+ };
829
+ if (!panRaf) panRaf = requestAnimationFrame(applyPendingPan);
830
+ };
831
+ const endDrag = () => {
832
+ if (!dragging) return;
833
+ dragging = false;
834
+ startYRange = null;
835
+ pendingPan = null;
836
+ if (panRaf) { cancelAnimationFrame(panRaf); panRaf = 0; }
837
+ document.body.style.cursor = '';
838
+ window.removeEventListener('mousemove', onMove);
839
+ window.removeEventListener('mouseup', endDrag);
840
+ };
841
+ chart.root.addEventListener('mousedown', (e) => {
842
+ if (!e || e.button !== 0 || e.ctrlKey || e.metaKey || e.altKey) return;
843
+ const rect = chart.root.getBoundingClientRect();
844
+ if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) return;
845
+ if (chart === priceChart && inYAxisZone(chart, e.clientX)) return;
846
+ if (inXAxisZone(chart, e.clientY)) return;
847
+ e.preventDefault();
848
+ e.stopPropagation();
849
+ dragging = true;
850
+ startClientX = e.clientX;
851
+ startClientY = e.clientY;
852
+ const s = chart.scales.x || {};
853
+ startMin = Number.isFinite(s.min) ? s.min : currentCandles[0].time;
854
+ startMax = Number.isFinite(s.max) ? s.max : currentCandles[currentCandles.length - 1].time;
855
+ if (chart === priceChart) startYRange = currentYRange(chart);
856
+ document.body.style.cursor = 'grabbing';
857
+ window.addEventListener('mousemove', onMove);
858
+ window.addEventListener('mouseup', endDrag, { once: true });
859
+ });
860
+ }
617
861
  function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
618
- // Significant-digit price formatting (5 "active" digits), matching the
619
- // DEXBot order-price style (formatCurrency) but with one extra digit.
862
+ // Significant-digit price formatting (4 "active" digits), matching the
863
+ // DEXBot order-price style (formatCurrency).
620
864
  // Very small values get SI micro-suffixes (m/µ/n/p/...); large values
621
- // get comma grouping. Examples: 34.935 -> "34.935", 119.975 -> "119.98",
622
- // 3014.9 -> "3,014.9", 0.00567 -> "5.67m".
865
+ // get comma grouping. Examples: 34.94 -> "34.94", 119.98 -> "120.0",
866
+ // 3014.9 -> "3,015", 0.00567 -> "5.670m".
623
867
  function fmtPrice(v) {
624
868
  if (v == null || !Number.isFinite(v)) return '-';
625
869
  const num = Number(v);
@@ -723,6 +967,9 @@ function generateHTML(data, title = 'TradingView Style Research') {
723
967
  ticks: { stroke: '#30363d', width: 1 },
724
968
  font: '11px Segoe UI, sans-serif',
725
969
  values: showLabels ? (u, vals) => {
970
+ if (String(currentTimeframe) === '1M') {
971
+ return vals.map((ts) => new Date(ts * 1000).toLocaleString(undefined, { month: 'short', year: 'numeric', timeZone: 'UTC' }));
972
+ }
726
973
  const xScale = u.scales.x || {};
727
974
  const spanSec = Number.isFinite(xScale.min) && Number.isFinite(xScale.max)
728
975
  ? Math.max(0, xScale.max - xScale.min)
@@ -746,6 +993,13 @@ function generateHTML(data, title = 'TradingView Style Research') {
746
993
  amaSlowPeriod: currentAmaSlowPeriod,
747
994
  vwapEnabled: currentVwapEnabled,
748
995
  vwapBars: currentVwapBars,
996
+ rangeEnabled: currentRangeEnabled,
997
+ rangeScaleEnabled: currentRangeScaleEnabled,
998
+ rangeWidthPct: currentRangeWidthPct,
999
+ rangeSpan: currentRangeSpan,
1000
+ ordersVisible: currentOrdersVisible,
1001
+ volumeVisible: currentVolumeVisible,
1002
+ volumeMode: currentVolumeMode,
749
1003
  amaInitOffset: currentAmaInitOffset,
750
1004
  priceScale: currentPriceScale,
751
1005
  pairMode: currentPairMode,
@@ -760,6 +1014,75 @@ function generateHTML(data, title = 'TradingView Style Research') {
760
1014
  ? (payload.pairLabelInverse || (payload.assetLabelB + '/' + payload.assetLabelA))
761
1015
  : (payload.pairLabelNormal || (payload.assetLabelA + '/' + payload.assetLabelB));
762
1016
  }
1017
+ // Volume units: base = displayed pair's base asset (TradingView
1018
+ // standard, VWMA-correct), quote = base * display close (per-candle
1019
+ // approximation of true quote volume). Persisted per chart in
1020
+ // localStorage like the AMA settings (volumeMode).
1021
+ function getVolumeSymbols() {
1022
+ const inverse = normalizePairMode(currentPairMode) === 'inverse';
1023
+ return {
1024
+ base: inverse ? payload.assetLabelB : payload.assetLabelA,
1025
+ quote: inverse ? payload.assetLabelA : payload.assetLabelB,
1026
+ };
1027
+ }
1028
+ function getVolumeUnit() {
1029
+ if (payload.volumeIsCount) return { mode: 'count', symbol: 'feeds' };
1030
+ const syms = getVolumeSymbols();
1031
+ return currentVolumeMode === 'quote'
1032
+ ? { mode: 'quote', symbol: syms.quote }
1033
+ : { mode: 'base', symbol: syms.base };
1034
+ }
1035
+ function getDisplayVolume(c) {
1036
+ const v = Number(c?.volume);
1037
+ if (!Number.isFinite(v)) return NaN;
1038
+ if (payload.volumeIsCount) return v;
1039
+ if (currentVolumeMode === 'quote') {
1040
+ const p = Number(c?.close);
1041
+ return Number.isFinite(p) && p > 0 ? v * p : NaN;
1042
+ }
1043
+ return v;
1044
+ }
1045
+ function fmtVolumeWithSym(v) {
1046
+ const s = fmtVolume(v);
1047
+ if (s == null || s === '-') return '-';
1048
+ const u = getVolumeUnit();
1049
+ return u.symbol ? s + ' ' + u.symbol : s;
1050
+ }
1051
+ function setActiveVolumeUnit() {
1052
+ const btn = document.getElementById('volume-unit-toggle');
1053
+ if (!btn) return;
1054
+ const u = getVolumeUnit();
1055
+ btn.dataset.volumeMode = u.mode;
1056
+ btn.textContent = u.symbol || u.mode;
1057
+ btn.title = payload.volumeIsCount
1058
+ ? 'Volume: feed publish count per bucket'
1059
+ : (u.mode === 'quote'
1060
+ ? 'Volume units: quote asset (' + u.symbol + ') — click for base asset'
1061
+ : 'Volume units: base asset (' + u.symbol + ') — click for quote asset');
1062
+ btn.disabled = !!payload.volumeIsCount;
1063
+ btn.style.opacity = payload.volumeIsCount ? '0.45' : '';
1064
+ btn.style.cursor = payload.volumeIsCount ? 'not-allowed' : 'pointer';
1065
+ const lv = document.getElementById('legend-volume');
1066
+ if (lv) {
1067
+ lv.style.cursor = payload.volumeIsCount ? 'default' : 'pointer';
1068
+ lv.title = payload.volumeIsCount
1069
+ ? 'Volume: feed publish count per bucket'
1070
+ : 'Volume units — click to switch base/quote';
1071
+ }
1072
+ const vp = document.getElementById('vol-panel');
1073
+ if (vp) {
1074
+ vp.style.cursor = payload.volumeIsCount ? 'default' : 'pointer';
1075
+ vp.title = payload.volumeIsCount
1076
+ ? 'Volume: feed publish count per bucket'
1077
+ : 'Volume units — click to switch base/quote';
1078
+ }
1079
+ }
1080
+ function toggleVolumeUnit() {
1081
+ if (payload.volumeIsCount) return;
1082
+ currentVolumeMode = currentVolumeMode === 'quote' ? 'base' : 'quote';
1083
+ setActiveVolumeUnit();
1084
+ rerender(true);
1085
+ }
763
1086
  function refreshSubtitle() {
764
1087
  const subtitleEl = document.getElementById('subtitle');
765
1088
  if (!subtitleEl) return;
@@ -808,38 +1131,65 @@ function generateHTML(data, title = 'TradingView Style Research') {
808
1131
  seriesCache.set(key, state);
809
1132
  return state;
810
1133
  }
811
- function aggregateCandles(rows, seconds) {
1134
+ function aggregateCandles(rows, seconds, label) {
1135
+ const monthly = String(label || '') === '1M';
812
1136
  const bucketSec = Math.max(1, Math.round(seconds || 3600));
813
- const cacheKey = currentPairMode + '|' + bucketSec;
1137
+ const cacheKey = currentPairMode + '|' + (monthly ? '1M' : String(bucketSec));
814
1138
  if (aggregateCache.has(cacheKey)) return aggregateCache.get(cacheKey);
815
1139
  const out = [];
816
1140
  const idxs = [];
817
1141
  let cur = null;
818
1142
  let curBucket = null;
819
1143
  let curIdx = -1;
1144
+ const finishBucket = (bucket) => {
1145
+ if (!bucket) return;
1146
+ // Feed volume is a publication rate, not trade quantity. Keep
1147
+ // it comparable across 1h/week/month views instead of turning
1148
+ // a monthly candle into a sum such as 388 publishes.
1149
+ if (payload.volumeIsCount && bucket.barCount > 0) {
1150
+ bucket.volume /= bucket.barCount;
1151
+ }
1152
+ delete bucket.barCount;
1153
+ out.push(bucket);
1154
+ };
820
1155
  for (let i = 0; i < rows.length; i++) {
821
1156
  const row = rows[i];
822
1157
  const ts = Number(row.time);
823
1158
  if (!Number.isFinite(ts)) continue;
824
- const bucket = Math.floor(ts / bucketSec) * bucketSec;
1159
+ let bucket;
1160
+ if (monthly) {
1161
+ const d = new Date(ts * 1000);
1162
+ bucket = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / 1000;
1163
+ } else {
1164
+ bucket = Math.floor(ts / bucketSec) * bucketSec;
1165
+ }
825
1166
  if (!cur || bucket !== curBucket) {
826
1167
  if (cur) {
827
- out.push(cur);
1168
+ finishBucket(cur);
828
1169
  idxs.push(curIdx);
829
1170
  }
830
1171
  curBucket = bucket;
831
1172
  curIdx = i;
832
- cur = { time: bucket, open: row.open, high: row.high, low: row.low, close: row.close, volume: row.volume || 0 };
1173
+ cur = {
1174
+ time: bucket,
1175
+ open: row.open,
1176
+ high: row.high,
1177
+ low: row.low,
1178
+ close: row.close,
1179
+ volume: row.volume || 0,
1180
+ barCount: 1,
1181
+ };
833
1182
  } else {
834
1183
  cur.high = Math.max(cur.high, row.high);
835
1184
  cur.low = Math.min(cur.low, row.low);
836
1185
  cur.close = row.close;
837
1186
  cur.volume += row.volume || 0;
1187
+ cur.barCount += 1;
838
1188
  curIdx = i;
839
1189
  }
840
1190
  }
841
1191
  if (cur) {
842
- out.push(cur);
1192
+ finishBucket(cur);
843
1193
  idxs.push(curIdx);
844
1194
  }
845
1195
  const result = { candles: out, idxs };
@@ -1075,6 +1425,90 @@ function generateHTML(data, title = 'TradingView Style Research') {
1075
1425
  vwapCache.set(cacheKey, out);
1076
1426
  return out;
1077
1427
  }
1428
+ // Bot-grid range band: min/max derive ONLY from the live AMA price.
1429
+ // All math below runs the canonical market_adapter/grid sources,
1430
+ // embedded verbatim (not hand-copied): computeAverageAmaSlopePct,
1431
+ // computeAmaSlopeClipThreshold, applyAsymmetricBounds (+ metrics/base),
1432
+ // applyNarrowingSideGuard, parseRelativeMultiplier. With Range Scaling
1433
+ // on, the clipped AMA slope tilts the span-symmetric band like the
1434
+ // grid build: trend side widens, opposite side tightens. Painted
1435
+ // red [AMA, upper], green [lower, AMA].
1436
+ // Never reads candles, pair-display mapping, or axis/zoom state —
1437
+ // inversion and timeframe sampling apply to the AMA first.
1438
+ ${embedFunctionSources([computeAverageAmaSlopePct, computeAmaSlopeClipThreshold, resolveBaseBounds, computeAsymmetricBoundsMetrics, applyAsymmetricBounds, applyNarrowingSideGuard, parseRelativeMultiplier])}
1439
+ function computeRangeBand(baseAma) {
1440
+ const n = Array.isArray(baseAma) ? baseAma.length : 0;
1441
+ const upper = new Array(n).fill(null);
1442
+ const lower = new Array(n).fill(null);
1443
+ const trend = new Array(n).fill(0);
1444
+ const slopeCfg = payload.rangeSlope || {};
1445
+ const gridCfg = payload.grid || null;
1446
+ const lookback = Math.max(1, Math.round(Number(slopeCfg.lookbackBars) || 9));
1447
+ const maxSlope = Number(slopeCfg.maxSlopePct) > 0 ? Number(slopeCfg.maxSlopePct) : 0.09;
1448
+ const neutral = Number(slopeCfg.neutralZonePct) >= 0 ? Number(slopeCfg.neutralZonePct) : 0;
1449
+ const maxSlopeOffset = Number(slopeCfg.maxSlopeOffset) > 0 ? Number(slopeCfg.maxSlopeOffset) : 0.5;
1450
+ const maxAsym = gridCfg && Number(gridCfg.maxAsymmetryFactor) > 0
1451
+ ? Number(gridCfg.maxAsymmetryFactor)
1452
+ : (Number(slopeCfg.maxAsymmetryFactor) > 0 ? Number(slopeCfg.maxAsymmetryFactor) : 0.35);
1453
+ const inc = gridCfg && Number(gridCfg.incrementPercent) > 0 ? Number(gridCfg.incrementPercent) : null;
1454
+ const minSlots = Math.floor(Number(gridCfg && gridCfg.minScaleSlots) > 0 ? Number(gridCfg.minScaleSlots) : (Number(slopeCfg.minScaleSlots) || 0));
1455
+ // User x-range span (slider 1.2x–2.0x, default 1.55x): the only
1456
+ // width input — symmetric base min = AMA/span, max = AMA*span.
1457
+ const span = Math.min(2, Math.max(1.2, Number(currentRangeSpan) > 0 ? Number(currentRangeSpan) : 1.55));
1458
+ // Canonical grid pipeline (ama_slope_model): adaptive percentile
1459
+ // clip over the AMA history, then offset + trend — the same numbers
1460
+ // the live grid build feeds into applyAsymmetricBounds.
1461
+ const clipPct = Number(slopeCfg.clipPercentile) >= 0 ? Number(slopeCfg.clipPercentile) : 10;
1462
+ const clipEr = Number.isFinite(currentAmaErPeriod) && currentAmaErPeriod > 0
1463
+ ? Math.ceil(currentAmaErPeriod)
1464
+ : Math.ceil(Number(payload.amaDefaults?.erPeriod) || 781);
1465
+ const clipThreshold = computeAmaSlopeClipThreshold(baseAma, clipEr, lookback, clipPct);
1466
+ const slopeScaling = !!currentRangeScaleEnabled;
1467
+ if (!currentAmaEnabled || n === 0) return { upper, lower, trend };
1468
+ for (let i = 0; i < n; i++) {
1469
+ const ama = baseAma[i];
1470
+ const past = i - lookback >= 0 ? baseAma[i - lookback] : null;
1471
+ if (!Number.isFinite(ama) || ama <= 0 || !Number.isFinite(past) || past <= 0) continue;
1472
+ const slopePct = computeAverageAmaSlopePct(ama, past, lookback);
1473
+ if (slopePct == null || !Number.isFinite(slopePct)) continue;
1474
+ const csp = Math.max(-clipThreshold, Math.min(clipThreshold, slopePct));
1475
+ const dir = Math.abs(csp) <= neutral ? 0 : (csp > 0 ? 1 : -1);
1476
+ // Symmetric base from this bar's AMA and the user span —
1477
+ // no chart or config price level.
1478
+ let rMin = ama / span;
1479
+ let rMax = ama * span;
1480
+ if (slopeScaling && dir !== 0) {
1481
+ // Canonical tilt: clipped slope offset applied through
1482
+ // asymmetric_bounds, then the narrowing-side guard.
1483
+ const trendName = dir > 0 ? 'UP' : 'DOWN';
1484
+ const slopeOffset = clamp(csp / maxSlope, -1, 1) * maxSlopeOffset;
1485
+ const tilt = applyAsymmetricBounds({
1486
+ centerPrice: ama,
1487
+ minPrice: ama / span,
1488
+ maxPrice: ama * span,
1489
+ trend: trendName,
1490
+ slopeOffset,
1491
+ maxSlopeOffset,
1492
+ maxAsymmetryFactor: maxAsym,
1493
+ });
1494
+ const guard = applyNarrowingSideGuard({
1495
+ centerPrice: ama,
1496
+ minPrice: tilt.resolvedMinPrice,
1497
+ maxPrice: tilt.resolvedMaxPrice,
1498
+ trend: trendName,
1499
+ incrementPercent: inc,
1500
+ minScaleSlots: minSlots,
1501
+ });
1502
+ rMin = guard.minPrice;
1503
+ rMax = guard.maxPrice;
1504
+ }
1505
+ if (!Number.isFinite(rMin) || !Number.isFinite(rMax) || rMin <= 0 || rMax <= rMin) continue;
1506
+ upper[i] = rMax;
1507
+ lower[i] = rMin;
1508
+ trend[i] = dir;
1509
+ }
1510
+ return { upper, lower, trend };
1511
+ }
1078
1512
  function lowerBound(arr, value) {
1079
1513
  let lo = 0;
1080
1514
  let hi = arr.length;
@@ -1095,6 +1529,25 @@ function generateHTML(data, title = 'TradingView Style Research') {
1095
1529
  let end = Math.min(xs.length, lowerBound(xs, maxX) + 2);
1096
1530
  let min = Infinity;
1097
1531
  let max = -Infinity;
1532
+ // Range scaling active: fit the price axis to the full envelope
1533
+ // (lower ↔ upper through AMA) instead of the candles, so the
1534
+ // switch visibly adjusts the range. Falls through to the candle
1535
+ // fit when the band has no finite values in view.
1536
+ if (currentRangeEnabled && currentRangeScaleEnabled && currentAmaEnabled) {
1537
+ let bmin = Infinity;
1538
+ let bmax = -Infinity;
1539
+ for (let i = start; i < end; i++) {
1540
+ const a = currentAma[i];
1541
+ const up = currentRangeUpper[i];
1542
+ const lo = currentRangeLower[i];
1543
+ if (Number.isFinite(a) && a > 0) { if (a < bmin) bmin = a; if (a > bmax) bmax = a; }
1544
+ if (Number.isFinite(up) && up > 0) { if (up < bmin) bmin = up; if (up > bmax) bmax = up; }
1545
+ if (Number.isFinite(lo) && lo > 0) { if (lo < bmin) bmin = lo; if (lo > bmax) bmax = lo; }
1546
+ }
1547
+ if (Number.isFinite(bmin) && Number.isFinite(bmax) && bmin > 0 && bmax > bmin) {
1548
+ return [bmin * 0.97, bmax * 1.03];
1549
+ }
1550
+ }
1098
1551
  for (let i = start; i < end; i++) {
1099
1552
  const lo = currentLow[i];
1100
1553
  const hi = currentHigh[i];
@@ -1122,7 +1575,7 @@ function generateHTML(data, title = 'TradingView Style Research') {
1122
1575
  let end = Math.min(xs.length, lowerBound(xs, maxX) + 2);
1123
1576
  let max = 0;
1124
1577
  for (let i = start; i < end; i++) {
1125
- const v = currentCandles[i]?.volume;
1578
+ const v = getDisplayVolume(currentCandles[i]);
1126
1579
  if (Number.isFinite(v) && v > max) max = v;
1127
1580
  }
1128
1581
  if (!Number.isFinite(max) || max <= 0) return [0, 1];
@@ -1141,12 +1594,29 @@ function generateHTML(data, title = 'TradingView Style Research') {
1141
1594
  btn.title = next === 'linear' ? 'Toggle to log scale' : 'Toggle to linear scale';
1142
1595
  btn.setAttribute('aria-label', next === 'linear' ? 'Toggle to log scale' : 'Toggle to linear scale');
1143
1596
  }
1144
- function resetAmaDefaults() {
1145
- currentAmaErPeriod = Number(payload.amaDefaults?.erPeriod || ${MARKET_ADAPTER.AMAS.AMA3.erPeriod});
1146
- currentAmaFastPeriod = Number(payload.amaDefaults?.fastPeriod || ${MARKET_ADAPTER.AMAS.AMA3.fastPeriod});
1147
- currentAmaSlowPeriod = Number(payload.amaDefaults?.slowPeriod || ${MARKET_ADAPTER.AMAS.AMA3.slowPeriod});
1148
- setControls();
1149
- rerender(false);
1597
+ // AMA presets for the quick buttons (values from constants.ts AMAS).
1598
+ const AMA_PRESETS = {
1599
+ AMA1: { erPeriod: ${MARKET_ADAPTER.AMAS.AMA1.erPeriod}, fastPeriod: ${MARKET_ADAPTER.AMAS.AMA1.fastPeriod}, slowPeriod: ${MARKET_ADAPTER.AMAS.AMA1.slowPeriod} },
1600
+ AMA2: { erPeriod: ${MARKET_ADAPTER.AMAS.AMA2.erPeriod}, fastPeriod: ${MARKET_ADAPTER.AMAS.AMA2.fastPeriod}, slowPeriod: ${MARKET_ADAPTER.AMAS.AMA2.slowPeriod} },
1601
+ AMA3: { erPeriod: ${MARKET_ADAPTER.AMAS.AMA3.erPeriod}, fastPeriod: ${MARKET_ADAPTER.AMAS.AMA3.fastPeriod}, slowPeriod: ${MARKET_ADAPTER.AMAS.AMA3.slowPeriod} },
1602
+ AMA4: { erPeriod: ${MARKET_ADAPTER.AMAS.AMA4.erPeriod}, fastPeriod: ${MARKET_ADAPTER.AMAS.AMA4.fastPeriod}, slowPeriod: ${MARKET_ADAPTER.AMAS.AMA4.slowPeriod} },
1603
+ };
1604
+ function markActiveAmaPreset() {
1605
+ const cur = { e: Math.round(Number(currentAmaErPeriod)), f: Number(currentAmaFastPeriod), s: Number(currentAmaSlowPeriod) };
1606
+ document.querySelectorAll('.ama-preset-btn').forEach((b) => {
1607
+ const p = AMA_PRESETS[b.dataset.amaPreset];
1608
+ const match = p && Math.round(p.erPeriod) === cur.e && Number(p.fastPeriod) === cur.f && Number(p.slowPeriod) === cur.s;
1609
+ b.classList.toggle('active', !!match);
1610
+ });
1611
+ }
1612
+ function applyAmaPreset(key) {
1613
+ const p = AMA_PRESETS[key];
1614
+ if (!p) return;
1615
+ document.getElementById('ama-er').value = String(p.erPeriod);
1616
+ document.getElementById('ama-fast').value = String(p.fastPeriod);
1617
+ document.getElementById('ama-slow').value = String(p.slowPeriod);
1618
+ document.getElementById('ama-toggle').checked = true;
1619
+ syncInputs();
1150
1620
  }
1151
1621
  function setIndicatorSeriesVisible(seriesIndex, visible) {
1152
1622
  if (!priceChart) return;
@@ -1171,11 +1641,22 @@ function generateHTML(data, title = 'TradingView Style Research') {
1171
1641
  refreshLegend();
1172
1642
  saveState();
1173
1643
  }
1644
+ function hideRangeBandImmediate() {
1645
+ currentRangeUpper = new Array(currentCandles.length).fill(null);
1646
+ currentRangeLower = new Array(currentCandles.length).fill(null);
1647
+ // The band is a draw hook with no uPlot series: nothing else
1648
+ // repaints on hide, so redraw explicitly (stale pixels otherwise linger).
1649
+ if (priceChart && typeof priceChart.redraw === 'function') priceChart.redraw();
1650
+ refreshLegend();
1651
+ saveState();
1652
+ }
1174
1653
  function hideAmaSeriesImmediate() {
1175
1654
  currentAma = new Array(currentCandles.length).fill(null);
1176
1655
  currentAmaOff = new Array(currentCandles.length).fill(null);
1177
1656
  currentSmaInit = new Array(currentCandles.length).fill(null);
1178
1657
  currentSmaInitOff = new Array(currentCandles.length).fill(null);
1658
+ currentRangeUpper = new Array(currentCandles.length).fill(null);
1659
+ currentRangeLower = new Array(currentCandles.length).fill(null);
1179
1660
  if (Array.isArray(currentPriceData) && currentPriceData.length >= 11) {
1180
1661
  currentPriceData[6] = currentAma;
1181
1662
  currentPriceData[8] = currentSmaInit;
@@ -1209,14 +1690,11 @@ function generateHTML(data, title = 'TradingView Style Research') {
1209
1690
  document.getElementById('legend-delta').textContent = Number.isFinite(delta)
1210
1691
  ? ((delta >= 0 ? '+' : '') + fmtPrice(delta))
1211
1692
  : '-';
1212
- document.getElementById('legend-volume').textContent = fmtVolume(c.volume);
1213
- document.getElementById('legend-scale').textContent = currentPriceScale === 'linear' ? 'Linear' : 'Log';
1693
+ document.getElementById('legend-volume').textContent = fmtVolumeWithSym(getDisplayVolume(c));
1214
1694
  document.getElementById('legend-sma').textContent = Number.isFinite(currentSma[idx]) ? fmtPrice(currentSma[idx]) : (smaPending ? '...' : '-');
1215
- document.getElementById('legend-ama').textContent = Number.isFinite(currentAma[idx]) ? fmtPrice(currentAma[idx]) : '-';
1216
- document.getElementById('legend-sma-init').textContent = Number.isFinite(currentSmaInit[idx]) ? fmtPrice(currentSmaInit[idx]) : '-';
1217
- document.getElementById('legend-off-init').textContent = Number.isFinite(currentSmaInitOff[idx]) ? fmtPrice(currentSmaInitOff[idx]) : '-';
1218
- document.getElementById('legend-ama-off').textContent = Number.isFinite(currentAmaOff[idx]) ? fmtPrice(currentAmaOff[idx]) : '-';
1219
1695
  document.getElementById('legend-vwap').textContent = Number.isFinite(currentVwap[idx]) ? fmtPrice(currentVwap[idx]) : '-';
1696
+ document.getElementById('legend-sma-init').textContent = Number.isFinite(currentSmaInit[idx]) ? fmtPrice(currentSmaInit[idx]) : '-';
1697
+ document.getElementById('legend-ama').textContent = Number.isFinite(currentAma[idx]) ? fmtPrice(currentAma[idx]) : '-';
1220
1698
  }
1221
1699
  function candlePlugin() {
1222
1700
  function drawCandles(u) {
@@ -1359,12 +1837,86 @@ function generateHTML(data, title = 'TradingView Style Research') {
1359
1837
  hooks: { drawAxes: [drawVolumeBars] },
1360
1838
  };
1361
1839
  }
1840
+ function rangeBandPlugin() {
1841
+ function drawRangeBand(u) {
1842
+ if (!currentRangeEnabled || !currentAmaEnabled || !currentCandles.length) return;
1843
+ const xs = u.data[0];
1844
+ if (!Array.isArray(xs) || xs.length === 0) return;
1845
+ u.ctx.save();
1846
+ u.ctx.beginPath();
1847
+ u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
1848
+ u.ctx.clip();
1849
+ // Behind candles/AMA lines (same trick as the dynamic-weight signal background).
1850
+ u.ctx.globalCompositeOperation = 'destination-over';
1851
+ const UP_FILL = 'rgba(239,83,80,0.16)';
1852
+ const DOWN_FILL = 'rgba(38,166,154,0.16)';
1853
+ // Symmetric envelope, always both sides: red [AMA, upper]
1854
+ // plus green [lower, AMA] along the whole AMA, tilted by the
1855
+ // grid's range scaling. Paint per contiguous valid segment.
1856
+ let segTop = [];
1857
+ let segBot = [];
1858
+ const flush = () => {
1859
+ if (segTop.length > 1) {
1860
+ u.ctx.fillStyle = UP_FILL;
1861
+ u.ctx.beginPath();
1862
+ u.ctx.moveTo(segTop[0][0], segTop[0][1]);
1863
+ for (let k = 1; k < segTop.length; k++) u.ctx.lineTo(segTop[k][0], segTop[k][1]);
1864
+ for (let k = segTop.length - 1; k >= 0; k--) u.ctx.lineTo(segTop[k][0], segTop[k][2]);
1865
+ u.ctx.closePath();
1866
+ u.ctx.fill();
1867
+ }
1868
+ if (segBot.length > 1) {
1869
+ u.ctx.fillStyle = DOWN_FILL;
1870
+ u.ctx.beginPath();
1871
+ u.ctx.moveTo(segBot[0][0], segBot[0][1]);
1872
+ for (let k = 1; k < segBot.length; k++) u.ctx.lineTo(segBot[k][0], segBot[k][1]);
1873
+ for (let k = segBot.length - 1; k >= 0; k--) u.ctx.lineTo(segBot[k][0], segBot[k][2]);
1874
+ u.ctx.closePath();
1875
+ u.ctx.fill();
1876
+ }
1877
+ u.ctx.strokeStyle = 'rgba(239,83,80,0.55)';
1878
+ u.ctx.lineWidth = 1;
1879
+ u.ctx.beginPath();
1880
+ segTop.forEach((p, k) => { if (k === 0) u.ctx.moveTo(p[0], p[1]); else u.ctx.lineTo(p[0], p[1]); });
1881
+ u.ctx.stroke();
1882
+ u.ctx.strokeStyle = 'rgba(38,166,154,0.55)';
1883
+ u.ctx.beginPath();
1884
+ segBot.forEach((p, k) => { if (k === 0) u.ctx.moveTo(p[0], p[1]); else u.ctx.lineTo(p[0], p[1]); });
1885
+ u.ctx.stroke();
1886
+ segTop = [];
1887
+ segBot = [];
1888
+ };
1889
+ for (let i = 0; i < xs.length; i++) {
1890
+ const a = currentAma[i];
1891
+ const up = currentRangeUpper[i];
1892
+ const lo = currentRangeLower[i];
1893
+ if (!Number.isFinite(a) || a <= 0 || !Number.isFinite(up) || up <= a
1894
+ || !Number.isFinite(lo) || lo <= 0 || lo >= a) {
1895
+ flush();
1896
+ continue;
1897
+ }
1898
+ const x = u.valToPos(xs[i], 'x', true);
1899
+ const yA = u.valToPos(a, 'y', true);
1900
+ const yU = u.valToPos(up, 'y', true);
1901
+ const yL = u.valToPos(lo, 'y', true);
1902
+ if (!Number.isFinite(x) || !Number.isFinite(yA) || !Number.isFinite(yU) || !Number.isFinite(yL)) {
1903
+ flush();
1904
+ continue;
1905
+ }
1906
+ segTop.push([x, yU, yA]);
1907
+ segBot.push([x, yL, yA]);
1908
+ }
1909
+ flush();
1910
+ u.ctx.restore();
1911
+ }
1912
+ return { hooks: { draw: [drawRangeBand] } };
1913
+ }
1362
1914
  function buildData() {
1363
1915
  const tf = timeframeMap.get(currentTimeframe) || timeframeMap.get(defaultTimeframe.label) || timeframes[0];
1364
1916
  currentSeriesState = getSeriesState(currentPairMode);
1365
1917
  currentSeriesCandles = currentSeriesState.candles;
1366
1918
  currentSeriesCloseValues = currentSeriesState.closeValues;
1367
- const aggregated = aggregateCandles(currentSeriesCandles, tf?.seconds || 3600);
1919
+ const aggregated = aggregateCandles(currentSeriesCandles, tf?.seconds || 3600, tf?.label || currentTimeframe);
1368
1920
  currentCandles = aggregated.candles;
1369
1921
  if (currentCandles.length) {
1370
1922
  xMin = currentCandles[0].time;
@@ -1393,21 +1945,33 @@ function generateHTML(data, title = 'TradingView Style Research') {
1393
1945
  currentSma = new Array(currentCandles.length).fill(null);
1394
1946
  }
1395
1947
  currentAma = currentAmaEnabled ? sampleSeriesByIndex(baseAma, aggregated.idxs) : new Array(currentCandles.length).fill(null);
1948
+ const baseRange = computeRangeBand(baseAma);
1949
+ currentRangeUpper = sampleSeriesByIndex(baseRange.upper, aggregated.idxs);
1950
+ currentRangeLower = sampleSeriesByIndex(baseRange.lower, aggregated.idxs);
1396
1951
  currentAmaOff = currentAmaEnabled && currentAmaInitOffset !== 0 ? sampleSeriesByIndex(baseAmaOff, aggregated.idxs) : new Array(currentCandles.length).fill(null);
1397
1952
  currentVwap = currentVwapEnabled ? sampleSeriesByIndex(baseVwap, aggregated.idxs) : new Array(currentCandles.length).fill(null);
1398
1953
  const amaCfg = currentAmaConfig();
1399
1954
  const warmupInit = currentAmaEnabled && baseAma.length > amaCfg.erPeriod ? baseAma[amaCfg.erPeriod] : null;
1400
- const baseSmaInit = warmupInit !== null
1401
- ? currentSeriesCandles.map((c, i) => i <= amaCfg.erPeriod ? warmupInit : null)
1402
- : new Array(currentSeriesCandles.length).fill(null);
1403
- currentSmaInit = sampleSeriesByIndex(baseSmaInit, aggregated.idxs);
1955
+ // Build the init segment in the selected timeframe. Sampling the
1956
+ // 1h init series by each bucket's final 1h index can make it end
1957
+ // inside a week/month bucket, leaving a gap before the first
1958
+ // sampled AMA value appears.
1959
+ const firstAmaIndex = currentAma.findIndex((value) => Number.isFinite(value));
1960
+ // The first visible higher-timeframe AMA sample can already be
1961
+ // several 1h updates past the true warmup point. Anchor that
1962
+ // sample to the warmup average so the AMA starts where the init
1963
+ // line ends instead of appearing detached at a different value.
1964
+ if (warmupInit !== null && firstAmaIndex >= 0) currentAma[firstAmaIndex] = warmupInit;
1965
+ currentSmaInit = warmupInit !== null
1966
+ ? currentAma.map((value, i) => i <= firstAmaIndex || (firstAmaIndex < 0 && i === currentAma.length - 1) ? warmupInit : null)
1967
+ : new Array(currentCandles.length).fill(null);
1404
1968
  const offsetInit = currentAmaEnabled && currentAmaInitOffset !== 0 && baseAmaOff.length > amaCfg.erPeriod
1405
1969
  ? baseAmaOff[amaCfg.erPeriod]
1406
1970
  : null;
1407
- const baseSmaInitOff = offsetInit !== null
1408
- ? currentSeriesCandles.map((c, i) => i <= amaCfg.erPeriod ? offsetInit : null)
1409
- : new Array(currentSeriesCandles.length).fill(null);
1410
- currentSmaInitOff = sampleSeriesByIndex(baseSmaInitOff, aggregated.idxs);
1971
+ const firstAmaOffIndex = currentAmaOff.findIndex((value) => Number.isFinite(value));
1972
+ currentSmaInitOff = offsetInit !== null
1973
+ ? currentAmaOff.map((value, i) => i < firstAmaOffIndex || (firstAmaOffIndex < 0 && i === currentAmaOff.length - 1) ? offsetInit : null)
1974
+ : new Array(currentCandles.length).fill(null);
1411
1975
  currentPriceData = [
1412
1976
  currentCandles.map((c) => c.time),
1413
1977
  currentOpen,
@@ -1423,7 +1987,7 @@ function generateHTML(data, title = 'TradingView Style Research') {
1423
1987
  ];
1424
1988
  currentVolumeData = [
1425
1989
  currentCandles.map((c) => c.time),
1426
- currentCandles.map((c) => c.volume),
1990
+ currentCandles.map((c) => getDisplayVolume(c)),
1427
1991
  ];
1428
1992
  return {
1429
1993
  priceData: currentPriceData,
@@ -1440,7 +2004,7 @@ function generateHTML(data, title = 'TradingView Style Research') {
1440
2004
  padding: [14, 8, 8, 8],
1441
2005
  legend: { show: false },
1442
2006
  select: { show: false },
1443
- cursor: { sync: { key: STORAGE_KEY, setSeries: false, scales: ['x', null] }, drag: { x: false, y: false, setScale: false }, focus: { prox: 20 } },
2007
+ cursor: { sync: { key: SYNC_KEY, setSeries: false, scales: ['x', null] }, drag: { x: false, y: false, setScale: false }, focus: { prox: 20 } },
1444
2008
  scales: {
1445
2009
  x: { time: true },
1446
2010
  y: {
@@ -1491,13 +2055,18 @@ function generateHTML(data, title = 'TradingView Style Research') {
1491
2055
  },
1492
2056
  ],
1493
2057
  hooks: {
1494
- draw: [(u) => positionPriceMarker(u)],
2058
+ draw: [(u) => { positionPriceMarker(u); positionReserveLine(u); positionOrderLines(u); positionUpdateMarker(u, true); }],
2059
+ // Keep the bottom-right stat badges glued to the visible
2060
+ // window while zooming/panning (rAF-throttled text swap).
2061
+ // Refit Y to the visible window on timeframe (x) moves —
2062
+ // uPlot does not recompute Y on setScale('x') by itself.
2063
+ setScale: [(u, key) => { if (key === 'x') scheduleYRefit(); scheduleStatPanels(); }],
1495
2064
  },
1496
2065
  };
1497
2066
 
1498
2067
  const plugin = candlePlugin();
1499
2068
  plugin.opts(null, priceOpts);
1500
- priceOpts.plugins = [plugin];
2069
+ priceOpts.plugins = [rangeBandPlugin(), plugin];
1501
2070
  if (!priceChart) priceChart = new uPlot(priceOpts, data.priceData, priceEl);
1502
2071
  else priceChart.setData(data.priceData, false);
1503
2072
 
@@ -1507,7 +2076,7 @@ function generateHTML(data, title = 'TradingView Style Research') {
1507
2076
  padding: [6, 8, 8, 8],
1508
2077
  legend: { show: false },
1509
2078
  select: { show: false },
1510
- cursor: { sync: { key: STORAGE_KEY, setSeries: false, scales: ['x', null] }, drag: { x: false, y: false, setScale: false }, focus: { prox: 20 } },
2079
+ cursor: { sync: { key: SYNC_KEY, setSeries: false, scales: ['x', null] }, drag: { x: false, y: false, setScale: false }, focus: { prox: 20 } },
1511
2080
  scales: {
1512
2081
  x: { time: true },
1513
2082
  y: {
@@ -1528,6 +2097,11 @@ function generateHTML(data, title = 'TradingView Style Research') {
1528
2097
  makeTimeAxis(true),
1529
2098
  { scale: 'y', side: 1, size: 84, space: 22, stroke: '#ffffff', grid: { stroke: '#1c2128' }, ticks: { stroke: '#30363d', width: 1 }, font: '600 12px Segoe UI, sans-serif', values: (u, vals) => vals.map((v) => (v == null ? '' : fmtVolume(v))) },
1530
2099
  ],
2100
+ hooks: {
2101
+ // x is synced from the price chart; refit volume Y to the
2102
+ // visible window and refresh the max badge.
2103
+ setScale: [(u, key) => { if (key === 'x') scheduleYRefit(); scheduleStatPanels(); }],
2104
+ },
1531
2105
  };
1532
2106
 
1533
2107
  const volumePluginInst = volumePlugin();
@@ -1540,32 +2114,33 @@ function generateHTML(data, title = 'TradingView Style Research') {
1540
2114
  lastRenderedPriceScale = currentPriceScale;
1541
2115
  return priceChart;
1542
2116
  }
1543
- // True when clientX sits over the price-axis region. The chart canvas
1544
- // (plot + axis gutters) can be wider than the viewport and the right
1545
- // gutter clipped off-screen, so we key the price zone off the ON-SCREEN
1546
- // chart container width (always reachable) rather than the far-right gutter.
1547
- // The rightmost ~120px of the container zooms price; the rest pans time.
2117
+ // True when clientX sits over the true price-axis gutter (right of
2118
+ // the plot area). Keyed off uPlot's plot rect, not the container
2119
+ // width, so grabs on candles — even the newest ones at the plot's
2120
+ // right edge — always pan instead of scaling. Only the axis itself
2121
+ // zooms price; the rest pans time.
1548
2122
  function inYAxisZone(chart, clientX) {
1549
- const rootRect = chart.root.getBoundingClientRect();
1550
- if (!rootRect || rootRect.width <= 0) return false;
1551
- const x = clientX - rootRect.left;
1552
- if (chart === priceChart) {
1553
- return x > rootRect.width - 120;
1554
- }
1555
- return false;
2123
+ if (chart !== priceChart || !chart.over) return false;
2124
+ const overRect = chart.over.getBoundingClientRect();
2125
+ if (!overRect || overRect.width <= 0) return false;
2126
+ return clientX >= overRect.right - 2;
2127
+ }
2128
+ // True when the pointer sits over the time-axis gutter (below the
2129
+ // plot area) of either chart. Dragging there scales the timeframe;
2130
+ // drags on candles always pan instead.
2131
+ function inXAxisZone(chart, clientY) {
2132
+ if (!chart || !chart.over) return false;
2133
+ const overRect = chart.over.getBoundingClientRect();
2134
+ if (!overRect || overRect.height <= 0) return false;
2135
+ return clientY >= overRect.bottom - 2;
1556
2136
  }
1557
2137
  let priceMarkerLabel = null;
1558
- let priceMarkerLine = null;
1559
2138
  function ensurePriceMarker(u) {
1560
2139
  if (priceMarkerLabel && priceMarkerLabel.parentNode === u.root) return;
1561
2140
  if (priceMarkerLabel && priceMarkerLabel.parentNode) priceMarkerLabel.parentNode.removeChild(priceMarkerLabel);
1562
- if (priceMarkerLine && priceMarkerLine.parentNode) priceMarkerLine.parentNode.removeChild(priceMarkerLine);
1563
2141
  try { if (getComputedStyle(u.root).position === 'static') u.root.style.position = 'relative'; } catch (e) {}
1564
2142
  priceMarkerLabel = document.createElement('div');
1565
2143
  priceMarkerLabel.style.cssText = 'position:absolute;z-index:30;pointer-events:none;font:600 12px Segoe UI, sans-serif;line-height:19px;height:19px;padding:0 7px;border-radius:3px;color:#ffffff;white-space:nowrap;box-sizing:border-box;text-align:center;overflow:hidden;';
1566
- priceMarkerLine = document.createElement('div');
1567
- priceMarkerLine.style.cssText = 'position:absolute;z-index:1;pointer-events:none;height:0;border-top:1px dashed currentColor;opacity:0.6;';
1568
- u.root.appendChild(priceMarkerLine);
1569
2144
  u.root.appendChild(priceMarkerLabel);
1570
2145
  }
1571
2146
  function positionPriceMarker(u) {
@@ -1576,7 +2151,6 @@ function generateHTML(data, title = 'TradingView Style Research') {
1576
2151
  const price = last.close;
1577
2152
  if (!Number.isFinite(price)) {
1578
2153
  priceMarkerLabel.style.display = 'none';
1579
- priceMarkerLine.style.display = 'none';
1580
2154
  return;
1581
2155
  }
1582
2156
  const up = price >= (Number.isFinite(prev.close) ? prev.close : price);
@@ -1586,7 +2160,6 @@ function generateHTML(data, title = 'TradingView Style Research') {
1586
2160
  const sMax = Number.isFinite(s.max) ? s.max : null;
1587
2161
  if (sMin == null || sMax == null || sMax <= sMin) {
1588
2162
  priceMarkerLabel.style.display = 'none';
1589
- priceMarkerLine.style.display = 'none';
1590
2163
  return;
1591
2164
  }
1592
2165
  let frac;
@@ -1603,7 +2176,8 @@ function generateHTML(data, title = 'TradingView Style Research') {
1603
2176
  if (!overRect || overRect.height <= 0) return;
1604
2177
  const plotTop = overRect.top - rootRect.top;
1605
2178
  const yRel = (1 - frac) * overRect.height;
1606
- const inRange = frac >= 0 && frac <= 1;
2179
+ // No full-width dashed line by design (disturbs reading) — the
2180
+ // axis label above carries the last price on its own.
1607
2181
  priceMarkerLabel.style.display = 'block';
1608
2182
  priceMarkerLabel.textContent = fmtPriceLabel(price);
1609
2183
  priceMarkerLabel.style.background = color;
@@ -1619,15 +2193,335 @@ function generateHTML(data, title = 'TradingView Style Research') {
1619
2193
  priceMarkerLabel.style.right = '0px';
1620
2194
  priceMarkerLabel.style.width = Math.max(40, rightAxisW - 4) + 'px';
1621
2195
  }
1622
- if (inRange) {
1623
- priceMarkerLine.style.display = 'block';
1624
- priceMarkerLine.style.color = color;
1625
- priceMarkerLine.style.top = (plotTop + yRel) + 'px';
1626
- priceMarkerLine.style.left = (overRect.left - rootRect.left) + 'px';
1627
- priceMarkerLine.style.width = overRect.width + 'px';
2196
+ }
2197
+ // ── Order overlay ──
2198
+ // Active grid orders as dashed levels: green = buys, red = sells.
2199
+ // Grid bounds span the full grid (live + virtual slots), never
2200
+ // calculated: reserve = lowest grid buy, ceiling = highest grid sell.
2201
+ // Spread = best buy/best sell gap.
2202
+ // Pair-aware: order prices invert (1/p) with the candles so B/A shows
2203
+ // the same levels in display units (side colors preserved).
2204
+ let reserveLine = null;
2205
+ let reserveLabel = null;
2206
+ let ceilingLine = null;
2207
+ let ceilingLabel = null;
2208
+ let spreadMidLabel = null;
2209
+ let orderLineDivs = [];
2210
+ let orderBuyLabel = null;
2211
+ let orderSellLabel = null;
2212
+ let orderPriceTags = [];
2213
+ let lastOverlayKey = '';
2214
+ function getDisplayOrders() {
2215
+ const rawBuys = Array.isArray(payload.orderBuys) ? payload.orderBuys.filter(Number.isFinite).filter((p) => p > 0) : [];
2216
+ const rawSells = Array.isArray(payload.orderSells) ? payload.orderSells.filter(Number.isFinite).filter((p) => p > 0) : [];
2217
+ if (normalizePairMode(currentPairMode) !== 'inverse') return { buys: rawBuys, sells: rawSells };
2218
+ const inv = (arr) => arr.map((p) => 1 / p).filter(Number.isFinite).filter((p) => p > 0);
2219
+ return { buys: inv(rawBuys), sells: inv(rawSells) };
2220
+ }
2221
+ function yForPriceCached(ys, overRect, rootRect, price) {
2222
+ const sMin = Number.isFinite(ys.min) ? ys.min : null;
2223
+ const sMax = Number.isFinite(ys.max) ? ys.max : null;
2224
+ if (sMin == null || sMax == null || sMax <= sMin) return null;
2225
+ let frac;
2226
+ if (currentPriceScale === 'log') {
2227
+ if (!(sMin > 0) || !(price > 0)) return null;
2228
+ const lmin = Math.log10(sMin), lmax = Math.log10(sMax);
2229
+ frac = (Math.log10(price) - lmin) / (lmax - lmin);
1628
2230
  } else {
1629
- priceMarkerLine.style.display = 'none';
2231
+ frac = (price - sMin) / (sMax - sMin);
1630
2232
  }
2233
+ if (frac < 0 || frac > 1) return null;
2234
+ return (overRect.top - rootRect.top) + (1 - frac) * overRect.height;
2235
+ }
2236
+ function hideOverlayNodes() {
2237
+ [reserveLine, reserveLabel, ceilingLine, ceilingLabel, spreadMidLabel, orderBuyLabel, orderSellLabel].forEach((n) => { if (n) n.style.display = 'none'; });
2238
+ orderLineDivs.forEach((d) => { d.style.display = 'none'; });
2239
+ orderPriceTags.forEach((t) => { t.style.display = 'none'; });
2240
+ }
2241
+ function positionReserveLine(u) {
2242
+ if (!u || !u.over || !currentCandles.length) return;
2243
+ if (!currentOrdersVisible || (!hasOrders && !hasBounds)) { hideOverlayNodes(); return; }
2244
+ try { if (getComputedStyle(u.root).position === 'static') u.root.style.position = 'relative'; } catch (e) {}
2245
+ if (!reserveLine || reserveLine.parentNode !== u.root) {
2246
+ if (reserveLine && reserveLine.parentNode) reserveLine.parentNode.removeChild(reserveLine);
2247
+ if (reserveLabel && reserveLabel.parentNode) reserveLabel.parentNode.removeChild(reserveLabel);
2248
+ reserveLine = document.createElement('div');
2249
+ reserveLine.style.cssText = 'position:absolute;z-index:1;pointer-events:none;height:0;border-top:2px dashed #f97316;opacity:0.7;left:0;right:0;display:none;';
2250
+ reserveLabel = document.createElement('div');
2251
+ reserveLabel.style.cssText = 'position:absolute;z-index:25;pointer-events:none;font:600 10px Segoe UI, sans-serif;line-height:15px;height:15px;padding:0 5px;border-radius:3px;color:#f97316;background:rgba(30,41,59,0.9);white-space:nowrap;display:none;';
2252
+ u.root.appendChild(reserveLine);
2253
+ u.root.appendChild(reserveLabel);
2254
+ }
2255
+ // Grid bounds span the full DEXBot grid (live + virtual slots
2256
+ // in base units), never calculated: bottom = lowest grid buy,
2257
+ // top = highest grid sell. Reciprocals flip the ordering, so
2258
+ // the sides swap in B/A view. A missing side hides its line
2259
+ // instead of drawing an invented level.
2260
+ const { buys: boundBuys, sells: boundSells } = getDisplayOrders();
2261
+ const rawLow = Number(payload.gridBounds?.low);
2262
+ const rawHigh = Number(payload.gridBounds?.high);
2263
+ const gridLow = Number.isFinite(rawLow) && rawLow > 0 ? rawLow : null;
2264
+ const gridHigh = Number.isFinite(rawHigh) && rawHigh > 0 ? rawHigh : null;
2265
+ const inversePairs = normalizePairMode(currentPairMode) === 'inverse';
2266
+ const reservePrice = inversePairs
2267
+ ? (gridHigh != null ? 1 / gridHigh : null)
2268
+ : gridLow;
2269
+ const ceilingPrice = inversePairs
2270
+ ? (gridLow != null ? 1 / gridLow : null)
2271
+ : gridHigh;
2272
+ const ys = u.scales.y || {};
2273
+ const rootRect = u.root.getBoundingClientRect();
2274
+ const overRect = u.over.getBoundingClientRect();
2275
+ const y = reservePrice != null ? yForPriceCached(ys, overRect, rootRect, reservePrice) : null;
2276
+ if (y == null) {
2277
+ reserveLine.style.display = 'none';
2278
+ reserveLabel.style.display = 'none';
2279
+ } else {
2280
+ reserveLine.style.display = 'block';
2281
+ reserveLine.style.top = y + 'px';
2282
+ const lastClose = currentCandles[currentCandles.length - 1].close;
2283
+ const dipRaw = Number.isFinite(lastClose) && lastClose > 0 ? (lastClose - reservePrice) / lastClose * 100 : NaN;
2284
+ const dipTxt = Number.isFinite(dipRaw) ? (dipRaw >= 0 ? '-' : '+') + Math.abs(Math.round(dipRaw)) + '%' : '-';
2285
+ reserveLabel.style.display = 'block';
2286
+ reserveLabel.textContent = 'reserve ' + fmtPriceLabel(reservePrice) + ' (' + dipTxt + ')';
2287
+ reserveLabel.style.left = '8px';
2288
+ reserveLabel.style.top = (y - 16) + 'px';
2289
+ }
2290
+ if (!ceilingLine || ceilingLine.parentNode !== u.root) {
2291
+ if (ceilingLine && ceilingLine.parentNode) ceilingLine.parentNode.removeChild(ceilingLine);
2292
+ if (ceilingLabel && ceilingLabel.parentNode) ceilingLabel.parentNode.removeChild(ceilingLabel);
2293
+ ceilingLine = document.createElement('div');
2294
+ ceilingLine.style.cssText = 'position:absolute;z-index:1;pointer-events:none;height:0;border-top:2px dashed #f97316;opacity:0.7;left:0;right:0;display:none;';
2295
+ ceilingLabel = document.createElement('div');
2296
+ ceilingLabel.style.cssText = 'position:absolute;z-index:25;pointer-events:none;font:600 10px Segoe UI, sans-serif;line-height:15px;height:15px;padding:0 5px;border-radius:3px;color:#f97316;background:rgba(30,41,59,0.9);white-space:nowrap;display:none;';
2297
+ u.root.appendChild(ceilingLine);
2298
+ u.root.appendChild(ceilingLabel);
2299
+ }
2300
+ const cY = ceilingPrice != null ? yForPriceCached(ys, overRect, rootRect, ceilingPrice) : null;
2301
+ if (cY == null) {
2302
+ ceilingLine.style.display = 'none';
2303
+ ceilingLabel.style.display = 'none';
2304
+ } else {
2305
+ ceilingLine.style.display = 'block';
2306
+ ceilingLine.style.top = cY + 'px';
2307
+ const lastCloseTop = currentCandles[currentCandles.length - 1].close;
2308
+ const upRaw = Number.isFinite(lastCloseTop) && lastCloseTop > 0 ? (ceilingPrice - lastCloseTop) / lastCloseTop * 100 : NaN;
2309
+ const upTxt = Number.isFinite(upRaw) ? (upRaw >= 0 ? '+' : '-') + Math.abs(Math.round(upRaw)) + '%' : '-';
2310
+ ceilingLabel.style.display = 'block';
2311
+ ceilingLabel.textContent = 'top ' + fmtPriceLabel(ceilingPrice) + ' (' + upTxt + ')';
2312
+ ceilingLabel.style.left = '8px';
2313
+ ceilingLabel.style.top = (cY + 4) + 'px';
2314
+ }
2315
+ if (!spreadMidLabel || spreadMidLabel.parentNode !== u.root) {
2316
+ if (spreadMidLabel && spreadMidLabel.parentNode) spreadMidLabel.parentNode.removeChild(spreadMidLabel);
2317
+ spreadMidLabel = document.createElement('div');
2318
+ spreadMidLabel.style.cssText = 'position:absolute;z-index:25;pointer-events:none;font:700 11px Segoe UI, sans-serif;line-height:16px;height:16px;padding:0 6px;border-radius:3px;color:#0b0f14;background:#facc15;white-space:nowrap;display:none;';
2319
+ u.root.appendChild(spreadMidLabel);
2320
+ }
2321
+ if (boundBuys.length && boundSells.length) {
2322
+ const bestBuy = Math.max(...boundBuys);
2323
+ const bestSell = Math.min(...boundSells);
2324
+ const yB = yForPriceCached(ys, overRect, rootRect, bestBuy);
2325
+ const yS = yForPriceCached(ys, overRect, rootRect, bestSell);
2326
+ if (yB != null && yS != null && bestBuy > 0) {
2327
+ const spr = (bestSell - bestBuy) / bestBuy * 100;
2328
+ spreadMidLabel.style.display = 'block';
2329
+ spreadMidLabel.textContent = 'spread ' + spr.toFixed(2) + '%';
2330
+ spreadMidLabel.style.left = '8px';
2331
+ spreadMidLabel.style.top = ((yB + yS) / 2 - 8) + 'px';
2332
+ } else {
2333
+ spreadMidLabel.style.display = 'none';
2334
+ }
2335
+ } else if (spreadMidLabel) {
2336
+ spreadMidLabel.style.display = 'none';
2337
+ }
2338
+ }
2339
+ function positionOrderLines(u) {
2340
+ if (!u || !u.over) return;
2341
+ if (!currentOrdersVisible) {
2342
+ orderLineDivs.forEach((d) => { d.style.display = 'none'; });
2343
+ orderPriceTags.forEach((t) => { t.style.display = 'none'; });
2344
+ if (orderBuyLabel) orderBuyLabel.style.display = 'none';
2345
+ if (orderSellLabel) orderSellLabel.style.display = 'none';
2346
+ return;
2347
+ }
2348
+ try { if (getComputedStyle(u.root).position === 'static') u.root.style.position = 'relative'; } catch (e) {}
2349
+ const { buys, sells } = getDisplayOrders();
2350
+ const total = buys.length + sells.length;
2351
+ if (orderLineDivs.length && orderLineDivs[0].parentNode !== u.root) {
2352
+ orderLineDivs.forEach((d) => u.root.appendChild(d));
2353
+ orderPriceTags.forEach((t) => u.root.appendChild(t));
2354
+ lastOverlayKey = '';
2355
+ }
2356
+ while (orderLineDivs.length < total) {
2357
+ const d = document.createElement('div');
2358
+ d.style.cssText = 'position:absolute;z-index:1;pointer-events:none;height:0;left:0;right:0;display:none;';
2359
+ u.root.appendChild(d);
2360
+ orderLineDivs.push(d);
2361
+ }
2362
+ const ys = u.scales.y || {};
2363
+ const overlayKey = (ys.min || 0) + '|' + (ys.max || 0) + '|' + (u.root.clientWidth || 0) + '|' + (u.root.clientHeight || 0) + '|' + currentPriceScale + '|' + currentPairMode;
2364
+ if (overlayKey === lastOverlayKey) return;
2365
+ lastOverlayKey = overlayKey;
2366
+ const rootRect = u.root.getBoundingClientRect();
2367
+ const overRect = u.over.getBoundingClientRect();
2368
+ if (!orderBuyLabel || orderBuyLabel.parentNode !== u.root) {
2369
+ if (orderBuyLabel && orderBuyLabel.parentNode) orderBuyLabel.parentNode.removeChild(orderBuyLabel);
2370
+ orderBuyLabel = document.createElement('div');
2371
+ orderBuyLabel.style.cssText = 'position:absolute;z-index:25;pointer-events:none;font:600 10px Segoe UI, sans-serif;line-height:15px;height:15px;padding:0 5px;border-radius:3px;color:#26a69a;background:rgba(30,41,59,0.9);white-space:nowrap;display:none;';
2372
+ u.root.appendChild(orderBuyLabel);
2373
+ }
2374
+ if (!orderSellLabel || orderSellLabel.parentNode !== u.root) {
2375
+ if (orderSellLabel && orderSellLabel.parentNode) orderSellLabel.parentNode.removeChild(orderSellLabel);
2376
+ orderSellLabel = document.createElement('div');
2377
+ orderSellLabel.style.cssText = 'position:absolute;z-index:25;pointer-events:none;font:600 10px Segoe UI, sans-serif;line-height:15px;height:15px;padding:0 5px;border-radius:3px;color:#ef5350;background:rgba(30,41,59,0.9);white-space:nowrap;display:none;';
2378
+ u.root.appendChild(orderSellLabel);
2379
+ }
2380
+ let idx = 0;
2381
+ const place = (price, color) => {
2382
+ const d = orderLineDivs[idx++];
2383
+ const y = yForPriceCached(ys, overRect, rootRect, price);
2384
+ if (y == null) { d.style.display = 'none'; return null; }
2385
+ d.style.display = 'block';
2386
+ d.style.top = y + 'px';
2387
+ d.style.borderTop = '1.5px dashed ' + color;
2388
+ d.style.opacity = '0.55';
2389
+ return y;
2390
+ };
2391
+ let botBuyY = null;
2392
+ buys.forEach((p) => { const y = place(p, '#26a69a'); if (y != null && (botBuyY == null || y > botBuyY)) botBuyY = y; });
2393
+ let topSellY = null;
2394
+ sells.forEach((p) => { const y = place(p, '#ef5350'); if (y != null && (topSellY == null || y < topSellY)) topSellY = y; });
2395
+ for (; idx < orderLineDivs.length; idx++) orderLineDivs[idx].style.display = 'none';
2396
+ if (orderPriceTags.length && orderPriceTags[0].parentNode !== u.root) {
2397
+ orderPriceTags.forEach((t) => u.root.appendChild(t));
2398
+ }
2399
+ const levels = [
2400
+ ...buys.map((p) => ({ p, c: '#26a69a', bg: 'rgba(20,30,28,0.92)' })),
2401
+ ...sells.map((p) => ({ p, c: '#ef5350', bg: 'rgba(30,20,22,0.92)' })),
2402
+ ].sort((a, b) => a.p - b.p);
2403
+ let ti = 0, lastTagY = -Infinity;
2404
+ const ensureTag = () => {
2405
+ if (ti >= orderPriceTags.length) {
2406
+ const t = document.createElement('div');
2407
+ t.style.cssText = 'position:absolute;z-index:25;pointer-events:none;font:600 9px Segoe UI, sans-serif;line-height:14px;height:14px;padding:0 4px;border-radius:3px;white-space:nowrap;display:none;';
2408
+ u.root.appendChild(t);
2409
+ orderPriceTags.push(t);
2410
+ }
2411
+ return orderPriceTags[ti++];
2412
+ };
2413
+ for (const lv of levels) {
2414
+ const tag = ensureTag();
2415
+ const y = yForPriceCached(ys, overRect, rootRect, lv.p);
2416
+ if (y == null || Math.abs(y - lastTagY) < 13) { tag.style.display = 'none'; continue; }
2417
+ tag.style.display = 'block';
2418
+ tag.style.top = (y - 7) + 'px';
2419
+ tag.style.right = '4px';
2420
+ tag.style.color = lv.c;
2421
+ tag.style.background = lv.bg;
2422
+ tag.style.border = '1px solid ' + lv.c;
2423
+ tag.textContent = fmtPriceLabel(lv.p);
2424
+ lastTagY = y;
2425
+ }
2426
+ for (; ti < orderPriceTags.length; ti++) orderPriceTags[ti].style.display = 'none';
2427
+ if (buys.length && botBuyY != null) {
2428
+ orderBuyLabel.style.display = 'block';
2429
+ orderBuyLabel.textContent = 'BUYS (' + buys.length + ') ' + fmtPriceLabel(Math.min(...buys));
2430
+ orderBuyLabel.style.left = '8px';
2431
+ orderBuyLabel.style.top = (botBuyY + 4) + 'px';
2432
+ } else {
2433
+ orderBuyLabel.style.display = 'none';
2434
+ }
2435
+ if (sells.length && topSellY != null) {
2436
+ orderSellLabel.style.display = 'block';
2437
+ orderSellLabel.textContent = 'SELLS (' + sells.length + ') ' + fmtPriceLabel(Math.max(...sells));
2438
+ orderSellLabel.style.left = '8px';
2439
+ orderSellLabel.style.top = (topSellY - 16) + 'px';
2440
+ } else {
2441
+ orderSellLabel.style.display = 'none';
2442
+ }
2443
+ }
2444
+ function resetOverlayNodes() {
2445
+ reserveLine = null;
2446
+ reserveLabel = null;
2447
+ ceilingLine = null;
2448
+ ceilingLabel = null;
2449
+ spreadMidLabel = null;
2450
+ orderLineDivs = [];
2451
+ orderBuyLabel = null;
2452
+ orderSellLabel = null;
2453
+ orderPriceTags = [];
2454
+ lastOverlayKey = '';
2455
+ }
2456
+ // ── Volume hover tooltip ──
2457
+ let volumeHoverTip = null;
2458
+ // ── Update marker ("updated from here"): vertical line where the
2459
+ // latest incremental fetch started, with a date label. Shown only
2460
+ // when the generator was given a marker timestamp.
2461
+ const UPDATE_MARKER_SEC = Number(payload.updateMarkerTsSec) > 0 ? Number(payload.updateMarkerTsSec) : null;
2462
+ function positionUpdateMarker(u, withLabel) {
2463
+ if (!UPDATE_MARKER_SEC || !u || !u.over || !currentCandles.length) return;
2464
+ let wrap = u.root.querySelector(':scope > .um-wrap');
2465
+ if (!wrap) {
2466
+ try { if (getComputedStyle(u.root).position === 'static') u.root.style.position = 'relative'; } catch (e) {}
2467
+ wrap = document.createElement('div');
2468
+ wrap.style.cssText = 'position:absolute;z-index:24;pointer-events:none;';
2469
+ wrap.innerHTML =
2470
+ '<div class="um-line" style="position:absolute;top:0;height:100%;width:0;border-left:2px dashed #22d3ee;opacity:0.75;"></div>' +
2471
+ '<div class="um-tag" style="position:absolute;top:2px;left:6px;font:600 10px Segoe UI, sans-serif;line-height:15px;color:#22d3ee;white-space:nowrap;"></div>';
2472
+ u.root.appendChild(wrap);
2473
+ }
2474
+ const s = u.scales.x || {};
2475
+ const sMin = Number.isFinite(s.min) ? s.min : null;
2476
+ const sMax = Number.isFinite(s.max) ? s.max : null;
2477
+ if (sMin == null || sMax == null || sMax <= sMin) { wrap.style.display = 'none'; return; }
2478
+ const frac = (UPDATE_MARKER_SEC - sMin) / (sMax - sMin);
2479
+ const inView = frac >= 0 && frac <= 1;
2480
+ if (!inView) { wrap.style.display = 'none'; return; }
2481
+ const rootRect = u.root.getBoundingClientRect();
2482
+ const overRect = u.over.getBoundingClientRect();
2483
+ const xRoot = (overRect.left - rootRect.left) + frac * overRect.width;
2484
+ wrap.style.display = 'block';
2485
+ wrap.style.left = xRoot + 'px';
2486
+ wrap.style.top = (overRect.top - rootRect.top) + 'px';
2487
+ wrap.style.height = overRect.height + 'px';
2488
+ if (withLabel) {
2489
+ const tag = wrap.querySelector('.um-tag');
2490
+ const d = new Date(UPDATE_MARKER_SEC * 1000);
2491
+ const hh = String(d.getUTCHours()).padStart(2, '0');
2492
+ const mm = String(d.getUTCMinutes()).padStart(2, '0');
2493
+ const bars = Number(payload.updateMarkerNewBars) > 0 ? ' (+' + Math.round(Number(payload.updateMarkerNewBars)) + ')' : '';
2494
+ tag.textContent = 'update ' + d.toLocaleDateString(undefined) + ' ' + hh + ':' + mm + bars + ' →';
2495
+ tag.style.left = (frac > 0.8 ? -(tag.offsetWidth + 8) : 6) + 'px';
2496
+ }
2497
+ }
2498
+ function ensureVolumeHoverTip(u) {
2499
+ if (volumeHoverTip && volumeHoverTip.parentNode === u.root) return;
2500
+ if (volumeHoverTip && volumeHoverTip.parentNode) volumeHoverTip.parentNode.removeChild(volumeHoverTip);
2501
+ volumeHoverTip = document.createElement('div');
2502
+ volumeHoverTip.style.cssText = 'position:absolute;z-index:31;pointer-events:none;display:none;font:600 11px Segoe UI, sans-serif;line-height:18px;height:18px;padding:0 8px;border-radius:4px;color:#ffffff;background:rgba(48,54,61,0.95);border:1px solid #6e7681;white-space:nowrap;box-sizing:border-box;';
2503
+ u.root.appendChild(volumeHoverTip);
2504
+ }
2505
+ function showVolumeHover(u) {
2506
+ if (!u || !u.over || !currentCandles.length) return;
2507
+ ensureVolumeHoverTip(u);
2508
+ const idx = u.cursor.idx;
2509
+ if (idx == null || idx < 0 || idx >= currentCandles.length) {
2510
+ volumeHoverTip.style.display = 'none';
2511
+ return;
2512
+ }
2513
+ const c = currentCandles[idx];
2514
+ const dv = getDisplayVolume(c);
2515
+ if (!Number.isFinite(dv)) {
2516
+ volumeHoverTip.style.display = 'none';
2517
+ return;
2518
+ }
2519
+ const barX = u.valToPos(u.data[0][idx], 'x', true);
2520
+ volumeHoverTip.style.display = 'block';
2521
+ volumeHoverTip.textContent = 'Vol ' + fmtVolumeWithSym(dv);
2522
+ volumeHoverTip.style.left = Math.max(2, barX - volumeHoverTip.offsetWidth / 2) + 'px';
2523
+ // Pinned to the panel top — never covers the bars
2524
+ volumeHoverTip.style.top = '2px';
1631
2525
  }
1632
2526
  function currentYRange(chart) {
1633
2527
  const s = chart.scales.y || {};
@@ -1654,12 +2548,13 @@ function generateHTML(data, title = 'TradingView Style Research') {
1654
2548
  priceBounds = null;
1655
2549
  return;
1656
2550
  }
1657
- const range = max - min;
1658
2551
  // Uloszoomauksen rajat: alin naytettava hinta voi painua viimeistaan
1659
- // ~35 % datan minimin alapuolelle, ylin ~1.6x datan maksimin, ja
1660
- // kokonaisvali saa olla enintaan ~1.6x datan hintahaarukasta —
1661
- // ei siis paase "lipsahtamaan" candleita nakyvasta kadottaen.
1662
- priceBounds = { min: min, max: max, floor: min * 0.35, ceil: max * 1.6, maxSpan: range * 1.6 };
2552
+ // ~1/2 datan minimin alapuolelle, ylin ~2x datan maksimin —
2553
+ // paneminen kayttaa tata 2x-bandia ja pysahtyy jaykkana sen
2554
+ // reunaan (ei siis paase "lipsahtamaan" candleita nakyvasta
2555
+ // kadottaen). Kokonaisvalille ei ole ylarajaa: pystyakselia saa
2556
+ // zoomata ulos vapaasti.
2557
+ priceBounds = { min: min, max: max, floor: min / 2, ceil: max * 2.0 };
1663
2558
  }
1664
2559
  function applyYRange(chart, min, max) {
1665
2560
  if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return false;
@@ -1670,18 +2565,37 @@ function generateHTML(data, title = 'TradingView Style Research') {
1670
2565
  if (max <= min) return false;
1671
2566
  }
1672
2567
  if (priceBounds) {
1673
- let nMin = Math.max(min, priceBounds.floor);
1674
- let nMax = Math.min(max, priceBounds.ceil);
1675
- if (nMax - nMin > priceBounds.maxSpan) {
1676
- const c = (nMin + nMax) / 2;
1677
- nMin = c - priceBounds.maxSpan / 2;
1678
- nMax = c + priceBounds.maxSpan / 2;
1679
- nMin = Math.max(nMin, priceBounds.floor);
1680
- nMax = Math.min(nMax, priceBounds.ceil);
2568
+ // Span-locked slide within the 2x zoom band: a pan that would
2569
+ // cross the zoom-out limits stops as a rigid block instead of
2570
+ // getting squashed (squashing reads as scaling halfway through
2571
+ // a drag). Only a span wider than the band itself snaps to the
2572
+ // full band. Scaling lives on the axis gutters alone.
2573
+ if (currentPriceScale === 'log') {
2574
+ const spanRatio = max / min;
2575
+ if (spanRatio > priceBounds.ceil / priceBounds.floor) {
2576
+ min = priceBounds.floor;
2577
+ max = priceBounds.ceil;
2578
+ } else {
2579
+ if (max > priceBounds.ceil) {
2580
+ const f = max / priceBounds.ceil;
2581
+ min /= f; max /= f;
2582
+ }
2583
+ if (min < priceBounds.floor) {
2584
+ const f = priceBounds.floor / min;
2585
+ min *= f; max *= f;
2586
+ }
2587
+ }
2588
+ } else {
2589
+ const span = max - min;
2590
+ if (span > priceBounds.ceil - priceBounds.floor) {
2591
+ min = priceBounds.floor;
2592
+ max = priceBounds.ceil;
2593
+ } else {
2594
+ if (max > priceBounds.ceil) { max = priceBounds.ceil; min = max - span; }
2595
+ if (min < priceBounds.floor) { min = priceBounds.floor; max = min + span; }
2596
+ }
1681
2597
  }
1682
- if (nMax <= nMin) return false;
1683
- min = nMin;
1684
- max = nMax;
2598
+ if (!(max > min) || !Number.isFinite(min) || !Number.isFinite(max)) return false;
1685
2599
  }
1686
2600
  manualYRange = { min: min, max: max };
1687
2601
  chart.setScale('y', { min: min, max: max });
@@ -1740,6 +2654,56 @@ function generateHTML(data, title = 'TradingView Style Research') {
1740
2654
  window.addEventListener('mouseup', endDrag, { once: true });
1741
2655
  }, true);
1742
2656
  }
2657
+ // Time-axis drag scales the timeframe around its center (mirror of
2658
+ // the price-axis drag): drag right to zoom out, left to zoom in.
2659
+ // Synced across charts via syncXRange; capture phase pre-empts plot pan.
2660
+ function bindXAxisDrag(chart) {
2661
+ let dragging = false;
2662
+ let startClientX = 0;
2663
+ let startRange = null;
2664
+ const onMove = (e) => {
2665
+ if (!dragging || !startRange) return;
2666
+ e.preventDefault();
2667
+ chart.root.style.cursor = 'ew-resize';
2668
+ const deltaPx = e.clientX - startClientX;
2669
+ if (!Number.isFinite(deltaPx) || deltaPx === 0) return;
2670
+ const factor = Math.exp(deltaPx / 350);
2671
+ const span = startRange.max - startRange.min;
2672
+ if (!Number.isFinite(span) || span <= 0) return;
2673
+ const center = (startRange.min + startRange.max) / 2;
2674
+ const nextSpan = span * factor;
2675
+ syncXRange(center - nextSpan / 2, center + nextSpan / 2);
2676
+ };
2677
+ const endDrag = () => {
2678
+ if (!dragging) return;
2679
+ dragging = false;
2680
+ startRange = null;
2681
+ chart.root.style.cursor = '';
2682
+ window.removeEventListener('mousemove', onMove);
2683
+ window.removeEventListener('mouseup', endDrag);
2684
+ };
2685
+ chart.root.addEventListener('mousedown', (e) => {
2686
+ if (!e || e.button !== 0 || e.ctrlKey || e.metaKey || e.altKey) return;
2687
+ // capture phase so we pre-empt the plot pan when in the x-zone
2688
+ const rect = chart.root.getBoundingClientRect();
2689
+ if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) return;
2690
+ if (!inXAxisZone(chart, e.clientY)) return;
2691
+ const s = chart.scales.x || {};
2692
+ const range = {
2693
+ min: Number.isFinite(s.min) ? s.min : xMin,
2694
+ max: Number.isFinite(s.max) ? s.max : xMax,
2695
+ };
2696
+ if (!Number.isFinite(range.min) || !Number.isFinite(range.max) || range.max <= range.min) return;
2697
+ e.preventDefault();
2698
+ e.stopPropagation();
2699
+ dragging = true;
2700
+ startClientX = e.clientX;
2701
+ startRange = range;
2702
+ chart.root.style.cursor = 'ew-resize';
2703
+ window.addEventListener('mousemove', onMove);
2704
+ window.addEventListener('mouseup', endDrag, { once: true });
2705
+ }, true);
2706
+ }
1743
2707
  function bindYAxisReset(chart) {
1744
2708
  chart.root.addEventListener('dblclick', (e) => {
1745
2709
  if (chart !== priceChart) return;
@@ -1754,6 +2718,12 @@ function generateHTML(data, title = 'TradingView Style Research') {
1754
2718
  function setControls() {
1755
2719
  setActivePairMode(currentPairMode);
1756
2720
  setActivePriceScale(currentPriceScale);
2721
+ const ordersToggle = document.getElementById('orders-toggle');
2722
+ if (ordersToggle) ordersToggle.checked = currentOrdersVisible;
2723
+ const volumeToggleEl = document.getElementById('volume-toggle');
2724
+ if (volumeToggleEl) volumeToggleEl.checked = currentVolumeVisible;
2725
+ setActiveVolumeUnit();
2726
+ markActiveAmaPreset();
1757
2727
  document.getElementById('sma-toggle').checked = currentSmaEnabled;
1758
2728
  document.getElementById('sma-period').value = String(currentSmaPeriod);
1759
2729
  document.getElementById('ama-toggle').checked = currentAmaEnabled;
@@ -1762,6 +2732,11 @@ function generateHTML(data, title = 'TradingView Style Research') {
1762
2732
  document.getElementById('ama-slow').value = currentAmaSlowPeriod.toFixed(1);
1763
2733
  document.getElementById('vwap-toggle').checked = currentVwapEnabled;
1764
2734
  document.getElementById('vwap-bars').value = String(currentVwapBars);
2735
+ document.getElementById('range-toggle').checked = currentRangeEnabled;
2736
+ document.getElementById('range-scale-toggle').checked = currentRangeScaleEnabled;
2737
+ document.getElementById('range-span').value = Number(currentRangeSpan).toFixed(2);
2738
+ document.getElementById('range-span-val').textContent = Number(currentRangeSpan).toFixed(2) + 'x';
2739
+ document.getElementById('range-grid-wrap').style.display = 'inline';
1765
2740
  document.getElementById('ama-init-offset-toggle').checked = currentAmaInitOffsetEnabled;
1766
2741
  document.getElementById('ama-init-offset').value = String(currentAmaInitOffset);
1767
2742
  document.getElementById('ama-init-offset').disabled = !currentAmaInitOffsetEnabled;
@@ -1775,7 +2750,8 @@ function generateHTML(data, title = 'TradingView Style Research') {
1775
2750
  if (needsRebuild) {
1776
2751
  manualYRange = null;
1777
2752
  priceMarkerLabel = null;
1778
- priceMarkerLine = null;
2753
+ resetOverlayNodes();
2754
+ volumeHoverTip = null;
1779
2755
  if (priceEl) priceEl.innerHTML = '';
1780
2756
  if (volumeEl) volumeEl.innerHTML = '';
1781
2757
  priceChart = null;
@@ -1806,16 +2782,53 @@ function generateHTML(data, title = 'TradingView Style Research') {
1806
2782
  charts.forEach((chart) => {
1807
2783
  bindWheelZoom(chart);
1808
2784
  bindPan(chart);
2785
+ bindXAxisDrag(chart);
1809
2786
  if (chart === priceChart) {
1810
2787
  bindYAxisDrag(chart);
1811
2788
  bindYAxisReset(chart);
1812
2789
  chart.root.addEventListener('mousemove', (e) => {
1813
- chart.root.style.cursor = inYAxisZone(chart, e.clientX) ? 'ns-resize' : '';
2790
+ chart.root.style.cursor = inYAxisZone(chart, e.clientX) ? 'ns-resize' : (inXAxisZone(chart, e.clientY) ? 'ew-resize' : '');
2791
+ });
2792
+ // Cursor price tag: floating label next to the mouse
2793
+ // showing the price under the cursor (price chart only).
2794
+ let cursorPriceTag = null;
2795
+ const ensureCursorPriceTag = () => {
2796
+ if (!cursorPriceTag || cursorPriceTag.parentNode !== chart.root) {
2797
+ if (cursorPriceTag && cursorPriceTag.parentNode) cursorPriceTag.parentNode.removeChild(cursorPriceTag);
2798
+ cursorPriceTag = document.createElement('div');
2799
+ cursorPriceTag.style.cssText = 'position:absolute;z-index:30;pointer-events:none;font:700 11px ui-monospace,SFMono-Regular,Menlo,monospace;line-height:16px;height:16px;padding:0 6px;border-radius:3px;color:#0b0f14;background:#e8eef5;white-space:nowrap;display:none;';
2800
+ chart.root.appendChild(cursorPriceTag);
2801
+ }
2802
+ return cursorPriceTag;
2803
+ };
2804
+ chart.over.addEventListener('mousemove', (e) => {
2805
+ const tag = ensureCursorPriceTag();
2806
+ const rect = chart.root.getBoundingClientRect();
2807
+ const overRect = chart.over.getBoundingClientRect();
2808
+ const price = chart.posToVal(e.clientY - overRect.top, 'y');
2809
+ if (!Number.isFinite(price)) { tag.style.display = 'none'; return; }
2810
+ tag.style.display = 'block';
2811
+ tag.textContent = fmtPriceLabel(price);
2812
+ let x = e.clientX - rect.left + 14;
2813
+ const y = e.clientY - rect.top - 8;
2814
+ const maxX = rect.width - tag.offsetWidth - 6;
2815
+ if (x > maxX) x = e.clientX - rect.left - tag.offsetWidth - 14;
2816
+ tag.style.left = Math.max(2, x) + 'px';
2817
+ tag.style.top = Math.max(2, y) + 'px';
2818
+ });
2819
+ chart.over.addEventListener('mouseleave', () => {
2820
+ if (cursorPriceTag) cursorPriceTag.style.display = 'none';
1814
2821
  });
1815
2822
  }
1816
2823
  chart.root.addEventListener('mousemove', () => {
1817
2824
  if (chart.cursor.idx != null) updateLegend(chart.cursor.idx);
1818
2825
  });
2826
+ if (chart === volumeChart) {
2827
+ chart.over.addEventListener('mousemove', () => showVolumeHover(chart));
2828
+ chart.over.addEventListener('mouseleave', () => {
2829
+ if (volumeHoverTip) volumeHoverTip.style.display = 'none';
2830
+ });
2831
+ }
1819
2832
  chart.root.addEventListener('mouseleave', refreshLegend);
1820
2833
  chart.root.addEventListener('mouseenter', () => chart.root.classList.add('is-hovered'));
1821
2834
  chart.root.addEventListener('mouseleave', () => chart.root.classList.remove('is-hovered'));
@@ -1823,16 +2836,18 @@ function generateHTML(data, title = 'TradingView Style Research') {
1823
2836
  chartEventsBound = true;
1824
2837
  }
1825
2838
  if (oldRange && Number.isFinite(oldRange.min) && Number.isFinite(oldRange.max)) {
1826
- const next = clampXRange(oldRange.min, oldRange.max);
2839
+ const next = clampRange(oldRange.min, oldRange.max);
1827
2840
  if (next) charts.forEach((chart) => chart.batch(() => chart.setScale('x', next)));
1828
2841
  }
1829
2842
  refreshLegend();
2843
+ renderMarketPanel();
2844
+ renderRangePanel();
2845
+ renderVolumePanel();
1830
2846
  saveState();
1831
2847
  }
1832
2848
 
1833
2849
  setControls();
1834
2850
  rerender(false);
1835
-
1836
2851
  document.querySelectorAll('.time-btn').forEach((btn) => {
1837
2852
  btn.addEventListener('click', () => {
1838
2853
  if (btn.disabled) return;
@@ -1843,7 +2858,6 @@ function generateHTML(data, title = 'TradingView Style Research') {
1843
2858
  rerender(true);
1844
2859
  });
1845
2860
  });
1846
-
1847
2861
  const scaleToggle = document.getElementById('scale-toggle');
1848
2862
  if (scaleToggle) {
1849
2863
  scaleToggle.addEventListener('click', () => {
@@ -1857,10 +2871,62 @@ function generateHTML(data, title = 'TradingView Style Research') {
1857
2871
  pairToggle.addEventListener('click', () => {
1858
2872
  currentPairMode = currentPairMode === 'inverse' ? 'normal' : 'inverse';
1859
2873
  manualYRange = null;
2874
+ lastOverlayKey = '';
1860
2875
  setControls();
1861
2876
  rerender(true);
2877
+ padXRight();
2878
+ });
2879
+ }
2880
+ const ordersToggle = document.getElementById('orders-toggle');
2881
+ if (ordersToggle) {
2882
+ ordersToggle.addEventListener('change', () => {
2883
+ currentOrdersVisible = ordersToggle.checked;
2884
+ lastOverlayKey = '';
2885
+ if (!currentOrdersVisible && priceChart) hideOverlayNodes();
2886
+ setControls();
2887
+ saveState();
2888
+ if (priceChart) {
2889
+ positionReserveLine(priceChart);
2890
+ positionOrderLines(priceChart);
2891
+ }
2892
+ renderMarketPanel();
1862
2893
  });
1863
2894
  }
2895
+ // Volume toggle: hide the volume chart so flex yields the space to
2896
+ // the price chart (larger). No rerender, resize only.
2897
+ const applyVolumeVisibility = () => {
2898
+ if (!volumeEl || !priceChart) return;
2899
+ volumeEl.style.display = currentVolumeVisible ? '' : 'none';
2900
+ requestAnimationFrame(() => {
2901
+ if (!priceChart) return;
2902
+ priceChart.setSize({ width: priceEl.clientWidth, height: priceEl.clientHeight });
2903
+ if (currentVolumeVisible && volumeChart && volumeEl.clientWidth > 0) {
2904
+ volumeChart.setSize({ width: volumeEl.clientWidth, height: volumeEl.clientHeight });
2905
+ }
2906
+ });
2907
+ };
2908
+ const volumeToggleInit = document.getElementById('volume-toggle');
2909
+ if (volumeToggleInit) {
2910
+ volumeToggleInit.checked = currentVolumeVisible;
2911
+ volumeToggleInit.addEventListener('change', () => {
2912
+ currentVolumeVisible = volumeToggleInit.checked;
2913
+ saveState();
2914
+ applyVolumeVisibility();
2915
+ });
2916
+ }
2917
+ const volumeUnitToggle = document.getElementById('volume-unit-toggle');
2918
+ if (volumeUnitToggle) {
2919
+ volumeUnitToggle.addEventListener('click', () => toggleVolumeUnit());
2920
+ }
2921
+ const legendVolume = document.getElementById('legend-volume');
2922
+ if (legendVolume) {
2923
+ legendVolume.addEventListener('click', () => toggleVolumeUnit());
2924
+ }
2925
+ // Initial state on load (previously stored in localStorage)
2926
+ applyVolumeVisibility();
2927
+ document.querySelectorAll('.ama-preset-btn').forEach((b) => {
2928
+ b.addEventListener('click', () => applyAmaPreset(b.dataset.amaPreset));
2929
+ });
1864
2930
 
1865
2931
  const syncInputs = () => {
1866
2932
  currentSmaEnabled = document.getElementById('sma-toggle').checked;
@@ -1876,7 +2942,13 @@ function generateHTML(data, title = 'TradingView Style Research') {
1876
2942
  document.getElementById('ama-init-offset-val').textContent = currentAmaInitOffset + '%';
1877
2943
  currentVwapEnabled = document.getElementById('vwap-toggle').checked;
1878
2944
  currentVwapBars = clamp(Math.round(Number(document.getElementById('vwap-bars').value) || 500), 24, 2000);
2945
+ currentRangeEnabled = document.getElementById('range-toggle').checked;
2946
+ currentRangeScaleEnabled = document.getElementById('range-scale-toggle').checked;
2947
+ currentRangeSpan = Math.min(2, Math.max(1.2, Math.round((Number(document.getElementById('range-span').value) || 1.55) * 20) / 20));
2948
+ const ordersEl = document.getElementById('orders-toggle');
2949
+ if (ordersEl) currentOrdersVisible = ordersEl.checked;
1879
2950
  setControls();
2951
+ markActiveAmaPreset();
1880
2952
  rerender(false);
1881
2953
  };
1882
2954
 
@@ -1914,6 +2986,25 @@ function generateHTML(data, title = 'TradingView Style Research') {
1914
2986
  rerender(false);
1915
2987
  });
1916
2988
  document.getElementById('ama-init-offset-toggle').addEventListener('change', syncInputs);
2989
+ document.getElementById('range-toggle').addEventListener('change', () => {
2990
+ const nextEnabled = document.getElementById('range-toggle').checked;
2991
+ if (currentRangeEnabled === nextEnabled) return;
2992
+ currentRangeEnabled = nextEnabled;
2993
+ setControls();
2994
+ if (!currentRangeEnabled) {
2995
+ hideRangeBandImmediate();
2996
+ return;
2997
+ }
2998
+ rerender(false);
2999
+ });
3000
+ document.getElementById('range-scale-toggle').addEventListener('change', () => {
3001
+ const nextEnabled = document.getElementById('range-scale-toggle').checked;
3002
+ if (currentRangeScaleEnabled === nextEnabled) return;
3003
+ currentRangeScaleEnabled = nextEnabled;
3004
+ manualYRange = null;
3005
+ setControls();
3006
+ rerender(false);
3007
+ });
1917
3008
  ['sma-period', 'ama-er', 'ama-fast', 'ama-slow', 'vwap-bars'].forEach((id) => {
1918
3009
  document.getElementById(id).addEventListener('change', syncInputs);
1919
3010
  document.getElementById(id).addEventListener('blur', syncInputs);
@@ -1957,7 +3048,6 @@ function generateHTML(data, title = 'TradingView Style Research') {
1957
3048
  makeStepper('ama-er', 1, 1, 0);
1958
3049
  makeStepper('ama-fast', 0.1, 0.1, 1);
1959
3050
  makeStepper('ama-slow', 1, 0.1, 1);
1960
- document.getElementById('ama-reset').addEventListener('click', resetAmaDefaults);
1961
3051
  document.getElementById('ama-init-offset').addEventListener('input', () => {
1962
3052
  const input = document.getElementById('ama-init-offset');
1963
3053
  const snapped = snapInitOffset(input.value);
@@ -1965,20 +3055,192 @@ function generateHTML(data, title = 'TradingView Style Research') {
1965
3055
  document.getElementById('ama-init-offset-val').textContent = snapped + '%';
1966
3056
  syncInputs();
1967
3057
  });
3058
+ document.getElementById('range-span').addEventListener('input', () => {
3059
+ const input = document.getElementById('range-span');
3060
+ document.getElementById('range-span-val').textContent = Number(input.value).toFixed(2) + 'x';
3061
+ syncInputs();
3062
+ });
3063
+
3064
+ // Room to the right past the last bar (~12%): for the market panel
3065
+ // and future candles. Applied at init and on pair flip; user
3066
+ // zoom/pan never trigger it, so it does not fight the user zoom.
3067
+ function padXRight() {
3068
+ if (!priceChart || !currentCandles.length) return;
3069
+ const first = currentCandles[0]?.time;
3070
+ const last = currentCandles[currentCandles.length - 1]?.time;
3071
+ if (!Number.isFinite(first) || !Number.isFinite(last) || last <= first) return;
3072
+ const span = last - first;
3073
+ syncXRange(first - span * 0.02, last + span * 0.12);
3074
+ }
3075
+ // Market panel top-right: SELL / Market / BUY with distance-to-market
3076
+ // %. Pair-aware (same display levels as the overlay). Static per
3077
+ // generation, so drawn once (no draw hook); refreshed on pair flip
3078
+ // via rerender. Hidden while the Orders overlay is unchecked — the
3079
+ // BUY/SELL rows are overlay data, so they go away with it.
3080
+ function renderMarketPanel() {
3081
+ if (!priceChart) return;
3082
+ let panel = document.getElementById('mkt-panel');
3083
+ if (!panel) {
3084
+ panel = document.createElement('div');
3085
+ panel.id = 'mkt-panel';
3086
+ panel.style.cssText = 'position:absolute;z-index:26;top:10px;right:88px;pointer-events:none;font:600 14px ui-monospace,SFMono-Regular,Menlo,monospace;line-height:1.6;padding:8px 12px;border-radius:8px;background:rgba(13,17,23,0.85);border:1px solid #263241;white-space:nowrap;text-align:right;';
3087
+ priceChart.root.appendChild(panel);
3088
+ }
3089
+ if (!currentOrdersVisible && (hasOrders || hasBounds)) { panel.style.display = 'none'; return; }
3090
+ panel.style.display = 'block';
3091
+ const last = currentCandles[currentCandles.length - 1];
3092
+ const mkt = last ? last.close : NaN;
3093
+ const { buys, sells } = getDisplayOrders();
3094
+ let html = '';
3095
+ if (sells.length && Number.isFinite(mkt)) {
3096
+ const s = Math.min(...sells);
3097
+ const p = (s - mkt) / mkt * 100;
3098
+ html += '<div style="color:#ef5350">SELL ' + fmtPriceLabel(s) + ' ' + (p >= 0 ? '+' : '') + p.toFixed(1) + '%</div>';
3099
+ }
3100
+ html += '<div style="color:#e8eef5">Market ' + (Number.isFinite(mkt) ? fmtPriceLabel(mkt) : '-') + '</div>';
3101
+ if (buys.length && Number.isFinite(mkt)) {
3102
+ const b = Math.max(...buys);
3103
+ const p = (b - mkt) / mkt * 100;
3104
+ html += '<div style="color:#26a69a">BUY ' + fmtPriceLabel(b) + ' ' + (p >= 0 ? '+' : '') + p.toFixed(1) + '%</div>';
3105
+ }
3106
+ panel.innerHTML = html;
3107
+ }
3108
+ // Range panel bottom-right: visible-window candle High (red) /
3109
+ // Low (green) — sell-side red on top, buy-side green below, mirroring
3110
+ // the top-right market badge. Volume max lives in its own badge on
3111
+ // the volume chart below. Slightly smaller type than the top-right market badge.
3112
+ // Pair-aware via currentCandles (already inverted in B/A view).
3113
+ // Refreshed on rerender and on x zoom/pan through the setScale hooks.
3114
+ function visibleCandleStats() {
3115
+ if (!currentCandles.length) return null;
3116
+ let minX = NaN;
3117
+ let maxX = NaN;
3118
+ if (priceChart) {
3119
+ const xs = priceChart.scales.x || {};
3120
+ if (Number.isFinite(xs.min)) minX = xs.min;
3121
+ if (Number.isFinite(xs.max)) maxX = xs.max;
3122
+ }
3123
+ if (!Number.isFinite(minX)) minX = currentCandles[0].time;
3124
+ if (!Number.isFinite(maxX)) maxX = currentCandles[currentCandles.length - 1].time;
3125
+ const times = currentCandles.map((c) => c.time);
3126
+ const start = Math.max(0, lowerBound(times, minX) - 1);
3127
+ const end = Math.min(times.length, lowerBound(times, maxX) + 2);
3128
+ let hi = -Infinity;
3129
+ let lo = Infinity;
3130
+ let vol = 0;
3131
+ for (let i = start; i < end; i++) {
3132
+ const c = currentCandles[i];
3133
+ if (!c) continue;
3134
+ if (Number.isFinite(c.high) && c.high > hi) hi = c.high;
3135
+ if (Number.isFinite(c.low) && c.low < lo) lo = c.low;
3136
+ const dv = getDisplayVolume(c);
3137
+ if (Number.isFinite(dv) && dv > vol) vol = dv;
3138
+ }
3139
+ if (!Number.isFinite(hi) || !Number.isFinite(lo)) return null;
3140
+ return { hi, lo, vol };
3141
+ }
3142
+ function renderRangePanel() {
3143
+ if (!priceChart) return;
3144
+ let panel = document.getElementById('range-panel');
3145
+ if (!panel) {
3146
+ panel = document.createElement('div');
3147
+ panel.id = 'range-panel';
3148
+ panel.style.cssText = 'position:absolute;z-index:26;bottom:10px;right:88px;pointer-events:none;font:600 12px ui-monospace,SFMono-Regular,Menlo,monospace;line-height:1.6;padding:6px 10px;border-radius:8px;background:rgba(13,17,23,0.85);border:1px solid #263241;white-space:nowrap;text-align:right;';
3149
+ priceChart.root.appendChild(panel);
3150
+ }
3151
+ const stats = visibleCandleStats();
3152
+ if (!stats) { panel.style.display = 'none'; return; }
3153
+ panel.style.display = 'block';
3154
+ panel.innerHTML = '<div style="color:#ef5350">H ' + fmtPriceLabel(stats.hi) + '</div>'
3155
+ + '<div style="color:#26a69a">L ' + fmtPriceLabel(stats.lo) + '</div>';
3156
+ }
3157
+ // Volume badge top-right on the volume chart: max volume of the
3158
+ // visible window only. Same small type as the range panel. Lives on
3159
+ // the volume root, so hiding the volume chart hides the badge too.
3160
+ function renderVolumePanel() {
3161
+ if (!volumeChart) return;
3162
+ let panel = document.getElementById('vol-panel');
3163
+ if (!panel) {
3164
+ panel = document.createElement('div');
3165
+ panel.id = 'vol-panel';
3166
+ panel.style.cssText = 'position:absolute;z-index:26;top:10px;right:88px;pointer-events:auto;font:600 12px ui-monospace,SFMono-Regular,Menlo,monospace;line-height:1.6;padding:6px 10px;border-radius:8px;background:rgba(13,17,23,0.85);border:1px solid #263241;white-space:nowrap;text-align:right;';
3167
+ panel.addEventListener('click', () => toggleVolumeUnit());
3168
+ volumeChart.root.appendChild(panel);
3169
+ }
3170
+ panel.style.cursor = payload.volumeIsCount ? 'default' : 'pointer';
3171
+ panel.title = payload.volumeIsCount
3172
+ ? 'Volume: feed publish count per bucket'
3173
+ : 'Volume units — click to switch base/quote';
3174
+ const stats = visibleCandleStats();
3175
+ if (!stats) { panel.style.display = 'none'; return; }
3176
+ panel.style.display = 'block';
3177
+ panel.innerHTML = '<div style="color:#e8eef5">max ' + fmtVolumeWithSym(stats.vol) + '</div>';
3178
+ }
3179
+ function scheduleStatPanels() {
3180
+ if (rangePanelRaf) return;
3181
+ rangePanelRaf = requestAnimationFrame(() => {
3182
+ rangePanelRaf = 0;
3183
+ renderRangePanel();
3184
+ renderVolumePanel();
3185
+ });
3186
+ }
3187
+ // uPlot does not recompute an auto Y scale when X moves via
3188
+ // setScale('x') — the scale range fns only run on init/data
3189
+ // change. Refit Y to the visible window after every timeframe
3190
+ // move so candles/volume follow the pan/zoom instead of getting
3191
+ // stuck outside the view. A user-locked price range (axis
3192
+ // wheel/drag → manualYRange) is respected; double-click the
3193
+ // price axis to return to autofit. y-keyed setScale calls never
3194
+ // schedule a refit, so this cannot loop with itself.
3195
+ function yRangeDirty(u, vis) {
3196
+ const s = u.scales.y || {};
3197
+ if (!Number.isFinite(s.min) || !Number.isFinite(s.max)) return true;
3198
+ const span = Math.max(1e-12, Math.abs(vis[1] - vis[0]));
3199
+ return Math.abs(s.min - vis[0]) / span > 1e-9 || Math.abs(s.max - vis[1]) / span > 1e-9;
3200
+ }
3201
+ function scheduleYRefit() {
3202
+ if (yRefitRaf) return;
3203
+ yRefitRaf = requestAnimationFrame(() => {
3204
+ yRefitRaf = 0;
3205
+ if (priceChart && !manualYRange) {
3206
+ const vis = visiblePriceRange(priceChart);
3207
+ if (vis && yRangeDirty(priceChart, vis)) priceChart.setScale('y', { min: vis[0], max: vis[1] });
3208
+ }
3209
+ if (volumeChart) {
3210
+ const vvis = visibleVolumeRange(volumeChart);
3211
+ if (vvis && yRangeDirty(volumeChart, vvis)) volumeChart.setScale('y', { min: vvis[0], max: vvis[1] });
3212
+ }
3213
+ });
3214
+ }
3215
+ padXRight();
3216
+ renderMarketPanel();
3217
+ renderRangePanel();
3218
+ renderVolumePanel();
1968
3219
 
1969
3220
  window.addEventListener('resize', () => {
1970
3221
  if (!charts.length) return;
1971
3222
  charts.forEach((chart) => {
1972
3223
  const el = chart === priceChart ? priceEl : volumeEl;
3224
+ if (el.clientWidth <= 0 || el.clientHeight <= 0) return;
1973
3225
  chart.setSize({ width: el.clientWidth, height: el.clientHeight });
1974
3226
  });
1975
3227
  });
1976
3228
 
3229
+ window.addEventListener('keydown', (e) => {
3230
+ const tag = (document.activeElement && document.activeElement.tagName) || '';
3231
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
3232
+ if ((e.ctrlKey || e.metaKey) && e.key === '0' && charts.length) {
3233
+ const first = currentCandles[0]?.time;
3234
+ const last = currentCandles[currentCandles.length - 1]?.time;
3235
+ if (Number.isFinite(first) && Number.isFinite(last)) syncXRange(first, last);
3236
+ }
3237
+ });
3238
+
1977
3239
  ${zoomResetScript()}
1978
3240
  })();
1979
3241
  </script>
1980
3242
  </body>
1981
3243
  </html>`;
1982
3244
  }
1983
- export { generateHTML, normalizeCandle, loadMarketProfiles };
3245
+ export { generateHTML, normalizeCandle, loadMarketProfiles, resolveChartStorageKey, sanitizeStorageComponent, TRADINGVIEW_PREFS_KEY_PREFIX, TRADINGVIEW_SYNC_KEY };
1984
3246
  //# sourceMappingURL=tradingview_uplot_chart_generator.js.map