diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/YahooFinanceClient.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/YahooFinanceClient.swift index 6095eb4..e6e642f 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/YahooFinanceClient.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/YahooFinanceClient.swift @@ -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(_ 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(_ 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.. [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.none as Any, key: key) + store(Optional.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) diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/RecommendationsViewModel.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/RecommendationsViewModel.swift index 3864440..e960aeb 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/RecommendationsViewModel.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/RecommendationsViewModel.swift @@ -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) diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Portfolio/PortfolioView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Portfolio/PortfolioView.swift index a28d1e0..8ffcd1c 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Portfolio/PortfolioView.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Portfolio/PortfolioView.swift @@ -109,6 +109,7 @@ struct AddPositionSheet: View { Section("Stock") { TextField("Ticker (e.g. AAPL)", text: $ticker) .textInputAutocapitalization(.characters) + .autocorrectionDisabled() .focused($focusedField, equals: .ticker) } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/LogTradeSheet.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/LogTradeSheet.swift index b368fce..1056350 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/LogTradeSheet.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/LogTradeSheet.swift @@ -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) { diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationsView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationsView.swift index e7375c7..9922abc 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationsView.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationsView.swift @@ -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 {