diff --git a/IrisCompanion/iris/DataSources/StockDataSource.swift b/IrisCompanion/iris/DataSources/StockDataSource.swift index a189317..7f8cd82 100644 --- a/IrisCompanion/iris/DataSources/StockDataSource.swift +++ b/IrisCompanion/iris/DataSources/StockDataSource.swift @@ -80,106 +80,97 @@ final class StockDataSource { return Snapshot(data: cache.data, diagnostics: diagnostics) } - let symbolsParam = limitedSymbols.joined(separator: ",") - guard let encodedSymbols = symbolsParam.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), - let url = URL(string: "https://query1.finance.yahoo.com/v7/finance/quote?symbols=\(encodedSymbols)") else { - diagnostics["error"] = "invalid_url" - throw StockError.networkFailed(message: "Invalid URL", diagnostics: diagnostics) - } + // Fetch each symbol using v8 chart API (supports one symbol per request) + var quotes: [StockQuote] = [] + var fetchErrors: [String] = [] - var request = URLRequest(url: url) - request.setValue("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", forHTTPHeaderField: "User-Agent") - request.setValue("application/json", forHTTPHeaderField: "Accept") - - let data: Data - let response: URLResponse - do { - (data, response) = try await URLSession.shared.data(for: request) - } catch { - diagnostics["error"] = "network_error" - diagnostics["error_message"] = String(describing: error) - if let cached = cache { - diagnostics["source"] = "cache_fallback" - return Snapshot(data: cached.data, diagnostics: diagnostics) - } - throw StockError.networkFailed(message: String(describing: error), diagnostics: diagnostics) - } - - if let httpResponse = response as? HTTPURLResponse { - diagnostics["http_status"] = String(httpResponse.statusCode) - if httpResponse.statusCode == 403 { - diagnostics["error"] = "rate_limited" - if let cached = cache { - diagnostics["source"] = "cache_fallback" - return Snapshot(data: cached.data, diagnostics: diagnostics) + for symbol in limitedSymbols { + do { + if let quote = try await fetchQuote(symbol: symbol) { + quotes.append(quote) } - throw StockError.rateLimited(diagnostics: diagnostics) + } catch { + fetchErrors.append("\(symbol): \(error.localizedDescription)") } - guard (200..<300).contains(httpResponse.statusCode) else { - diagnostics["error"] = "http_error" - if let cached = cache { - diagnostics["source"] = "cache_fallback" - return Snapshot(data: cached.data, diagnostics: diagnostics) - } - throw StockError.networkFailed(message: "HTTP \(httpResponse.statusCode)", diagnostics: diagnostics) - } - } - - let yahooResponse: YahooQuoteResponse - do { - yahooResponse = try JSONDecoder().decode(YahooQuoteResponse.self, from: data) - } catch { - diagnostics["error"] = "json_decode_error" - diagnostics["error_message"] = String(describing: error) - if let cached = cache { - diagnostics["source"] = "cache_fallback" - return Snapshot(data: cached.data, diagnostics: diagnostics) - } - throw StockError.invalidResponse(diagnostics: diagnostics) - } - - let quotes: [StockQuote] = (yahooResponse.quoteResponse.result ?? []).compactMap { quote in - guard let price = quote.regularMarketPrice else { return nil } - return StockQuote( - symbol: quote.symbol, - shortName: quote.shortName ?? quote.symbol, - price: price, - change: quote.regularMarketChange ?? 0, - changePercent: quote.regularMarketChangePercent ?? 0, - marketState: quote.marketState ?? "CLOSED" - ) } diagnostics["source"] = "network" diagnostics["quotes_returned"] = String(quotes.count) + if !fetchErrors.isEmpty { + diagnostics["fetch_errors"] = fetchErrors.joined(separator: "; ") + } let stockData = StockData(quotes: quotes) cache = (timestamp: now, data: stockData) return Snapshot(data: stockData, diagnostics: diagnostics) } + + private func fetchQuote(symbol: String) async throws -> StockQuote? { + guard let encodedSymbol = symbol.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://query1.finance.yahoo.com/v8/finance/chart/\(encodedSymbol)?interval=1d&range=1d") else { + return nil + } + + var request = URLRequest(url: url) + request.setValue("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", forHTTPHeaderField: "User-Agent") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + guard (200..<300).contains(httpResponse.statusCode) else { + throw StockError.networkFailed(message: "HTTP \(httpResponse.statusCode)", diagnostics: [:]) + } + } + + let chartResponse = try JSONDecoder().decode(YahooChartResponse.self, from: data) + + guard let result = chartResponse.chart.result?.first, + let meta = result.meta, + let price = meta.regularMarketPrice else { + return nil + } + + let previousClose = meta.chartPreviousClose ?? meta.previousClose ?? price + let change = price - previousClose + let changePercent = previousClose > 0 ? (change / previousClose) * 100 : 0 + + return StockQuote( + symbol: meta.symbol ?? symbol, + shortName: meta.shortName ?? meta.symbol ?? symbol, + price: price, + change: change, + changePercent: changePercent, + marketState: meta.marketState ?? "CLOSED" + ) + } } -// MARK: - Yahoo Finance API Response Models +// MARK: - Yahoo Finance v8 Chart API Response Models -private struct YahooQuoteResponse: Codable { - let quoteResponse: QuoteResponse +private struct YahooChartResponse: Codable { + let chart: Chart - struct QuoteResponse: Codable { - let result: [Quote]? - let error: QuoteError? + struct Chart: Codable { + let result: [ChartResult]? + let error: ChartError? } - struct Quote: Codable { - let symbol: String + struct ChartResult: Codable { + let meta: ChartMeta? + } + + struct ChartMeta: Codable { + let symbol: String? let shortName: String? let regularMarketPrice: Double? - let regularMarketChange: Double? - let regularMarketChangePercent: Double? + let chartPreviousClose: Double? + let previousClose: Double? let marketState: String? } - struct QuoteError: Codable { + struct ChartError: Codable { let code: String? let description: String? }