From d153296ac147fac59f3831c22d64dd68a1cf2028 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Thu, 9 Apr 2026 16:29:36 -0400 Subject: [PATCH] Fix YahooFinanceClient Swift 6 actor isolation and type-path errors Two bugs: 1. Swift 6 strict concurrency: JSONDecoder().decode() inside actor-isolated methods triggers "Main actor-isolated conformance cannot be used in actor-isolated context". Fix: move all Decodable usage into nonisolated helper methods (parseChart, parseOptions, makeContract) so the conformance is invoked outside the actor's isolation boundary. 2. Wrong nested type path: OptionsResponse.OptionData.YFOption does not exist; YFOption is nested directly under OptionsResponse. Fix: reference OptionsResponse.YFOption everywhere and remove the old private extension in favour of the nonisolated makeContract(from:) factory method. Co-Authored-By: Claude Sonnet 4.6 --- .../Services/YahooFinanceClient.swift | 103 ++++++++++-------- 1 file changed, 58 insertions(+), 45 deletions(-) diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/YahooFinanceClient.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/YahooFinanceClient.swift index 77ec74a..a76bf71 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/YahooFinanceClient.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/YahooFinanceClient.swift @@ -13,17 +13,17 @@ struct PriceBar { struct PriceHistory { let ticker: String let bars: [PriceBar] - var closes: [Double] { bars.map(\.close) } - var highs: [Double] { bars.map(\.high) } - var lows: [Double] { bars.map(\.low) } - var currentPrice: Double { bars.last?.close ?? 0 } + var closes: [Double] { bars.map(\.close) } + var highs: [Double] { bars.map(\.high) } + var lows: [Double] { bars.map(\.low) } + var currentPrice: Double { bars.last?.close ?? 0 } } struct OptionContract { let strike: Double let bid: Double let ask: Double - let impliedVolatility: Double // annualised fraction (e.g. 0.30 = 30%) + let impliedVolatility: Double // annualised fraction (e.g. 0.30 = 30 %) let volume: Int let openInterest: Int var mid: Double { (bid + ask) / 2 } @@ -40,8 +40,8 @@ enum YFError: LocalizedError { case badURL var errorDescription: String? { switch self { - case .noData: return "No data returned from Yahoo Finance." - case .badURL: return "Could not construct Yahoo Finance URL." + case .noData: return "No data returned from Yahoo Finance." + case .badURL: return "Could not construct Yahoo Finance URL." } } } @@ -64,7 +64,7 @@ actor YahooFinanceClient { }() private var cache: [String: (value: Any, storedAt: Date)] = [:] - private let ttl: TimeInterval = 900 // 15 min + private let ttl: TimeInterval = 900 // 15 min private func get(_ key: String) -> T? { guard let entry = cache[key], @@ -72,12 +72,22 @@ actor YahooFinanceClient { let typed = entry.value as? T else { return nil } return typed } - private func set(_ value: Any, key: String) { - cache[key] = (value, Date()) - } + private func set(_ value: Any, key: String) { cache[key] = (value, Date()) } func clearCache() { cache.removeAll() } - // MARK: Price history + // MARK: - nonisolated decode helpers + // Placed outside actor isolation so Swift 6 doesn't flag the Decodable + // conformance as being used in an actor-isolated context. + + nonisolated private func parseChart(_ data: Data) throws -> ChartResponse { + try JSONDecoder().decode(ChartResponse.self, from: data) + } + + nonisolated private func parseOptions(_ data: Data) throws -> OptionsResponse { + try JSONDecoder().decode(OptionsResponse.self, from: data) + } + + // MARK: - Price history func priceHistory(ticker: String) async throws -> PriceHistory { let key = "hist_\(ticker)" @@ -88,13 +98,13 @@ actor YahooFinanceClient { else { throw YFError.badURL } let (data, _) = try await session.data(from: url) - let resp = try JSONDecoder().decode(ChartResponse.self, from: data) + let resp = try parseChart(data) guard let r = resp.chart.result?.first, let q = r.indicators.quote.first else { throw YFError.noData } var bars: [PriceBar] = [] - let ts = r.timestamp + let ts = r.timestamp let opens = q.open let highs = q.high let lows = q.low @@ -116,7 +126,7 @@ actor YahooFinanceClient { return result } - // MARK: Option expirations + // MARK: - Option expirations func expirations(ticker: String) async throws -> [Date] { let key = "exp_\(ticker)" @@ -127,7 +137,7 @@ actor YahooFinanceClient { else { throw YFError.badURL } let (data, _) = try await session.data(from: url) - let resp = try JSONDecoder().decode(OptionsResponse.self, from: data) + let resp = try parseOptions(data) guard let r = resp.optionChain.result?.first else { throw YFError.noData } let dates = r.expirationDates.map { Date(timeIntervalSince1970: Double($0)) } @@ -148,7 +158,7 @@ actor YahooFinanceClient { return exps.first { Calendar.current.isDate($0, inSameDayAs: today) } } - // MARK: Option chain + // MARK: - Option chain func optionChain(ticker: String, expiration: Date) async throws -> OptionChain { let epoch = Int(expiration.timeIntervalSince1970) @@ -160,20 +170,34 @@ actor YahooFinanceClient { else { throw YFError.badURL } let (data, _) = try await session.data(from: url) - let resp = try JSONDecoder().decode(OptionsResponse.self, from: data) + let resp = try parseOptions(data) guard let r = resp.optionChain.result?.first, let opts = r.options.first else { throw YFError.noData } - let calls = opts.calls.compactMap { OptionContract(from: $0) } - let puts = opts.puts .compactMap { OptionContract(from: $0) } + let calls = opts.calls.compactMap { makeContract(from: $0) } + let puts = opts.puts .compactMap { makeContract(from: $0) } let chain = OptionChain(expiration: expiration, calls: calls, puts: puts) set(chain, key: key) return chain } - // MARK: Earnings date + /// nonisolated factory so the OptionsResponse.YFOption type is not + /// referenced inside actor-isolated code (avoids Swift 6 conformance error). + nonisolated private func makeContract(from yf: OptionsResponse.YFOption) -> OptionContract? { + guard let bid = yf.bid, let ask = yf.ask, bid >= 0, ask >= 0 else { return nil } + return OptionContract( + strike: yf.strike, + bid: bid, + ask: ask, + impliedVolatility: yf.impliedVolatility ?? 0.3, + volume: yf.volume ?? 0, + openInterest: yf.openInterest ?? 0 + ) + } + + // MARK: - Earnings date func nextEarningsDate(ticker: String) async throws -> Date? { let key = "earn_\(ticker)" @@ -184,32 +208,32 @@ actor YahooFinanceClient { else { return nil } guard let (data, _) = try? await session.data(from: url), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let qs = json["quoteSummary"] as? [String: Any], - let results = qs["result"] as? [[String: Any]], - let first = results.first, - let cal = first["calendarEvents"] as? [String: Any], - let earn = cal["earnings"] as? [String: Any], - let dates = earn["earningsDate"] as? [[String: Any]], - let rawDate = dates.first?["raw"] as? TimeInterval + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let qs = json["quoteSummary"] as? [String: Any], + let results = qs["result"] as? [[String: Any]], + let first = results.first, + let cal = first["calendarEvents"] as? [String: Any], + let earn = cal["earnings"] as? [String: Any], + let dates = earn["earningsDate"] as? [[String: Any]], + let rawDate = dates.first?["raw"] as? TimeInterval else { set(Optional.none as Any, key: key) return nil } let date: Date? = Date(timeIntervalSince1970: rawDate) > Date() - ? Date(timeIntervalSince1970: rawDate) - : nil + ? Date(timeIntervalSince1970: rawDate) : nil set(date as Any, key: key) return date } } // MARK: - Private JSON models +// Defined at file scope (outside the actor) as plain Decodable value types. private struct ChartResponse: Decodable { let chart: Wrapper - struct Wrapper: Decodable { let result: [Result]? } + struct Wrapper: Decodable { let result: [Result]? } struct Result: Decodable { let timestamp: [Int] let indicators: Indicators @@ -225,7 +249,7 @@ private struct ChartResponse: Decodable { private struct OptionsResponse: Decodable { let optionChain: Wrapper - struct Wrapper: Decodable { let result: [Result]? } + struct Wrapper: Decodable { let result: [Result]? } struct Result: Decodable { let expirationDates: [Int] let options: [OptionData] @@ -234,6 +258,7 @@ private struct OptionsResponse: Decodable { let calls: [YFOption] let puts: [YFOption] } + // YFOption is a direct nested type of OptionsResponse, NOT of OptionData. struct YFOption: Decodable { let strike: Double let bid: Double? @@ -243,15 +268,3 @@ private struct OptionsResponse: Decodable { let openInterest: Int? } } - -private extension OptionContract { - init?(from yf: OptionsResponse.OptionData.YFOption) { - guard let bid = yf.bid, let ask = yf.ask, bid >= 0, ask >= 0 else { return nil } - self.strike = yf.strike - self.bid = bid - self.ask = ask - self.impliedVolatility = yf.impliedVolatility ?? 0.3 - self.volume = yf.volume ?? 0 - self.openInterest = yf.openInterest ?? 0 - } -}