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": {
|
"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",
|
||||||
|
623
src/App.tsx
623
src/App.tsx
@ -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:
|
Pair:
|
||||||
<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>
|
||||||
Interval:
|
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>
|
||||||
Show:
|
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;
|
|
||||||
|
@ -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={{
|
||||||
|
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 (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;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user