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

@@ -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<T>(_ 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<Date>.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
}
}