SoftTraderFrontend/components/candlestick-chart/useCandlestickData.ts
2026-03-21 09:26:43 -04:00

344 lines
11 KiB
TypeScript

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<string, string> = {
'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<string, number> = {
'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<Candle[]>([]);
const [visibleStartOffset, setVisibleStartOffset] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(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);
}