/** * yahooFinanceAPI.js * Fetches historical OHLCV data and live price updates from Yahoo Finance * via the Vite dev proxy. */ const YAHOO_BASE = '/api/yahoo/v8/finance/chart'; export const YAHOO_SYMBOL_MAP = { BTCUSDT: 'BTC-USD', // Bitcoin Spot XAUUSD: 'GC=F', // Gold Futures GBPUSD: 'GBPUSD=X', // Spot GBP/USD USDCAD: 'USDCAD=X', // Spot USD/CAD }; let xauusdOffset = null; let xauusdOffsetTime = 0; const XAU_OFFSET_TTL_MS = 5 * 60 * 1000; // refresh the futures->spot offset every 5 min /** * Calculates or returns the cached offset between Swissquote Spot Gold and Yahoo Finance Futures (GC=F). * * @param {number} latestYahooClose * @returns {Promise} */ async function getXauusdOffset(latestYahooClose) { // Cache the offset but refresh periodically — futures vs spot basis drifts // intraday, so a once-only offset goes stale over a session. if (xauusdOffset !== null && (Date.now() - xauusdOffsetTime) < XAU_OFFSET_TTL_MS) { return xauusdOffset; } try { const res = await fetch('/api/swissquote'); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const instrument = data[0]; if (instrument && instrument.spreadProfilePrices && instrument.spreadProfilePrices.length > 0) { const priceObj = instrument.spreadProfilePrices[0]; const bid = parseFloat(priceObj.bid); const ask = parseFloat(priceObj.ask); const spotPrice = (bid + ask) / 2; xauusdOffset = spotPrice - latestYahooClose; xauusdOffsetTime = Date.now(); console.log(`[yahooFinanceAPI] Calculated XAUUSD spot offset: ${xauusdOffset.toFixed(4)} (Spot: ${spotPrice.toFixed(2)}, Futures: ${latestYahooClose.toFixed(2)})`); return xauusdOffset; } } catch (err) { console.error('[yahooFinanceAPI] Failed to fetch Swissquote spot price for offset:', err); } // Keep the last known offset on failure rather than snapping back to 0. return xauusdOffset ?? 0; } const TIMEFRAME_CONFIG = { '1m': { interval: '1m', range: '5d', aggFactor: 1 }, '5m': { interval: '5m', range: '10d', aggFactor: 1 }, '15m': { interval: '15m', range: '15d', aggFactor: 1 }, '1H': { interval: '60m', range: '60d', aggFactor: 1 }, '4H': { interval: '60m', range: '120d', aggFactor: 4 }, // aggregate hourly by 4 '1D': { interval: '1d', range: '2y', aggFactor: 1 }, }; /** * Fetch historical candles from Yahoo Finance. * * @param {string} symbol - Local symbol key (XAUUSD, EURUSD, etc.) * @param {string} timeframe - Local timeframe key (1m, 5m, 15m, 1H, 4H, 1D) * @returns {Promise>} */ /** * Neighbour-relative outlier-wick filter for thin Yahoo Forex feeds. * Yahoo's `=X` FX data occasionally emits rollover / bad-tick spikes (100-200+ pip * wicks) that don't exist on a real broker feed. A fixed clamp also clips genuine * news candles, so instead a wick is only treated as a glitch when it is BOTH large * in absolute terms AND a big multiple of the LOCAL median bar range; it is then * pulled back proportionally to local volatility. Genuine news bars (whose * neighbours are also volatile) raise the local median and are left untouched. * * @param {Array<{open:number,high:number,low:number,close:number}>} candles * @param {string} symbol * @returns {Array} filtered candles (forex only; other symbols returned unchanged) */ export function filterForexOutlierWicks(candles, symbol) { if (!['GBPUSD', 'USDCAD'].includes(symbol) || !candles || candles.length === 0) { return candles; } const WINDOW = 10; // bars of context on each side const MIN_WICK = 0.0025; // only consider wicks above ~25 pips; the neighbour // ratio below decides the rest, so real news wicks // (volatile neighbours) are spared while isolated // calm-market spikes — i.e. Yahoo rollover glitches — get caught const OUTLIER_MULT = 4; // relative trip: wick exceeds 4x the local median range const ABS_MAX = 0.0150; // absolute backstop: any wick > ~150 pips on 15m FX is a // Yahoo glitch even if neighbours are also bad (rollover clusters) const median = (arr) => { if (arr.length === 0) return 0; const srt = [...arr].sort((a, b) => a - b); const mid = Math.floor(srt.length / 2); return srt.length % 2 ? srt[mid] : (srt[mid - 1] + srt[mid]) / 2; }; return candles.map((c, i) => { const maxVal = Math.max(c.open, c.close); const minVal = Math.min(c.open, c.close); const upperWick = c.high - maxVal; const lowerWick = minVal - c.low; // Cheap skip: neither wick is even large enough to be suspect. if (upperWick <= MIN_WICK && lowerWick <= MIN_WICK) return c; // Local median of the full bar range from surrounding candles (self excluded). const ranges = []; for (let j = Math.max(0, i - WINDOW); j <= Math.min(candles.length - 1, i + WINDOW); j++) { if (j === i) continue; ranges.push(candles[j].high - candles[j].low); } // medRange = 0 when there isn't enough local context; the absolute backstop // below still applies in that case. const medRange = ranges.length >= 5 ? median(ranges) : 0; // Remaining wick after clamping: proportional to local volatility but bounded // to a realistic 15-40 pips, so even a glitch cluster can't leave a huge wick. const clampAmt = Math.min(Math.max(medRange > 0 ? 2 * medRange : 0.0010, 0.0008), 0.0040); let cleanHigh = c.high; let cleanLow = c.low; const relUpper = medRange > 0 && upperWick > OUTLIER_MULT * medRange; const relLower = medRange > 0 && lowerWick > OUTLIER_MULT * medRange; if (upperWick > MIN_WICK && (upperWick > ABS_MAX || relUpper)) { cleanHigh = maxVal + clampAmt; } if (lowerWick > MIN_WICK && (lowerWick > ABS_MAX || relLower)) { cleanLow = minVal - clampAmt; } if (cleanHigh === c.high && cleanLow === c.low) return c; return { ...c, high: cleanHigh, low: cleanLow }; }); } export async function fetchCandles(symbol, timeframe, rangeOverride = null) { const yahooSymbol = YAHOO_SYMBOL_MAP[symbol] ?? symbol; const config = TIMEFRAME_CONFIG[timeframe] || TIMEFRAME_CONFIG['15m']; const params = new URLSearchParams({ interval: config.interval, // Callers (e.g. the backtester) may request a longer window than the app's // default; Yahoo allows up to 60d for 15m. Falls back to the config default. range: rangeOverride || config.range, }); const url = `${YAHOO_BASE}/${yahooSymbol}?${params}`; let response; try { response = await fetch(url); } catch (err) { console.error(`[yahooFinanceAPI] Network error for ${symbol}:`, err); throw new Error(`Yahoo Finance network error: ${err.message}`); } if (!response.ok) { const text = await response.text().catch(() => ''); console.error(`[yahooFinanceAPI] HTTP ${response.status} for ${symbol}:`, text); throw new Error(`Yahoo Finance error ${response.status}`); } const data = await response.json(); const result = data.chart?.result?.[0]; if (!result || !result.timestamp || result.timestamp.length === 0) { console.warn(`[yahooFinanceAPI] No candles returned for ${symbol} (${timeframe})`); return []; } const timestamps = result.timestamp; const quotes = result.indicators.quote[0]; const durationMap = { '1m': 60, '5m': 300, '15m': 900, '1H': 3600, '4H': 14400, '1D': 86400, }; const duration = durationMap[timeframe] || 900; // Parse raw parallel arrays into aligned, deduplicated candle objects let parsed = []; const unique = new Map(); for (let i = 0; i < timestamps.length; i++) { // Yahoo Finance can sometimes return nulls for illiquid candles if ( quotes.open[i] === null || quotes.high[i] === null || quotes.low[i] === null || quotes.close[i] === null ) { continue; } const alignedTime = Math.floor(timestamps[i] / duration) * duration; if (unique.has(alignedTime)) { const existing = unique.get(alignedTime); existing.high = Math.max(existing.high, parseFloat(quotes.high[i])); existing.low = Math.min(existing.low, parseFloat(quotes.low[i])); existing.close = parseFloat(quotes.close[i]); existing.volume += parseFloat(quotes.volume[i] ?? 0); } else { unique.set(alignedTime, { time: alignedTime, open: parseFloat(quotes.open[i]), high: parseFloat(quotes.high[i]), low: parseFloat(quotes.low[i]), close: parseFloat(quotes.close[i]), volume: parseFloat(quotes.volume[i] ?? 0), }); } } parsed = Array.from(unique.values()); if (symbol === 'XAUUSD' && parsed.length > 0) { const latestYahooClose = parsed[parsed.length - 1].close; const offset = await getXauusdOffset(latestYahooClose); if (offset !== 0) { parsed = parsed.map(c => ({ ...c, open: c.open + offset, high: c.high + offset, low: c.low + offset, close: c.close + offset, })); } } // Remove Yahoo's fake forex rollover / bad-tick wick spikes (neighbour-relative, // preserves genuine news candles — see filterForexOutlierWicks). parsed = filterForexOutlierWicks(parsed, symbol); // Handle client-side candle aggregation if required (e.g. 4H timeframe) if (config.aggFactor > 1) { parsed = aggregateCandles(parsed, config.aggFactor); } return parsed; } /** * Aggregate smaller interval candles into larger ones. * * @param {Array} candles * @param {number} factor * @returns {Array} */ function aggregateCandles(candles, factor) { const result = []; for (let i = 0; i < candles.length; i += factor) { const chunk = candles.slice(i, i + factor); if (chunk.length === 0) continue; const highs = chunk.map(c => c.high); const lows = chunk.map(c => c.low); result.push({ time: chunk[0].time, // start of period open: chunk[0].open, high: Math.max(...highs), low: Math.min(...lows), close: chunk[chunk.length - 1].close, volume: chunk.reduce((sum, c) => sum + c.volume, 0), }); } return result; } /** * Fetch the latest price quote for a symbol. * * @param {string} symbol * @returns {Promise<{current:number, open:number, high:number, low:number}>} */ export async function fetchQuote(symbol) { const yahooSymbol = YAHOO_SYMBOL_MAP[symbol] ?? symbol; const url = `${YAHOO_BASE}/${yahooSymbol}?interval=1m&range=1d`; const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status} fetching quote`); const data = await response.json(); const result = data.chart?.result?.[0]; if (!result || !result.timestamp || result.timestamp.length === 0) { throw new Error('No quote data returned'); } const quotes = result.indicators.quote[0]; const lastIdx = result.timestamp.length - 1; // Search backwards to find the last non-null close let current = null; let open = null; let high = null; let low = null; for (let i = lastIdx; i >= 0; i--) { if (quotes.close[i] !== null) { current = quotes.close[i]; open = quotes.open[i]; high = quotes.high[i]; low = quotes.low[i]; break; } } if (current === null) { throw new Error('Quote chart contained only null values'); } let offset = 0; if (symbol === 'XAUUSD') { const rawClose = parseFloat(current); offset = await getXauusdOffset(rawClose); } return { current: parseFloat(current) + offset, open: (parseFloat(open ?? current)) + offset, high: (parseFloat(high ?? current)) + offset, low: (parseFloat(low ?? current)) + offset, }; }