File size: 11,977 Bytes
66b7a93
 
 
 
 
 
 
 
 
a87a76b
66b7a93
 
 
 
 
01369a3
ebfcd8b
 
01369a3
 
 
 
 
 
 
 
ebfcd8b
 
 
 
 
01369a3
 
 
 
 
 
 
 
 
 
 
 
 
ebfcd8b
01369a3
 
 
 
 
 
ebfcd8b
 
01369a3
 
66b7a93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67616e2
 
 
 
 
 
 
 
 
 
 
 
 
 
7a6768e
67616e2
 
 
db00ed6
1d20ea7
 
 
 
db00ed6
 
 
67616e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
db00ed6
 
 
 
 
 
1d20ea7
67616e2
 
 
db00ed6
 
 
 
67616e2
db00ed6
 
67616e2
 
 
 
 
 
 
f43243e
66b7a93
 
 
 
 
f43243e
 
 
66b7a93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
992517d
 
 
 
 
 
 
 
 
 
 
66b7a93
992517d
 
66b7a93
 
 
 
 
 
 
 
 
 
 
992517d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66b7a93
 
992517d
 
01369a3
 
 
 
 
 
 
 
 
 
 
 
 
 
67616e2
 
 
c32a31a
66b7a93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01369a3
 
 
ebfcd8b
01369a3
 
66b7a93
01369a3
 
 
 
66b7a93
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
/**
 * 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<number>}
 */
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<Array<{time:number, open:number, high:number, low:number, close:number, volume:number}>>}
 */
/**
 * 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<object>} candles
 * @param {number} factor
 * @returns {Array<object>}
 */
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,
  };
}