Created new chart, defaulted to 1D candles, and made chart fit full size of window. Also added clear and update trade cache buttons, and volume indication upon mouseover of any candle.
This commit is contained in:
parent
fd23b2e5a6
commit
3dc4f96c57
1750
package-lock.json
generated
1750
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -16,8 +16,9 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^7.0.1",
|
||||
"@mui/material": "^7.0.1",
|
||||
"@mui/icons-material": "^7.1.1",
|
||||
"@mui/material": "^7.1.1",
|
||||
"@mui/x-date-pickers": "^8.5.1",
|
||||
"apexcharts": "^4.7.0",
|
||||
"i18next": "^25.1.2",
|
||||
"jotai": "^2.12.4",
|
||||
@ -30,7 +31,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.21.0",
|
||||
|
751
src/App.tsx
751
src/App.tsx
@ -1,20 +1,19 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import QortCandlestickChart, {
|
||||
Candle,
|
||||
} from './components/QortCandlestickChart';
|
||||
import {
|
||||
aggregateCandles,
|
||||
// aggregateDailyCandles,
|
||||
Trade,
|
||||
} from './utils/qortTrades';
|
||||
import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Container from '@mui/material/Container';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import FetchAllTradesModal from './components/FetchAllTradesModal';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { CircularProgress } from '@mui/material';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Trade, fetchTrades, aggregateCandles } from './utils/qortTrades';
|
||||
import { Candle } from './utils/qortTrades';
|
||||
import { QortMultiChart } from './components/QortMultiChart';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Box,
|
||||
Container,
|
||||
// Paper,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
// --- Constants ---
|
||||
const CHAINS = [
|
||||
{ value: 'LITECOIN', label: 'LTC' },
|
||||
{ value: 'BITCOIN', label: 'BTC' },
|
||||
@ -25,449 +24,365 @@ const CHAINS = [
|
||||
];
|
||||
|
||||
const PERIODS = [
|
||||
{ label: '1D', days: 1 },
|
||||
{ label: '5D', days: 5 },
|
||||
{ label: '10D', days: 10 },
|
||||
{ label: '15D', days: 15 },
|
||||
{ label: '20D', days: 20 },
|
||||
{ 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: '3M', months: 3 },
|
||||
{ label: '6M', months: 6 },
|
||||
{ label: '1Y', months: 12 },
|
||||
{ label: '1.5Y', months: 18 },
|
||||
{ label: '2Y', months: 24 },
|
||||
{ label: '2.5Y', months: 30 },
|
||||
{ label: '3Y', months: 36 },
|
||||
{ label: 'All', months: null },
|
||||
];
|
||||
|
||||
const ONE_HOUR = 60 * 60 * 1000;
|
||||
const STORAGE_KEY = 'QORT_CANDLE_TRADES';
|
||||
const ONE_DAY = 24 * ONE_HOUR;
|
||||
const LS_KEY = 'QORT_CANDLE_TRADES';
|
||||
const LS_VERSION = 1;
|
||||
|
||||
const App: React.FC = () => {
|
||||
type ChainMap<T> = Record<string, T>;
|
||||
|
||||
export default function App() {
|
||||
const theme = useTheme();
|
||||
const [candles, setCandles] = useState<Candle[]>([]);
|
||||
const [interval, setInterval] = useState(ONE_HOUR);
|
||||
const [period, setPeriod] = useState('3M');
|
||||
const [selectedChain, setSelectedChain] = useState(CHAINS[0].value);
|
||||
|
||||
const [allChainTrades, setAllChainTrades] = useState<Record<string, Trade[]>>(
|
||||
{}
|
||||
);
|
||||
const [isFetchingAll, setIsFetchingAll] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
const [fetchProgress, setFetchProgress] = useState<Record<string, number>>(
|
||||
{}
|
||||
);
|
||||
const [fetchError, setFetchError] = useState<Record<string, string | null>>(
|
||||
{}
|
||||
);
|
||||
const [fetchModalOpen, setFetchModalOpen] = useState(false);
|
||||
// --- UI state ---
|
||||
const [selectedChain, setSelectedChain] = useState<string>(CHAINS[0].value);
|
||||
const [interval, setInterval] = useState<number>(ONE_DAY);
|
||||
const [period, setPeriod] = useState<string>('1Y');
|
||||
|
||||
const [cacheLoaded, setCacheLoaded] = useState(false);
|
||||
// --- 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 [isFiltering, setIsFiltering] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState<Record<string, boolean>>({});
|
||||
// --- Helpers ---
|
||||
const getLatest = (trades: Trade[]) =>
|
||||
trades.length ? Math.max(...trades.map((t) => t.tradeTimestamp)) : 0;
|
||||
|
||||
function getLatestTradeTimestamp(trades: Trade[]): number {
|
||||
if (!trades || !trades.length) return 0;
|
||||
return Math.max(...trades.map((t) => t.tradeTimestamp));
|
||||
}
|
||||
|
||||
// function fastPercentileFilter(trades: Trade[], lower = 0.01, upper = 0.99) {
|
||||
// // 1. Extract price array (one pass)
|
||||
// const prices = [];
|
||||
// const validTrades = [];
|
||||
// for (const t of trades) {
|
||||
// const qort = parseFloat(t.qortAmount);
|
||||
// const price = parseFloat(t.foreignAmount) / qort;
|
||||
// if (isFinite(price) && price > 0) {
|
||||
// prices.push(price);
|
||||
// validTrades.push({ trade: t, price });
|
||||
// }
|
||||
// }
|
||||
// // 2. Get percentiles (sort once)
|
||||
// prices.sort((a, b) => a - b);
|
||||
// const min = prices[Math.floor(prices.length * lower)];
|
||||
// const max = prices[Math.ceil(prices.length * upper) - 1];
|
||||
// // 3. Filter in single pass
|
||||
// return validTrades
|
||||
// .filter(({ price }) => price >= min && price <= max)
|
||||
// .map(({ trade }) => trade);
|
||||
// }
|
||||
|
||||
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);
|
||||
const sorted = [...prices].sort((a, b) => a - b);
|
||||
const lowerIdx = Math.floor(sorted.length * lower);
|
||||
const upperIdx = Math.ceil(sorted.length * upper) - 1;
|
||||
const min = sorted[lowerIdx];
|
||||
const max = sorted[upperIdx];
|
||||
return trades.filter((t) => {
|
||||
const price = parseFloat(t.foreignAmount) / parseFloat(t.qortAmount);
|
||||
return price >= min && price <= max;
|
||||
});
|
||||
}
|
||||
|
||||
// --- LocalStorage LOAD on mount ---
|
||||
// --- 1) Load cache ---
|
||||
useEffect(() => {
|
||||
const cached = localStorage.getItem(STORAGE_KEY);
|
||||
if (cached) {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
setAllChainTrades(JSON.parse(cached));
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
localStorage.removeItem(STORAGE_KEY); // Bad cache, nuke it
|
||||
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(() => {
|
||||
// Always save to localStorage when allChainTrades updates
|
||||
if (cacheLoaded) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(allChainTrades));
|
||||
}
|
||||
if (!cacheLoaded) return;
|
||||
const payload = { version: LS_VERSION, allChainTrades };
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(payload));
|
||||
}, [allChainTrades, cacheLoaded]);
|
||||
|
||||
// --- Filtering candles for chart based on selected time period ---
|
||||
// --- 3) Decide fetch strategy ---
|
||||
useEffect(() => {
|
||||
if (!cacheLoaded) {
|
||||
console.log('Filter effect skipped - waiting for cacheLoaded');
|
||||
return;
|
||||
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 }));
|
||||
}
|
||||
}, [cacheLoaded, selectedChain, allChainTrades]);
|
||||
|
||||
setIsFiltering(true);
|
||||
// --- 4) Prepare candles ---
|
||||
const candles = useMemo<Candle[]>(() => {
|
||||
if (!cacheLoaded) return [];
|
||||
const trades = allChainTrades[selectedChain] || [];
|
||||
if (!trades.length) return [];
|
||||
|
||||
setTimeout(() => {
|
||||
// --- Determine minTimestamp ---
|
||||
const now = new Date();
|
||||
const periodObj = PERIODS.find((p) => p.label === period);
|
||||
let minTimestamp = 0;
|
||||
// let useDaily = false;
|
||||
if (periodObj) {
|
||||
if ('days' in periodObj && periodObj.days !== undefined) {
|
||||
now.setDate(now.getDate() - periodObj.days);
|
||||
minTimestamp = now.getTime();
|
||||
} else if (
|
||||
'months' in periodObj &&
|
||||
periodObj.months !== undefined &&
|
||||
periodObj.months !== null
|
||||
) {
|
||||
now.setMonth(now.getMonth() - periodObj.months);
|
||||
minTimestamp = now.getTime();
|
||||
// For 1M or more, use daily candles----------------------------------------------DISABLED FORCING DAILY CANDLES, AND MODIFIED FILTERING.
|
||||
// if (periodObj.months >= 6) useDaily = false;
|
||||
} else if ('months' in periodObj && periodObj.months === null) {
|
||||
// 'All'
|
||||
minTimestamp = 0;
|
||||
// useDaily = false;
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
// --- Filter trades ---
|
||||
const trades = allChainTrades[selectedChain] || [];
|
||||
let filtered = minTimestamp
|
||||
? trades.filter((t) => t.tradeTimestamp >= minTimestamp)
|
||||
: trades;
|
||||
filtered = fastPercentileFilter(filtered, 0.00005, 0.995);
|
||||
}
|
||||
const filtered = cutoff
|
||||
? trades.filter((t) => t.tradeTimestamp >= cutoff)
|
||||
: trades;
|
||||
|
||||
// // --- Aggregate ---
|
||||
// if (useDaily) {
|
||||
// setCandles(aggregateDailyCandles(filtered));
|
||||
// } else {
|
||||
// setCandles(aggregateCandles(filtered, interval));
|
||||
// }
|
||||
// percentile filter
|
||||
const cleaned = fastPercentileFilter(filtered, 0.00005, 0.995);
|
||||
return aggregateCandles(cleaned, interval);
|
||||
}, [allChainTrades, selectedChain, period, interval, cacheLoaded]);
|
||||
|
||||
setCandles(aggregateCandles(filtered, interval));
|
||||
setIsFiltering(false);
|
||||
}, 10);
|
||||
}, [interval, period, selectedChain, allChainTrades, cacheLoaded]);
|
||||
|
||||
// --- Full-history fetch logic (background, not tied to modal) ---
|
||||
const startFetchAll = (chain: string) => {
|
||||
setIsFetchingAll((prev) => ({ ...prev, [chain]: true }));
|
||||
setFetchError((prev) => ({ ...prev, [chain]: null }));
|
||||
setFetchModalOpen(true);
|
||||
setFetchProgress((prev) => ({ ...prev, [chain]: 0 }));
|
||||
|
||||
let allTrades: Trade[] = [];
|
||||
// --- Full fetch ---
|
||||
async function doFullFetch(chain: string) {
|
||||
setIsFetching((m) => ({ ...m, [chain]: true }));
|
||||
let all: Trade[] = [];
|
||||
let offset = 0;
|
||||
const BATCH_SIZE = 100;
|
||||
let keepGoing = true;
|
||||
|
||||
(async function fetchLoop() {
|
||||
try {
|
||||
while (keepGoing) {
|
||||
const url = `/crosschain/trades?foreignBlockchain=${chain}&limit=${BATCH_SIZE}&offset=${offset}&reverse=true`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const trades: Trade[] = await resp.json();
|
||||
allTrades = allTrades.concat(trades);
|
||||
setAllChainTrades((prev) => ({
|
||||
...prev,
|
||||
[chain]: [...allTrades],
|
||||
}));
|
||||
setFetchProgress((prev) => ({ ...prev, [chain]: allTrades.length }));
|
||||
if (trades.length < BATCH_SIZE) {
|
||||
keepGoing = false;
|
||||
} else {
|
||||
offset += BATCH_SIZE;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setFetchError((prev) => ({ ...prev, [chain]: String(err) }));
|
||||
} finally {
|
||||
setIsFetchingAll((prev) => ({ ...prev, [chain]: false }));
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const updateTrades = async (chain: string) => {
|
||||
setIsUpdating((prev) => ({ ...prev, [chain]: true }));
|
||||
const BATCH = 100;
|
||||
try {
|
||||
const localTrades = allChainTrades[chain] || [];
|
||||
const latest = getLatestTradeTimestamp(localTrades);
|
||||
let offset = 0;
|
||||
const BATCH_SIZE = 100;
|
||||
let keepGoing = true;
|
||||
let newTrades: Trade[] = [];
|
||||
while (keepGoing) {
|
||||
const url = `/crosschain/trades?foreignBlockchain=${chain}&limit=${BATCH_SIZE}&offset=${offset}&minimumTimestamp=${latest + 1}&reverse=true`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const batch: Trade[] = await resp.json();
|
||||
newTrades = newTrades.concat(batch);
|
||||
if (batch.length < BATCH_SIZE) {
|
||||
keepGoing = false;
|
||||
} else {
|
||||
offset += BATCH_SIZE;
|
||||
}
|
||||
}
|
||||
if (newTrades.length) {
|
||||
setAllChainTrades((prev) => ({
|
||||
...prev,
|
||||
[chain]: [...newTrades, ...(prev[chain] || [])],
|
||||
}));
|
||||
while (true) {
|
||||
const batch = await fetchTrades({
|
||||
foreignBlockchain: chain,
|
||||
minimumTimestamp: 0,
|
||||
limit: BATCH,
|
||||
offset,
|
||||
reverse: true,
|
||||
});
|
||||
all = all.concat(batch);
|
||||
setAllChainTrades((m) => ({ ...m, [chain]: all }));
|
||||
if (batch.length < BATCH) break;
|
||||
offset += BATCH;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Full fetch error', e);
|
||||
} finally {
|
||||
setIsUpdating((prev) => ({ ...prev, [chain]: false }));
|
||||
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);
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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 (!cacheLoaded)
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
height="100vh"
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
const tradesCount = (allChainTrades[selectedChain] || []).length;
|
||||
const latestTS = getLatest(allChainTrades[selectedChain] || []);
|
||||
const latestDate = latestTS ? new Date(latestTS).toLocaleString() : 'N/A';
|
||||
const stale = needsUpdate[selectedChain];
|
||||
const loading = isFetching[selectedChain];
|
||||
|
||||
// --- clear cache ---
|
||||
const clearCache = () => {
|
||||
localStorage.removeItem(LS_KEY);
|
||||
setAllChainTrades({});
|
||||
setNeedsUpdate({});
|
||||
};
|
||||
|
||||
// --- UI state helpers ---
|
||||
const chainFetched = !!allChainTrades[selectedChain];
|
||||
const chainFetching = !!isFetchingAll[selectedChain];
|
||||
|
||||
if (!cacheLoaded) return <div>Loading trade cache...</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFiltering && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.35)',
|
||||
zIndex: 20000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={64} sx={{ color: '#00eaff', mb: 2 }} />
|
||||
<Box sx={{ color: '#fff', fontWeight: 600, fontSize: 24 }}>
|
||||
Filtering trades for chart...
|
||||
</Box>
|
||||
<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
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
p: 2,
|
||||
position: 'relative',
|
||||
background: theme.palette.background.default,
|
||||
}}
|
||||
>
|
||||
{/* Status & Clear */}
|
||||
<Box position="absolute" top={16} right={16} textAlign="right">
|
||||
<Typography variant="caption">
|
||||
Trades: {tradesCount.toLocaleString()}
|
||||
<br />
|
||||
Latest: {latestDate}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={clearCache}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Clear Cache
|
||||
</Button>
|
||||
|
||||
{/* Manual Update button */}
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
color="info"
|
||||
onClick={() => doIncrementalFetch(selectedChain)}
|
||||
disabled={isFetching[selectedChain]}
|
||||
sx={{ ml: 2 }}
|
||||
>
|
||||
Obtain New Trades
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{/* Fetch Progress Modal */}
|
||||
<FetchAllTradesModal
|
||||
open={fetchModalOpen}
|
||||
onClose={() => setFetchModalOpen(false)}
|
||||
isFetching={chainFetching}
|
||||
progress={fetchProgress[selectedChain] || 0}
|
||||
error={fetchError[selectedChain]}
|
||||
total={null}
|
||||
chain={selectedChain}
|
||||
/>
|
||||
<Container maxWidth={false} disableGutters>
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
width: '100vw',
|
||||
background: theme.palette.background.default,
|
||||
color: theme.palette.text.primary,
|
||||
p: { xs: 1, md: 3 },
|
||||
transition: 'background 0.3s, color 0.3s',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={5}
|
||||
|
||||
{/* 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.5 }}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Fetch Buttons */}
|
||||
<Box mb={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 (over 24h old)
|
||||
</Button>
|
||||
)}
|
||||
{loading && <CircularProgress size={24} sx={{ ml: 2 }} />}
|
||||
</Box>
|
||||
</Box>
|
||||
{/* 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={{
|
||||
width: '100%',
|
||||
margin: '36px 0 0 0', // Remove 'auto' to allow full width
|
||||
background: theme.palette.background.paper,
|
||||
boxShadow: theme.shadows[6],
|
||||
p: { xs: 1, md: 4 },
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
background: 'transparent', // or theme.palette.background.default
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
{/* Action Button */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
|
||||
{!chainFetched && !chainFetching && (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: 24,
|
||||
borderRadius: 3,
|
||||
px: 4,
|
||||
}}
|
||||
onClick={() => startFetchAll(selectedChain)}
|
||||
>
|
||||
Fetch ALL{' '}
|
||||
{CHAINS.find((c) => c.value === selectedChain)?.label ||
|
||||
selectedChain}{' '}
|
||||
Trades
|
||||
</Button>
|
||||
{/* <Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => updateTrades(selectedChain)}
|
||||
disabled={isUpdating[selectedChain] || chainFetching}
|
||||
>
|
||||
{isUpdating[selectedChain]
|
||||
? 'Updating...'
|
||||
: 'Check for new trades'}
|
||||
</Button> */}
|
||||
</>
|
||||
)}
|
||||
|
||||
{chainFetching && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: 24,
|
||||
borderRadius: 3,
|
||||
px: 4,
|
||||
}}
|
||||
onClick={() => setFetchModalOpen(true)}
|
||||
>
|
||||
Show fetch progress
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{chainFetched && !chainFetching && (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
size="large"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: 24,
|
||||
borderRadius: 3,
|
||||
px: 4,
|
||||
}}
|
||||
disabled
|
||||
>
|
||||
Data updated
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => updateTrades(selectedChain)}
|
||||
disabled={isUpdating[selectedChain] || chainFetching}
|
||||
>
|
||||
{isUpdating[selectedChain]
|
||||
? 'Updating...'
|
||||
: 'Check for new trades'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{/* Controls */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Pair:
|
||||
<select
|
||||
value={selectedChain}
|
||||
onChange={(e) => setSelectedChain(e.target.value)}
|
||||
style={{
|
||||
fontSize: 16,
|
||||
padding: '2px 8px',
|
||||
background: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary,
|
||||
borderRadius: 5,
|
||||
}}
|
||||
>
|
||||
{CHAINS.map((chain) => (
|
||||
<option key={chain.value} value={chain.value}>
|
||||
{chain.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={{ minWidth: 40, mx: 0.5 }}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: { xs: 320, md: 520, lg: '60vh' }, // adapt for screen
|
||||
mx: 'auto',
|
||||
minHeight: 240,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<QortCandlestickChart
|
||||
candles={candles}
|
||||
showSMA={true}
|
||||
themeMode={theme.palette.mode}
|
||||
background={theme.palette.background.paper}
|
||||
textColor={theme.palette.text.primary}
|
||||
pairLabel={selectedChain === 'LITECOIN' ? 'LTC' : selectedChain}
|
||||
interval={interval}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Container>
|
||||
</>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import Chart from 'react-apexcharts';
|
||||
import { ApexOptions } from 'apexcharts';
|
||||
import { Box, useTheme } from '@mui/material';
|
||||
@ -6,6 +6,7 @@ import { Box, useTheme } from '@mui/material';
|
||||
export interface Candle {
|
||||
x: number; // timestamp
|
||||
y: [number, number, number, number]; // [open, high, low, close]
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@ -29,6 +30,40 @@ function calculateSMA(data: Candle[], windowSize = 7) {
|
||||
return sma;
|
||||
}
|
||||
|
||||
function calculateRSI(data: Candle[], period = 14) {
|
||||
// not enough data → no RSI
|
||||
if (data.length < period + 1) return [];
|
||||
|
||||
const closes = data.map((c) => c.y[3]);
|
||||
const gains: number[] = [];
|
||||
const losses: number[] = [];
|
||||
|
||||
for (let i = 1; i < closes.length; i++) {
|
||||
const diff = closes[i] - closes[i - 1];
|
||||
gains.push(Math.max(diff, 0));
|
||||
losses.push(Math.max(-diff, 0));
|
||||
}
|
||||
|
||||
const rsi: { x: number; y: number }[] = [];
|
||||
let avgGain = gains.slice(0, period).reduce((a, b) => a + b, 0) / period;
|
||||
let avgLoss = losses.slice(0, period).reduce((a, b) => a + b, 0) / period;
|
||||
|
||||
// first RSI datapoint at data[period]
|
||||
rsi.push({
|
||||
x: data[period].x,
|
||||
y: 100 - 100 / (1 + avgGain / (avgLoss || 1)),
|
||||
});
|
||||
|
||||
for (let i = period; i < gains.length; i++) {
|
||||
avgGain = (avgGain * (period - 1) + gains[i]) / period;
|
||||
avgLoss = (avgLoss * (period - 1) + losses[i]) / period;
|
||||
const rs = avgGain / (avgLoss || 1);
|
||||
rsi.push({ x: data[i + 1].x, y: 100 - 100 / (1 + rs) });
|
||||
}
|
||||
|
||||
return rsi;
|
||||
}
|
||||
|
||||
const QortCandlestickChart: React.FC<Props> = ({
|
||||
candles,
|
||||
showSMA = true,
|
||||
@ -41,6 +76,21 @@ const QortCandlestickChart: React.FC<Props> = ({
|
||||
const smaData = showSMA ? calculateSMA(candles, 7) : [];
|
||||
const intervalLabel = interval === 24 * 60 * 60 * 1000 ? '1d' : '1h';
|
||||
const theme = useTheme();
|
||||
const volumeData = useMemo(
|
||||
() => candles.map((c) => ({ x: c.x, y: c.volume })),
|
||||
[candles]
|
||||
);
|
||||
const rsiData = useMemo(() => calculateRSI(candles, 14), [candles]);
|
||||
|
||||
const series = [
|
||||
{ name: 'Price', type: 'candlestick', data: candles, yAxis: 0 },
|
||||
...(showSMA && smaData.length
|
||||
? [{ name: 'SMA (7)', type: 'line', data: smaData, yAxis: 0 }]
|
||||
: []),
|
||||
{ name: 'Volume', type: 'bar', data: volumeData, yAxis: 1 },
|
||||
{ name: 'RSI (14)', type: 'line', data: rsiData, yAxis: 2 },
|
||||
];
|
||||
|
||||
const options: ApexOptions = {
|
||||
chart: {
|
||||
type: 'candlestick',
|
||||
@ -99,23 +149,8 @@ const QortCandlestickChart: React.FC<Props> = ({
|
||||
tooltip: {
|
||||
theme: themeMode,
|
||||
},
|
||||
// plotOptions: {
|
||||
// candlestick: {
|
||||
// // Width can be a number (pixels) or a string (percentage)
|
||||
// // e.g., width: 8 (pixels) or width: '80%' (of grid slot)
|
||||
// // @ts-expect-error: width is supported at runtime even if not in types
|
||||
// width: '100%',
|
||||
// },
|
||||
// },
|
||||
};
|
||||
|
||||
const series = [
|
||||
{ name: 'Price', type: 'candlestick', data: candles },
|
||||
...(showSMA && smaData.length
|
||||
? [{ name: `SMA (7)`, type: 'line', data: smaData }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
|
145
src/components/QortMultiChart.tsx
Normal file
145
src/components/QortMultiChart.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import Chart from 'react-apexcharts';
|
||||
import { ApexOptions } from 'apexcharts';
|
||||
import { Box } from '@mui/material';
|
||||
import { Candle } from '../utils/qortTrades';
|
||||
|
||||
interface Props {
|
||||
candles: Candle[];
|
||||
showSMA?: boolean;
|
||||
themeMode: 'light' | 'dark';
|
||||
background: string;
|
||||
textColor: string;
|
||||
pairLabel: string;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
// helper for SMA
|
||||
function rawSMA(data: Candle[], period = 7) {
|
||||
const out: { x: number; y: number }[] = [];
|
||||
for (let i = period - 1; i < data.length; i++) {
|
||||
const slice = data.slice(i - period + 1, i + 1);
|
||||
const avg = slice.reduce((sum, c) => sum + c.y[3], 0) / period;
|
||||
out.push({ x: data[i].x, y: avg });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const QortMultiChart: React.FC<Props> = ({
|
||||
candles,
|
||||
showSMA = true,
|
||||
themeMode,
|
||||
background,
|
||||
textColor,
|
||||
pairLabel,
|
||||
interval,
|
||||
}) => {
|
||||
// compute price bounds
|
||||
const [priceMin, priceMax] = useMemo(() => {
|
||||
if (!candles.length) return [0, 1];
|
||||
const highs = candles.map((c) => c.y[1]);
|
||||
const lows = candles.map((c) => c.y[2]);
|
||||
return [Math.min(...lows), Math.max(...highs)];
|
||||
}, [candles]);
|
||||
const pad = (priceMax - priceMin) * 0.02;
|
||||
|
||||
// volume data for tooltip
|
||||
const volumeData = useMemo(
|
||||
() => candles.map((c) => ({ x: c.x, y: c.volume ?? 0 })),
|
||||
[candles]
|
||||
);
|
||||
|
||||
// SMA padded series
|
||||
const smaRaw = useMemo(() => rawSMA(candles, 7), [candles]);
|
||||
const smaSeries = useMemo(
|
||||
() =>
|
||||
candles.map((c) => {
|
||||
const pt = smaRaw.find((s) => s.x === c.x);
|
||||
return { x: c.x, y: pt ? pt.y : null };
|
||||
}),
|
||||
[candles, smaRaw]
|
||||
);
|
||||
|
||||
// combined series (candlestick + SMA)
|
||||
const series = useMemo(
|
||||
() => [
|
||||
{ name: 'Price', type: 'candlestick', data: candles },
|
||||
...(showSMA ? [{ name: 'SMA(7)', type: 'line', data: smaSeries }] : []),
|
||||
],
|
||||
[candles, smaSeries, showSMA]
|
||||
);
|
||||
|
||||
const options: ApexOptions = useMemo(
|
||||
() => ({
|
||||
chart: {
|
||||
type: 'candlestick',
|
||||
background,
|
||||
toolbar: { show: true, autoSelected: 'zoom' },
|
||||
zoom: { enabled: true, type: 'xy', autoScaleYaxis: false },
|
||||
},
|
||||
title: {
|
||||
text: `QORT/${pairLabel} — ${interval === 864e5 ? '1d' : '1h'} candles`,
|
||||
align: 'center',
|
||||
style: { color: textColor },
|
||||
},
|
||||
xaxis: { type: 'datetime', labels: { style: { colors: textColor } } },
|
||||
yaxis: [
|
||||
{
|
||||
min: priceMin - pad,
|
||||
max: priceMax + pad,
|
||||
tickAmount: 6,
|
||||
labels: {
|
||||
style: { colors: textColor },
|
||||
formatter: (v) => v.toFixed(8),
|
||||
},
|
||||
title: { text: 'Price' },
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
shared: false,
|
||||
custom: ({ dataPointIndex, w }) => {
|
||||
const ohlc = w.config.series[0].data[dataPointIndex].y;
|
||||
const vol = volumeData[dataPointIndex]?.y ?? 0;
|
||||
return `
|
||||
<div style="padding:8px;">
|
||||
<div>Open : ${ohlc[0].toFixed(8)}</div>
|
||||
<div>High : ${ohlc[1].toFixed(8)}</div>
|
||||
<div>Low : ${ohlc[2].toFixed(8)}</div>
|
||||
<div>Close: ${ohlc[3].toFixed(8)}</div>
|
||||
<div>Volume: ${vol.toLocaleString()}</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
theme: themeMode,
|
||||
},
|
||||
plotOptions: { bar: { columnWidth: '80%' } },
|
||||
dataLabels: { enabled: false },
|
||||
theme: { mode: themeMode },
|
||||
}),
|
||||
[
|
||||
background,
|
||||
pairLabel,
|
||||
interval,
|
||||
priceMin,
|
||||
priceMax,
|
||||
pad,
|
||||
textColor,
|
||||
themeMode,
|
||||
volumeData,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '100%', width: '100%' }}>
|
||||
<Chart
|
||||
options={options}
|
||||
series={series}
|
||||
type="candlestick"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default QortMultiChart;
|
@ -34,6 +34,9 @@ export async function fetchTrades({
|
||||
});
|
||||
if (buyerPublicKey) params.append('buyerPublicKey', buyerPublicKey);
|
||||
if (sellerPublicKey) params.append('sellerPublicKey', sellerPublicKey);
|
||||
if (minimumTimestamp === 0) {
|
||||
params.delete('minimumTimestamp');
|
||||
}
|
||||
|
||||
const url = `crosschain/trades?${params.toString()}`;
|
||||
const resp = await fetch(url);
|
||||
@ -42,7 +45,11 @@ export async function fetchTrades({
|
||||
}
|
||||
|
||||
// Candle chart utility
|
||||
export type Candle = { x: number; y: [number, number, number, number] };
|
||||
export type Candle = {
|
||||
x: number;
|
||||
y: [number, number, number, number];
|
||||
volume: number;
|
||||
};
|
||||
|
||||
export function aggregateDailyCandles(trades: Trade[]): Candle[] {
|
||||
if (!trades.length) return [];
|
||||
@ -81,6 +88,7 @@ export function aggregateCandles(
|
||||
const sorted = trades
|
||||
.slice()
|
||||
.sort((a, b) => a.tradeTimestamp - b.tradeTimestamp);
|
||||
|
||||
const candles: Candle[] = [];
|
||||
let current: {
|
||||
bucket: number;
|
||||
@ -88,41 +96,52 @@ export function aggregateCandles(
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
} | null = null;
|
||||
|
||||
const getPrice = (trade: Trade) => {
|
||||
const qort = parseFloat(trade.qortAmount);
|
||||
const ltc = parseFloat(trade.foreignAmount);
|
||||
return qort > 0 ? ltc / qort : null;
|
||||
};
|
||||
for (const t of sorted) {
|
||||
const q = parseFloat(t.qortAmount);
|
||||
const f = parseFloat(t.foreignAmount);
|
||||
if (!isFinite(q) || q <= 0) continue;
|
||||
const price = f / q;
|
||||
|
||||
const bucket = Math.floor(t.tradeTimestamp / intervalMs) * intervalMs;
|
||||
|
||||
for (const trade of sorted) {
|
||||
const price = getPrice(trade);
|
||||
if (!price) continue;
|
||||
const bucket = Math.floor(trade.tradeTimestamp / intervalMs) * intervalMs;
|
||||
if (!current || current.bucket !== bucket) {
|
||||
if (current)
|
||||
// flush previous candle
|
||||
if (current) {
|
||||
candles.push({
|
||||
x: current.bucket,
|
||||
y: [current.open, current.high, current.low, current.close],
|
||||
volume: current.volume,
|
||||
});
|
||||
}
|
||||
// start a new bucket
|
||||
current = {
|
||||
bucket,
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
close: price,
|
||||
volume: q, // initialize volume to this trade's QORT
|
||||
};
|
||||
} else {
|
||||
// same bucket → update high/low/close & accumulate
|
||||
current.high = Math.max(current.high, price);
|
||||
current.low = Math.min(current.low, price);
|
||||
current.close = price;
|
||||
current.volume += q; // add this trade's QORT to the bucket's volume
|
||||
}
|
||||
}
|
||||
if (current)
|
||||
|
||||
// push the last bucket
|
||||
if (current) {
|
||||
candles.push({
|
||||
x: current.bucket,
|
||||
y: [current.open, current.high, current.low, current.close],
|
||||
volume: current.volume,
|
||||
});
|
||||
}
|
||||
|
||||
return candles;
|
||||
}
|
||||
|
@ -1,15 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
Loading…
x
Reference in New Issue
Block a user