Fix Yahoo Finance auth (crumb/cookie), add error UI, disable autocorrect on tickers

Yahoo Finance auth fix (root cause of empty Setups tab):
- Options endpoint has required cookie+crumb auth since 2024; our old
  client sent no auth and got 401, which try? silently turned into nil.
- New flow in ensureAuth(): (1) GET finance.yahoo.com/quote/AAPL/ with
  a desktop Chrome User-Agent to seed the A1S session cookie into
  URLSession.shared cookie storage, (2) GET /v1/test/getcrumb with that
  cookie, cache the crumb for 1 hour. All options API calls now append
  ?crumb= and automatically retry once on 401.
- Switched User-Agent from mobile Safari to desktop Chrome — Yahoo Finance
  returns different (broken) auth behaviour for the mobile UA on options.
- Price history endpoint still works without crumb (200 confirmed) so it
  uses a plain request; only the options endpoints go through fetch().

Error surfacing:
- RecommendationsViewModel.refresh() now uses do/catch instead of try?
  so failures are printed to console and surfaced via vm.error
- RecommendationsView shows a wifi-exclamationmark error card with a
  Retry button when vm.error is set

Autocorrect disabled:
- Added .autocorrectionDisabled() to the ticker TextField in
  AddPositionSheet (PortfolioView) and LogTradeSheet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 08:55:31 -04:00
parent 7b3c5691e1
commit b140a1163d
5 changed files with 200 additions and 84 deletions

View File

