344 lines
11 KiB
TypeScript
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);
|
|
}
|