// // StockDataSource.swift // iris // import Foundation struct StockDataSourceConfig: Sendable { var maxSymbols: Int = 5 var cacheValiditySec: Int = 300 var ttlSec: Int = 600 init() {} } final class StockDataSource { struct StockQuote: Sendable, Equatable { let symbol: String let shortName: String let price: Double let change: Double let changePercent: Double let marketState: String } struct StockData: Sendable, Equatable { let quotes: [StockQuote] } struct Snapshot: Sendable { let data: StockData let diagnostics: [String: String] } enum StockError: Error, LocalizedError, Sendable { case noSymbolsConfigured case networkFailed(message: String, diagnostics: [String: String]) case rateLimited(diagnostics: [String: String]) case invalidResponse(diagnostics: [String: String]) var errorDescription: String? { switch self { case .noSymbolsConfigured: return "No symbols configured" case .networkFailed(let message, _): return message case .rateLimited: return "Rate limited by Yahoo Finance" case .invalidResponse: return "Invalid response from Yahoo Finance" } } } private let config: StockDataSourceConfig private var cache: (timestamp: Int, data: StockData)? init(config: StockDataSourceConfig = .init()) { self.config = config } func dataWithDiagnostics(symbols: [String], now: Int) async throws -> Snapshot { var diagnostics: [String: String] = [ "now": String(now), "symbols_requested": symbols.joined(separator: ","), "max_symbols": String(config.maxSymbols), ] guard !symbols.isEmpty else { diagnostics["result"] = "no_symbols" return Snapshot(data: StockData(quotes: []), diagnostics: diagnostics) } let limitedSymbols = Array(symbols.prefix(config.maxSymbols)) diagnostics["symbols_queried"] = limitedSymbols.joined(separator: ",") if let cache = cache, now - cache.timestamp < config.cacheValiditySec { diagnostics["source"] = "cache" diagnostics["cache_age_sec"] = String(now - cache.timestamp) 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) } 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) } throw StockError.rateLimited(diagnostics: diagnostics) } 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) let stockData = StockData(quotes: quotes) cache = (timestamp: now, data: stockData) return Snapshot(data: stockData, diagnostics: diagnostics) } } // MARK: - Yahoo Finance API Response Models private struct YahooQuoteResponse: Codable { let quoteResponse: QuoteResponse struct QuoteResponse: Codable { let result: [Quote]? let error: QuoteError? } struct Quote: Codable { let symbol: String let shortName: String? let regularMarketPrice: Double? let regularMarketChange: Double? let regularMarketChangePercent: Double? let marketState: String? } struct QuoteError: Codable { let code: String? let description: String? } }