// // 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) } // Fetch each symbol using v8 chart API (supports one symbol per request) var quotes: [StockQuote] = [] var fetchErrors: [String] = [] for symbol in limitedSymbols { do { if let quote = try await fetchQuote(symbol: symbol) { quotes.append(quote) } } catch { fetchErrors.append("\(symbol): \(error.localizedDescription)") } } 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 v8 Chart API Response Models private struct YahooChartResponse: Codable { let chart: Chart struct Chart: Codable { let result: [ChartResult]? let error: ChartError? } struct ChartResult: Codable { let meta: ChartMeta? } struct ChartMeta: Codable { let symbol: String? let shortName: String? let regularMarketPrice: Double? let chartPreviousClose: Double? let previousClose: Double? let marketState: String? } struct ChartError: Codable { let code: String? let description: String? } }