working charts,still making improvements

This commit is contained in:
crowetic 2025-06-06 19:18:44 -07:00
parent d3a17fde00
commit 84ab5946e7
5 changed files with 595 additions and 215 deletions

View File

@ -2,72 +2,477 @@ import React, { useEffect, useState } from 'react';
import QortCandlestickChart, { import QortCandlestickChart, {
Candle, Candle,
} from './components/QortCandlestickChart'; } 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 ONE_HOUR = 60 * 60 * 1000;
const STORAGE_KEY = 'QORT_CANDLE_TRADES';
const App: React.FC = () => { const App: React.FC = () => {
const theme = useTheme();
const [candles, setCandles] = useState<Candle[]>([]); const [candles, setCandles] = useState<Candle[]>([]);
const [interval, setInterval] = useState(ONE_HOUR); const [interval, setInterval] = useState(ONE_HOUR);
const [minTimestamp, setMinTimestamp] = useState( const [period, setPeriod] = useState('3M');
Date.now() - 30 * 24 * ONE_HOUR const [selectedChain, setSelectedChain] = useState(CHAINS[0].value);
); // 30 days ago
// Youd 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(() => { useEffect(() => {
let cancelled = false; const cached = localStorage.getItem(STORAGE_KEY);
fetchTrades({ if (cached) {
foreignBlockchain: DEFAULT_BLOCKCHAIN, try {
minimumTimestamp: minTimestamp, setAllChainTrades(JSON.parse(cached));
limit: 1000, } catch (err) {
offset: 0, console.log(err);
reverse: true, localStorage.removeItem(STORAGE_KEY); // Bad cache, nuke it
}) }
.then((trades) => { }
if (!cancelled) setCandles(aggregateCandles(trades, interval)); setCacheLoaded(true);
}) }, []);
.catch((e) => {
if (!cancelled) setCandles([]);
console.error(e); useEffect(() => {
}); // Always save to localStorage when allChainTrades updates
return () => { if (cacheLoaded) {
cancelled = true; localStorage.setItem(STORAGE_KEY, JSON.stringify(allChainTrades));
}; }
}, [interval, minTimestamp]); }, [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 ( return (
<div style={{ background: '#10161c', minHeight: '100vh', padding: 32 }}> <>
<div style={{ marginBottom: 16 }}> {isFiltering && (
<label> <Box
Pair:&nbsp; sx={{
<select position: 'fixed',
value={blockchain} top: 0,
onChange={(e) => setBlockchain(e.target.value)} 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> {/* Action Button */}
<option value="RAVENCOIN">RVN</option> <Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
<option value="BITCOIN">BTC</option> {!chainFetched && !chainFetching && (
<option value="DIGIBYTE">DGB</option> <>
<option value="PIRATECHAIN">ARRR</option> <Button
<option value="DOGECOIN">DOGE</option> variant="contained"
</select> color="primary"
</label> size="large"
&nbsp; Interval:&nbsp; sx={{
<button onClick={() => setInterval(ONE_HOUR)}>1H</button> fontWeight: 700,
<button onClick={() => setInterval(24 * ONE_HOUR)}>1D</button> fontSize: 24,
&nbsp; Start Date:&nbsp; borderRadius: 3,
<input px: 4,
type="date" }}
value={new Date(minTimestamp).toISOString().slice(0, 10)} onClick={() => startFetchAll(selectedChain)}
onChange={(e) => setMinTimestamp(new Date(e.target.value).getTime())} >
/> Fetch ALL{' '}
</div> {CHAINS.find((c) => c.value === selectedChain)?.label ||
<QortCandlestickChart candles={candles} /> selectedChain}{' '}
</div> 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:&nbsp;
<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>
&nbsp; Interval:&nbsp;
<Button size="small" onClick={() => setInterval(ONE_HOUR)}>
1H
</Button>
<Button size="small" onClick={() => setInterval(24 * ONE_HOUR)}>
1D
</Button>
&nbsp; Show:&nbsp;
{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; export default App;

View File

@ -1,44 +1,120 @@
import React from 'react'; import React from 'react';
import Chart from 'react-apexcharts'; import Chart from 'react-apexcharts';
import type { ApexOptions } from 'apexcharts'; import { ApexOptions } from 'apexcharts';
export interface Candle { export interface Candle {
x: number; x: number; // timestamp
y: [number, number, number, number]; y: [number, number, number, number]; // [open, high, low, close]
} }
interface Props { interface Props {
candles: Candle[]; 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 = { const options: ApexOptions = {
chart: { chart: {
type: 'candlestick', type: 'candlestick',
height: 420, background: background,
background: '#181e24', toolbar: {
toolbar: { show: true }, 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: { title: {
text: 'QORT/LTC Price (1h Candles)', text: `QORT/${pairLabel} Price (${intervalLabel} Candles) (${themeMode === 'dark' ? 'Dark' : 'Light'} Theme)`,
align: 'left', align: 'center',
style: { color: '#fff' }, style: { color: textColor, fontWeight: 700, fontSize: '1.11rem' },
offsetY: 8, //adjust if necessary
}, },
xaxis: { xaxis: {
type: 'datetime', type: 'datetime',
labels: { style: { colors: '#ccc' } }, labels: { style: { colors: textColor } },
axisBorder: { color: textColor },
axisTicks: { color: textColor },
tooltip: { enabled: true },
}, },
yaxis: { yaxis: {
tooltip: { enabled: true }, 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 = [ const series = [
{ { name: 'Price', type: 'candlestick', data: candles },
data: candles, ...(showSMA && smaData.length
}, ? [{ name: `SMA (7)`, type: 'line', data: smaData }]
: []),
]; ];
return ( return (

View File

@ -1,23 +1,23 @@
import React, { FC } from 'react'; import React from 'react';
import { ThemeProvider } from '@emotion/react'; import { ThemeProvider } from '@mui/material/styles';
import { lightTheme, darkTheme } from './theme';
import { CssBaseline } from '@mui/material'; import { CssBaseline } from '@mui/material';
import { EnumTheme, themeAtom } from '../../state/global/system';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { EnumTheme, themeAtom } from '../../state/global/system';
import { lightTheme, darkTheme } from './theme';
interface ThemeProviderWrapperProps { const ThemeProviderWrapper: React.FC<{ children: React.ReactNode }> = ({
children: React.ReactNode; children,
} }) => {
const [themeMode] = useAtom(themeAtom);
const ThemeProviderWrapper: FC<ThemeProviderWrapperProps> = ({ children }) => { const theme = React.useMemo(
const [theme] = useAtom(themeAtom); () => (themeMode === EnumTheme.LIGHT ? lightTheme : darkTheme),
[themeMode]
);
return ( return (
<ThemeProvider theme={theme === EnumTheme.LIGHT ? lightTheme : darkTheme}> <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
{children} {children}
</ThemeProvider> </ThemeProvider>
); );
}; };
export default ThemeProviderWrapper; export default ThemeProviderWrapper;

View File

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

View File

@ -21,7 +21,7 @@ export async function fetchTrades({
minimumTimestamp, minimumTimestamp,
buyerPublicKey, buyerPublicKey,
sellerPublicKey, sellerPublicKey,
limit = 1000, limit = 100,
offset = 0, offset = 0,
reverse = false, reverse = false,
}: FetchTradesOptions): Promise<Trade[]> { }: FetchTradesOptions): Promise<Trade[]> {
@ -44,6 +44,36 @@ 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] };
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( export function aggregateCandles(
trades: Trade[], trades: Trade[],
intervalMs: number intervalMs: number