modified timeframe selectors to 1-18M and added additional trade stats.

This commit is contained in:
crowetic 2025-06-10 17:32:17 -07:00
parent 0652d85f56
commit d640e05804
2 changed files with 298 additions and 37 deletions

View File

@ -2,12 +2,14 @@ import { useEffect, useState, useMemo } from 'react';
import { Trade, fetchTrades, aggregateCandles } from './utils/qortTrades'; import { Trade, fetchTrades, aggregateCandles } from './utils/qortTrades';
import { Candle } from './utils/qortTrades'; import { Candle } from './utils/qortTrades';
import { QortMultiChart } from './components/QortMultiChart'; import { QortMultiChart } from './components/QortMultiChart';
import { QortalAccountName } from './utils/qortTrades';
import { import {
Button, Button,
Box, Box,
Container, Container,
// Paper, Paper,
Divider,
CircularProgress, CircularProgress,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
@ -24,14 +26,17 @@ const CHAINS = [
]; ];
const PERIODS = [ 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: '1M', months: 1 },
{ label: '2M', months: 2 },
{ label: '3M', months: 3 }, { label: '3M', months: 3 },
{ label: '4M', months: 4 },
{ label: '5M', months: 5 },
{ label: '6M', months: 6 }, { 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: '1Y', months: 12 },
{ label: '1.5Y', months: 18 }, { label: '1.5Y', months: 18 },
{ label: '2Y', months: 24 }, { label: '2Y', months: 24 },
@ -64,6 +69,9 @@ export default function App() {
{} {}
); );
// --- Top Buyer/Seller account names state ---
const [accountNames, setAccountNames] = useState<Record<string, string>>({});
// --- Helpers --- // --- Helpers ---
const getLatest = (trades: Trade[]) => const getLatest = (trades: Trade[]) =>
trades.length ? Math.max(...trades.map((t) => t.tradeTimestamp)) : 0; trades.length ? Math.max(...trades.map((t) => t.tradeTimestamp)) : 0;
@ -101,32 +109,32 @@ export default function App() {
} }
}, [cacheLoaded, selectedChain, allChainTrades]); }, [cacheLoaded, selectedChain, allChainTrades]);
// --- 4) Prepare candles --- // // --- 4) Prepare candles ---
const candles = useMemo<Candle[]>(() => { // const candles = useMemo<Candle[]>(() => {
if (!cacheLoaded) return []; // if (!cacheLoaded) return [];
const trades = allChainTrades[selectedChain] || []; // const trades = allChainTrades[selectedChain] || [];
if (!trades.length) return []; // if (!trades.length) return [];
// apply period filter // // apply period filter
const now = Date.now(); // const now = Date.now();
const p = PERIODS.find((p) => p.label === period); // const p = PERIODS.find((p) => p.label === period);
let cutoff = 0; // let cutoff = 0;
if (p) { // if (p) {
if (p.days != null) cutoff = now - p.days * ONE_DAY; // if (p.days != null) cutoff = now - p.days * ONE_DAY;
else if (p.months != null) { // else if (p.months != null) {
const d = new Date(now); // const d = new Date(now);
d.setMonth(d.getMonth() - p.months); // d.setMonth(d.getMonth() - p.months);
cutoff = d.getTime(); // cutoff = d.getTime();
} // }
} // }
const filtered = cutoff // const filtered = cutoff
? trades.filter((t) => t.tradeTimestamp >= cutoff) // ? trades.filter((t) => t.tradeTimestamp >= cutoff)
: trades; // : trades;
// percentile filter // // percentile filter
const cleaned = fastPercentileFilter(filtered, 0.00005, 0.995); // const cleaned = fastPercentileFilter(filtered, 0.00005, 0.995);
return aggregateCandles(cleaned, interval); // return aggregateCandles(cleaned, interval);
}, [allChainTrades, selectedChain, period, interval, cacheLoaded]); // }, [allChainTrades, selectedChain, period, interval, cacheLoaded]);
// --- Full fetch --- // --- Full fetch ---
async function doFullFetch(chain: string) { 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 --- // --- percentile filter ---
function fastPercentileFilter(trades: Trade[], lower = 0.002, upper = 0.998) { function fastPercentileFilter(trades: Trade[], lower = 0.002, upper = 0.998) {
if (trades.length < 200) return trades; 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<string, Agg> = {};
const s: Record<string, Agg> = {};
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<string, Agg>) {
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 thats 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<string, string> = {};
pairs.forEach(({ addr, name }) => {
map[addr] = name;
});
setAccountNames(map);
});
}, [buyerStats.addr, sellerStats.addr]);
if (!cacheLoaded) { if (!cacheLoaded) {
const prog = fetchProgress[selectedChain] || 0; const prog = fetchProgress[selectedChain] || 0;
return ( return (
@ -291,7 +474,15 @@ export default function App() {
> >
Clear Cache Clear Cache
</Button> </Button>
<Button
variant="contained"
color="secondary"
onClick={() => doHistoricalFetch(selectedChain)}
disabled={isFetching[selectedChain]}
sx={{ ml: 2 }}
>
Fetch Older Trades
</Button>
{/* Manual Update button */} {/* Manual Update button */}
<Button <Button
variant="outlined" variant="outlined"
@ -301,7 +492,7 @@ export default function App() {
disabled={isFetching[selectedChain]} disabled={isFetching[selectedChain]}
sx={{ ml: 2 }} sx={{ ml: 2 }}
> >
Obtain New Trades Fetch Newer Trades
</Button> </Button>
</Box> </Box>
@ -364,6 +555,62 @@ export default function App() {
{loading && <CircularProgress size={24} sx={{ ml: 2 }} />} {loading && <CircularProgress size={24} sx={{ ml: 2 }} />}
</Box> </Box>
</Box> </Box>
{/* --- Pretty Metrics Row --- */}
<Paper
elevation={1}
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr', // single column on mobile
sm: 'repeat(3, 1fr)', // three columns on tablet+
md: 'repeat(6, auto)', // six autosized columns on desktop
},
alignItems: 'center',
gap: 2,
px: 2,
py: 1,
mb: 2,
background: theme.palette.background.paper,
}}
>
<Typography variant="body2" noWrap>
<strong>{period} Trades:</strong> {tradeCount.toLocaleString()}
</Typography>
<Divider orientation="vertical" flexItem />
<Typography variant="body2" noWrap>
<strong>{period} Vol (QORT):</strong> {totalQ.toFixed(4)}
</Typography>
<Divider orientation="vertical" flexItem />
<Typography variant="body2" noWrap>
<strong>
{period} Vol ({selectedChain}):
</strong>{' '}
{totalF.toFixed(4)}
</Typography>
<Divider orientation="vertical" flexItem />
<Typography variant="body2" noWrap>
<strong> {period} High:</strong> {highPrice.toFixed(8)}
</Typography>
<Divider orientation="vertical" flexItem />
<Typography variant="body2" noWrap>
<strong> {period} Low:</strong> {lowPrice.toFixed(8)}
</Typography>
<Divider orientation="vertical" flexItem />
<Typography>
<strong>Top Buyer:</strong> {accountNames[buyerStats.addr]} |{' '}
{buyerStats.addr}
<br />
<em>Bought:</em> {buyerStats.totalQ.toFixed(4)} QORT @ (avg/Q){' '}
{buyerStats.avgPrice.toFixed(8)} {selectedChain}
</Typography>
<Typography>
<strong>Top Seller:</strong> {accountNames[sellerStats.addr]} |{' '}
{sellerStats.addr}
<br />
<em>Sold:</em> {sellerStats.totalQ.toFixed(4)} QORT @ (avg/Q){' '}
{sellerStats.avgPrice.toFixed(8)} {selectedChain}
</Typography>
</Paper>
{/* Chart */} {/* Chart */}
<Box <Box
sx={{ sx={{

View File

@ -4,11 +4,19 @@ export interface Trade {
qortAmount: string; qortAmount: string;
btcAmount: string; btcAmount: string;
foreignAmount: string; foreignAmount: string;
sellerAddress?: string;
buyerReceivingAddress?: string;
}
export interface QortalAccountName {
name: string;
owner: string;
} }
export interface FetchTradesOptions { export interface FetchTradesOptions {
foreignBlockchain: string; foreignBlockchain: string;
minimumTimestamp: number; minimumTimestamp?: number;
maximumTimestamp?: number;
buyerPublicKey?: string; buyerPublicKey?: string;
sellerPublicKey?: string; sellerPublicKey?: string;
limit?: number; // default 1000 limit?: number; // default 1000
@ -19,6 +27,7 @@ export interface FetchTradesOptions {
export async function fetchTrades({ export async function fetchTrades({
foreignBlockchain, foreignBlockchain,
minimumTimestamp, minimumTimestamp,
maximumTimestamp,
buyerPublicKey, buyerPublicKey,
sellerPublicKey, sellerPublicKey,
limit = 100, limit = 100,
@ -27,19 +36,24 @@ export async function fetchTrades({
}: FetchTradesOptions): Promise<Trade[]> { }: FetchTradesOptions): Promise<Trade[]> {
const params = new URLSearchParams({ const params = new URLSearchParams({
foreignBlockchain, foreignBlockchain,
minimumTimestamp: String(minimumTimestamp),
limit: String(limit), limit: String(limit),
offset: String(offset), offset: String(offset),
reverse: String(reverse), reverse: String(reverse),
}); });
if (buyerPublicKey) params.append('buyerPublicKey', buyerPublicKey); if (buyerPublicKey) params.append('buyerPublicKey', buyerPublicKey);
if (sellerPublicKey) params.append('sellerPublicKey', sellerPublicKey); if (sellerPublicKey) params.append('sellerPublicKey', sellerPublicKey);
if (minimumTimestamp === 0) { // Set minimum timestamp if you like, but do not set it if it is 0 or null
params.delete('minimumTimestamp'); 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() { function getApiRoot() {
const { origin, pathname } = window.location; 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/'); const i = pathname.indexOf('/render/');
return i === -1 ? origin : origin + pathname.slice(0, i); return i === -1 ? origin : origin + pathname.slice(0, i);
} }