diff --git a/src/api/history.js b/src/api/history.js index ce1e5cb..348de8d 100644 --- a/src/api/history.js +++ b/src/api/history.js @@ -1,10 +1,10 @@ import { get } from 'svelte/store' import { DEFAULT_HISTORY_COUNT } from '@lib/config' -import { address, history, lastHistoryItemsCount, historySortKey, historyOrderStatusToShow } from '@lib/stores' -import { getLabelForAsset, getChainData } from '@lib/utils' +import { address, history, lastHistoryItemsCount, historyOrderStatusToShow } from '@lib/stores' +import { getChainData } from '@lib/utils' -export async function getUserHistory(params) { +function getHistoryRequestUrl(params) { const dataEndpoint = getChainData('dataEndpoint'); @@ -17,8 +17,7 @@ export async function getUserHistory(params) { let { first, - skip, - diff + skip } = params; if (!first) first = DEFAULT_HISTORY_COUNT; @@ -26,14 +25,33 @@ export async function getUserHistory(params) { const statusesToShow = get(historyOrderStatusToShow); - const sortKey = get(historySortKey); // [columnName, isDesc] - let sortBy = 'timestamp'; let sortDirection = 'desc'; + return `${dataEndpoint}/history/${_address}?chain=arbitrum&limit=${first}&skip=${skip}&sortBy=${sortBy}&sortDirection=${sortDirection}&status=${statusesToShow.join(',')}`; +} + +export async function fetchUserHistoryPage(params) { + const url = getHistoryRequestUrl(params); + if (!url) return []; + + const response = await fetch(url); + if (!response.ok) throw new Error(`History request failed with status ${response.status}`); + + const orders = await response.json() || []; + return Array.isArray(orders) ? orders : []; +} + +export async function getUserHistory(params) { + try { - const response = await fetch(`${dataEndpoint}/history/${_address}?chain=arbitrum&limit=${first}&skip=${skip}&sortBy=${sortBy}&sortDirection=${sortDirection}&status=${statusesToShow.join(',')}`); - const orders = await response.json() || []; + if (!params) params = {}; + let { first, skip, diff } = params; + + if (!first) first = DEFAULT_HISTORY_COUNT; + if (!skip) skip = 0; + + const orders = await fetchUserHistoryPage({first, skip}); lastHistoryItemsCount.set(orders.length); @@ -66,4 +84,33 @@ export async function getUserHistory(params) { } return true; -} \ No newline at end of file +} + +export async function getUserHistoryForExport(params) { + if (!params) params = {}; + + const first = params.first || 250; + const fromTimestamp = params.fromTimestamp; + const toTimestamp = params.toTimestamp; + let skip = 0; + let results = []; + + while (true) { + const orders = await fetchUserHistoryPage({first, skip}); + if (!orders.length) break; + + for (const order of orders) { + const timestamp = order.timestamp * 1; + if (toTimestamp && timestamp > toTimestamp) continue; + if (fromTimestamp && timestamp < fromTimestamp) continue; + results.push(order); + } + + if (orders.length < first) break; + if (fromTimestamp && orders.some((order) => order.timestamp * 1 < fromTimestamp)) break; + + skip += first; + } + + return results; +} diff --git a/src/components/trade/account/History.svelte b/src/components/trade/account/History.svelte index 68ee72e..b8cefdf 100644 --- a/src/components/trade/account/History.svelte +++ b/src/components/trade/account/History.svelte @@ -6,7 +6,7 @@ import Cell from '@components/layout/table/Cell.svelte' import { onMount, onDestroy } from 'svelte' - import { LOADING_ICON } from '@lib/icons' + import { DOWNLOAD_ICON, LOADING_ICON } from '@lib/icons' import { DEFAULT_HISTORY_COUNT, DEFAULT_HISTORY_SORT_KEY } from '@lib/config' import { @@ -20,10 +20,11 @@ formatPriceForDisplay } from '@lib/formatters' import { address, historySortKey, historySorted, historyColumnsToShow, lastHistoryItemsCount, orders } from '@lib/stores' - import { showModal } from '@lib/ui' + import tooltip from '@lib/tooltip' + import { showToast } from '@lib/ui' import { saveUserSetting } from '@lib/utils' - import { getUserHistory } from '@api/history' + import { getUserHistory, getUserHistoryForExport } from '@api/history' export let allColumns; @@ -117,6 +118,147 @@ return item.status; } + let exportFrom = ''; + let exportTo = ''; + let isExporting = false; + + const exportHeaders = [ + 'Order ID', + 'Time', + 'Side', + 'Market', + 'Price', + 'Size', + 'Margin', + 'Leverage', + 'Type', + 'Reduce-Only', + 'Status', + 'Reason', + 'P/L', + 'P/L %', + 'Fee', + 'Expiry', + 'OCO' + ]; + + function getDayStartTimestamp(value) { + if (!value) return; + return Math.floor(new Date(`${value}T00:00:00`).getTime() / 1000); + } + + function getDayEndTimestamp(value) { + if (!value) return; + return Math.floor(new Date(`${value}T23:59:59`).getTime() / 1000); + } + + function hasExportValue(value) { + return value !== undefined && value !== null && value !== ''; + } + + function getExportRow(rawItem) { + const item = formatHistoryItem(rawItem); + const pnlValue = item.pnl * 1; + const marginValue = item.margin * 1; + const pnlPercent = Number.isFinite(pnlValue) && Number.isFinite(marginValue) && marginValue !== 0 ? 100 * pnlValue / marginValue : ''; + + return [ + item.status == 'liquidated' ? 'liq' : item.orderId, + formatDate(item.timestamp) || '', + formatSide(item.isLong, item.isReduceOnly), + formatMarketName(item.market), + item.price * 1 > 0 ? formatPriceForDisplay(item.price) : '', + `${formatForDisplay(item.size)} ${item.asset}`, + `${formatForDisplay(item.margin)} ${item.asset}`, + item.leverage ? `${formatForDisplay(item.leverage)}x` : '', + formatOrderType(item.orderType), + item.isReduceOnly ? 'Yes' : 'No', + item.status, + item.reason || '', + hasExportValue(item.pnl) ? `${formatForDisplay(item.pnl)} ${item.asset}` : '', + hasExportValue(pnlPercent) ? `${formatForDisplay(pnlPercent, true)}%` : '', + hasExportValue(item.fee) ? `${formatForDisplay(item.fee)} ${item.asset}` : '', + formatDate(item.expiry) || '', + item.cancelOrderId * 1 > 0 ? item.cancelOrderId : '' + ]; + } + + function buildCsvInWorker(headers, rows) { + return new Promise((resolve, reject) => { + const workerSource = ` + self.onmessage = function(event) { + const escapeCell = function(value) { + if (value === undefined || value === null) return ''; + const text = String(value); + const safeText = /^[=+\\-@\\t\\r]/.test(text) ? "'" + text : text; + if (/[",\\n\\r]/.test(safeText)) return '"' + safeText.replace(/"/g, '""') + '"'; + return safeText; + }; + const csvRows = [event.data.headers].concat(event.data.rows); + self.postMessage(csvRows.map(function(row) { + return row.map(escapeCell).join(','); + }).join('\\n')); + }; + `; + const workerUrl = URL.createObjectURL(new Blob([workerSource], {type: 'text/javascript'})); + const worker = new Worker(workerUrl); + worker.onmessage = (event) => { + worker.terminate(); + URL.revokeObjectURL(workerUrl); + resolve(event.data); + }; + worker.onerror = (event) => { + worker.terminate(); + URL.revokeObjectURL(workerUrl); + reject(event.message); + }; + worker.postMessage({headers, rows}); + }); + } + + function downloadCsv(csv) { + const blob = new Blob([csv], {type: 'text/csv;charset=utf-8;'}); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + const range = exportFrom || exportTo ? `${exportFrom || 'start'}-${exportTo || 'today'}` : 'all'; + link.href = url; + link.download = `cap-trading-history-${range}.csv`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } + + async function exportHistory() { + if (isExporting) return; + + const fromTimestamp = getDayStartTimestamp(exportFrom); + const toTimestamp = getDayEndTimestamp(exportTo); + + if (fromTimestamp && toTimestamp && fromTimestamp > toTimestamp) { + showToast('Start date must be before end date.'); + return; + } + + isExporting = true; + try { + const rows = await getUserHistoryForExport({fromTimestamp, toTimestamp}); + if (!rows.length) { + showToast('No history found for this timeframe.', 2); + return; + } + + const csv = await buildCsvInWorker(exportHeaders, rows.map(getExportRow)); + downloadCsv(csv); + showToast('Trading history exported.', 1); + } catch(e) { + console.error('/history export error', e); + showToast('Could not export trading history.'); + } finally { + isExporting = false; + } + } + +