working charts,still making improvements
This commit is contained in:
parent
d3a17fde00
commit
84ab5946e7
513
src/App.tsx
513
src/App.tsx
@ -2,72 +2,477 @@ import React, { useEffect, useState } from 'react';
|
||||
import QortCandlestickChart, {
|
||||
Candle,
|
||||
} from './components/QortCandlestickChart';
|
||||
import { fetchTrades, aggregateCandles } from './utils/qortTrades';
|
||||
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';
|
||||
|
||||
const CHAINS = [
|
||||
{ value: 'LITECOIN', label: 'LTC' },
|
||||
{ value: 'BITCOIN', label: 'BTC' },
|
||||
{ value: 'RAVENCOIN', label: 'RVN' },
|
||||
{ value: 'DIGIBYTE', label: 'DGB' },
|
||||
{ value: 'PIRATECHAIN', label: 'ARRR' },
|
||||
{ value: 'DOGECOIN', label: 'DOGE' },
|
||||
];
|
||||
|
||||
const PERIODS = [
|
||||
{ label: '1D', days: 1 },
|
||||
{ label: '5D', days: 5 },
|
||||
{ label: '10D', days: 10 },
|
||||
{ label: '15D', days: 15 },
|
||||
{ label: '20D', days: 20 },
|
||||
{ label: '1M', months: 1 },
|
||||
{ label: '3M', months: 3 },
|
||||
{ label: '6M', months: 6 },
|
||||
{ label: '1Y', months: 12 },
|
||||
{ label: 'All', months: null },
|
||||
];
|
||||
|
||||
const DEFAULT_BLOCKCHAIN = 'LITECOIN';
|
||||
const ONE_HOUR = 60 * 60 * 1000;
|
||||
const STORAGE_KEY = 'QORT_CANDLE_TRADES';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const [candles, setCandles] = useState<Candle[]>([]);
|
||||
const [interval, setInterval] = useState(ONE_HOUR);
|
||||
const [minTimestamp, setMinTimestamp] = useState(
|
||||
Date.now() - 30 * 24 * ONE_HOUR
|
||||
); // 30 days ago
|
||||
const [period, setPeriod] = useState('3M');
|
||||
const [selectedChain, setSelectedChain] = useState(CHAINS[0].value);
|
||||
|
||||
// You’d call this whenever the user changes range, interval, etc
|
||||
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);
|
||||
|
||||
const [cacheLoaded, setCacheLoaded] = useState(false);
|
||||
|
||||
const [isFiltering, setIsFiltering] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState<Record<string, boolean>>({});
|
||||
|
||||
// function filterOutliersPercentile(
|
||||
// trades: Trade[],
|
||||
// lower = 0.01,
|
||||
// upper = 0.99
|
||||
// ): Trade[] {
|
||||
// if (trades.length < 10) return trades;
|
||||
|
||||
// // Compute prices
|
||||
// const prices = trades
|
||||
// .map((t) => parseFloat(t.foreignAmount) / parseFloat(t.qortAmount))
|
||||
// .filter((x) => isFinite(x) && x > 0);
|
||||
|
||||
// // Sort prices
|
||||
// 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];
|
||||
|
||||
// // Filter trades within percentile range
|
||||
// return trades.filter((t) => {
|
||||
// const price = parseFloat(t.foreignAmount) / parseFloat(t.qortAmount);
|
||||
// return price >= min && price <= max;
|
||||
// });
|
||||
// }
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// --- LocalStorage LOAD on mount ---
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchTrades({
|
||||
foreignBlockchain: DEFAULT_BLOCKCHAIN,
|
||||
minimumTimestamp: minTimestamp,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
reverse: true,
|
||||
})
|
||||
.then((trades) => {
|
||||
if (!cancelled) setCandles(aggregateCandles(trades, interval));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setCandles([]);
|
||||
const cached = localStorage.getItem(STORAGE_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
setAllChainTrades(JSON.parse(cached));
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
localStorage.removeItem(STORAGE_KEY); // Bad cache, nuke it
|
||||
}
|
||||
}
|
||||
setCacheLoaded(true);
|
||||
}, []);
|
||||
|
||||
console.error(e);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [interval, minTimestamp]);
|
||||
useEffect(() => {
|
||||
// Always save to localStorage when allChainTrades updates
|
||||
if (cacheLoaded) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(allChainTrades));
|
||||
}
|
||||
}, [allChainTrades, cacheLoaded]);
|
||||
|
||||
// --- Filtering candles for chart based on selected time period ---
|
||||
useEffect(() => {
|
||||
if (!cacheLoaded) {
|
||||
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
|
||||
if (periodObj.months >= 1) useDaily = true;
|
||||
} else if ('months' in periodObj && periodObj.months === null) {
|
||||
// 'All'
|
||||
minTimestamp = 0;
|
||||
useDaily = true;
|
||||
}
|
||||
}
|
||||
// --- Filter trades ---
|
||||
const trades = allChainTrades[selectedChain] || [];
|
||||
let filtered = minTimestamp
|
||||
? trades.filter((t) => t.tradeTimestamp >= minTimestamp)
|
||||
: trades;
|
||||
filtered = fastPercentileFilter(filtered, 0.01, 0.99);
|
||||
|
||||
// --- Aggregate ---
|
||||
if (useDaily) {
|
||||
setCandles(aggregateDailyCandles(filtered));
|
||||
} else {
|
||||
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[] = [];
|
||||
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 }));
|
||||
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] || [])],
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
setIsUpdating((prev) => ({ ...prev, [chain]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// --- UI state helpers ---
|
||||
const chainFetched = !!allChainTrades[selectedChain];
|
||||
const chainFetching = !!isFetchingAll[selectedChain];
|
||||
|
||||
if (!cacheLoaded) return <div>Loading trade cache...</div>;
|
||||
|
||||
// Add controls as needed for changing interval and time range
|
||||
const [blockchain, setBlockchain] = useState(DEFAULT_BLOCKCHAIN);
|
||||
return (
|
||||
<div style={{ background: '#10161c', minHeight: '100vh', padding: 32 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label>
|
||||
Pair:
|
||||
<select
|
||||
value={blockchain}
|
||||
onChange={(e) => setBlockchain(e.target.value)}
|
||||
<>
|
||||
{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>
|
||||
</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="xl" disableGutters>
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
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}
|
||||
sx={{
|
||||
p: { xs: 1, md: 4 },
|
||||
maxWidth: 1800,
|
||||
margin: '36px auto 0 auto',
|
||||
background: theme.palette.background.paper,
|
||||
boxShadow: theme.shadows[6],
|
||||
}}
|
||||
>
|
||||
<option value="LITECOIN">LTC</option>
|
||||
<option value="RAVENCOIN">RVN</option>
|
||||
<option value="BITCOIN">BTC</option>
|
||||
<option value="DIGIBYTE">DGB</option>
|
||||
<option value="PIRATECHAIN">ARRR</option>
|
||||
<option value="DOGECOIN">DOGE</option>
|
||||
</select>
|
||||
</label>
|
||||
Interval:
|
||||
<button onClick={() => setInterval(ONE_HOUR)}>1H</button>
|
||||
<button onClick={() => setInterval(24 * ONE_HOUR)}>1D</button>
|
||||
Start Date:
|
||||
<input
|
||||
type="date"
|
||||
value={new Date(minTimestamp).toISOString().slice(0, 10)}
|
||||
onChange={(e) => setMinTimestamp(new Date(e.target.value).getTime())}
|
||||
/>
|
||||
</div>
|
||||
<QortCandlestickChart candles={candles} />
|
||||
</div>
|
||||
{/* 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>
|
||||
{/* Chart */}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 1800,
|
||||
height: { xs: 320, md: 520 },
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
@ -1,44 +1,120 @@
|
||||
import React from 'react';
|
||||
import Chart from 'react-apexcharts';
|
||||
import type { ApexOptions } from 'apexcharts';
|
||||
import { ApexOptions } from 'apexcharts';
|
||||
|
||||
export interface Candle {
|
||||
x: number;
|
||||
y: [number, number, number, number];
|
||||
x: number; // timestamp
|
||||
y: [number, number, number, number]; // [open, high, low, close]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
candles: Candle[];
|
||||
showSMA?: boolean;
|
||||
themeMode?: 'dark' | 'light';
|
||||
background?: string;
|
||||
textColor?: string;
|
||||
pairLabel?: string; // e.g. 'LTC'
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
const QortCandlestickChart: React.FC<Props> = ({ candles }) => {
|
||||
function calculateSMA(data: Candle[], windowSize = 7) {
|
||||
const sma = [];
|
||||
for (let i = windowSize - 1; i < data.length; i++) {
|
||||
const sum = data
|
||||
.slice(i - windowSize + 1, i + 1)
|
||||
.reduce((acc, c) => acc + c.y[3], 0);
|
||||
sma.push({ x: data[i].x, y: sum / windowSize });
|
||||
}
|
||||
return sma;
|
||||
}
|
||||
|
||||
const QortCandlestickChart: React.FC<Props> = ({
|
||||
candles,
|
||||
showSMA = true,
|
||||
themeMode = 'dark',
|
||||
background = '#10161c',
|
||||
textColor = '#fff',
|
||||
pairLabel = 'LTC',
|
||||
interval = 60 * 60 * 1000,
|
||||
}) => {
|
||||
const smaData = showSMA ? calculateSMA(candles, 7) : [];
|
||||
const intervalLabel = interval === 24 * 60 * 60 * 1000 ? '1d' : '1h';
|
||||
// const [yMin, setYMin] = useState(undefined);
|
||||
// const [yMax, setYMax] = useState(undefined);
|
||||
const options: ApexOptions = {
|
||||
chart: {
|
||||
type: 'candlestick',
|
||||
height: 420,
|
||||
background: '#181e24',
|
||||
toolbar: { show: true },
|
||||
background: background,
|
||||
toolbar: {
|
||||
show: true,
|
||||
tools: {
|
||||
download: true,
|
||||
selection: true,
|
||||
// zoom: true,
|
||||
pan: true,
|
||||
reset: true,
|
||||
},
|
||||
autoSelected: 'zoom',
|
||||
},
|
||||
zoom: {
|
||||
enabled: true,
|
||||
type: 'xy',
|
||||
autoScaleYaxis: true,
|
||||
},
|
||||
animations: { enabled: true },
|
||||
},
|
||||
title: {
|
||||
text: 'QORT/LTC Price (1h Candles)',
|
||||
align: 'left',
|
||||
style: { color: '#fff' },
|
||||
text: `QORT/${pairLabel} Price (${intervalLabel} Candles) (${themeMode === 'dark' ? 'Dark' : 'Light'} Theme)`,
|
||||
align: 'center',
|
||||
style: { color: textColor, fontWeight: 700, fontSize: '1.11rem' },
|
||||
offsetY: 8, //adjust if necessary
|
||||
},
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
labels: { style: { colors: '#ccc' } },
|
||||
labels: { style: { colors: textColor } },
|
||||
axisBorder: { color: textColor },
|
||||
axisTicks: { color: textColor },
|
||||
tooltip: { enabled: true },
|
||||
},
|
||||
yaxis: {
|
||||
tooltip: { enabled: true },
|
||||
labels: { style: { colors: '#ccc' } },
|
||||
labels: { style: { colors: textColor } },
|
||||
axisBorder: { color: textColor },
|
||||
axisTicks: { color: textColor },
|
||||
},
|
||||
theme: { mode: 'dark' },
|
||||
theme: { mode: themeMode },
|
||||
legend: {
|
||||
labels: { colors: textColor },
|
||||
show: showSMA && smaData.length > 0,
|
||||
},
|
||||
grid: {
|
||||
borderColor: themeMode === 'dark' ? '#333' : '#ccc',
|
||||
},
|
||||
tooltip: {
|
||||
theme: themeMode,
|
||||
},
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 800,
|
||||
options: {
|
||||
chart: { height: 320 },
|
||||
title: { style: { fontSize: '1rem' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
breakpoint: 1200,
|
||||
options: {
|
||||
chart: { height: 400 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const series = [
|
||||
{
|
||||
data: candles,
|
||||
},
|
||||
{ name: 'Price', type: 'candlestick', data: candles },
|
||||
...(showSMA && smaData.length
|
||||
? [{ name: `SMA (7)`, type: 'line', data: smaData }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
|
@ -1,23 +1,23 @@
|
||||
import React, { FC } from 'react';
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import { lightTheme, darkTheme } from './theme';
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { CssBaseline } from '@mui/material';
|
||||
import { EnumTheme, themeAtom } from '../../state/global/system';
|
||||
import { useAtom } from 'jotai';
|
||||
import { EnumTheme, themeAtom } from '../../state/global/system';
|
||||
import { lightTheme, darkTheme } from './theme';
|
||||
|
||||
interface ThemeProviderWrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ThemeProviderWrapper: FC<ThemeProviderWrapperProps> = ({ children }) => {
|
||||
const [theme] = useAtom(themeAtom);
|
||||
|
||||
const ThemeProviderWrapper: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [themeMode] = useAtom(themeAtom);
|
||||
const theme = React.useMemo(
|
||||
() => (themeMode === EnumTheme.LIGHT ? lightTheme : darkTheme),
|
||||
[themeMode]
|
||||
);
|
||||
return (
|
||||
<ThemeProvider theme={theme === EnumTheme.LIGHT ? lightTheme : darkTheme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeProviderWrapper;
|
||||
|
@ -1,131 +0,0 @@
|
||||
/* eslint-disable prettier/prettier */
|
||||
import React, { useEffect, useState } 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';
|
||||
|
||||
export interface Trade {
|
||||
tradeTimestamp: number;
|
||||
qortAmount: string;
|
||||
btcAmount: string;
|
||||
foreignAmount: string;
|
||||
}
|
||||
|
||||
interface FetchAllTradesModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onComplete: (allTrades: Trade[]) => void;
|
||||
foreignBlockchain: string;
|
||||
batchSize?: number;
|
||||
minTimestamp?: number;
|
||||
}
|
||||
|
||||
const FetchAllTradesModal: React.FC<FetchAllTradesModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
foreignBlockchain,
|
||||
batchSize = 200,
|
||||
minTimestamp = 0,
|
||||
}) => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [totalFetched, setTotalFetched] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!open) return;
|
||||
|
||||
async function fetchAllTrades() {
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
setTotalFetched(0);
|
||||
setIsFetching(true);
|
||||
|
||||
let allTrades: Trade[] = [];
|
||||
let offset = 0;
|
||||
let keepFetching = true;
|
||||
|
||||
try {
|
||||
while (keepFetching && !cancelled) {
|
||||
const params = new URLSearchParams({
|
||||
foreignBlockchain,
|
||||
offset: offset.toString(),
|
||||
limit: batchSize.toString(),
|
||||
minimumTimestamp: minTimestamp.toString(),
|
||||
reverse: 'false',
|
||||
});
|
||||
const url = `/crosschain/trades?${params.toString()}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const trades: Trade[] = await resp.json();
|
||||
|
||||
allTrades = allTrades.concat(trades);
|
||||
setTotalFetched(allTrades.length);
|
||||
setProgress(trades.length);
|
||||
|
||||
offset += trades.length;
|
||||
if (trades.length < batchSize) keepFetching = false;
|
||||
}
|
||||
if (!cancelled) {
|
||||
onComplete(allTrades);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(String(e));
|
||||
} finally {
|
||||
if (!cancelled) setIsFetching(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchAllTrades();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, foreignBlockchain, batchSize, minTimestamp, onComplete]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={isFetching ? undefined : onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Fetching All Trades</DialogTitle>
|
||||
<DialogContent>
|
||||
{isFetching ? (
|
||||
<>
|
||||
<Typography gutterBottom>
|
||||
Obtaining all trades for <b>{foreignBlockchain}</b>.<br />
|
||||
This could take a while, please be patient...
|
||||
</Typography>
|
||||
<Typography gutterBottom>
|
||||
<b>{totalFetched}</b> trades fetched (last batch: {progress})
|
||||
</Typography>
|
||||
<CircularProgress />
|
||||
</>
|
||||
) : error ? (
|
||||
<Typography color="error" gutterBottom>
|
||||
Error: {error}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography color="success.main" gutterBottom>
|
||||
Fetch complete.
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
disabled={isFetching}
|
||||
variant="contained"
|
||||
color={isFetching ? 'inherit' : 'primary'}
|
||||
>
|
||||
{isFetching ? 'Fetching...' : 'Close'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default FetchAllTradesModal;
|
@ -21,7 +21,7 @@ export async function fetchTrades({
|
||||
minimumTimestamp,
|
||||
buyerPublicKey,
|
||||
sellerPublicKey,
|
||||
limit = 1000,
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
reverse = false,
|
||||
}: FetchTradesOptions): Promise<Trade[]> {
|
||||
@ -44,6 +44,36 @@ export async function fetchTrades({
|
||||
// Candle chart utility
|
||||
export type Candle = { x: number; y: [number, number, number, number] };
|
||||
|
||||
export function aggregateDailyCandles(trades: Trade[]): Candle[] {
|
||||
if (!trades.length) return [];
|
||||
const dayBuckets: { [day: string]: Trade[] } = {};
|
||||
trades.forEach((t) => {
|
||||
const d = new Date(t.tradeTimestamp);
|
||||
const dayKey = `${d.getUTCFullYear()}-${d.getUTCMonth() + 1}-${d.getUTCDate()}`;
|
||||
if (!dayBuckets[dayKey]) dayBuckets[dayKey] = [];
|
||||
dayBuckets[dayKey].push(t);
|
||||
});
|
||||
return Object.entries(dayBuckets)
|
||||
.map(([, tList]) => {
|
||||
const sorted = tList.sort((a, b) => a.tradeTimestamp - b.tradeTimestamp);
|
||||
const prices = sorted.map(
|
||||
(t) => parseFloat(t.foreignAmount) / parseFloat(t.qortAmount)
|
||||
);
|
||||
if (prices.length === 0) return null; // Should not happen, but for safety
|
||||
|
||||
// Force tuple type
|
||||
const open = prices[0];
|
||||
const high = Math.max(...prices);
|
||||
const low = Math.min(...prices);
|
||||
const close = prices[prices.length - 1];
|
||||
return {
|
||||
x: sorted[0].tradeTimestamp,
|
||||
y: [open, high, low, close] as [number, number, number, number],
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Candle[];
|
||||
}
|
||||
|
||||
export function aggregateCandles(
|
||||
trades: Trade[],
|
||||
intervalMs: number
|
||||
|
Loading…
x
Reference in New Issue
Block a user