version 0.2

This commit is contained in:
2025-07-08 20:04:31 -07:00
parent 4a712cc004
commit 275a0870c6
10 changed files with 873 additions and 443 deletions

12
package-lock.json generated
View File

@@ -2750,6 +2750,18 @@
"integrity": "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==",
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",

View File

@@ -1,8 +1,9 @@
import { useEffect, useState, useMemo } from 'react';
import { Trade, fetchTrades, aggregateCandles } from './utils/qortTrades';
import { Trade, aggregateCandles } from './utils/qortTrades';
import { Candle } from './utils/qortTrades';
import { QortMultiChart } from './components/QortMultiChart';
import { QortalAccountName } from './utils/qortTrades';
// import { QortalAccountName } from './utils/qortTrades';
import { useTradeData, useTradeActions } from './context/TradeDataProvider';
import {
Button,
@@ -50,8 +51,6 @@ const ONE_DAY = 24 * ONE_HOUR;
const LS_KEY = 'QORT_CANDLE_TRADES';
const LS_VERSION = 1;
type ChainMap<T> = Record<string, T>;
export default function App() {
const theme = useTheme();
@@ -59,37 +58,30 @@ export default function App() {
const [selectedChain, setSelectedChain] = useState<string>(CHAINS[0].value);
const [interval, setInterval] = useState<number>(ONE_DAY);
const [period, setPeriod] = useState<string>('1Y');
// --- Data state ---
const [allChainTrades, setAllChainTrades] = useState<ChainMap<Trade[]>>({});
const [cacheLoaded, setCacheLoaded] = useState<boolean>(false);
const [needsUpdate, setNeedsUpdate] = useState<ChainMap<boolean>>({});
const [isFetching, setIsFetching] = useState<ChainMap<boolean>>({});
const [fetchProgress, setFetchProgress] = useState<Record<string, number>>(
const [fetchedChains, setFetchedChains] = useState<Record<string, boolean>>(
{}
);
const {
allChainTrades,
isFetching,
needsUpdate,
fetchProgress,
showCacheStaleWarning,
cacheLoaded,
} = useTradeData();
const { doFullFetch, doIncrementalFetch, doHistoricalFetch, clearCache } =
useTradeActions();
// --- Top Buyer/Seller account names state ---
const [accountNames, setAccountNames] = useState<Record<string, string>>({});
// const [accountNames, setAccountNames] = useState<Record<string, string>>({});
// --- Helpers ---
const getLatest = (trades: Trade[]) =>
trades.length ? Math.max(...trades.map((t) => t.tradeTimestamp)) : 0;
// --- 1) Load cache ---
useEffect(() => {
const raw = localStorage.getItem(LS_KEY);
if (raw) {
try {
const { version, allChainTrades: saved } = JSON.parse(raw);
if (version === LS_VERSION) setAllChainTrades(saved);
else localStorage.removeItem(LS_KEY);
} catch {
localStorage.removeItem(LS_KEY);
}
}
setCacheLoaded(true);
}, []);
// --- 2) Save cache ---
useEffect(() => {
@@ -98,149 +90,82 @@ export default function App() {
localStorage.setItem(LS_KEY, JSON.stringify(payload));
}, [allChainTrades, cacheLoaded]);
// --- 3) Decide fetch strategy ---
useEffect(() => {
if (!cacheLoaded) return;
const trades = allChainTrades[selectedChain] || [];
if (!trades.length) doFullFetch(selectedChain);
else {
const age = Date.now() - getLatest(trades);
setNeedsUpdate((m) => ({ ...m, [selectedChain]: age > ONE_DAY }));
if (
cacheLoaded &&
!isFetching[selectedChain] &&
trades.length === 0 &&
!fetchedChains[selectedChain]
) {
console.log(`Auto-fetching ${selectedChain} trades...`);
doFullFetch(selectedChain);
setFetchedChains((prev) => ({ ...prev, [selectedChain]: true }));
}
}, [cacheLoaded, selectedChain, allChainTrades]);
}, [
cacheLoaded,
selectedChain,
allChainTrades,
isFetching,
fetchedChains,
doFullFetch,
]);
// // --- 4) Prepare candles ---
// const candles = useMemo<Candle[]>(() => {
// 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;
// // 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) {
setIsFetching((m) => ({ ...m, [chain]: true }));
let all: Trade[] = [];
let offset = 0;
const BATCH = 100;
try {
while (true) {
const batch = await fetchTrades({
foreignBlockchain: chain,
minimumTimestamp: 0,
limit: BATCH,
offset,
reverse: true,
});
all = all.concat(batch);
setFetchProgress((m) => ({ ...m, [chain]: all.length }));
setAllChainTrades((m) => ({ ...m, [chain]: all }));
if (batch.length < BATCH) break;
offset += BATCH;
}
} catch (e) {
console.error('Full fetch error', e);
} finally {
setIsFetching((m) => ({ ...m, [chain]: false }));
}
}
// --- Incremental fetch ---
async function doIncrementalFetch(chain: string) {
setIsFetching((m) => ({ ...m, [chain]: true }));
try {
const existing = allChainTrades[chain] || [];
const latest = getLatest(existing);
let newTrades: Trade[] = [];
let offset = 0;
const BATCH = 100;
while (true) {
const batch = await fetchTrades({
foreignBlockchain: chain,
minimumTimestamp: latest + 1,
limit: BATCH,
offset,
reverse: true,
});
newTrades = newTrades.concat(batch);
setFetchProgress((m) => ({ ...m, [chain]: newTrades.length }));
if (batch.length < BATCH) break;
offset += BATCH;
}
if (newTrades.length)
setAllChainTrades((m) => ({
...m,
[chain]: [...newTrades, ...(m[chain] || [])],
}));
setNeedsUpdate((m) => ({ ...m, [chain]: false }));
} catch (e) {
console.error('Incremental fetch error', e);
} finally {
setIsFetching((m) => ({ ...m, [chain]: false }));
}
}
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;
const prices = trades
.map((t) => parseFloat(t.foreignAmount) / parseFloat(t.qortAmount))
.filter((x) => isFinite(x) && x > 0)
.sort((a, b) => a - b);
const lo = prices[Math.floor(prices.length * lower)];
const hi = prices[Math.ceil(prices.length * upper) - 1];
return trades.filter((t) => {
const p = parseFloat(t.foreignAmount) / parseFloat(t.qortAmount);
return p >= lo && p <= hi;
function filterByWeightedAverage(trades: Trade[], tolerance = 1.0): Trade[] {
const validTrades = trades.filter((t) => {
const fq = parseFloat(t.qortAmount);
const ff = parseFloat(t.foreignAmount);
return isFinite(fq) && isFinite(ff) && fq > 0 && ff > 0;
});
if (!validTrades.length) return [];
// Step 1: Calculate weighted average
let totalWeight = 0;
let weightedSum = 0;
for (const t of validTrades) {
const fq = parseFloat(t.qortAmount);
const ff = parseFloat(t.foreignAmount);
const price = ff / fq;
totalWeight += fq;
weightedSum += fq * price;
}
const weightedAvg = weightedSum / totalWeight;
// Step 2: Reject outliers
const minPrice = weightedAvg / (1 + tolerance);
const maxPrice = weightedAvg * (1 + tolerance);
return validTrades.filter((t) => {
const fq = parseFloat(t.qortAmount);
const ff = parseFloat(t.foreignAmount);
const price = ff / fq;
return price >= minPrice && price <= maxPrice;
});
}
function filterTradesByPeriod(trades: Trade[], periodLabel: string): Trade[] {
const now = Date.now();
const p = PERIODS.find((p) => p.label === periodLabel);
let cutoff = 0;
if (p) {
if (p.months != null) {
const d = new Date(now);
d.setMonth(d.getMonth() - p.months);
cutoff = d.getTime();
}
}
return cutoff
? trades.filter(
(t) =>
typeof t.tradeTimestamp === 'number' && t.tradeTimestamp >= cutoff
)
: trades;
}
const {
@@ -262,33 +187,40 @@ export default function App() {
cutoff = d.getTime();
}
}
const filtered = cutoff
const timeFiltered = cutoff
? trades.filter((t) => t.tradeTimestamp >= cutoff)
: trades;
// clean and aggregate for chart
const cleaned = fastPercentileFilter(filtered, 0.00005, 0.995);
// const cleaned = fastPercentileFilter(filtered, 0.01, 0.99);
const cleaned = filterByWeightedAverage(timeFiltered, 6.3);
const agg = aggregateCandles(cleaned, interval);
return { candles: agg, filteredTrades: cleaned };
}, [allChainTrades, selectedChain, period, interval, cacheLoaded]);
const rawTrades = useMemo(() => {
return filterTradesByPeriod(allChainTrades[selectedChain] || [], period);
}, [allChainTrades, selectedChain, period]);
// compute metrics
const tradeCount = filteredTrades.length;
const tradeCount = rawTrades.length;
const totalQ = useMemo(
() => filteredTrades.reduce((s, t) => s + parseFloat(t.qortAmount), 0),
[filteredTrades]
() => rawTrades.reduce((s, t) => s + parseFloat(t.qortAmount), 0),
[rawTrades]
);
const totalF = useMemo(
() => filteredTrades.reduce((s, t) => s + parseFloat(t.foreignAmount), 0),
[filteredTrades]
() => rawTrades.reduce((s, t) => s + parseFloat(t.foreignAmount), 0),
[rawTrades]
);
const prices = useMemo(
() =>
filteredTrades
rawTrades
.map((t) => parseFloat(t.foreignAmount) / parseFloat(t.qortAmount))
.filter((v) => isFinite(v)),
[filteredTrades]
[rawTrades]
);
const highPrice = prices.length ? Math.max(...prices) : 0;
const lowPrice = prices.length ? Math.min(...prices) : 0;
// biggest buyer/seller
@@ -336,19 +268,8 @@ export default function App() {
};
}, [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'
)
);
}
const { resolveAccountNames } = useTradeActions();
const { accountNames } = useTradeData();
useEffect(() => {
const addrs = [buyerStats.addr, sellerStats.addr].filter(
@@ -356,38 +277,8 @@ export default function App() {
);
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]);
resolveAccountNames(addrs);
}, [buyerStats.addr, sellerStats.addr, resolveAccountNames]);
if (!cacheLoaded) {
const prog = fetchProgress[selectedChain] || 0;
@@ -430,242 +321,263 @@ export default function App() {
const stale = needsUpdate[selectedChain];
const loading = isFetching[selectedChain];
// --- clear cache ---
const clearCache = () => {
localStorage.removeItem(LS_KEY);
setAllChainTrades({});
setNeedsUpdate({});
};
return (
<Container
maxWidth={false}
disableGutters
sx={{
display: 'flex',
flexDirection: 'column',
height: '100vh', // take full viewport height
// overflow: 'hidden',
// background: theme.palette.background.default,
}}
>
{/* Top bar: status, controls, fetch buttons */}
<Box
// <TradeContext.Provider value={{ allChainTrades, accountNames }}>
<Container maxWidth={false} disableGutters>
<Container
maxWidth={false}
disableGutters
sx={{
flex: '0 0 auto',
p: 2,
position: 'relative',
background: theme.palette.background.default,
display: 'flex',
flexDirection: 'column',
height: '100vh', // take full viewport height
// overflow: 'hidden',
// background: theme.palette.background.default,
}}
>
{/* Status & Clear */}
{/* <Box position="absolute" top={16} right={16} textAlign="right"> */}
{/* Top bar: status, controls, fetch buttons */}
<Box
mx={1}
display="flex"
alignItems="center"
alignContent="flex-end"
flexDirection="row"
sx={{
flex: '0 0 auto',
p: 2,
position: 'relative',
background: theme.palette.background.default,
}}
>
<Typography variant="caption">
Trades: {tradesCount.toLocaleString()}
<br />
Latest: {latestDate}
</Typography>
<Button
size="small"
variant="contained"
color="warning"
onClick={clearCache}
sx={{ mt: 1 }}
{/* Status & Clear */}
{/* <Box position="absolute" top={16} right={16} textAlign="right"> */}
<Box
mx={1}
display="flex"
alignItems="center"
alignContent="flex-end"
flexDirection="row"
>
Clear Cache
</Button>
<Button
variant="contained"
size="small"
color="secondary"
onClick={() => doHistoricalFetch(selectedChain)}
disabled={isFetching[selectedChain]}
sx={{ ml: 2 }}
>
Fetch Older Trades
</Button>
{/* Manual Update button */}
<Button
variant="outlined"
size="small"
color="info"
onClick={() => doIncrementalFetch(selectedChain)}
disabled={isFetching[selectedChain]}
sx={{ ml: 2 }}
>
Fetch Newer Trades
</Button>
</Box>
{/* Controls */}
<Box mb={2} display="flex" alignItems="center" flexWrap="wrap">
<label>
Pair:&nbsp;
<select
value={selectedChain}
onChange={(e) => setSelectedChain(e.target.value)}
>
{CHAINS.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</label>
&nbsp;&nbsp;Interval:
<Button size="small" onClick={() => setInterval(ONE_HOUR)}>
1H
</Button>
<Button size="small" onClick={() => setInterval(24 * ONE_HOUR)}>
1D
</Button>
&nbsp;&nbsp;Show:
{PERIODS.map((p) => (
<Typography variant="caption">
Trades: {tradesCount.toLocaleString()}
<br />
Latest: {latestDate}
</Typography>
<Button
key={p.label}
size="small"
variant={period === p.label ? 'contained' : 'outlined'}
onClick={() => setPeriod(p.label)}
sx={{ mx: 0.1 }}
>
{p.label}
</Button>
))}
</Box>
{/* Fetch Buttons */}
<Box mx={2}>
{!tradesCount && !loading && (
<Button
variant="contained"
onClick={() => doFullFetch(selectedChain)}
>
Fetch ALL {selectedChain} Trades
</Button>
)}
{stale && !loading && (
<Button
variant="contained"
color="warning"
onClick={() => doIncrementalFetch(selectedChain)}
onClick={clearCache}
sx={{ mt: 1 }}
>
Clear Cache
</Button>
<Button
variant="contained"
size="small"
color="secondary"
onClick={() => doHistoricalFetch(selectedChain)}
disabled={isFetching[selectedChain]}
sx={{ ml: 2 }}
>
Fetch new trades (notice!)
Fetch Older Trades
</Button>
)}
{loading && <CircularProgress size={24} sx={{ ml: 2 }} />}
{/* Manual Update button */}
<Button
variant="outlined"
size="small"
color="info"
onClick={() => doIncrementalFetch(selectedChain)}
disabled={isFetching[selectedChain]}
sx={{ ml: 2 }}
>
Fetch Newer Trades
</Button>
</Box>
{/* Controls */}
<Box mb={2} display="flex" alignItems="center" flexWrap="wrap">
<label>
Pair:&nbsp;
<select
value={selectedChain}
onChange={(e) => setSelectedChain(e.target.value)}
>
{CHAINS.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</label>
&nbsp;&nbsp;Interval:
<Button size="small" onClick={() => setInterval(ONE_HOUR)}>
1H
</Button>
<Button size="small" onClick={() => setInterval(24 * ONE_HOUR)}>
1D
</Button>
&nbsp;&nbsp;Show:
{PERIODS.map((p) => (
<Button
key={p.label}
size="small"
variant={period === p.label ? 'contained' : 'outlined'}
onClick={() => setPeriod(p.label)}
sx={{ mx: 0.1 }}
>
{p.label}
</Button>
))}
</Box>
{/* Fetch Buttons */}
<Box mx={2}>
{!tradesCount && !loading && (
<Button
variant="contained"
onClick={() => doFullFetch(selectedChain)}
>
Fetch ALL {selectedChain} Trades
</Button>
)}
{stale && !loading && (
<Button
variant="contained"
color="warning"
onClick={() => doIncrementalFetch(selectedChain)}
sx={{ ml: 2 }}
>
Fetch new trades (notice!)
</Button>
)}
{/*display cache state warning */}
{showCacheStaleWarning && (
<Box
sx={{
mt: 2,
p: 2,
border: '1px dashed orange',
borderRadius: 2,
}}
>
<Typography variant="body2" color="warning.main">
The cached trade data may be outdated.
</Typography>
<Button
variant="outlined"
size="small"
onClick={() => doIncrementalFetch(selectedChain)}
sx={{ mt: 1 }}
>
Fetch Newer Trades
</Button>
</Box>
)}
{loading && <CircularProgress size={24} sx={{ ml: 2 }} />}
</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 */}
<Box
sx={{
flex: 1, // fill all leftover space
display: 'flex',
overflow: 'hidden', // clip any chart overflow
p: 2,
background: theme.palette.background.paper,
}}
>
{candles.length ? (
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
background: 'transparent', // or theme.palette.background.default
p: 2,
}}
>
<QortMultiChart
candles={candles}
showSMA
themeMode={theme.palette.mode as 'light' | 'dark'}
background={theme.palette.background.paper}
textColor={theme.palette.text.primary}
pairLabel={
CHAINS.find((c) => c.value === selectedChain)?.label ||
selectedChain
}
interval={interval}
/>
</Box>
) : (
<Box
sx={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress />
</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 */}
<Box
sx={{
flex: 1, // fill all leftover space
display: 'flex',
overflow: 'hidden', // clip any chart overflow
p: 2,
background: theme.palette.background.paper,
}}
>
{candles.length ? (
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
background: 'transparent', // or theme.palette.background.default
p: 2,
}}
>
<QortMultiChart
candles={candles}
showSMA
themeMode={theme.palette.mode as 'light' | 'dark'}
background={theme.palette.background.paper}
textColor={theme.palette.text.primary}
pairLabel={
CHAINS.find((c) => c.value === selectedChain)?.label ||
selectedChain
}
interval={interval}
/>
</Box>
) : (
<Box
sx={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress />
</Box>
)}
</Box>
</Container>
</Container>
// </TradeContext.Provider>
);
}

View File

@@ -1,6 +1,7 @@
import { Routes } from './routes/Routes.tsx';
import { GlobalProvider } from 'qapp-core';
import { publicSalt } from './qapp-config.ts';
import { TradeDataProvider } from './context/TradeDataProvider';
export const AppWrapper = () => {
return (
@@ -17,7 +18,9 @@ export const AppWrapper = () => {
publicSalt: publicSalt,
}}
>
<Routes />
<TradeDataProvider>
<Routes />
</TradeDataProvider>
</GlobalProvider>
);
};

