diff --git a/src/App.tsx b/src/App.tsx index 9a15567..e293f49 100644 --- a/src/App.tsx +++ b/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([]); 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>( + {} + ); + const [isFetchingAll, setIsFetchingAll] = useState>( + {} + ); + const [fetchProgress, setFetchProgress] = useState>( + {} + ); + const [fetchError, setFetchError] = useState>( + {} + ); + const [fetchModalOpen, setFetchModalOpen] = useState(false); + + const [cacheLoaded, setCacheLoaded] = useState(false); + + const [isFiltering, setIsFiltering] = useState(false); + const [isUpdating, setIsUpdating] = useState>({}); + + // 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
Loading trade cache...
; - // Add controls as needed for changing interval and time range - const [blockchain, setBlockchain] = useState(DEFAULT_BLOCKCHAIN); return ( -
-
- -   Interval:  - - -   Start Date:  - setMinTimestamp(new Date(e.target.value).getTime())} - /> -
- -
+ {/* Action Button */} + + {!chainFetched && !chainFetching && ( + <> + + {/* */} + + )} + + {chainFetching && ( + + )} + + {chainFetched && !chainFetching && ( + <> + + + + )} + + {/* Controls */} + + +   Interval:  + + +   Show:  + {PERIODS.map((p) => ( + + ))} + + {/* Chart */} + + + + + + + ); }; + export default App; diff --git a/src/components/QortCandlestickChart.tsx b/src/components/QortCandlestickChart.tsx index 199c52c..0823a30 100644 --- a/src/components/QortCandlestickChart.tsx +++ b/src/components/QortCandlestickChart.tsx @@ -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 = ({ 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 = ({ + 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 ( diff --git a/src/styles/theme/theme-provider.tsx b/src/styles/theme/theme-provider.tsx index cfda13b..e495a85 100644 --- a/src/styles/theme/theme-provider.tsx +++ b/src/styles/theme/theme-provider.tsx @@ -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 = ({ 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 ( - + {children} ); }; - export default ThemeProviderWrapper; diff --git a/src/utils/fetchAllTrades.tsx b/src/utils/fetchAllTrades.tsx deleted file mode 100644 index 2ccadfb..0000000 --- a/src/utils/fetchAllTrades.tsx +++ /dev/null @@ -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 = ({ - open, - onClose, - onComplete, - foreignBlockchain, - batchSize = 200, - minTimestamp = 0, -}) => { - const [progress, setProgress] = useState(0); - const [totalFetched, setTotalFetched] = useState(0); - const [error, setError] = useState(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 ( - - Fetching All Trades - - {isFetching ? ( - <> - - Obtaining all trades for {foreignBlockchain}.
- This could take a while, please be patient... -
- - {totalFetched} trades fetched (last batch: {progress}) - - - - ) : error ? ( - - Error: {error} - - ) : ( - - Fetch complete. - - )} -
- - - -
- ); -}; - -export default FetchAllTradesModal; diff --git a/src/utils/qortTrades.ts b/src/utils/qortTrades.ts index d0697b7..2c77168 100644 --- a/src/utils/qortTrades.ts +++ b/src/utils/qortTrades.ts @@ -21,7 +21,7 @@ export async function fetchTrades({ minimumTimestamp, buyerPublicKey, sellerPublicKey, - limit = 1000, + limit = 100, offset = 0, reverse = false, }: FetchTradesOptions): Promise { @@ -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