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