index.html 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>多平台价格与价差监控</title>
  7. <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  8. <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
  9. <style>
  10. body { font-family: Arial, sans-serif; margin: 20px; background-color: #f4f4f4; color: #333; }
  11. .container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); margin-bottom: 20px; }
  12. h1, h2 { text-align: center; color: #333; margin-top:10px; margin-bottom:15px; }
  13. table { width: 100%; border-collapse: collapse; margin-top: 10px; margin-bottom: 15px; }
  14. th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size:0.85em; word-break: break-all; }
  15. th { background-color: #e9e9e9; white-space: nowrap; }
  16. .price-up { color: green; }
  17. .price-down { color: red; }
  18. .error-message { color: #c00; font-style: italic; }
  19. .status-cell { min-width: 100px; } /* Give status cells some min width */
  20. .timestamp { font-size: 0.9em; color: #666; text-align: right; margin-top: 10px; }
  21. .chart-container { position: relative; height: 38vh; width: 95vw; margin: auto; margin-bottom: 10px; } /* Slightly smaller height for more charts if needed */
  22. .controls-container { text-align: center; margin-bottom: 15px; margin-top: 5px; }
  23. .control-button { background-color: #007bff; color: white; border: none; padding: 8px 15px; font-size: 14px; border-radius: 5px; cursor: pointer; margin:0 5px; }
  24. .control-button:hover { background-color: #0056b3; }
  25. .pause-button-active { background-color: #ffc107; color: #333; }
  26. .pause-button-active:hover { background-color: #e0a800; }
  27. .platform-name {font-weight: bold;}
  28. #main-title { font-size: 1.8em; }
  29. h2 { font-size: 1.3em; }
  30. </style>
  31. </head>
  32. <body>
  33. <div class="container">
  34. <h1 id="main-title">价格监控</h1>
  35. <div class="controls-container">
  36. <button id="pause-resume-button" class="control-button">暂停刷新</button>
  37. </div>
  38. <table>
  39. <thead>
  40. <tr>
  41. <th>平台</th>
  42. <th>价格 (USDT)</th>
  43. <th class="status-cell">状态/错误</th>
  44. </tr>
  45. </thead>
  46. <tbody>
  47. <tr>
  48. <td class="platform-name">OpenOcean (BSC)</td>
  49. <td id="oo-price">加载中...</td>
  50. <td id="oo-status" class="status-cell"></td>
  51. </tr>
  52. <tr>
  53. <td class="platform-name" id="gate-spot-label">Gate.io 现货</td>
  54. <td id="gate-spot-price">加载中...</td>
  55. <td id="gate-spot-status" class="status-cell"></td>
  56. </tr>
  57. <tr>
  58. <td class="platform-name" id="gate-futures-label">Gate.io 期货</td>
  59. <td id="gate-futures-price">加载中...</td>
  60. <td id="gate-futures-status" class="status-cell"></td>
  61. </tr>
  62. </tbody>
  63. </table>
  64. <h2>价差百分比</h2>
  65. <table>
  66. <thead>
  67. <tr>
  68. <th>对比</th>
  69. <th>价差 (%)</th>
  70. </tr>
  71. </thead>
  72. <tbody>
  73. <tr>
  74. <td id="diff-label-oo-spot">OO vs Gate.io 现货</td>
  75. <td id="diff-oo-vs-spot">计算中...</td>
  76. </tr>
  77. <tr>
  78. <td id="diff-label-oo-futures">OO vs Gate.io 期货</td>
  79. <td id="diff-oo-vs-futures">计算中...</td>
  80. </tr>
  81. <tr>
  82. <td id="diff-label-spot-futures">Gate.io 现货 vs 期货</td>
  83. <td id="diff-spot-vs-futures">计算中...</td>
  84. </tr>
  85. </tbody>
  86. </table>
  87. <div class="timestamp">最后更新: <span id="last-updated">N/A</span></div>
  88. </div>
  89. <div class="container">
  90. <h2 id="price-chart-title">价格历史曲线</h2>
  91. <div class="chart-container">
  92. <canvas id="priceHistoryChart"></canvas>
  93. </div>
  94. <div class="controls-container">
  95. <button id="reset-price-zoom-button" class="control-button">重置缩放</button>
  96. </div>
  97. </div>
  98. <div class="container">
  99. <h2 id="diff-chart-title">价差百分比历史曲线</h2>
  100. <div class="chart-container">
  101. <canvas id="diffPercentageChart"></canvas>
  102. </div>
  103. <div class="controls-container">
  104. <button id="reset-diff-zoom-button" class="control-button">重置缩放</button>
  105. </div>
  106. </div>
  107. <script>
  108. let priceChartInstance = null;
  109. let diffChartInstance = null;
  110. let dataUpdateIntervalID = null;
  111. let isPaused = false;
  112. const REFRESH_INTERVAL_MS = 1000; // 1秒刷新一次
  113. const pauseResumeButton = document.getElementById('pause-resume-button');
  114. let isSyncingZoomPan = false;
  115. const initialConfig = {
  116. GATEIO_SPOT_PAIR: "{{ config.GATEIO_SPOT_PAIR if config else 'SPOT_USDT' }}",
  117. GATEIO_FUTURES_CONTRACT: "{{ config.GATEIO_FUTURES_CONTRACT if config else 'FUTURES_USDT' }}",
  118. TARGET_ASSET_SYMBOL: "{{ config.TARGET_ASSET_SYMBOL if config else 'ASSET' }}"
  119. };
  120. let currentGateSpotPair = initialConfig.GATEIO_SPOT_PAIR;
  121. let currentGateFuturesContract = initialConfig.GATEIO_FUTURES_CONTRACT;
  122. let currentAssetSymbol = initialConfig.TARGET_ASSET_SYMBOL;
  123. function formatPrice(priceStr) {
  124. if (priceStr === null || priceStr === undefined || priceStr === "N/A" || priceStr.toLowerCase() === "n/a") return "N/A";
  125. const price = parseFloat(priceStr);
  126. if (isNaN(price)) return "N/A";
  127. if (price === 0) return "0.00000000"; // More precision for 0
  128. if (Math.abs(price) < 0.0000001 && price !== 0) return price.toExponential(3);
  129. if (Math.abs(price) < 0.001) return price.toFixed(8);
  130. if (Math.abs(price) < 1) return price.toFixed(6);
  131. return price.toFixed(4);
  132. }
  133. function formatPercentage(percStr) {
  134. if (percStr === null || percStr === undefined || percStr === "N/A" || percStr.toLowerCase() === "n/a") return "N/A";
  135. if (String(percStr).includes('%')) return percStr; // Already formatted
  136. const perc = parseFloat(percStr);
  137. if (isNaN(perc)) return "N/A";
  138. return `${perc > 0 ? '+' : ''}${perc.toFixed(4)}%`;
  139. }
  140. function syncXAxes(sourceChart, targetChart) {
  141. if (isSyncingZoomPan || !sourceChart || !targetChart) return;
  142. isSyncingZoomPan = true;
  143. const sourceXScale = sourceChart.scales.x;
  144. const targetXScale = targetChart.scales.x;
  145. if (sourceXScale && targetXScale && sourceXScale.min !== undefined && sourceXScale.max !== undefined) { // Ensure min/max are defined
  146. targetXScale.options.min = sourceXScale.min;
  147. targetXScale.options.max = sourceXScale.max;
  148. targetChart.update('none');
  149. }
  150. requestAnimationFrame(() => { isSyncingZoomPan = false; });
  151. }
  152. function initializeChart(ctx, chartIdentifier, datasetsConfig, initialLabels, yAxisTitle, chartTitleText, isPriceChart = true) {
  153. return new Chart(ctx, {
  154. type: 'line',
  155. data: { labels: initialLabels, datasets: datasetsConfig },
  156. options: {
  157. responsive: true, maintainAspectRatio: false, animation: { duration: 0 }, // Disable animation for faster updates
  158. scales: {
  159. x: { title: { display: true, text: '时间' } },
  160. y: {
  161. title: { display: true, text: yAxisTitle },
  162. beginAtZero: !isPriceChart ? false : undefined,
  163. ticks: {
  164. callback: function(value) {
  165. return isPriceChart ? formatPrice(String(value)) : parseFloat(value).toFixed(2) + '%';
  166. }
  167. }
  168. }
  169. },
  170. plugins: {
  171. legend: { position: 'top' }, title: { display: true, text: chartTitleText },
  172. tooltip: {
  173. mode: 'index', intersect: false,
  174. callbacks: {
  175. label: function(context) {
  176. let label = context.dataset.label || '';
  177. if (label) label += ': ';
  178. if (context.parsed.y !== null) {
  179. label += isPriceChart ? formatPrice(String(context.parsed.y)) : parseFloat(context.parsed.y).toFixed(4) + '%';
  180. }
  181. return label;
  182. }
  183. }
  184. },
  185. zoom: {
  186. pan: { enabled: true, mode: 'x', threshold: 5,
  187. onPanComplete({chart}) {
  188. if (chartIdentifier === 'price' && diffChartInstance) syncXAxes(chart, diffChartInstance);
  189. else if (chartIdentifier === 'diff' && priceChartInstance) syncXAxes(chart, priceChartInstance);
  190. }
  191. },
  192. zoom: { wheel: { enabled: true, speed: 0.1 }, pinch: { enabled: true },
  193. drag: { enabled: true, backgroundColor: 'rgba(0,123,255,0.25)'}, mode: 'x',
  194. onZoomComplete({chart}) {
  195. if (chartIdentifier === 'price' && diffChartInstance) syncXAxes(chart, diffChartInstance);
  196. else if (chartIdentifier === 'diff' && priceChartInstance) syncXAxes(chart, priceChartInstance);
  197. }
  198. }
  199. }
  200. },
  201. elements: { point:{ radius: 1.5, hoverRadius: 3 } }
  202. }
  203. });
  204. }
  205. function updateChartData(chartInstance, newLabels, newDatasetsDataArray) {
  206. if (!chartInstance) return;
  207. const currentXMin = chartInstance.scales.x.min;
  208. const currentXMax = chartInstance.scales.x.max;
  209. let isZoomed = (currentXMin !== undefined && currentXMax !== undefined);
  210. if (isZoomed && chartInstance.data.labels.length > 0) {
  211. isZoomed = (currentXMin !== chartInstance.data.labels[0] || currentXMax !== chartInstance.data.labels[chartInstance.data.labels.length - 1]);
  212. }
  213. chartInstance.data.labels = newLabels;
  214. newDatasetsDataArray.forEach((datasetData, index) => {
  215. if(chartInstance.data.datasets[index]) {
  216. chartInstance.data.datasets[index].data = datasetData;
  217. }
  218. });
  219. if (!isPaused) {
  220. chartInstance.options.scales.x.min = undefined;
  221. chartInstance.options.scales.x.max = undefined;
  222. } else {
  223. if(isZoomed) {
  224. chartInstance.options.scales.x.min = currentXMin;
  225. chartInstance.options.scales.x.max = currentXMax;
  226. } else {
  227. chartInstance.options.scales.x.min = undefined;
  228. chartInstance.options.scales.x.max = undefined;
  229. }
  230. }
  231. chartInstance.update('none'); // Use 'none' for no animation for faster update
  232. }
  233. function updateDisplayAndCharts() {
  234. fetch('/data')
  235. .then(response => response.json())
  236. .then(data => {
  237. const current = data.current;
  238. currentGateSpotPair = current.config_gate_spot_pair || currentGateSpotPair;
  239. currentGateFuturesContract = current.config_gate_futures_contract || currentGateFuturesContract;
  240. currentAssetSymbol = current.config_target_asset_symbol || currentAssetSymbol;
  241. document.getElementById('main-title').textContent = `${currentAssetSymbol}/USDT 多平台价格监控`;
  242. document.getElementById('gate-spot-label').textContent = `Gate.io 现货 (${currentGateSpotPair})`;
  243. document.getElementById('gate-futures-label').textContent = `Gate.io 期货 (${currentGateFuturesContract})`;
  244. document.getElementById('diff-label-oo-spot').textContent = `OO vs Gate.io 现货 (${currentGateSpotPair})`;
  245. document.getElementById('diff-label-oo-futures').textContent = `OO vs Gate.io 期货 (${currentGateFuturesContract})`;
  246. document.getElementById('diff-label-spot-futures').textContent = `Gate.io 现货 (${currentGateSpotPair}) vs 期货 (${currentGateFuturesContract})`;
  247. const priceChartTitle = `${currentAssetSymbol}/USDT 价格历史`;
  248. const priceChartYLabel = `价格 (${currentAssetSymbol}/USDT)`;
  249. const diffChartTitleText = "价差百分比历史";
  250. if (priceChartInstance) {
  251. priceChartInstance.options.plugins.title.text = priceChartTitle;
  252. priceChartInstance.options.scales.y.title.text = priceChartYLabel;
  253. }
  254. if (diffChartInstance) {
  255. diffChartInstance.options.plugins.title.text = diffChartTitleText;
  256. }
  257. document.getElementById('oo-price').textContent = formatPrice(current.oo_price);
  258. document.getElementById('gate-spot-price').textContent = formatPrice(current.gate_spot_price);
  259. document.getElementById('gate-futures-price').textContent = formatPrice(current.gate_futures_price);
  260. document.getElementById('oo-status').textContent = current.oo_error ? current.oo_error : '正常';
  261. document.getElementById('gate-spot-status').textContent = current.gate_spot_error ? current.gate_spot_error : '正常';
  262. document.getElementById('gate-futures-status').textContent = current.gate_futures_error ? current.gate_futures_error : '正常';
  263. document.getElementById('oo-status').className = current.oo_error ? 'status-cell error-message' : 'status-cell';
  264. document.getElementById('gate-spot-status').className = current.gate_spot_error ? 'status-cell error-message' : 'status-cell';
  265. document.getElementById('gate-futures-status').className = current.gate_futures_error ? 'status-cell error-message' : 'status-cell';
  266. const diffOOSpotEl = document.getElementById('diff-oo-vs-spot');
  267. const diffOOFuturesEl = document.getElementById('diff-oo-vs-futures');
  268. const diffSpotFuturesEl = document.getElementById('diff-spot-vs-futures');
  269. diffOOSpotEl.textContent = formatPercentage(current.diff_oo_vs_spot_percentage);
  270. diffOOFuturesEl.textContent = formatPercentage(current.diff_oo_vs_futures_percentage);
  271. diffSpotFuturesEl.textContent = formatPercentage(current.diff_spot_vs_futures_percentage);
  272. [diffOOSpotEl, diffOOFuturesEl, diffSpotFuturesEl].forEach(el => {
  273. const valStr = el.textContent.replace('%','').replace('+','');
  274. const val = parseFloat(valStr);
  275. if (!isNaN(val)) { el.className = val > 0 ? 'price-up' : (val < 0 ? 'price-down' : ''); }
  276. else { el.className = ''; }
  277. });
  278. document.getElementById('last-updated').textContent = current.last_updated || "N/A";
  279. const history = data.history;
  280. const priceCtx = document.getElementById('priceHistoryChart').getContext('2d');
  281. const priceDatasets = [
  282. { label: 'OpenOcean', data: history.prices.oo, borderColor: 'rgb(75, 192, 192)', tension: 0.1, fill: false, borderWidth: 1.5 },
  283. { label: `Gate Spot (${currentGateSpotPair})`, data: history.prices.spot, borderColor: 'rgb(255, 99, 132)', tension: 0.1, fill: false, borderWidth: 1.5 },
  284. { label: `Gate Futures (${currentGateFuturesContract})`, data: history.prices.futures, borderColor: 'rgb(54, 162, 235)', tension: 0.1, fill: false, borderWidth: 1.5 }
  285. ];
  286. if (!priceChartInstance) {
  287. priceChartInstance = initializeChart(priceCtx, 'price', priceDatasets, history.prices.labels, priceChartYLabel, priceChartTitle, true);
  288. } else {
  289. priceChartInstance.data.datasets[1].label = `Gate Spot (${currentGateSpotPair})`;
  290. priceChartInstance.data.datasets[2].label = `Gate Futures (${currentGateFuturesContract})`;
  291. updateChartData(priceChartInstance, history.prices.labels, [history.prices.oo, history.prices.spot, history.prices.futures]);
  292. }
  293. const diffCtx = document.getElementById('diffPercentageChart').getContext('2d');
  294. const diffDatasets = [
  295. { label: `OO vs Spot (${currentGateSpotPair})`, data: history.diffs.oo_vs_spot, borderColor: 'rgb(255, 159, 64)', tension: 0.1, fill: false, borderWidth: 1.5 },
  296. { label: `OO vs Futures (${currentGateFuturesContract})`, data: history.diffs.oo_vs_futures, borderColor: 'rgb(153, 102, 255)', tension: 0.1, fill: false, borderWidth: 1.5 },
  297. { label: `Spot (${currentGateSpotPair}) vs Futures (${currentGateFuturesContract})`, data: history.diffs.spot_vs_futures, borderColor: 'rgb(75, 192, 75)', tension: 0.1, fill: false, borderWidth: 1.5 }
  298. ];
  299. if (!diffChartInstance) {
  300. diffChartInstance = initializeChart(diffCtx, 'diff', diffDatasets, history.diffs.labels, '价差 (%)', diffChartTitleText, false);
  301. } else {
  302. diffChartInstance.data.datasets[0].label = `OO vs Spot (${currentGateSpotPair})`;
  303. diffChartInstance.data.datasets[1].label = `OO vs Futures (${currentGateFuturesContract})`;
  304. diffChartInstance.data.datasets[2].label = `Spot (${currentGateSpotPair}) vs Futures (${currentGateFuturesContract})`;
  305. updateChartData(diffChartInstance, history.diffs.labels, [history.diffs.oo_vs_spot, history.diffs.oo_vs_futures, history.diffs.spot_vs_futures]);
  306. }
  307. })
  308. .catch(error => {
  309. console.error('Error fetching data for all platforms:', error);
  310. });
  311. }
  312. function togglePauseResume() {
  313. isPaused = !isPaused;
  314. if (isPaused) {
  315. clearInterval(dataUpdateIntervalID); dataUpdateIntervalID = null;
  316. pauseResumeButton.textContent = '继续刷新'; pauseResumeButton.classList.add('pause-button-active');
  317. } else {
  318. pauseResumeButton.textContent = '暂停刷新'; pauseResumeButton.classList.remove('pause-button-active');
  319. updateDisplayAndCharts(); // Refresh immediately on resume
  320. dataUpdateIntervalID = setInterval(updateDisplayAndCharts, REFRESH_INTERVAL_MS);
  321. }
  322. }
  323. function resetAllZooms() {
  324. if (priceChartInstance) {
  325. priceChartInstance.resetZoom(); priceChartInstance.options.scales.x.min = undefined; priceChartInstance.options.scales.x.max = undefined;
  326. }
  327. if (diffChartInstance) {
  328. diffChartInstance.resetZoom(); diffChartInstance.options.scales.x.min = undefined; diffChartInstance.options.scales.x.max = undefined;
  329. }
  330. if(isPaused){ updateDisplayAndCharts(); } // If paused, manually trigger redraw
  331. else { // If not paused, next interval will update, or force faster view update
  332. if (priceChartInstance) priceChartInstance.update('none'); // 'none' to avoid animation interference
  333. if (diffChartInstance) diffChartInstance.update('none');
  334. }
  335. }
  336. pauseResumeButton.addEventListener('click', togglePauseResume);
  337. document.getElementById('reset-price-zoom-button').addEventListener('click', resetAllZooms);
  338. document.getElementById('reset-diff-zoom-button').addEventListener('click', resetAllZooms);
  339. console.log("Frontend Initializing...");
  340. console.log("Initial Config from Flask:", initialConfig);
  341. updateDisplayAndCharts(); // Initial load
  342. if (!isPaused) {
  343. dataUpdateIntervalID = setInterval(updateDisplayAndCharts, REFRESH_INTERVAL_MS);
  344. }
  345. </script>
  346. </body>
  347. </html>