import { useState, useEffect, useCallback } from 'react'; import type { Candle } from './chartTypes'; const API_BASE_URL = 'https://api.dongfeng-systems.org'; // Map internal barSize values to API barSize enum const BAR_SIZE_MAP: Record = { '1m': 'OneMin', '2m': 'TwoMins', '5m': 'FiveMins', '15m': 'FifteenMins', '30m': 'ThirtyMins', '1h': 'OneHour', '2h': 'TwoHours', '4h': 'FourHours', '1D': 'OneDay', '1W': 'OneWeek', '1M': 'OneMonth', }; interface ApiCandle { timestamp: string; open: number; high: number; low: number; close: number; volume: number; } interface UseCandlestickDataOptions { symbol: string; user: string; startDate: string | Date; endDate: string | Date; barSize: string; } interface UseCandlestickDataResult { candles: Candle[]; visibleStartOffset: number; isLoading: boolean; error: Error | null; refetch: () => void; } /** * Converts a date input to a Date object. */ function toDate(date: string | Date): Date { if (typeof date === 'string') { return new Date(date); } return date; } /** * Checks if two dates are the same calendar day. */ function isSameDay(date1: Date, date2: Date): boolean { return ( date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate() ); } /** * Calculates the number of trading days between two dates (excludes weekends). */ function calculateTradingDays(startDate: Date, endDate: Date): number { let tradingDays = 0; const current = new Date(startDate); while (current <= endDate) { const dayOfWeek = current.getDay(); // 0 = Sunday, 6 = Saturday if (dayOfWeek !== 0 && dayOfWeek !== 6) { tradingDays++; } current.setDate(current.getDate() + 1); } return tradingDays; } /** * Converts trading days to API duration format. * Format: "{number}+{unit}" where unit is D (days), W (weeks), M (months), Y (years) * * The API duration uses calendar units, so trading days must be converted: * ~5 trading days = 1 calendar week, ~21 trading days = 1 calendar month. */ function tradingDaysToDuration(tradingDays: number, barSize: string): string { if (tradingDays <= 0) { return '1+D'; } // Convert trading days to calendar days (7 calendar days per 5 trading days) const calendarDays = Math.ceil(tradingDays * 7 / 5); // For 30m and hourly bars, prefer W/M units over large D values. // The IB API can reject D-unit durations that exceed per-bar-size // thresholds even when the equivalent W/M duration would succeed. if (['30m', '1h', '2h', '4h'].includes(barSize)) { if (calendarDays <= 6) return `${calendarDays}+D`; const weeks = Math.ceil(calendarDays / 7); if (weeks <= 4) return `${weeks}+W`; return '1+M'; } // For short durations, use days if (calendarDays <= 90) { return `${calendarDays}+D`; } // For medium durations, use weeks const weeks = Math.ceil(calendarDays / 7); if (weeks <= 52) { return `${weeks}+W`; } // For longer durations, use months or years const months = Math.ceil(calendarDays / 30); if (months <= 24) { return `${months}+M`; } const years = Math.ceil(months / 12); return `${years}+Y`; } // Extra trading days fetched before the visible range so indicators (EMA, MACD, // etc.) have historical data to converge before the first visible candle. // Scaled by bar size because intraday bars produce many candles per day (a single // day of 5m bars is ~78 candles — more than enough for any indicator), while // daily/weekly bars need more calendar days to accumulate sufficient bars. // The IB API also enforces maximum duration limits per bar size, so large warmup // values cause errors for intraday bars. function getWarmupTradingDays(barSize: string): number { switch (barSize) { case '1m': case '2m': return 1; case '5m': return 2; case '15m': return 3; case '30m': return 5; case '1h': case '2h': return 10; case '4h': return 20; default: return 50; } } /** * Fetches historical candle data from the Dongfeng Systems API. * Uses duration-based query calculated from start/end dates excluding weekends. * Fetches extra warm-up bars before the start date for indicator accuracy. * If startDate equals endDate, fetches intraday data from 9:30 AM to 4:00 PM EST. */ async function fetchCandlesFromAPI( symbol: string, startDate: string | Date, endDate: string | Date, barSize: string, signal?: AbortSignal ): Promise<{ candles: Candle[]; visibleStartOffset: number }> { const start = toDate(startDate); const end = toDate(endDate); let duration: string; let endDateTime: string; let apiBarSize: string; // Check if same day - show intraday data for that day if (isSameDay(start, end)) { // Use end-of-day so the 1-day window always covers the full trading // session regardless of DST (EDT close is 20:00 UTC, EST is 21:00 UTC). const endOfDay = new Date(end); endOfDay.setUTCHours(23, 59, 59, 0); endDateTime = endOfDay.toISOString(); // Default to 5m bars for intraday if a daily/weekly bar size is specified const intradayBarSize = ['1D', '1W', '1M'].includes(barSize) ? '5m' : barSize; // Cap warmup days to IB API max duration per bar size // (e.g. 2m bars max 2D, 1m bars max 1D) const maxDays: Record = { '1m': 1, '2m': 2, '5m': 7, '15m': 14, '30m': 28, }; duration = `${Math.min(3, maxDays[intradayBarSize] ?? 3)}+D`; apiBarSize = BAR_SIZE_MAP[intradayBarSize] || 'FiveMins'; } else { const tradingDays = calculateTradingDays(start, end); duration = tradingDaysToDuration(tradingDays + getWarmupTradingDays(barSize), barSize); const endOfDay = new Date(end); endOfDay.setUTCHours(23, 59, 59, 0); endDateTime = endOfDay.toISOString(); apiBarSize = BAR_SIZE_MAP[barSize] || 'OneDay'; } const params = new URLSearchParams({ symbol: symbol.toUpperCase(), endDateTime, duration, barSize: apiBarSize, whatToShow: 'Trades', useRth: 'true', }); const url = `${API_BASE_URL}/api/market/historical?${params.toString()}`; const response = await fetch(url, { signal }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); const errorMsg: string = errorData.error || ''; // IB returns "no data" for non-trading days, pre-market, etc. — treat as empty result if (errorMsg.includes('HMDS query returned no data')) { return { candles: [], visibleStartOffset: 0 }; } throw new Error(errorMsg || `API request failed with status ${response.status}`); } const data: ApiCandle[] = await response.json(); const candles = normalizeCandles(data); // Find the first candle at or after the original start date. // Warm-up candles before this index are used for indicator calculations // but not displayed on the chart. const startOfDay = new Date(start); startOfDay.setUTCHours(0, 0, 0, 0); let visibleStartOffset = candles.findIndex(c => c.time >= startOfDay.getTime()); if (visibleStartOffset === -1) visibleStartOffset = 0; return { candles, visibleStartOffset }; } /** * Data layer hook for candlestick chart. * Manages fetching, caching, and updating candle data from the Dongfeng Systems API. * * TODO: Add WebSocket support for live updates * TODO: Add local caching/persistence */ export function useCandlestickData({ symbol, startDate, endDate, barSize, }: UseCandlestickDataOptions): UseCandlestickDataResult { const [candles, setCandles] = useState([]); const [visibleStartOffset, setVisibleStartOffset] = useState(0); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); // Stabilize date deps — Date objects are compared by reference in React's // dependency arrays, so convert to strings (compared by value) to prevent // infinite re-fetch loops if the parent provides new Date instances. const startDateStr = typeof startDate === 'string' ? startDate : startDate.toISOString(); const endDateStr = typeof endDate === 'string' ? endDate : endDate.toISOString(); // Manual refetch — bump a counter to re-trigger the effect const [refetchCount, setRefetchCount] = useState(0); const refetch = useCallback(() => { setRefetchCount((c) => c + 1); }, []); useEffect(() => { const abortController = new AbortController(); let cancelled = false; setIsLoading(true); setError(null); fetchCandlesFromAPI(symbol, startDateStr, endDateStr, barSize, abortController.signal) .then(({ candles: data, visibleStartOffset: offset }) => { if (!cancelled) { setCandles(data); setVisibleStartOffset(offset); setIsLoading(false); } }) .catch((err) => { if (!cancelled) { if (err instanceof DOMException && err.name === 'AbortError') return; setError(err instanceof Error ? err : new Error('Failed to fetch candles')); setIsLoading(false); } }); return () => { cancelled = true; abortController.abort(); }; }, [symbol, startDateStr, endDateStr, barSize, refetchCount]); return { candles, visibleStartOffset, isLoading, error, refetch, }; } /** * Normalizes raw API candle data to our internal Candle format. * API returns: { timestamp: string, open, high, low, close, volume } * Internal format: { time: number (ms), open, high, low, close, volume } */ export function normalizeCandles(rawData: ApiCandle[]): Candle[] { return rawData.map((item) => ({ time: new Date(item.timestamp).getTime(), open: item.open, high: item.high, low: item.low, close: item.close, volume: item.volume, })).sort((a, b) => a.time - b.time); } /** * Merges new candles with existing data, handling updates to the latest candle. * Useful for live data updates. */ export function mergeCandles(existing: Candle[], incoming: Candle[]): Candle[] { if (incoming.length === 0) return existing; if (existing.length === 0) return incoming; const existingMap = new Map(existing.map(c => [c.time, c])); // Update or add incoming candles for (const candle of incoming) { existingMap.set(candle.time, candle); } // Sort by time and return return Array.from(existingMap.values()).sort((a, b) => a.time - b.time); }