diff --git a/src/api/history.js b/src/api/history.js index ce1e5cb..ad064aa 100644 --- a/src/api/history.js +++ b/src/api/history.js @@ -1,24 +1,20 @@ 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' - -export async function getUserHistory(params) { +import { address, history, lastHistoryItemsCount, historyOrderStatusToShow } from '@lib/stores' +import { getChainData } from '@lib/utils' +async function fetchHistoryPage(params = {}) { const dataEndpoint = getChainData('dataEndpoint'); let _address = get(address); - if (!_address) return; + if (!_address) return []; _address = _address.toLowerCase(); - if (!params) params = {}; - let { first, - skip, - diff + skip } = params; if (!first) first = DEFAULT_HISTORY_COUNT; @@ -26,14 +22,37 @@ export async function getUserHistory(params) { const statusesToShow = get(historyOrderStatusToShow); - const sortKey = get(historySortKey); // [columnName, isDesc] - let sortBy = 'timestamp'; let sortDirection = 'desc'; + const query = new URLSearchParams({ + chain: 'arbitrum', + limit: first, + skip, + sortBy, + sortDirection, + status: statusesToShow.join(',') + }); + + const response = await fetch(`${dataEndpoint}/history/${_address}?${query}`); + const orders = await response.json() || []; + return Array.isArray(orders) ? orders : []; +} + +export async function getUserHistory(params) { + + if (!params) params = {}; + + let { + first, + skip, + diff + } = params; + + if (!first) first = DEFAULT_HISTORY_COUNT; + if (!skip) skip = 0; 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() || []; + const orders = await fetchHistoryPage({first, skip}); lastHistoryItemsCount.set(orders.length); @@ -66,4 +85,37 @@ export async function getUserHistory(params) { } return true; -} \ No newline at end of file +} + +export async function getUserHistoryExport(params = {}) { + const { + start, + end, + maxItems = 5000 + } = params; + + let allOrders = []; + let skip = 0; + const first = DEFAULT_HISTORY_COUNT; + + while (allOrders.length < maxItems) { + const orders = await fetchHistoryPage({first, skip}); + if (!orders.length) break; + + for (const order of orders) { + const timestamp = order.timestamp * 1; + if ((!start || timestamp >= start) && (!end || timestamp <= end)) { + allOrders.push(order); + } + } + + if (orders.length < first) break; + + const lastOrder = orders[orders.length - 1]; + if (start && lastOrder?.timestamp * 1 < start) break; + + skip += first; + } + + return allOrders.slice(0, maxItems); +} diff --git a/src/components/trade/account/History.svelte b/src/components/trade/account/History.svelte index 68ee72e..aaeab86 100644 --- a/src/components/trade/account/History.svelte +++ b/src/components/trade/account/History.svelte @@ -4,6 +4,7 @@ import Table from '@components/layout/table/Table.svelte' import Row from '@components/layout/table/Row.svelte' import Cell from '@components/layout/table/Cell.svelte' + import Button from '@components/layout/Button.svelte' import { onMount, onDestroy } from 'svelte' import { LOADING_ICON } from '@lib/icons' @@ -20,10 +21,10 @@ formatPriceForDisplay } from '@lib/formatters' import { address, historySortKey, historySorted, historyColumnsToShow, lastHistoryItemsCount, orders } from '@lib/stores' - import { showModal } from '@lib/ui' + import { showModal, showToast } from '@lib/ui' import { saveUserSetting } from '@lib/utils' - import { getUserHistory } from '@api/history' + import { getUserHistory, getUserHistoryExport } from '@api/history' export let allColumns; @@ -105,6 +106,95 @@ let formattedHistory = []; $: formattedHistory = $historySorted.map((item) => formatHistoryItem(item)); + let isExporting = false; + let exportTimeframe = 'all'; + const exportTimeframes = [ + {value: 'all', label: 'All'}, + {value: '7d', label: '7D', seconds: 7 * 24 * 60 * 60}, + {value: '30d', label: '30D', seconds: 30 * 24 * 60 * 60}, + {value: '90d', label: '90D', seconds: 90 * 24 * 60 * 60} + ]; + + function getExportStart() { + const timeframe = exportTimeframes.find((item) => item.value == exportTimeframe); + if (!timeframe?.seconds) return; + return Math.floor(Date.now() / 1000) - timeframe.seconds; + } + + function escapeCsv(value) { + if (value == undefined || value == null) value = ''; + value = `${value}`; + return `"${value.replace(/"/g, '""')}"`; + } + + function buildHistoryCsv(items) { + const columns = [ + ['Time', (item) => formatDate(item.timestamp)], + ['Order ID', (item) => item.status == 'liquidated' ? 'liq' : item.orderId], + ['Market', (item) => formatMarketName(item.market)], + ['Side', (item) => formatSide(item.isLong, item.isReduceOnly, item.pnl)], + ['Type', (item) => formatOrderType(item.orderType)], + ['Status', (item) => item.status], + ['Reason', (item) => item.reason], + ['Price', (item) => item.price * 1 > 0 ? formatPriceForDisplay(item.price) : ''], + ['Size', (item) => item.size], + ['Margin', (item) => item.margin], + ['Asset', (item) => item.asset], + ['Leverage', (item) => item.leverage], + ['P/L', (item) => item.pnl ? formatPnl(item.pnl) : ''], + ['P/L %', (item) => item.pnl && item.margin ? formatPnl(100 * item.pnl / item.margin, true) : ''], + ['Fee', (item) => item.fee], + ['Expiry', (item) => formatDate(item.expiry)], + ['OCO', (item) => item.cancelOrderId * 1 > 0 ? item.cancelOrderId : ''] + ]; + + const rows = [ + columns.map(([label]) => escapeCsv(label)).join(',') + ]; + + for (const item of items) { + rows.push(columns.map(([, getValue]) => escapeCsv(getValue(item))).join(',')); + } + + return rows.join('\n'); + } + + function downloadCsv(filename, contents) { + const blob = new Blob([contents], {type: 'text/csv;charset=utf-8;'}); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + + async function exportHistoryCsv() { + if (!$address) return showToast('Connect wallet before exporting history.'); + + isExporting = true; + try { + const rawHistory = await getUserHistoryExport({start: getExportStart()}); + const exportRows = rawHistory.map((item) => formatHistoryItem(item)); + + if (!exportRows.length) { + showToast('No history found for that timeframe.', 2); + return; + } + + const date = new Date().toISOString().slice(0, 10); + downloadCsv(`cap-history-${exportTimeframe}-${date}.csv`, buildHistoryCsv(exportRows)); + showToast(`Exported ${exportRows.length} history rows.`, 1); + } catch(e) { + console.error('/history export error', e); + showToast('History export failed.'); + } finally { + isExporting = false; + } + } + let showDetails = {}; function getItemStatus(item) { @@ -234,13 +324,52 @@ .wrapper { max-height: calc(var(--account-height) - 50px - 39px); } + .history-panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + } + .history-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--base-padding); + padding: 0 var(--base-padding) var(--semi-padding); + } + .history-toolbar select { + height: 38px; + color: var(--text0); + background-color: var(--layer25); + border: 1px solid var(--layer100); + border-radius: var(--base-radius); + padding: 0 12px; + } + .history-table { + flex: 1; + min-height: 0; + } @media all and (max-width: 600px) { .wrapper { max-height: 100%; } + .history-toolbar { + justify-content: flex-start; + overflow-x: auto; + } } +