version 0.2
This commit is contained in:
12
package-lock.json
generated
12
package-lock.json
generated
@@ -2750,6 +2750,18 @@
|
|||||||
"integrity": "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==",
|
"integrity": "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.13",
|
"version": "1.11.13",
|
||||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
|
||||||
|
|||||||
788
src/App.tsx
788
src/App.tsx
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
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 { Candle } from './utils/qortTrades';
|
||||||
import { QortMultiChart } from './components/QortMultiChart';
|
import { QortMultiChart } from './components/QortMultiChart';
|
||||||
import { QortalAccountName } from './utils/qortTrades';
|
// import { QortalAccountName } from './utils/qortTrades';
|
||||||
|
import { useTradeData, useTradeActions } from './context/TradeDataProvider';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -50,8 +51,6 @@ const ONE_DAY = 24 * ONE_HOUR;
|
|||||||
const LS_KEY = 'QORT_CANDLE_TRADES';
|
const LS_KEY = 'QORT_CANDLE_TRADES';
|
||||||
const LS_VERSION = 1;
|
const LS_VERSION = 1;
|
||||||
|
|
||||||
type ChainMap<T> = Record<string, T>;
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
@@ -59,37 +58,30 @@ export default function App() {
|
|||||||
const [selectedChain, setSelectedChain] = useState<string>(CHAINS[0].value);
|
const [selectedChain, setSelectedChain] = useState<string>(CHAINS[0].value);
|
||||||
const [interval, setInterval] = useState<number>(ONE_DAY);
|
const [interval, setInterval] = useState<number>(ONE_DAY);
|
||||||
const [period, setPeriod] = useState<string>('1Y');
|
const [period, setPeriod] = useState<string>('1Y');
|
||||||
|
const [fetchedChains, setFetchedChains] = useState<Record<string, boolean>>(
|
||||||
// --- 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 {
|
||||||
|
allChainTrades,
|
||||||
|
isFetching,
|
||||||
|
needsUpdate,
|
||||||
|
fetchProgress,
|
||||||
|
showCacheStaleWarning,
|
||||||
|
cacheLoaded,
|
||||||
|
} = useTradeData();
|
||||||
|
|
||||||
|
const { doFullFetch, doIncrementalFetch, doHistoricalFetch, clearCache } =
|
||||||
|
useTradeActions();
|
||||||
|
|
||||||
// --- Top Buyer/Seller account names state ---
|
// --- Top Buyer/Seller account names state ---
|
||||||
const [accountNames, setAccountNames] = useState<Record<string, string>>({});
|
// 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;
|
||||||
|
|
||||||
// --- 1) Load cache ---
|
// --- 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 ---
|
// --- 2) Save cache ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,149 +90,82 @@ export default function App() {
|
|||||||
localStorage.setItem(LS_KEY, JSON.stringify(payload));
|
localStorage.setItem(LS_KEY, JSON.stringify(payload));
|
||||||
}, [allChainTrades, cacheLoaded]);
|
}, [allChainTrades, cacheLoaded]);
|
||||||
|
|
||||||
// --- 3) Decide fetch strategy ---
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cacheLoaded) return;
|
|
||||||
const trades = allChainTrades[selectedChain] || [];
|
const trades = allChainTrades[selectedChain] || [];
|
||||||
if (!trades.length) doFullFetch(selectedChain);
|
if (
|
||||||
else {
|
cacheLoaded &&
|
||||||
const age = Date.now() - getLatest(trades);
|
!isFetching[selectedChain] &&
|
||||||
setNeedsUpdate((m) => ({ ...m, [selectedChain]: age > ONE_DAY }));
|
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 ---
|
function filterByWeightedAverage(trades: Trade[], tolerance = 1.0): Trade[] {
|
||||||
// const candles = useMemo<Candle[]>(() => {
|
const validTrades = trades.filter((t) => {
|
||||||
// if (!cacheLoaded) return [];
|
const fq = parseFloat(t.qortAmount);
|
||||||
// const trades = allChainTrades[selectedChain] || [];
|
const ff = parseFloat(t.foreignAmount);
|
||||||
// if (!trades.length) return [];
|
return isFinite(fq) && isFinite(ff) && fq > 0 && ff > 0;
|
||||||
|
|
||||||
// // 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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 {
|
const {
|
||||||
@@ -262,33 +187,40 @@ export default function App() {
|
|||||||
cutoff = d.getTime();
|
cutoff = d.getTime();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const filtered = cutoff
|
const timeFiltered = cutoff
|
||||||
? trades.filter((t) => t.tradeTimestamp >= cutoff)
|
? trades.filter((t) => t.tradeTimestamp >= cutoff)
|
||||||
: trades;
|
: trades;
|
||||||
|
|
||||||
// clean and aggregate for chart
|
// 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);
|
const agg = aggregateCandles(cleaned, interval);
|
||||||
|
|
||||||
return { candles: agg, filteredTrades: cleaned };
|
return { candles: agg, filteredTrades: cleaned };
|
||||||
}, [allChainTrades, selectedChain, period, interval, cacheLoaded]);
|
}, [allChainTrades, selectedChain, period, interval, cacheLoaded]);
|
||||||
|
|
||||||
|
const rawTrades = useMemo(() => {
|
||||||
|
return filterTradesByPeriod(allChainTrades[selectedChain] || [], period);
|
||||||
|
}, [allChainTrades, selectedChain, period]);
|
||||||
|
|
||||||
// compute metrics
|
// compute metrics
|
||||||
const tradeCount = filteredTrades.length;
|
const tradeCount = rawTrades.length;
|
||||||
const totalQ = useMemo(
|
const totalQ = useMemo(
|
||||||
() => filteredTrades.reduce((s, t) => s + parseFloat(t.qortAmount), 0),
|
() => rawTrades.reduce((s, t) => s + parseFloat(t.qortAmount), 0),
|
||||||
[filteredTrades]
|
[rawTrades]
|
||||||
);
|
);
|
||||||
const totalF = useMemo(
|
const totalF = useMemo(
|
||||||
() => filteredTrades.reduce((s, t) => s + parseFloat(t.foreignAmount), 0),
|
() => rawTrades.reduce((s, t) => s + parseFloat(t.foreignAmount), 0),
|
||||||
[filteredTrades]
|
[rawTrades]
|
||||||
);
|
);
|
||||||
const prices = useMemo(
|
const prices = useMemo(
|
||||||
() =>
|
() =>
|
||||||
filteredTrades
|
rawTrades
|
||||||
.map((t) => parseFloat(t.foreignAmount) / parseFloat(t.qortAmount))
|
.map((t) => parseFloat(t.foreignAmount) / parseFloat(t.qortAmount))
|
||||||
.filter((v) => isFinite(v)),
|
.filter((v) => isFinite(v)),
|
||||||
[filteredTrades]
|
[rawTrades]
|
||||||
);
|
);
|
||||||
|
|
||||||
const highPrice = prices.length ? Math.max(...prices) : 0;
|
const highPrice = prices.length ? Math.max(...prices) : 0;
|
||||||
const lowPrice = prices.length ? Math.min(...prices) : 0;
|
const lowPrice = prices.length ? Math.min(...prices) : 0;
|
||||||
// biggest buyer/seller
|
// biggest buyer/seller
|
||||||
@@ -336,19 +268,8 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [filteredTrades]);
|
}, [filteredTrades]);
|
||||||
|
|
||||||
function isQortalAccountNameArray(arr: unknown): arr is QortalAccountName[] {
|
const { resolveAccountNames } = useTradeActions();
|
||||||
return (
|
const { accountNames } = useTradeData();
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
const addrs = [buyerStats.addr, sellerStats.addr].filter(
|
const addrs = [buyerStats.addr, sellerStats.addr].filter(
|
||||||
@@ -356,38 +277,8 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
if (!addrs.length) return;
|
if (!addrs.length) return;
|
||||||
|
|
||||||
Promise.all(
|
resolveAccountNames(addrs);
|
||||||
addrs.map(async (addr) => {
|
}, [buyerStats.addr, sellerStats.addr, resolveAccountNames]);
|
||||||
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<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;
|
||||||
@@ -430,242 +321,263 @@ export default function App() {
|
|||||||
const stale = needsUpdate[selectedChain];
|
const stale = needsUpdate[selectedChain];
|
||||||
const loading = isFetching[selectedChain];
|
const loading = isFetching[selectedChain];
|
||||||
|
|
||||||
// --- clear cache ---
|
|
||||||
const clearCache = () => {
|
|
||||||
localStorage.removeItem(LS_KEY);
|
|
||||||
setAllChainTrades({});
|
|
||||||
setNeedsUpdate({});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
// <TradeContext.Provider value={{ allChainTrades, accountNames }}>
|
||||||
maxWidth={false}
|
<Container maxWidth={false} disableGutters>
|
||||||
disableGutters
|
<Container
|
||||||
sx={{
|
maxWidth={false}
|
||||||
display: 'flex',
|
disableGutters
|
||||||
flexDirection: 'column',
|
|
||||||
height: '100vh', // take full viewport height
|
|
||||||
// overflow: 'hidden',
|
|
||||||
// background: theme.palette.background.default,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Top bar: status, controls, fetch buttons */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
sx={{
|
||||||
flex: '0 0 auto',
|
display: 'flex',
|
||||||
p: 2,
|
flexDirection: 'column',
|
||||||
position: 'relative',
|
height: '100vh', // take full viewport height
|
||||||
background: theme.palette.background.default,
|
// overflow: 'hidden',
|
||||||
|
// background: theme.palette.background.default,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Status & Clear */}
|
{/* Top bar: status, controls, fetch buttons */}
|
||||||
{/* <Box position="absolute" top={16} right={16} textAlign="right"> */}
|
|
||||||
<Box
|
<Box
|
||||||
mx={1}
|
sx={{
|
||||||
display="flex"
|
flex: '0 0 auto',
|
||||||
alignItems="center"
|
p: 2,
|
||||||
alignContent="flex-end"
|
position: 'relative',
|
||||||
flexDirection="row"
|
background: theme.palette.background.default,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption">
|
{/* Status & Clear */}
|
||||||
Trades: {tradesCount.toLocaleString()}
|
{/* <Box position="absolute" top={16} right={16} textAlign="right"> */}
|
||||||
<br />
|
<Box
|
||||||
Latest: {latestDate}
|
mx={1}
|
||||||
</Typography>
|
display="flex"
|
||||||
<Button
|
alignItems="center"
|
||||||
size="small"
|
alignContent="flex-end"
|
||||||
variant="contained"
|
flexDirection="row"
|
||||||
color="warning"
|
|
||||||
onClick={clearCache}
|
|
||||||
sx={{ mt: 1 }}
|
|
||||||
>
|
>
|
||||||
Clear Cache
|
<Typography variant="caption">
|
||||||
</Button>
|
Trades: {tradesCount.toLocaleString()}
|
||||||
<Button
|
<br />
|
||||||
variant="contained"
|
Latest: {latestDate}
|
||||||
size="small"
|
</Typography>
|
||||||
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:
|
|
||||||
<select
|
|
||||||
value={selectedChain}
|
|
||||||
onChange={(e) => setSelectedChain(e.target.value)}
|
|
||||||
>
|
|
||||||
{CHAINS.map((c) => (
|
|
||||||
<option key={c.value} value={c.value}>
|
|
||||||
{c.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
Interval:
|
|
||||||
<Button size="small" onClick={() => setInterval(ONE_HOUR)}>
|
|
||||||
1H
|
|
||||||
</Button>
|
|
||||||
<Button size="small" onClick={() => setInterval(24 * ONE_HOUR)}>
|
|
||||||
1D
|
|
||||||
</Button>
|
|
||||||
Show:
|
|
||||||
{PERIODS.map((p) => (
|
|
||||||
<Button
|
<Button
|
||||||
key={p.label}
|
|
||||||
size="small"
|
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"
|
variant="contained"
|
||||||
color="warning"
|
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 }}
|
sx={{ ml: 2 }}
|
||||||
>
|
>
|
||||||
Fetch new trades (notice!)
|
Fetch Older Trades
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{/* Manual Update button */}
|
||||||
{loading && <CircularProgress size={24} sx={{ ml: 2 }} />}
|
<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:
|
||||||
|
<select
|
||||||
|
value={selectedChain}
|
||||||
|
onChange={(e) => setSelectedChain(e.target.value)}
|
||||||
|
>
|
||||||
|
{CHAINS.map((c) => (
|
||||||
|
<option key={c.value} value={c.value}>
|
||||||
|
{c.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
Interval:
|
||||||
|
<Button size="small" onClick={() => setInterval(ONE_HOUR)}>
|
||||||
|
1H
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => setInterval(24 * ONE_HOUR)}>
|
||||||
|
1D
|
||||||
|
</Button>
|
||||||
|
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>
|
||||||
</Box>
|
{/* --- Pretty Metrics Row --- */}
|
||||||
{/* --- Pretty Metrics Row --- */}
|
<Paper
|
||||||
<Paper
|
elevation={1}
|
||||||
elevation={1}
|
sx={{
|
||||||
sx={{
|
display: 'grid',
|
||||||
display: 'grid',
|
gridTemplateColumns: {
|
||||||
gridTemplateColumns: {
|
xs: '1fr', // single column on mobile
|
||||||
xs: '1fr', // single column on mobile
|
sm: 'repeat(3, 1fr)', // three columns on tablet+
|
||||||
sm: 'repeat(3, 1fr)', // three columns on tablet+
|
md: 'repeat(6, auto)', // six auto‐sized columns on desktop
|
||||||
md: 'repeat(6, auto)', // six auto‐sized columns on desktop
|
},
|
||||||
},
|
alignItems: 'center',
|
||||||
alignItems: 'center',
|
gap: 2,
|
||||||
gap: 2,
|
px: 2,
|
||||||
px: 2,
|
py: 1,
|
||||||
py: 1,
|
mb: 2,
|
||||||
mb: 2,
|
background: theme.palette.background.paper,
|
||||||
background: theme.palette.background.paper,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Typography variant="body2" noWrap>
|
||||||
<Typography variant="body2" noWrap>
|
<strong>{period} Trades:</strong> {tradeCount.toLocaleString()}
|
||||||
<strong>{period} Trades:</strong> {tradeCount.toLocaleString()}
|
</Typography>
|
||||||
</Typography>
|
<Divider orientation="vertical" flexItem />
|
||||||
<Divider orientation="vertical" flexItem />
|
<Typography variant="body2" noWrap>
|
||||||
<Typography variant="body2" noWrap>
|
<strong>{period} Vol (QORT):</strong> {totalQ.toFixed(4)}
|
||||||
<strong>{period} Vol (QORT):</strong> {totalQ.toFixed(4)}
|
</Typography>
|
||||||
</Typography>
|
<Divider orientation="vertical" flexItem />
|
||||||
<Divider orientation="vertical" flexItem />
|
<Typography variant="body2" noWrap>
|
||||||
<Typography variant="body2" noWrap>
|
<strong>
|
||||||
<strong>
|
{period} Vol ({selectedChain}):
|
||||||
{period} Vol ({selectedChain}):
|
</strong>{' '}
|
||||||
</strong>{' '}
|
{totalF.toFixed(4)}
|
||||||
{totalF.toFixed(4)}
|
</Typography>
|
||||||
</Typography>
|
<Divider orientation="vertical" flexItem />
|
||||||
<Divider orientation="vertical" flexItem />
|
<Typography variant="body2" noWrap>
|
||||||
<Typography variant="body2" noWrap>
|
<strong> {period} High:</strong> {highPrice.toFixed(8)}
|
||||||
<strong> {period} High:</strong> {highPrice.toFixed(8)}
|
</Typography>
|
||||||
</Typography>
|
<Divider orientation="vertical" flexItem />
|
||||||
<Divider orientation="vertical" flexItem />
|
<Typography variant="body2" noWrap>
|
||||||
<Typography variant="body2" noWrap>
|
<strong> {period} Low:</strong> {lowPrice.toFixed(8)}
|
||||||
<strong> {period} Low:</strong> {lowPrice.toFixed(8)}
|
</Typography>
|
||||||
</Typography>
|
<Divider orientation="vertical" flexItem />
|
||||||
<Divider orientation="vertical" flexItem />
|
<Typography>
|
||||||
<Typography>
|
<strong>Top Buyer:</strong> {accountNames[buyerStats.addr]} |{' '}
|
||||||
<strong>Top Buyer:</strong> {accountNames[buyerStats.addr]} |{' '}
|
{buyerStats.addr}
|
||||||
{buyerStats.addr}
|
<br />
|
||||||
<br />
|
<em>Bought:</em> {buyerStats.totalQ.toFixed(4)} QORT @ (avg/Q){' '}
|
||||||
<em>Bought:</em> {buyerStats.totalQ.toFixed(4)} QORT @ (avg/Q){' '}
|
{buyerStats.avgPrice.toFixed(8)} {selectedChain}
|
||||||
{buyerStats.avgPrice.toFixed(8)} {selectedChain}
|
</Typography>
|
||||||
</Typography>
|
<Typography>
|
||||||
<Typography>
|
<strong>Top Seller:</strong> {accountNames[sellerStats.addr]} |{' '}
|
||||||
<strong>Top Seller:</strong> {accountNames[sellerStats.addr]} |{' '}
|
{sellerStats.addr}
|
||||||
{sellerStats.addr}
|
<br />
|
||||||
<br />
|
<em>Sold:</em> {sellerStats.totalQ.toFixed(4)} QORT @ (avg/Q){' '}
|
||||||
<em>Sold:</em> {sellerStats.totalQ.toFixed(4)} QORT @ (avg/Q){' '}
|
{sellerStats.avgPrice.toFixed(8)} {selectedChain}
|
||||||
{sellerStats.avgPrice.toFixed(8)} {selectedChain}
|
</Typography>
|
||||||
</Typography>
|
</Paper>
|
||||||
</Paper>
|
|
||||||
{/* Chart */}
|
{/* Chart */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1, // fill all leftover space
|
flex: 1, // fill all leftover space
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
overflow: 'hidden', // clip any chart overflow
|
overflow: 'hidden', // clip any chart overflow
|
||||||
p: 2,
|
p: 2,
|
||||||
background: theme.palette.background.paper,
|
background: theme.palette.background.paper,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{candles.length ? (
|
{candles.length ? (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
background: 'transparent', // or theme.palette.background.default
|
background: 'transparent', // or theme.palette.background.default
|
||||||
p: 2,
|
p: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<QortMultiChart
|
<QortMultiChart
|
||||||
candles={candles}
|
candles={candles}
|
||||||
showSMA
|
showSMA
|
||||||
themeMode={theme.palette.mode as 'light' | 'dark'}
|
themeMode={theme.palette.mode as 'light' | 'dark'}
|
||||||
background={theme.palette.background.paper}
|
background={theme.palette.background.paper}
|
||||||
textColor={theme.palette.text.primary}
|
textColor={theme.palette.text.primary}
|
||||||
pairLabel={
|
pairLabel={
|
||||||
CHAINS.find((c) => c.value === selectedChain)?.label ||
|
CHAINS.find((c) => c.value === selectedChain)?.label ||
|
||||||
selectedChain
|
selectedChain
|
||||||
}
|
}
|
||||||
interval={interval}
|
interval={interval}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
</Container>
|
||||||
</Container>
|
</Container>
|
||||||
|
// </TradeContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Routes } from './routes/Routes.tsx';
|
import { Routes } from './routes/Routes.tsx';
|
||||||
import { GlobalProvider } from 'qapp-core';
|
import { GlobalProvider } from 'qapp-core';
|
||||||
import { publicSalt } from './qapp-config.ts';
|
import { publicSalt } from './qapp-config.ts';
|
||||||
|
import { TradeDataProvider } from './context/TradeDataProvider';
|
||||||
|
|
||||||
export const AppWrapper = () => {
|
export const AppWrapper = () => {
|
||||||
return (
|
return (
|
||||||
@@ -17,7 +18,9 @@ export const AppWrapper = () => {
|
|||||||
publicSalt: publicSalt,
|
publicSalt: publicSalt,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Routes />
|
<TradeDataProvider>
|
||||||
|
<Routes />
|
||||||
|
</TradeDataProvider>
|
||||||
</GlobalProvider>
|
</GlobalProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
67
src/components/FetchAllTradesModal.tsx
Normal file
67
src/components/FetchAllTradesModal.tsx
Normal 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
20
src/components/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
330
src/context/TradeDataProvider.tsx
Normal file
330
src/context/TradeDataProvider.tsx
Normal 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
80
src/pages/Stats.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||||
import Layout from '../styles/Layout';
|
import Layout from '../styles/Layout';
|
||||||
import App from '../App';
|
import App from '../App';
|
||||||
|
import Stats from '../pages/Stats';
|
||||||
|
|
||||||
// Use a custom type if you need it
|
// Use a custom type if you need it
|
||||||
interface CustomWindow extends Window {
|
interface CustomWindow extends Window {
|
||||||
@@ -20,6 +21,10 @@ export function Routes() {
|
|||||||
index: true,
|
index: true,
|
||||||
element: <App />,
|
element: <App />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'stats',
|
||||||
|
element: <Stats />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { useIframe } from '../hooks/useIframeListener';
|
import { useIframe } from '../hooks/useIframeListener';
|
||||||
|
import Header from '../components/Header';
|
||||||
|
|
||||||
const Layout = () => {
|
const Layout = () => {
|
||||||
useIframe();
|
useIframe();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Add Header here */}
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
<Outlet /> {/* This is where page content will be rendered */}
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
{/* Add Footer here */}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,5 +25,5 @@
|
|||||||
"noUncheckedSideEffectImports": true,
|
"noUncheckedSideEffectImports": true,
|
||||||
"types": ["qapp-core/global"]
|
"types": ["qapp-core/global"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "BACKUPS/tradeFetcher.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user