diff --git a/src/App.tsx b/src/App.tsx index ca16c47..305c256 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,12 +2,14 @@ import { useEffect, useState, useMemo } from 'react'; import { Trade, fetchTrades, aggregateCandles } from './utils/qortTrades'; import { Candle } from './utils/qortTrades'; import { QortMultiChart } from './components/QortMultiChart'; +import { QortalAccountName } from './utils/qortTrades'; import { Button, Box, Container, - // Paper, + Paper, + Divider, CircularProgress, Typography, } from '@mui/material'; @@ -24,14 +26,17 @@ const CHAINS = [ ]; const PERIODS = [ - { label: '2W', days: 14 }, - { label: '3W', days: 21 }, - { label: '4W', days: 30 }, - { label: '5W', days: 38 }, - { label: '6W', days: 45 }, { label: '1M', months: 1 }, + { label: '2M', months: 2 }, { label: '3M', months: 3 }, + { label: '4M', months: 4 }, + { label: '5M', months: 5 }, { label: '6M', months: 6 }, + { label: '7M', months: 7 }, + { label: '8M', months: 8 }, + { label: '9M', months: 9 }, + { label: '10M', months: 12 }, + { label: '11M', months: 12 }, { label: '1Y', months: 12 }, { label: '1.5Y', months: 18 }, { label: '2Y', months: 24 }, @@ -64,6 +69,9 @@ export default function App() { {} ); + // --- Top Buyer/Seller account names state --- + const [accountNames, setAccountNames] = useState>({}); + // --- Helpers --- const getLatest = (trades: Trade[]) => trades.length ? Math.max(...trades.map((t) => t.tradeTimestamp)) : 0; @@ -101,32 +109,32 @@ export default function App() { } }, [cacheLoaded, selectedChain, allChainTrades]); - // --- 4) Prepare candles --- - const candles = useMemo(() => { - if (!cacheLoaded) return []; - const trades = allChainTrades[selectedChain] || []; - if (!trades.length) return []; + // // --- 4) Prepare candles --- + // const candles = useMemo(() => { + // if (!cacheLoaded) return []; + // const trades = allChainTrades[selectedChain] || []; + // if (!trades.length) return []; - // apply period filter - const now = Date.now(); - const p = PERIODS.find((p) => p.label === period); - let cutoff = 0; - if (p) { - if (p.days != null) cutoff = now - p.days * ONE_DAY; - else if (p.months != null) { - const d = new Date(now); - d.setMonth(d.getMonth() - p.months); - cutoff = d.getTime(); - } - } - const filtered = cutoff - ? trades.filter((t) => t.tradeTimestamp >= cutoff) - : trades; + // // apply period filter + // const now = Date.now(); + // const p = PERIODS.find((p) => p.label === period); + // let cutoff = 0; + // if (p) { + // if (p.days != null) cutoff = now - p.days * ONE_DAY; + // else if (p.months != null) { + // const d = new Date(now); + // d.setMonth(d.getMonth() - p.months); + // cutoff = d.getTime(); + // } + // } + // const filtered = cutoff + // ? trades.filter((t) => t.tradeTimestamp >= cutoff) + // : trades; - // percentile filter - const cleaned = fastPercentileFilter(filtered, 0.00005, 0.995); - return aggregateCandles(cleaned, interval); - }, [allChainTrades, selectedChain, period, interval, cacheLoaded]); + // // percentile filter + // const cleaned = fastPercentileFilter(filtered, 0.00005, 0.995); + // return aggregateCandles(cleaned, interval); + // }, [allChainTrades, selectedChain, period, interval, cacheLoaded]); // --- Full fetch --- async function doFullFetch(chain: string) { @@ -191,6 +199,35 @@ export default function App() { } } + async function doHistoricalFetch(chain: string) { + const existing = allChainTrades[chain] || []; + if (!existing.length) return doFullFetch(chain); + + const earliest = Math.min(...existing.map((t) => t.tradeTimestamp)); + let allOld: Trade[] = []; + let offset = 0; + const BATCH = 100; + while (true) { + const batch = await fetchTrades({ + foreignBlockchain: chain, + minimumTimestamp: 0, + maximumTimestamp: earliest - 1, + limit: BATCH, + offset, + reverse: false, // ascending older trades + }); + if (!batch.length) break; + allOld = allOld.concat(batch); + offset += BATCH; + } + if (allOld.length) { + setAllChainTrades((prev) => ({ + ...prev, + [chain]: [...prev[chain], ...allOld], + })); + } + } + // --- percentile filter --- function fastPercentileFilter(trades: Trade[], lower = 0.002, upper = 0.998) { if (trades.length < 200) return trades; @@ -206,6 +243,152 @@ export default function App() { }); } + const { + candles, + filteredTrades, + }: { candles: Candle[]; filteredTrades: Trade[] } = useMemo(() => { + const trades = allChainTrades[selectedChain] || []; + if (!cacheLoaded || !trades.length) + return { candles: [], filteredTrades: [] }; + + // cutoff by period + const now = Date.now(); + const p = PERIODS.find((p) => p.label === period); + let cutoff = 0; + if (p) { + if (p.months != null) { + const d = new Date(now); + d.setMonth(d.getMonth() - p.months); + cutoff = d.getTime(); + } + } + const filtered = cutoff + ? trades.filter((t) => t.tradeTimestamp >= cutoff) + : trades; + + // clean and aggregate for chart + const cleaned = fastPercentileFilter(filtered, 0.00005, 0.995); + const agg = aggregateCandles(cleaned, interval); + return { candles: agg, filteredTrades: cleaned }; + }, [allChainTrades, selectedChain, period, interval, cacheLoaded]); + + // compute metrics + const tradeCount = filteredTrades.length; + const totalQ = useMemo( + () => filteredTrades.reduce((s, t) => s + parseFloat(t.qortAmount), 0), + [filteredTrades] + ); + const totalF = useMemo( + () => filteredTrades.reduce((s, t) => s + parseFloat(t.foreignAmount), 0), + [filteredTrades] + ); + const prices = useMemo( + () => + filteredTrades + .map((t) => parseFloat(t.foreignAmount) / parseFloat(t.qortAmount)) + .filter((v) => isFinite(v)), + [filteredTrades] + ); + const highPrice = prices.length ? Math.max(...prices) : 0; + const lowPrice = prices.length ? Math.min(...prices) : 0; + // biggest buyer/seller + // compute buyer/seller aggregates + const { buyerStats, sellerStats } = useMemo(() => { + type Agg = { q: number; f: number }; + const b: Record = {}; + const s: Record = {}; + + for (const t of filteredTrades) { + const q = parseFloat(t.qortAmount); + const f = parseFloat(t.foreignAmount); + + const buyer = t.buyerReceivingAddress || 'unknown'; + const seller = t.sellerAddress || 'unknown'; + + if (!b[buyer]) b[buyer] = { q: 0, f: 0 }; + b[buyer].q += q; + b[buyer].f += f; + + if (!s[seller]) s[seller] = { q: 0, f: 0 }; + s[seller].q += q; + s[seller].f += f; + } + // helper to pick top + function top(agg: Record) { + let bestAddr = 'N/A'; + let bestQ = 0; + for (const [addr, { q }] of Object.entries(agg)) { + if (q > bestQ) { + bestQ = q; + bestAddr = addr; + } + } + const { q, f } = agg[bestAddr] || { q: 0, f: 0 }; + return { + addr: bestAddr, + totalQ: q, + avgPrice: q ? f / q : 0, + }; + } + return { + buyerStats: top(b), + sellerStats: top(s), + }; + }, [filteredTrades]); + + function isQortalAccountNameArray(arr: unknown): arr is QortalAccountName[] { + return ( + Array.isArray(arr) && + // every element is an object with string `name` and `owner` + arr.every( + (el) => + typeof el === 'object' && + el !== null && + typeof el.name === 'string' && + typeof el.owner === 'string' + ) + ); + } + + useEffect(() => { + const addrs = [buyerStats.addr, sellerStats.addr].filter( + (a) => a && a !== 'N/A' + ); + if (!addrs.length) return; + + Promise.all( + addrs.map(async (addr) => { + try { + const resp = await qortalRequest({ + action: 'GET_ACCOUNT_NAMES', + address: addr, // or `account: addr` if that’s what your node expects + limit: 1, + offset: 0, + reverse: false, + }); + if (!isQortalAccountNameArray(resp)) { + console.warn('Unexpected GET_ACCOUNT_NAMES response:', resp); + return { addr, name: 'No Name' }; + } + const list = Array.isArray(resp) ? resp : []; + // find the entry matching our address, or fallback to the first + const entry = list.find((x) => x.owner === addr) || list[0] || {}; + const name = entry.name?.trim() || 'No Name'; + return { addr, name }; + } catch (err) { + console.error('Name lookup failed for', addr, err); + return { addr, name: 'No Name' }; + } + }) + ).then((pairs) => { + const map: Record = {}; + pairs.forEach(({ addr, name }) => { + map[addr] = name; + }); + setAccountNames(map); + }); + }, [buyerStats.addr, sellerStats.addr]); + if (!cacheLoaded) { const prog = fetchProgress[selectedChain] || 0; return ( @@ -291,7 +474,15 @@ export default function App() { > Clear Cache - + {/* Manual Update button */} @@ -364,6 +555,62 @@ export default function App() { {loading && } + {/* --- Pretty Metrics Row --- */} + + + {period} Trades: {tradeCount.toLocaleString()} + + + + {period} Vol (QORT): {totalQ.toFixed(4)} + + + + + {period} Vol ({selectedChain}): + {' '} + {totalF.toFixed(4)} + + + + {period} High: {highPrice.toFixed(8)} + + + + {period} Low: {lowPrice.toFixed(8)} + + + + Top Buyer: {accountNames[buyerStats.addr]} |{' '} + {buyerStats.addr} +
+ Bought: {buyerStats.totalQ.toFixed(4)} QORT @ (avg/Q){' '} + {buyerStats.avgPrice.toFixed(8)} {selectedChain} +
+ + Top Seller: {accountNames[sellerStats.addr]} |{' '} + {sellerStats.addr} +
+ Sold: {sellerStats.totalQ.toFixed(4)} QORT @ (avg/Q){' '} + {sellerStats.avgPrice.toFixed(8)} {selectedChain} +
+
{/* Chart */} { const params = new URLSearchParams({ foreignBlockchain, - minimumTimestamp: String(minimumTimestamp), limit: String(limit), offset: String(offset), reverse: String(reverse), }); if (buyerPublicKey) params.append('buyerPublicKey', buyerPublicKey); if (sellerPublicKey) params.append('sellerPublicKey', sellerPublicKey); - if (minimumTimestamp === 0) { - params.delete('minimumTimestamp'); + // Set minimum timestamp if you like, but do not set it if it is 0 or null + if (minimumTimestamp != null && minimumTimestamp > 0) { + params.set('minimumTimestamp', String(minimumTimestamp)); } + // Set maximum timestamp if passed + if (maximumTimestamp != null) { + params.set('maximumTimestamp', String(maximumTimestamp)); + } + function getApiRoot() { const { origin, pathname } = window.location; - // if path contains “/render”, cut from there + // if path contains “/render”, remove it. This was failing in hosted hub and anything outside dev mode prior to this addition. const i = pathname.indexOf('/render/'); return i === -1 ? origin : origin + pathname.slice(0, i); }