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