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:
crowetic 2025-06-10 08:51:53 -07:00
parent fd23b2e5a6
commit 3dc4f96c57
7 changed files with 1700 additions and 1069 deletions

1750
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,8 +16,9 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.0.1", "@mui/icons-material": "^7.1.1",
"@mui/material": "^7.0.1", "@mui/material": "^7.1.1",
"@mui/x-date-pickers": "^8.5.1",
"apexcharts": "^4.7.0", "apexcharts": "^4.7.0",
"i18next": "^25.1.2", "i18next": "^25.1.2",
"jotai": "^2.12.4", "jotai": "^2.12.4",
@ -30,7 +31,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.21.0", "@eslint/js": "^9.21.0",
"@types/react": "^19.0.10", "@types/react": "^19.1.6",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0", "eslint": "^9.21.0",

View File

@ -1,20 +1,19 @@
import React, { useEffect, useState } from 'react'; import { useEffect, useState, useMemo } from 'react';
import QortCandlestickChart, { import { Trade, fetchTrades, aggregateCandles } from './utils/qortTrades';
Candle, import { Candle } from './utils/qortTrades';
} from './components/QortCandlestickChart'; import { QortMultiChart } from './components/QortMultiChart';
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 {
Button,
Box,
Container,
// Paper,
CircularProgress,
Typography,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
// --- Constants ---
const CHAINS = [ const CHAINS = [
{ value: 'LITECOIN', label: 'LTC' }, { value: 'LITECOIN', label: 'LTC' },
{ value: 'BITCOIN', label: 'BTC' }, { value: 'BITCOIN', label: 'BTC' },
@ -25,449 +24,365 @@ const CHAINS = [
]; ];
const PERIODS = [ const PERIODS = [
{ label: '1D', days: 1 }, { label: '2W', days: 14 },
{ label: '5D', days: 5 }, { label: '3W', days: 21 },
{ label: '10D', days: 10 }, { label: '4W', days: 30 },
{ label: '15D', days: 15 }, { label: '5W', days: 38 },
{ label: '20D', days: 20 }, { label: '6W', days: 45 },
{ label: '1M', months: 1 }, { label: '1M', months: 1 },
{ label: '3M', months: 3 }, { label: '3M', months: 3 },
{ label: '6M', months: 6 }, { label: '6M', months: 6 },
{ label: '1Y', months: 12 }, { 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 }, { label: 'All', months: null },
]; ];
const ONE_HOUR = 60 * 60 * 1000; 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 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[]>>( // --- UI state ---
{} const [selectedChain, setSelectedChain] = useState<string>(CHAINS[0].value);
); const [interval, setInterval] = useState<number>(ONE_DAY);
const [isFetchingAll, setIsFetchingAll] = useState<Record<string, boolean>>( const [period, setPeriod] = useState<string>('1Y');
{}
);
const [fetchProgress, setFetchProgress] = useState<Record<string, number>>(
{}
);
const [fetchError, setFetchError] = useState<Record<string, string | null>>(
{}
);
const [fetchModalOpen, setFetchModalOpen] = useState(false);
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); // --- Helpers ---
const [isUpdating, setIsUpdating] = useState<Record<string, boolean>>({}); const getLatest = (trades: Trade[]) =>
trades.length ? Math.max(...trades.map((t) => t.tradeTimestamp)) : 0;
function getLatestTradeTimestamp(trades: Trade[]): number { // --- 1) Load cache ---
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 ---
useEffect(() => { useEffect(() => {
const cached = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(LS_KEY);
if (cached) { if (raw) {
try { try {
setAllChainTrades(JSON.parse(cached)); const { version, allChainTrades: saved } = JSON.parse(raw);
} catch (err) { if (version === LS_VERSION) setAllChainTrades(saved);
console.log(err); else localStorage.removeItem(LS_KEY);
localStorage.removeItem(STORAGE_KEY); // Bad cache, nuke it } catch {
localStorage.removeItem(LS_KEY);
} }
} }
setCacheLoaded(true); setCacheLoaded(true);
}, []); }, []);
// --- 2) Save cache ---
useEffect(() => { useEffect(() => {
// Always save to localStorage when allChainTrades updates if (!cacheLoaded) return;
if (cacheLoaded) { const payload = { version: LS_VERSION, allChainTrades };
localStorage.setItem(STORAGE_KEY, JSON.stringify(allChainTrades)); localStorage.setItem(LS_KEY, JSON.stringify(payload));
}
}, [allChainTrades, cacheLoaded]); }, [allChainTrades, cacheLoaded]);
// --- Filtering candles for chart based on selected time period --- // --- 3) Decide fetch strategy ---
useEffect(() => { useEffect(() => {
if (!cacheLoaded) { if (!cacheLoaded) return;
console.log('Filter effect skipped - waiting for cacheLoaded');
return;
}
setIsFiltering(true);
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;
}
}
// --- Filter trades ---
const trades = allChainTrades[selectedChain] || []; const trades = allChainTrades[selectedChain] || [];
let filtered = minTimestamp if (!trades.length) doFullFetch(selectedChain);
? trades.filter((t) => t.tradeTimestamp >= minTimestamp) else {
const age = Date.now() - getLatest(trades);
setNeedsUpdate((m) => ({ ...m, [selectedChain]: age > ONE_DAY }));
}
}, [cacheLoaded, selectedChain, allChainTrades]);
// --- 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; : trades;
filtered = fastPercentileFilter(filtered, 0.00005, 0.995);
// // --- Aggregate --- // percentile filter
// if (useDaily) { const cleaned = fastPercentileFilter(filtered, 0.00005, 0.995);
// setCandles(aggregateDailyCandles(filtered)); return aggregateCandles(cleaned, interval);
// } else { }, [allChainTrades, selectedChain, period, interval, cacheLoaded]);
// setCandles(aggregateCandles(filtered, interval));
// }
setCandles(aggregateCandles(filtered, interval)); // --- Full fetch ---
setIsFiltering(false); async function doFullFetch(chain: string) {
}, 10); setIsFetching((m) => ({ ...m, [chain]: true }));
}, [interval, period, selectedChain, allChainTrades, cacheLoaded]); let all: Trade[] = [];
// --- 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[] = [];
let offset = 0; let offset = 0;
const BATCH_SIZE = 100; const BATCH = 100;
let keepGoing = true;
(async function fetchLoop() {
try { try {
while (keepGoing) { while (true) {
const url = `/crosschain/trades?foreignBlockchain=${chain}&limit=${BATCH_SIZE}&offset=${offset}&reverse=true`; const batch = await fetchTrades({
const resp = await fetch(url); foreignBlockchain: chain,
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); minimumTimestamp: 0,
const trades: Trade[] = await resp.json(); limit: BATCH,
allTrades = allTrades.concat(trades); offset,
setAllChainTrades((prev) => ({ reverse: true,
...prev, });
[chain]: [...allTrades], all = all.concat(batch);
})); setAllChainTrades((m) => ({ ...m, [chain]: all }));
setFetchProgress((prev) => ({ ...prev, [chain]: allTrades.length })); if (batch.length < BATCH) break;
if (trades.length < BATCH_SIZE) { offset += BATCH;
keepGoing = false;
} else {
offset += BATCH_SIZE;
} }
} } catch (e) {
} catch (err) { console.error('Full fetch error', e);
setFetchError((prev) => ({ ...prev, [chain]: String(err) }));
} finally { } finally {
setIsFetchingAll((prev) => ({ ...prev, [chain]: false })); setIsFetching((m) => ({ ...m, [chain]: false }));
}
} }
})();
};
const updateTrades = async (chain: string) => { // --- Incremental fetch ---
setIsUpdating((prev) => ({ ...prev, [chain]: true })); async function doIncrementalFetch(chain: string) {
setIsFetching((m) => ({ ...m, [chain]: true }));
try { try {
const localTrades = allChainTrades[chain] || []; const existing = allChainTrades[chain] || [];
const latest = getLatestTradeTimestamp(localTrades); const latest = getLatest(existing);
let offset = 0;
const BATCH_SIZE = 100;
let keepGoing = true;
let newTrades: Trade[] = []; let newTrades: Trade[] = [];
while (keepGoing) { let offset = 0;
const url = `/crosschain/trades?foreignBlockchain=${chain}&limit=${BATCH_SIZE}&offset=${offset}&minimumTimestamp=${latest + 1}&reverse=true`; const BATCH = 100;
const resp = await fetch(url); while (true) {
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const batch = await fetchTrades({
const batch: Trade[] = await resp.json(); foreignBlockchain: chain,
minimumTimestamp: latest + 1,
limit: BATCH,
offset,
reverse: true,
});
newTrades = newTrades.concat(batch); newTrades = newTrades.concat(batch);
if (batch.length < BATCH_SIZE) { if (batch.length < BATCH) break;
keepGoing = false; offset += BATCH;
} else {
offset += BATCH_SIZE;
} }
} if (newTrades.length)
if (newTrades.length) { setAllChainTrades((m) => ({
setAllChainTrades((prev) => ({ ...m,
...prev, [chain]: [...newTrades, ...(m[chain] || [])],
[chain]: [...newTrades, ...(prev[chain] || [])],
})); }));
} setNeedsUpdate((m) => ({ ...m, [chain]: false }));
} catch (e) {
console.error('Incremental fetch error', e);
} finally { } finally {
setIsUpdating((prev) => ({ ...prev, [chain]: false })); 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 ( return (
<> <Container
{isFiltering && ( maxWidth={false}
<Box disableGutters
sx={{ sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.35)',
zIndex: 20000,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', height: '100vh', // take full viewport height
justifyContent: 'center', // overflow: 'hidden',
// background: theme.palette.background.default,
}} }}
> >
<CircularProgress size={64} sx={{ color: '#00eaff', mb: 2 }} /> {/* Top bar: status, controls, fetch buttons */}
<Box sx={{ color: '#fff', fontWeight: 600, fontSize: 24 }}>
Filtering trades for chart...
</Box>
</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 <Box
sx={{ sx={{
minHeight: '100vh', flex: '0 0 auto',
width: '100vw', p: 2,
position: 'relative',
background: theme.palette.background.default, background: theme.palette.background.default,
color: theme.palette.text.primary,
p: { xs: 1, md: 3 },
transition: 'background 0.3s, color 0.3s',
}} }}
> >
<Paper {/* Status & Clear */}
elevation={5} <Box position="absolute" top={16} right={16} textAlign="right">
sx={{ <Typography variant="caption">
width: '100%', Trades: {tradesCount.toLocaleString()}
margin: '36px 0 0 0', // Remove 'auto' to allow full width <br />
background: theme.palette.background.paper, Latest: {latestDate}
boxShadow: theme.shadows[6], </Typography>
p: { xs: 1, md: 4 },
}}
>
{/* Action Button */}
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
{!chainFetched && !chainFetching && (
<>
<Button <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" size="small"
onClick={() => updateTrades(selectedChain)}
disabled={isUpdating[selectedChain] || chainFetching}
>
{isUpdating[selectedChain]
? 'Updating...'
: 'Check for new trades'}
</Button> */}
</>
)}
{chainFetching && (
<Button
variant="contained" variant="contained"
color="primary" color="warning"
size="large" onClick={clearCache}
sx={{ sx={{ mt: 1 }}
fontWeight: 700,
fontSize: 24,
borderRadius: 3,
px: 4,
}}
onClick={() => setFetchModalOpen(true)}
> >
Show fetch progress Clear Cache
</Button> </Button>
)}
{chainFetched && !chainFetching && ( {/* Manual Update button */}
<>
<Button
variant="contained"
color="success"
size="large"
sx={{
fontWeight: 700,
fontSize: 24,
borderRadius: 3,
px: 4,
}}
disabled
>
Data updated
</Button>
<Button <Button
variant="outlined" variant="outlined"
size="small" size="small"
onClick={() => updateTrades(selectedChain)} color="info"
disabled={isUpdating[selectedChain] || chainFetching} onClick={() => doIncrementalFetch(selectedChain)}
disabled={isFetching[selectedChain]}
sx={{ ml: 2 }}
> >
{isUpdating[selectedChain] Obtain New Trades
? 'Updating...'
: 'Check for new trades'}
</Button> </Button>
</>
)}
</Box> </Box>
{/* Controls */} {/* Controls */}
<Box <Box mb={2} display="flex" alignItems="center" flexWrap="wrap">
sx={{
display: 'flex',
alignItems: 'center',
mb: 2,
flexWrap: 'wrap',
}}
>
<label> <label>
Pair:&nbsp; Pair:&nbsp;
<select <select
value={selectedChain} value={selectedChain}
onChange={(e) => setSelectedChain(e.target.value)} 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) => ( {CHAINS.map((c) => (
<option key={chain.value} value={chain.value}> <option key={c.value} value={c.value}>
{chain.label} {c.label}
</option> </option>
))} ))}
</select> </select>
</label> </label>
&nbsp; Interval:&nbsp; &nbsp;&nbsp;Interval:
<Button size="small" onClick={() => setInterval(ONE_HOUR)}> <Button size="small" onClick={() => setInterval(ONE_HOUR)}>
1H 1H
</Button> </Button>
<Button size="small" onClick={() => setInterval(24 * ONE_HOUR)}> <Button size="small" onClick={() => setInterval(24 * ONE_HOUR)}>
1D 1D
</Button> </Button>
&nbsp; Show:&nbsp; &nbsp;&nbsp;Show:
{PERIODS.map((p) => ( {PERIODS.map((p) => (
<Button <Button
key={p.label} key={p.label}
size="small" size="small"
variant={period === p.label ? 'contained' : 'outlined'} variant={period === p.label ? 'contained' : 'outlined'}
onClick={() => setPeriod(p.label)} onClick={() => setPeriod(p.label)}
sx={{ minWidth: 40, mx: 0.5 }} sx={{ mx: 0.5 }}
> >
{p.label} {p.label}
</Button> </Button>
))} ))}
</Box> </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 <Box
sx={{ sx={{
width: '100%', flex: 1, // fill all leftover space
height: { xs: 320, md: 520, lg: '60vh' }, // adapt for screen display: 'flex',
mx: 'auto', overflow: 'hidden', // clip any chart overflow
minHeight: 240, p: 2,
position: 'relative', background: theme.palette.background.paper,
}} }}
> >
<QortCandlestickChart {candles.length ? (
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
background: 'transparent', // or theme.palette.background.default
p: 2,
}}
>
<QortMultiChart
candles={candles} candles={candles}
showSMA={true} showSMA
themeMode={theme.palette.mode} 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={selectedChain === 'LITECOIN' ? 'LTC' : selectedChain} pairLabel={
CHAINS.find((c) => c.value === selectedChain)?.label ||
selectedChain
}
interval={interval} interval={interval}
/> />
</Box> </Box>
</Paper> ) : (
<Box
sx={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress />
</Box>
)}
</Box> </Box>
</Container> </Container>
</>
); );
}; }
export default App;

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useMemo } from 'react';
import Chart from 'react-apexcharts'; import Chart from 'react-apexcharts';
import { ApexOptions } from 'apexcharts'; import { ApexOptions } from 'apexcharts';
import { Box, useTheme } from '@mui/material'; import { Box, useTheme } from '@mui/material';
@ -6,6 +6,7 @@ import { Box, useTheme } from '@mui/material';
export interface Candle { export interface Candle {
x: number; // timestamp x: number; // timestamp
y: [number, number, number, number]; // [open, high, low, close] y: [number, number, number, number]; // [open, high, low, close]
volume?: number;
} }
interface Props { interface Props {
@ -29,6 +30,40 @@ function calculateSMA(data: Candle[], windowSize = 7) {
return sma; 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> = ({ const QortCandlestickChart: React.FC<Props> = ({
candles, candles,
showSMA = true, showSMA = true,
@ -41,6 +76,21 @@ const QortCandlestickChart: React.FC<Props> = ({
const smaData = showSMA ? calculateSMA(candles, 7) : []; const smaData = showSMA ? calculateSMA(candles, 7) : [];
const intervalLabel = interval === 24 * 60 * 60 * 1000 ? '1d' : '1h'; const intervalLabel = interval === 24 * 60 * 60 * 1000 ? '1d' : '1h';
const theme = useTheme(); 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 = { const options: ApexOptions = {
chart: { chart: {
type: 'candlestick', type: 'candlestick',
@ -99,23 +149,8 @@ const QortCandlestickChart: React.FC<Props> = ({
tooltip: { tooltip: {
theme: themeMode, 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 ( return (
<Box <Box
sx={{ sx={{

View 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;

View File

@ -34,6 +34,9 @@ export async function fetchTrades({
}); });
if (buyerPublicKey) params.append('buyerPublicKey', buyerPublicKey); if (buyerPublicKey) params.append('buyerPublicKey', buyerPublicKey);
if (sellerPublicKey) params.append('sellerPublicKey', sellerPublicKey); if (sellerPublicKey) params.append('sellerPublicKey', sellerPublicKey);
if (minimumTimestamp === 0) {
params.delete('minimumTimestamp');
}
const url = `crosschain/trades?${params.toString()}`; const url = `crosschain/trades?${params.toString()}`;
const resp = await fetch(url); const resp = await fetch(url);
@ -42,7 +45,11 @@ export async function fetchTrades({
} }
// Candle chart utility // 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[] { export function aggregateDailyCandles(trades: Trade[]): Candle[] {
if (!trades.length) return []; if (!trades.length) return [];
@ -81,6 +88,7 @@ export function aggregateCandles(
const sorted = trades const sorted = trades
.slice() .slice()
.sort((a, b) => a.tradeTimestamp - b.tradeTimestamp); .sort((a, b) => a.tradeTimestamp - b.tradeTimestamp);
const candles: Candle[] = []; const candles: Candle[] = [];
let current: { let current: {
bucket: number; bucket: number;
@ -88,41 +96,52 @@ export function aggregateCandles(
high: number; high: number;
low: number; low: number;
close: number; close: number;
volume: number;
} | null = null; } | null = null;
const getPrice = (trade: Trade) => { for (const t of sorted) {
const qort = parseFloat(trade.qortAmount); const q = parseFloat(t.qortAmount);
const ltc = parseFloat(trade.foreignAmount); const f = parseFloat(t.foreignAmount);
return qort > 0 ? ltc / qort : null; 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 || current.bucket !== bucket) {
if (current) // flush previous candle
if (current) {
candles.push({ candles.push({
x: current.bucket, x: current.bucket,
y: [current.open, current.high, current.low, current.close], y: [current.open, current.high, current.low, current.close],
volume: current.volume,
}); });
}
// start a new bucket
current = { current = {
bucket, bucket,
open: price, open: price,
high: price, high: price,
low: price, low: price,
close: price, close: price,
volume: q, // initialize volume to this trade's QORT
}; };
} else { } else {
// same bucket → update high/low/close & accumulate
current.high = Math.max(current.high, price); current.high = Math.max(current.high, price);
current.low = Math.min(current.low, price); current.low = Math.min(current.low, price);
current.close = 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({ candles.push({
x: current.bucket, x: current.bucket,
y: [current.open, current.high, current.low, current.close], y: [current.open, current.high, current.low, current.close],
volume: current.volume,
}); });
}
return candles; return candles;
} }

View File

@ -1,15 +1,17 @@
{ {
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020", "target": "ES2022",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"isolatedModules": true, "isolatedModules": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,