View File

@@ -0,0 +1,67 @@
import React from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
interface FetchAllTradesModalProps {
open: boolean;
onClose: () => void;
isFetching: boolean;
progress: number;
total?: number | null;
error?: string | null;
chain: string;
}
const FetchAllTradesModal: React.FC<FetchAllTradesModalProps> = ({
open,
onClose,
isFetching,
progress,
total,
error,
chain,
}) => {
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>Fetching All {chain} Trades</DialogTitle>
<DialogContent>
{isFetching ? (
<>
<Typography gutterBottom>
Obtaining all trades for <b>{chain}</b>.<br />
This could take a while, please be patient...
</Typography>
<Typography gutterBottom>
<b>{progress}</b> trades fetched{total ? ` / ${total}` : ''}.
</Typography>
<CircularProgress />
</>
) : error ? (
<Typography color="error" gutterBottom>
Error: {error}
</Typography>
) : (
<Typography color="success.main" gutterBottom>
Fetch complete.
</Typography>
)}
</DialogContent>
<DialogActions>
<Button
onClick={onClose}
color={isFetching ? 'inherit' : 'primary'}
variant="contained"
>
{isFetching ? 'Hide' : 'Close'}
</Button>
</DialogActions>
</Dialog>
);
};
export default FetchAllTradesModal;

