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 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 16:29:36 -04:00
parent be6cd86f11
commit d153296ac1

View File

@@ -72,12 +72,22 @@ actor YahooFinanceClient {
let typed = entry.value as? T else { return nil } let typed = entry.value as? T else { return nil }
return typed return typed
} }
private func set(_ value: Any, key: String) { private func set(_ value: Any, key: String) { cache[key] = (value, Date()) }
cache[key] = (value, Date())
}
func clearCache() { cache.removeAll() } 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 { func priceHistory(ticker: String) async throws -> PriceHistory {
let key = "hist_\(ticker)" let key = "hist_\(ticker)"
@@ -88,7 +98,7 @@ actor YahooFinanceClient {
else { throw YFError.badURL } else { throw YFError.badURL }
let (data, _) = try await session.data(from: url) 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, guard let r = resp.chart.result?.first,
let q = r.indicators.quote.first else { throw YFError.noData } let q = r.indicators.quote.first else { throw YFError.noData }
@@ -116,7 +126,7 @@ actor YahooFinanceClient {
return result return result
} }
// MARK: Option expirations // MARK: - Option expirations
func expirations(ticker: String) async throws -> [Date] { func expirations(ticker: String) async throws -> [Date] {
let key = "exp_\(ticker)" let key = "exp_\(ticker)"
@@ -127,7 +137,7 @@ actor YahooFinanceClient {
else { throw YFError.badURL } else { throw YFError.badURL }
let (data, _) = try await session.data(from: url) 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 } guard let r = resp.optionChain.result?.first else { throw YFError.noData }
let dates = r.expirationDates.map { Date(timeIntervalSince1970: Double($0)) } let dates = r.expirationDates.map { Date(timeIntervalSince1970: Double($0)) }
@@ -148,7 +158,7 @@ actor YahooFinanceClient {
return exps.first { Calendar.current.isDate($0, inSameDayAs: today) } return exps.first { Calendar.current.isDate($0, inSameDayAs: today) }
} }
// MARK: Option chain // MARK: - Option chain
func optionChain(ticker: String, expiration: Date) async throws -> OptionChain { func optionChain(ticker: String, expiration: Date) async throws -> OptionChain {
let epoch = Int(expiration.timeIntervalSince1970) let epoch = Int(expiration.timeIntervalSince1970)
@@ -160,20 +170,34 @@ actor YahooFinanceClient {
else { throw YFError.badURL } else { throw YFError.badURL }
let (data, _) = try await session.data(from: url) 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, guard let r = resp.optionChain.result?.first,
let opts = r.options.first else { throw YFError.noData } let opts = r.options.first else { throw YFError.noData }
let calls = opts.calls.compactMap { OptionContract(from: $0) } let calls = opts.calls.compactMap { makeContract(from: $0) }
let puts = opts.puts .compactMap { OptionContract(from: $0) } let puts = opts.puts .compactMap { makeContract(from: $0) }
let chain = OptionChain(expiration: expiration, calls: calls, puts: puts) let chain = OptionChain(expiration: expiration, calls: calls, puts: puts)
set(chain, key: key) set(chain, key: key)
return chain 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? { func nextEarningsDate(ticker: String) async throws -> Date? {
let key = "earn_\(ticker)" let key = "earn_\(ticker)"
@@ -198,14 +222,14 @@ actor YahooFinanceClient {
} }
let date: Date? = Date(timeIntervalSince1970: rawDate) > Date() let date: Date? = Date(timeIntervalSince1970: rawDate) > Date()
? Date(timeIntervalSince1970: rawDate) ? Date(timeIntervalSince1970: rawDate) : nil
: nil
set(date as Any, key: key) set(date as Any, key: key)
return date return date
} }
} }
// MARK: - Private JSON models // MARK: - Private JSON models
// Defined at file scope (outside the actor) as plain Decodable value types.
private struct ChartResponse: Decodable { private struct ChartResponse: Decodable {
let chart: Wrapper let chart: Wrapper
@@ -234,6 +258,7 @@ private struct OptionsResponse: Decodable {
let calls: [YFOption] let calls: [YFOption]
let puts: [YFOption] let puts: [YFOption]
} }
// YFOption is a direct nested type of OptionsResponse, NOT of OptionData.
struct YFOption: Decodable { struct YFOption: Decodable {
let strike: Double let strike: Double
let bid: Double? let bid: Double?
@@ -243,15 +268,3 @@ private struct OptionsResponse: Decodable {
let openInterest: Int? 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
}
}