|
5 | 5 | import { useAuth } from './auth'; |
6 | 6 | import { supabase } from './supabase'; |
7 | 7 |
|
8 | | -const KNOWN_CRYPTO_QUOTES = ['USD', 'USDT', 'USDC', 'EUR', 'GBP']; |
| 8 | +const addUnique = (list: string[], value?: string | null) => { |
| 9 | + if (!value) return; |
| 10 | + const normalized = value.trim().toUpperCase(); |
| 11 | + if (!normalized) return; |
| 12 | + if (!list.includes(normalized)) { |
| 13 | + list.push(normalized); |
| 14 | + } |
| 15 | +}; |
9 | 16 |
|
10 | | -const ensureCryptoPairSymbol = (symbol: string): string => { |
11 | | - const upper = symbol.toUpperCase(); |
12 | | - if (upper.includes('/')) { |
13 | | - return upper; |
| 17 | +const buildQuoteLengths = (length: number): number[] => { |
| 18 | + const minQuoteLength = 2; |
| 19 | + const maxQuoteLength = Math.min(6, length - 1); |
| 20 | + |
| 21 | + if (maxQuoteLength < minQuoteLength) { |
| 22 | + return []; |
14 | 23 | } |
15 | 24 |
|
16 | | - for (const quote of KNOWN_CRYPTO_QUOTES) { |
17 | | - if (upper.endsWith(quote) && upper.length > quote.length) { |
18 | | - return `${upper.slice(0, upper.length - quote.length)}/${quote}`; |
| 25 | + const values: number[] = []; |
| 26 | + for (let candidate = minQuoteLength; candidate <= maxQuoteLength; candidate++) { |
| 27 | + values.push(candidate); |
| 28 | + } |
| 29 | + |
| 30 | + const ideal = Math.round(length / 2); |
| 31 | + |
| 32 | + return values.sort((a, b) => { |
| 33 | + const diff = Math.abs(a - ideal) - Math.abs(b - ideal); |
| 34 | + if (diff !== 0) { |
| 35 | + return diff; |
| 36 | + } |
| 37 | + return a - b; |
| 38 | + }); |
| 39 | +}; |
| 40 | + |
| 41 | +const generateCryptoSymbolCandidates = (symbol: string): string[] => { |
| 42 | + const upper = symbol.trim().toUpperCase(); |
| 43 | + const sanitized = upper.replace(/[^A-Z0-9]/g, ''); |
| 44 | + const slashCandidates: string[] = []; |
| 45 | + const plainCandidates: string[] = []; |
| 46 | + |
| 47 | + addUnique(plainCandidates, sanitized); |
| 48 | + |
| 49 | + if (upper.includes('/')) { |
| 50 | + addUnique(slashCandidates, upper); |
| 51 | + } else { |
| 52 | + addUnique(plainCandidates, upper); |
| 53 | + |
| 54 | + if (sanitized.length >= 5) { |
| 55 | + const quoteLengths = buildQuoteLengths(sanitized.length); |
| 56 | + |
| 57 | + for (const quoteLength of quoteLengths) { |
| 58 | + const splitIndex = sanitized.length - quoteLength; |
| 59 | + if (splitIndex < 2) { |
| 60 | + continue; |
| 61 | + } |
| 62 | + |
| 63 | + const base = sanitized.slice(0, splitIndex); |
| 64 | + const quote = sanitized.slice(splitIndex); |
| 65 | + addUnique(slashCandidates, `${base}/${quote}`); |
| 66 | + } |
19 | 67 | } |
20 | 68 | } |
21 | 69 |
|
22 | | - if (upper.length > 3) { |
23 | | - return `${upper.slice(0, upper.length - 3)}/${upper.slice(-3)}`; |
| 70 | + const combined = [...slashCandidates, ...plainCandidates]; |
| 71 | + return combined.length > 0 ? combined : [upper]; |
| 72 | +}; |
| 73 | + |
| 74 | +const MAX_CRYPTO_CANDIDATES = 3; |
| 75 | + |
| 76 | +const looksLikeCrypto = (symbol: string): boolean => { |
| 77 | + const upper = symbol.toUpperCase(); |
| 78 | + if (upper.includes('/')) { |
| 79 | + return true; |
| 80 | + } |
| 81 | + |
| 82 | + const sanitized = upper.replace(/[^A-Z0-9]/g, ''); |
| 83 | + if (sanitized.length >= 6) { |
| 84 | + return true; |
24 | 85 | } |
25 | 86 |
|
26 | | - return upper; |
| 87 | + return generateCryptoSymbolCandidates(upper).some((value) => value.includes('/')); |
27 | 88 | }; |
28 | 89 |
|
29 | 90 | interface AlpacaConfig { |
@@ -324,44 +385,62 @@ class AlpacaAPI { |
324 | 385 | limit?: number |
325 | 386 | ): Promise<any> { |
326 | 387 | const upperSymbol = symbol.toUpperCase(); |
327 | | - const isLikelyCrypto = upperSymbol.includes('/') || KNOWN_CRYPTO_QUOTES.some(quote => upperSymbol.endsWith(quote) && upperSymbol.length > quote.length); |
| 388 | + const isLikelyCrypto = looksLikeCrypto(upperSymbol); |
328 | 389 |
|
329 | 390 | if (isLikelyCrypto) { |
330 | | - const cryptoSymbol = ensureCryptoPairSymbol(upperSymbol); |
331 | | - const params: Record<string, string> = { |
332 | | - symbols: cryptoSymbol, |
333 | | - timeframe, |
334 | | - limit: (limit || 10000).toString() |
335 | | - }; |
| 391 | + const cryptoCandidates = generateCryptoSymbolCandidates(upperSymbol).slice(0, MAX_CRYPTO_CANDIDATES); |
| 392 | + let lastError: Error | null = null; |
| 393 | + let lastErrorMessage: string | null = null; |
| 394 | + |
| 395 | + for (const candidate of cryptoCandidates) { |
| 396 | + const params: Record<string, string> = { |
| 397 | + symbols: candidate, |
| 398 | + timeframe, |
| 399 | + limit: (limit || 10000).toString() |
| 400 | + }; |
336 | 401 |
|
337 | | - if (start) params.start = start; |
338 | | - if (end) params.end = end; |
| 402 | + if (start) params.start = start; |
| 403 | + if (end) params.end = end; |
339 | 404 |
|
340 | | - const { data, error } = await supabase.functions.invoke('alpaca-proxy', { |
341 | | - body: { |
342 | | - method: 'GET', |
343 | | - endpoint: '/v1beta3/crypto/us/bars', |
344 | | - params |
| 405 | + const { data, error } = await supabase.functions.invoke('alpaca-proxy', { |
| 406 | + body: { |
| 407 | + method: 'GET', |
| 408 | + endpoint: '/v1beta3/crypto/us/bars', |
| 409 | + params |
| 410 | + } |
| 411 | + }); |
| 412 | + |
| 413 | + if (error) { |
| 414 | + console.error(`Failed to fetch crypto bars for ${symbol} via ${candidate}:`, error); |
| 415 | + lastError = error; |
| 416 | + continue; |
345 | 417 | } |
346 | | - }); |
347 | 418 |
|
348 | | - if (error) { |
349 | | - console.error(`Failed to fetch crypto bars for ${symbol}:`, error); |
350 | | - throw new Error(`Failed to fetch bars for ${symbol}: ${error.message}`); |
351 | | - } |
| 419 | + if (!data) { |
| 420 | + lastErrorMessage = `No bar data received for ${symbol}`; |
| 421 | + continue; |
| 422 | + } |
352 | 423 |
|
353 | | - if (!data) { |
354 | | - throw new Error(`No bar data received for ${symbol}`); |
| 424 | + if (data.error) { |
| 425 | + console.error(`Failed to fetch crypto bars for ${symbol} via ${candidate}:`, data.error); |
| 426 | + lastErrorMessage = typeof data.error === 'string' ? data.error : JSON.stringify(data.error); |
| 427 | + continue; |
| 428 | + } |
| 429 | + |
| 430 | + const bars = data.bars?.[candidate] || data.bars?.[candidate.replace('/', '')] || []; |
| 431 | + if (Array.isArray(bars) && bars.length > 0) { |
| 432 | + console.log(`Alpaca crypto bars response for ${symbol} (${timeframe}) via ${candidate}: ${bars.length} bars`); |
| 433 | + return bars; |
| 434 | + } |
| 435 | + |
| 436 | + console.log(`No crypto bars returned for ${symbol} via ${candidate}`); |
355 | 437 | } |
356 | 438 |
|
357 | | - if (data.error) { |
358 | | - console.error(`Failed to fetch crypto bars for ${symbol}:`, data.error); |
359 | | - throw new Error(`Failed to fetch bars for ${symbol}: ${typeof data.error === 'string' ? data.error : JSON.stringify(data.error)}`); |
| 439 | + if (lastError) { |
| 440 | + throw new Error(`Failed to fetch bars for ${symbol}: ${lastError.message}`); |
360 | 441 | } |
361 | 442 |
|
362 | | - const bars = data.bars?.[cryptoSymbol] || data.bars?.[cryptoSymbol.replace('/', '')] || []; |
363 | | - console.log(`Alpaca crypto bars response for ${symbol} (${timeframe}): ${Array.isArray(bars) ? bars.length : 0} bars`); |
364 | | - return Array.isArray(bars) ? bars : []; |
| 443 | + throw new Error(`Failed to fetch bars for ${symbol}: ${lastErrorMessage ?? 'No historical data returned'}`); |
365 | 444 | } |
366 | 445 |
|
367 | 446 | const params: Record<string, string> = { |
|
0 commit comments