20
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { Link as RouterLink } from 'react-router-dom';
import { AppBar, Toolbar, Button, Typography } from '@mui/material';
export default function Header() {
return (
<AppBar position="static">
<Toolbar>
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Q-Charts
</Typography>
<Button color="inherit" component={RouterLink} to="/">
Home
</Button>
<Button color="inherit" component={RouterLink} to="/stats">
History
</Button>
</Toolbar>
</AppBar>
);
}

View File

@@ -0,0 +1,330 @@
import React, {
createContext,
useContext,
useEffect,
useState,
useCallback,
} from 'react';
import {
fetchTrades,
// aggregateCandles,
Trade,
QortalAccountName,
} from '../utils/qortTrades';
const LS_KEY = 'QORT_CANDLE_TRADES';
const LS_VERSION = 1;
const ONE_DAY = 24 * 60 * 60 * 1000;
interface TradeData {
allChainTrades: Record<string, Trade[]>;
accountNames: Record<string, string>;
cacheLoaded: boolean;
isFetching: Record<string, boolean>;
needsUpdate: Record<string, boolean>;
fetchProgress: Record<string, number>;
showCacheStaleWarning: boolean;
namesLoading: boolean;
namesRemaining: number;
}
interface TradeActions {
doFullFetch: (chain: string) => Promise<void>;
doIncrementalFetch: (chain: string) => Promise<void>;
doHistoricalFetch: (chain: string) => Promise<void>;
clearCache: () => void;
resolveAccountNames: (addresses: string[]) => Promise<void>;
}
const TradeDataContext = createContext<TradeData | undefined>(undefined);
const TradeActionsContext = createContext<TradeActions | undefined>(undefined);
export const useTradeData = (): TradeData => {
const context = useContext(TradeDataContext);
if (!context)
throw new Error('useTradeData must be used within TradeDataProvider');
return context;
};
export const useTradeActions = (): TradeActions => {
const context = useContext(TradeActionsContext);
if (!context)
throw new Error('useTradeActions must be used within TradeDataProvider');
return context;
};
export const TradeDataProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [allChainTrades, setAllChainTrades] = useState<Record<string, Trade[]>>(
{}
);
const [accountNames, setAccountNames] = useState<Record<string, string>>({});
const [cacheLoaded, setCacheLoaded] = useState(false);
const [isFetching, setIsFetching] = useState<Record<string, boolean>>({});
const [needsUpdate, setNeedsUpdate] = useState<Record<string, boolean>>({});
const [fetchProgress, setFetchProgress] = useState<Record<string, number>>(
{}
);
const [showCacheStaleWarning, setShowCacheStaleWarning] = useState(false);
const BATCH_SIZE = 25;
const [namesLoading, setNamesLoading] = useState(false);
const [namesRemaining, setNamesRemaining] = useState(0);
const resolveAccountNames = useCallback(
async (addresses: string[]) => {
const uniqueAddresses = addresses.filter(
(addr) => addr && !accountNames[addr]
);
if (!uniqueAddresses.length) return;
await Promise.all(
uniqueAddresses.map(async (addr) => {
try {
const resp = await qortalRequest({
action: 'GET_ACCOUNT_NAMES',
address: addr,
limit: 1,
offset: 0,
reverse: false,
});
if (Array.isArray(resp) && resp.every(isQortalAccountName)) {
const entry = resp.find((x) => x.owner === addr) || resp[0] || {};
const name = entry.name?.trim() || 'No Name';
setAccountNames((prev) => ({ ...prev, [addr]: name }));
} else {
setAccountNames((prev) => ({ ...prev, [addr]: 'No Name' }));
}
} catch (err) {
console.error('Failed to fetch account name for', addr, err);
setAccountNames((prev) => ({ ...prev, [addr]: 'No Name' }));
}
})
);
},
[accountNames]
);
useEffect(() => {
const raw = localStorage.getItem(LS_KEY);
if (raw) {
try {
const parsed = JSON.parse(raw);
if (
parsed.version === LS_VERSION &&
typeof parsed.allChainTrades === 'object'
) {
setAllChainTrades(parsed.allChainTrades);
if (parsed.accountNames && typeof parsed.accountNames === 'object') {
setAccountNames(parsed.accountNames);
}
let hasRecent = false;
for (const chain in parsed.allChainTrades) {
const trades: Trade[] = parsed.allChainTrades[chain];
if (!Array.isArray(trades) || !trades.length) continue;
const timestamps = trades
.map((t) => t.tradeTimestamp)
.filter(Boolean);
const latest = Math.max(...timestamps);
const age = Date.now() - latest;
if (age < 7 * ONE_DAY) {
hasRecent = true;
break;
}
}
setShowCacheStaleWarning(!hasRecent);
}
} catch (err) {
console.warn('Failed to load trade cache:', err);
}
}
setCacheLoaded(true);
}, []);
useEffect(() => {
if (cacheLoaded) {
const payload = {
version: LS_VERSION,
allChainTrades,
accountNames,
};
localStorage.setItem(LS_KEY, JSON.stringify(payload));
}
}, [allChainTrades, accountNames, cacheLoaded]);
const doFullFetch = async (chain: string) => {
setIsFetching((prev) => ({ ...prev, [chain]: true }));
let all: Trade[] = [];
let offset = 0;
const BATCH = 100;
while (true) {
const batch = await fetchTrades({
foreignBlockchain: chain,
minimumTimestamp: 0,
limit: BATCH,
offset,
reverse: true,
});
all = all.concat(batch);
setFetchProgress((prev) => ({ ...prev, [chain]: all.length }));
if (batch.length < BATCH) break;
offset += BATCH;
}
setAllChainTrades((prev) => ({ ...prev, [chain]: all }));
setIsFetching((prev) => ({ ...prev, [chain]: false }));
};
useEffect(() => {
if (!cacheLoaded) return;
const allAddresses = new Set<string>();
Object.values(allChainTrades).forEach((trades) => {
trades.forEach((t) => {
if (t.buyerReceivingAddress) allAddresses.add(t.buyerReceivingAddress);
if (t.sellerAddress) allAddresses.add(t.sellerAddress);
});
});
const unresolved = Array.from(allAddresses).filter(
(addr) => addr && !accountNames[addr]
);
if (!unresolved.length) return;
setNamesRemaining(unresolved.length);
setNamesLoading(true);
let cancelled = false;
const resolveBatches = async () => {
for (let i = 0; i < unresolved.length; i += BATCH_SIZE) {
if (cancelled) return;
const batch = unresolved.slice(i, i + BATCH_SIZE);
await resolveAccountNames(batch);
setNamesRemaining((r) => Math.max(r - batch.length, 0));
}
if (!cancelled) setNamesLoading(false);
};
resolveBatches();
return () => {
cancelled = true;
};
}, [allChainTrades, cacheLoaded, accountNames, resolveAccountNames]);
const doIncrementalFetch = async (chain: string) => {
setIsFetching((prev) => ({ ...prev, [chain]: true }));
const existing = allChainTrades[chain] || [];
const latest = existing.length
? Math.max(...existing.map((t) => t.tradeTimestamp))
: 0;
let newTrades: Trade[] = [];
let offset = 0;
const BATCH = 100;
while (true) {
const batch = await fetchTrades({
foreignBlockchain: chain,
minimumTimestamp: latest + 1,
limit: BATCH,
offset,
reverse: true,
});
newTrades = newTrades.concat(batch);
setFetchProgress((prev) => ({ ...prev, [chain]: newTrades.length }));
if (batch.length < BATCH) break;
offset += BATCH;
}
if (newTrades.length) {
setAllChainTrades((prev) => ({
...prev,
[chain]: [...newTrades, ...(prev[chain] || [])],
}));
}
setNeedsUpdate((prev) => ({ ...prev, [chain]: false }));
setIsFetching((prev) => ({ ...prev, [chain]: false }));
setShowCacheStaleWarning(false);
};
const doHistoricalFetch = async (chain: string) => {
const existing = allChainTrades[chain] || [];
if (!existing.length) return doFullFetch(chain);
setIsFetching((prev) => ({ ...prev, [chain]: true }));
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,
maximumTimestamp: earliest - 1,
limit: BATCH,
offset,
reverse: false,
});
if (!batch.length) break;
allOld = allOld.concat(batch);
offset += BATCH;
}
if (allOld.length) {
setAllChainTrades((prev) => ({
...prev,
[chain]: [...prev[chain], ...allOld],
}));
}
setIsFetching((prev) => ({ ...prev, [chain]: false }));
};
const clearCache = () => {
localStorage.removeItem(LS_KEY);
setAllChainTrades({});
setAccountNames({});
setIsFetching({});
setNeedsUpdate({});
setFetchProgress({});
setShowCacheStaleWarning(false);
setNamesLoading(false);
setNamesRemaining(0);
};
function isQortalAccountName(obj: unknown): obj is QortalAccountName {
return (
typeof obj === 'object' &&
obj !== null &&
typeof (obj as QortalAccountName).name === 'string' &&
typeof (obj as QortalAccountName).owner === 'string'
);
}
return (
<TradeDataContext.Provider
value={{
allChainTrades,
accountNames,
cacheLoaded,
isFetching,
needsUpdate,
fetchProgress,
showCacheStaleWarning,
namesLoading,
namesRemaining,
}}
>
<TradeActionsContext.Provider
value={{
doFullFetch,
doIncrementalFetch,
doHistoricalFetch,
clearCache,
resolveAccountNames,
}}
>
{children}
</TradeActionsContext.Provider>
</TradeDataContext.Provider>
);
};

