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:
@@ -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 .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)
|
||||
|
||||
@@ -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,32 +36,45 @@ 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 }
|
||||
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 }
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -74,16 +87,31 @@ final class RecommendationsViewModel: ObservableObject {
|
||||
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)
|
||||
|
||||
@@ -109,6 +109,7 @@ struct AddPositionSheet: View {
|
||||
Section("Stock") {
|
||||
TextField("Ticker (e.g. AAPL)", text: $ticker)
|
||||
.textInputAutocapitalization(.characters)
|
||||
.autocorrectionDisabled()
|
||||
.focused($focusedField, equals: .ticker)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user