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