80
src/pages/Stats.tsx Normal file
View File

@@ -0,0 +1,80 @@
import { useTradeData } from '../context/TradeDataProvider';
import { Box, Typography, Divider, Pagination, useTheme } from '@mui/material';
import { format } from 'date-fns';
import { useMemo, useState } from 'react';
const PER_PAGE = 100;
export default function Stats() {
const theme = useTheme();
const { allChainTrades, accountNames, namesLoading, namesRemaining } =
useTradeData();
const [page, setPage] = useState(1);
const mergedRawTrades = useMemo(() => {
return Object.entries(allChainTrades)
.flatMap(([chain, trades]) => trades.map((t) => ({ ...t, chain })))
.sort((a, b) => b.tradeTimestamp - a.tradeTimestamp);
}, [allChainTrades]);
const totalPages = Math.ceil(mergedRawTrades.length / PER_PAGE);
const tradesOnPage = mergedRawTrades.slice(
(page - 1) * PER_PAGE,
page * PER_PAGE
);
if (mergedRawTrades.length === 0) {
return <div>Loading</div>;
}
return (
<Box p={2}>
<Typography variant="h4" gutterBottom>
Merged Trade History
</Typography>
<Divider sx={{ mb: 2 }} />
{namesLoading && (
<Typography variant="body2" color="warning.main" sx={{ mb: 2 }}>
Resolving account names {namesRemaining} remaining
</Typography>
)}
{tradesOnPage.map((trade, idx) => {
const date = format(trade.tradeTimestamp, 'yyyy-MM-dd HH:mm');
const buyerAddr = trade.buyerReceivingAddress ?? '';
const sellerAddr = trade.sellerAddress ?? '';
const buyerName = buyerAddr ? accountNames[buyerAddr] || '—' : '—';
const sellerName = sellerAddr ? accountNames[sellerAddr] || '—' : '—';
return (
<Typography key={idx} variant="body2" sx={{ mb: 0.5 }}>
<span style={{ color: theme.palette.error.main }}>
<strong>{sellerName}</strong> ({sellerAddr})
</span>{' '}
{' '}
<span style={{ color: theme.palette.success.main }}>
<strong>{buyerName}</strong> ({buyerAddr})
</span>{' '}
sold{' '}
<strong>
{parseFloat(trade.qortAmount).toFixed(4)} QORT {' '}
{parseFloat(trade.foreignAmount).toFixed(4)}
</strong>{' '}
<strong>{trade.chain}</strong> at {date}
</Typography>
);
})}
<Divider sx={{ my: 2 }} />
<Pagination
count={totalPages}
page={page}
onChange={(_, val) => setPage(val)}
variant="outlined"
shape="rounded"
/>
</Box>
);
}

View File

@@ -1,6 +1,7 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Layout from '../styles/Layout';
import App from '../App';
import Stats from '../pages/Stats';
// Use a custom type if you need it
interface CustomWindow extends Window {
@@ -20,6 +21,10 @@ export function Routes() {
index: true,
element: <App />,
},
{
path: 'stats',
element: <Stats />,
},
],
},
],

View File

@@ -1,15 +1,16 @@
import { Outlet } from 'react-router-dom';
import { useIframe } from '../hooks/useIframeListener';
import Header from '../components/Header';
const Layout = () => {
useIframe();
return (
<>
{/* Add Header here */}
<Header />
<main>
<Outlet /> {/* This is where page content will be rendered */}
<Outlet />
</main>
{/* Add Footer here */}
</>
);
};

View File

@@ -25,5 +25,5 @@
"noUncheckedSideEffectImports": true,
"types": ["qapp-core/global"]
},
"include": ["src"]
"include": ["src", "BACKUPS/tradeFetcher.ts"]
}