@@ -38,10 +38,12 @@ struct OptionChain {
enum YFError: LocalizedError {
case noData
case badURL
case authFailed(Int)
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."
case .authFailed(let code): return "Yahoo Finance auth failed (HTTP \(code))."
}
}
}
@@ -50,115 +52,187 @@ enum YFError: LocalizedError {
/// Direct HTTP client for Yahoo Finance unofficial endpoints.
/// Thread-safe via Swift actor. Results are cached for 15 minutes.
/// Uses Yahoo Finance's cookie+crumb auth (required since 2024).
actor YahooFinanceClient {
static let shared = YahooFinanceClient()
// Desktop Chrome UA required for Yahoo Finance auth; mobile UAs get 401 on options
private let userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
private let session: URLSession = {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 20
cfg.timeoutIntervalForResource = 30
cfg.httpAdditionalHeaders = [
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15"
]
cfg.timeoutIntervalForResource = 40
// Accept all cookies so the Yahoo A1S session cookie is stored automatically
cfg.httpCookieAcceptPolicy = .always
cfg.httpCookieStorage = .shared
return URLSession(configuration: cfg)
}()
private var cache: [String: (value: Any, storedAt: Date)] = [:]
private let ttl: TimeInterval = 900 // 15 min
private var crumb: String?
private var crumbFetchedAt: Date?
private let crumbTTL: TimeInterval = 3600 // refresh crumb every hour
private func get<T>(_ key: String) -> T? {
guard let entry = cache[key],
Date().timeIntervalSince(entry.storedAt) < ttl,
private var dataCache: [String: (value: Any, storedAt: Date)] = [:]
private let cacheTTL: TimeInterval = 900 // 15 min for market data
// MARK: - Cache helpers
private func cached<T>(_ key: String) -> T? {
guard let entry = dataCache[key],
Date().timeIntervalSince(entry.storedAt) < cacheTTL,
let typed = entry.value as? T else { return nil }
return typed
}
private func set(_ value: Any, key: String) { cache[key] = (value, Date()) }
func clearCache() { cache.removeAll() }
private func store(_ value: Any, key: String) { dataCache[key] = (value, Date()) }
func clearCache() { dataCache.removeAll(); crumb = nil }
// MARK: - Price history
// MARK: - Auth: cookie + crumb
/// Visits finance.yahoo.com to seed the A1S session cookie, then fetches the crumb.
/// Call before any authenticated API request.
private func ensureAuth() async throws {
// Reuse crumb if still fresh
if let c = crumb, let t = crumbFetchedAt,
Date().timeIntervalSince(t) < crumbTTL { return }
// Step 1: visit a quote page Yahoo sets A1S cookie via Set-Cookie
guard let warmURL = URL(string: "https://finance.yahoo.com/quote/AAPL/") else { return }
var warmReq = URLRequest(url: warmURL)
warmReq.setValue(userAgent, forHTTPHeaderField: "User-Agent")
warmReq.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept")
_ = try? await session.data(for: warmReq)
// Step 2: fetch crumb (cookie is now in .shared storage)
guard let crumbURL = URL(string: "https://query1.finance.yahoo.com/v1/test/getcrumb") else { return }
var crumbReq = URLRequest(url: crumbURL)
crumbReq.setValue(userAgent, forHTTPHeaderField: "User-Agent")
let (crumbData, _) = try await session.data(for: crumbReq)
let fetched = String(data: crumbData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if fetched.isEmpty || fetched.contains("Too Many") {
// Retry after a short back-off
try await Task.sleep(nanoseconds: 3_000_000_000)
let (data2, _) = try await session.data(for: crumbReq)
let retried = String(data: data2, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !retried.isEmpty { crumb = retried; crumbFetchedAt = Date() }
} else {
crumb = fetched
crumbFetchedAt = Date()
}
}
/// Builds a URLRequest with User-Agent set; appends crumb to the URL if available.
private func request(url: URL) -> URLRequest {
var u = url
if let c = crumb {
// Append crumb query param
let sep = url.query != nil ? "&" : "?"
if let full = URL(string: url.absoluteString + "\(sep)crumb=\(c.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? c)") {
u = full
}
}
var req = URLRequest(url: u)
req.setValue(userAgent, forHTTPHeaderField: "User-Agent")
return req
}
/// Performs the request; on 401 invalidates crumb and retries once.
private func fetch(_ url: URL) async throws -> Data {
try await ensureAuth()
let (data, resp) = try await session.data(for: request(url: url))
if let http = resp as? HTTPURLResponse, http.statusCode == 401 {
// Crumb expired force refresh and retry once
crumb = nil
try await ensureAuth()
let (data2, resp2) = try await session.data(for: request(url: url))
if let http2 = resp2 as? HTTPURLResponse, http2.statusCode != 200 {
throw YFError.authFailed(http2.statusCode)
}
return data2
}
return data
}
// MARK: - Price history (no auth required, but send UA)
func priceHistory(ticker: String) async throws -> PriceHistory {
let key = "hist_\(ticker)"
if let hit: PriceHistory = get(key) { return hit }
if let hit: PriceHistory = cached(key) { return hit }
guard let url = URL(string:
"https://query1.finance.yahoo.com/v8/finance/chart/\(ticker)?interval=1d&range=1y&includePrePost=false")
else { throw YFError.badURL }
let (data, _) = try await session.data(from: url)
// Decode outside actor isolation via file-scope free function
var req = URLRequest(url: url)
req.setValue(userAgent, forHTTPHeaderField: "User-Agent")
let (data, _) = try await session.data(for: req)
let resp = try yfDecodeChart(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 opens = q.open
let highs = q.high
let lows = q.low
let closes = q.close
for i in 0..<min(ts.count, closes.count) {
guard let c = closes[i] else { continue }
for i in 0..<min(r.timestamp.count, q.close.count) {
guard let c = q.close[i] else { continue }
bars.append(PriceBar(
date: Date(timeIntervalSince1970: Double(ts[i])),
open: opens[i] ?? c,
high: highs[i] ?? c,
low: lows[i] ?? c,
date: Date(timeIntervalSince1970: Double(r.timestamp[i])),
open: q.open[i] ?? c,
high: q.high[i] ?? c,
low: q.low[i] ?? c,
close: c
))
}
guard !bars.isEmpty else { throw YFError.noData }
let result = PriceHistory(ticker: ticker, bars: bars)
set(result, key: key)
store(result, key: key)
return result
}
// MARK: - Option expirations
// MARK: - Option expirations (requires auth)
func expirations(ticker: String) async throws -> [Date] {
let key = "exp_\(ticker)"
if let hit: [Date] = get(key) { return hit }
if let hit: [Date] = cached(key) { return hit }
guard let url = URL(string:
"https://query1.finance.yahoo.com/v7/finance/options/\(ticker)")
else { throw YFError.badURL }
let (data, _) = try await session.data(from: url)
let data = try await fetch(url)
let resp = try yfDecodeOptions(data)
guard let r = resp.optionChain.result?.first else { throw YFError.noData }
let dates = r.expirationDates.map { Date(timeIntervalSince1970: Double($0)) }
set(dates, key: key)
guard !dates.isEmpty else { throw YFError.noData }
store(dates, key: key)
return dates
}
/// Returns the closest available expiration on or after `target`.
func nearestExpiration(ticker: String, on target: Date) async throws -> Date? {
let exps = try await expirations(ticker: ticker)
return exps.first { $0 >= target }
try await expirations(ticker: ticker).first { $0 >= target }
}
/// Returns today's expiration if one exists (for 0DTE).
func sameDayExpiration(ticker: String) async throws -> Date? {
let today = Calendar.current.startOfDay(for: Date())
let exps = try await expirations(ticker: ticker)
return exps.first { Calendar.current.isDate($0, inSameDayAs: today) }
return try await expirations(ticker: ticker)
.first { Calendar.current.isDate($0, inSameDayAs: today) }
}
// MARK: - Option chain
// MARK: - Option chain (requires auth)
func optionChain(ticker: String, expiration: Date) async throws -> OptionChain {
let epoch = Int(expiration.timeIntervalSince1970)
let key = "chain_\(ticker)_\(epoch)"
if let hit: OptionChain = get(key) { return hit }
if let hit: OptionChain = cached(key) { return hit }
guard let url = URL(string:
"https://query1.finance.yahoo.com/v7/finance/options/\(ticker)?date=\(epoch)")
else { throw YFError.badURL }
let (data, _) = try await session.data(from: url)
let data = try await fetch(url)
let resp = try yfDecodeOptions(data)
guard let r = resp.optionChain.result?.first,
@@ -168,7 +242,7 @@ actor YahooFinanceClient {
let puts = opts.puts .compactMap { yfMakeContract($0) }
let chain = OptionChain(expiration: expiration, calls: calls, puts: puts)
set(chain, key: key)
store(chain, key: key)
return chain
}
@@ -176,13 +250,14 @@ actor YahooFinanceClient {
func nextEarningsDate(ticker: String) async throws -> Date? {
let key = "earn_\(ticker)"
if let hit: Date? = get(key) { return hit }
if let hit: Date? = cached(key) { return hit }
guard let url = URL(string:
"https://query1.finance.yahoo.com/v10/finance/quoteSummary/\(ticker)?modules=calendarEvents")
else { return nil }
guard let (data, _) = try? await session.data(from: url),
// Best-effort don't throw if this fails
guard let data = try? await fetch(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]],
@@ -192,20 +267,20 @@ actor YahooFinanceClient {
let dates = earn["earningsDate"] as? [[String: Any]],
let rawDate = dates.first?["raw"] as? TimeInterval
else {
set(Optional<Date>.none as Any, key: key)
store(Optional<Date>.none as Any, key: key)
return nil
}
let date: Date? = Date(timeIntervalSince1970: rawDate) > Date()
? Date(timeIntervalSince1970: rawDate) : nil
set(date as Any, key: key)
store(date as Any, key: key)
return date
}
}
// MARK: - File-scope free functions (naturally nonisolated no actor context)
// Swift 6 requires Decodable conformances used in nonisolated code to themselves
// be nonisolated. Placing the decode calls here, outside any actor, satisfies that.
// MARK: - File-scope free functions (naturally nonisolated)
// Decode helpers live outside the actor so Swift 6 doesn't flag the
// Decodable conformance as being used in an actor-isolated context.
private func yfDecodeChart(_ data: Data) throws -> YFChartResponse {
try JSONDecoder().decode(YFChartResponse.self, from: data)

View File

@@ -4,8 +4,8 @@ import Combine
@MainActor
final class RecommendationsViewModel: ObservableObject {
@Published var recommendations: [Recommendation] = []
@Published var isLoading = false
@Published var isRefreshing = false
@Published var isLoading = false
@Published var error: String? = nil
@Published var selectedHorizon: String = TimeHorizon.weekly.rawValue
@Published var selectedStrategy: String = "covered_call"
@@ -36,54 +36,82 @@ final class RecommendationsViewModel: ObservableObject {
let tickers = DataStore.shared.tickers
guard !tickers.isEmpty else {
error = "Add stocks in the Portfolio tab first."
isRefreshing = false
return
}
var freshRecs: [Recommendation] = []
var tickerErrors: [String] = []
let yfClient = YahooFinanceClient.shared
for ticker in tickers {
guard let history = try? await yfClient.priceHistory(ticker: ticker) else { continue }
let earningsDate = try? await yfClient.nextEarningsDate(ticker: ticker)
let signal = SignalEngine.compute(ticker: ticker, history: history, earningsDate: earningsDate)
do {
let history = try await yfClient.priceHistory(ticker: ticker)
let earningsDate = try? await yfClient.nextEarningsDate(ticker: ticker)
let signal = SignalEngine.compute(ticker: ticker, history: history, earningsDate: earningsDate)
guard let expirations = try? await yfClient.expirations(ticker: ticker),
!expirations.isEmpty else { continue }
for horizon in TimeHorizon.allCases {
// 0DTE only makes sense during market hours on a trading day
if horizon == .zeroDTE {
guard let _ = expirations.first(where: {
Calendar.current.isDate($0, inSameDayAs: Date())
}) else { continue }
let expirations = try await yfClient.expirations(ticker: ticker)
guard !expirations.isEmpty else {
print("[Recs] \(ticker): no expirations available (no listed options?)")
tickerErrors.append("\(ticker): no listed options")
continue
}
guard let expDate = RecommendationEngine.expiration(for: horizon, available: expirations),
let chain = try? await yfClient.optionChain(ticker: ticker, expiration: expDate)
else { continue }
for horizon in TimeHorizon.allCases {
if horizon == .zeroDTE {
guard expirations.first(where: {
Calendar.current.isDate($0, inSameDayAs: Date())
}) != nil else { continue }
}
for strategy in ["covered_call", "cash_secured_put"] {
if let rec = RecommendationEngine.recommend(
ticker: ticker,
strategy: strategy,
horizon: horizon,
history: history,
signal: signal,
chain: chain,
expiration: expDate
) {
freshRecs.append(rec)
guard let expDate = RecommendationEngine.expiration(
for: horizon, available: expirations
) else {
print("[Recs] \(ticker)/\(horizon.rawValue): no matching expiration")
continue
}
do {
let chain = try await yfClient.optionChain(ticker: ticker, expiration: expDate)
print("[Recs] \(ticker)/\(horizon.rawValue): \(chain.calls.count) calls, \(chain.puts.count) puts")
for strategy in ["covered_call", "cash_secured_put"] {
if let rec = RecommendationEngine.recommend(
ticker: ticker,
strategy: strategy,
horizon: horizon,
history: history,
signal: signal,
chain: chain,
expiration: expDate
) {
freshRecs.append(rec)
print("[Recs] ✓ \(ticker) \(strategy)/\(horizon.rawValue) strike=\(rec.recommendedStrike) premium=\(rec.estimatedPremium)")
} else {
print("[Recs] ✗ \(ticker) \(strategy)/\(horizon.rawValue): selectBestContract returned nil")
}
}
} catch {
print("[Recs] \(ticker)/\(horizon.rawValue) chain error: \(error.localizedDescription)")
}
}
} catch {
print("[Recs] \(ticker) fetch error: \(error.localizedDescription)")
tickerErrors.append("\(ticker): \(error.localizedDescription)")
}
}
if freshRecs.isEmpty && !tickerErrors.isEmpty {
self.error = "Could not load options data. Check your connection and try again.\n" +
tickerErrors.prefix(3).joined(separator: "\n")
}
print("[Recs] Refresh complete. \(freshRecs.count) recommendations for \(tickers.count) tickers.")
DataStore.shared.replaceRecommendations(freshRecs)
isRefreshing = false
}
/// Returns the SignalSnapshot for a ticker (re-computed on demand).
func signalSnapshot(for ticker: String) async -> SignalSnapshot? {
guard let history = try? await YahooFinanceClient.shared.priceHistory(ticker: ticker) else { return nil }
let earningsDate = try? await YahooFinanceClient.shared.nextEarningsDate(ticker: ticker)

View File

@@ -109,6 +109,7 @@ struct AddPositionSheet: View {
Section("Stock") {
TextField("Ticker (e.g. AAPL)", text: $ticker)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.focused($focusedField, equals: .ticker)
}

View File

@@ -31,6 +31,7 @@ struct LogTradeSheet: View {
Section("Trade") {
TextField("Ticker (e.g. AAPL)", text: $ticker)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.focused($focusedField, equals: .ticker)
Picker("Strategy", selection: $strategy) {

View File

@@ -32,14 +32,25 @@ struct RecommendationsView: View {
// Content
if vm.isRefreshing {
// Show a full-screen spinner while fetching
VStack(spacing: 16) {
Spacer()
ProgressView()
.scaleEffect(1.4)
ProgressView().scaleEffect(1.4)
Text("Fetching market data…")
.font(.subheadline)
.foregroundStyle(.secondary)
.font(.subheadline).foregroundStyle(.secondary)
Spacer()
}
} else if let err = vm.error {
VStack(spacing: 16) {
Spacer()
Image(systemName: "wifi.exclamationmark")
.font(.system(size: 44)).foregroundStyle(.secondary)
Text("Could not load data")
.font(.headline)
Text(err)
.font(.caption).foregroundStyle(.secondary)
.multilineTextAlignment(.center).padding(.horizontal)
Button("Retry") { Task { await vm.refresh() } }
.buttonStyle(.bordered)
Spacer()
}
} else if vm.filtered.isEmpty {