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 {
|
enum YFError: LocalizedError {
|
||||||
case noData
|
case noData
|
||||||
case badURL
|
case badURL
|
||||||
|
case authFailed(Int)
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .noData: return "No data returned from Yahoo Finance."
|
case .noData: return "No data returned from Yahoo Finance."
|
||||||
case .badURL: return "Could not construct Yahoo Finance URL."
|
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.
|
/// Direct HTTP client for Yahoo Finance unofficial endpoints.
|
||||||
/// Thread-safe via Swift actor. Results are cached for 15 minutes.
|
/// Thread-safe via Swift actor. Results are cached for 15 minutes.
|
||||||
|
/// Uses Yahoo Finance's cookie+crumb auth (required since 2024).
|
||||||
actor YahooFinanceClient {
|
actor YahooFinanceClient {
|
||||||
static let shared = 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 = {
|
private let session: URLSession = {
|
||||||
let cfg = URLSessionConfiguration.default
|
let cfg = URLSessionConfiguration.default
|
||||||
cfg.timeoutIntervalForRequest = 20
|
cfg.timeoutIntervalForRequest = 20
|
||||||
cfg.timeoutIntervalForResource = 30
|
cfg.timeoutIntervalForResource = 40
|
||||||
cfg.httpAdditionalHeaders = [
|
// Accept all cookies so the Yahoo A1S session cookie is stored automatically
|
||||||
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15"
|
cfg.httpCookieAcceptPolicy = .always
|
||||||
]
|
cfg.httpCookieStorage = .shared
|
||||||
return URLSession(configuration: cfg)
|
return URLSession(configuration: cfg)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private var cache: [String: (value: Any, storedAt: Date)] = [:]
|
private var crumb: String?
|
||||||
private let ttl: TimeInterval = 900 // 15 min
|
private var crumbFetchedAt: Date?
|
||||||
|
private let crumbTTL: TimeInterval = 3600 // refresh crumb every hour
|
||||||
|
|
||||||
private func get<T>(_ key: String) -> T? {
|
private var dataCache: [String: (value: Any, storedAt: Date)] = [:]
|
||||||
guard let entry = cache[key],
|
private let cacheTTL: TimeInterval = 900 // 15 min for market data
|
||||||
Date().timeIntervalSince(entry.storedAt) < ttl,
|
|
||||||
|
// 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 }
|
let typed = entry.value as? T else { return nil }
|
||||||
return typed
|
return typed
|
||||||
}
|
}
|
||||||
private func set(_ value: Any, key: String) { cache[key] = (value, Date()) }
|
private func store(_ value: Any, key: String) { dataCache[key] = (value, Date()) }
|
||||||
func clearCache() { cache.removeAll() }
|
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 {
|
func priceHistory(ticker: String) async throws -> PriceHistory {
|
||||||
let key = "hist_\(ticker)"
|
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:
|
guard let url = URL(string:
|
||||||
"https://query1.finance.yahoo.com/v8/finance/chart/\(ticker)?interval=1d&range=1y&includePrePost=false")
|
"https://query1.finance.yahoo.com/v8/finance/chart/\(ticker)?interval=1d&range=1y&includePrePost=false")
|
||||||
else { throw YFError.badURL }
|
else { throw YFError.badURL }
|
||||||
|
|
||||||
let (data, _) = try await session.data(from: url)
|
var req = URLRequest(url: url)
|
||||||
// Decode outside actor isolation via file-scope free function
|
req.setValue(userAgent, forHTTPHeaderField: "User-Agent")
|
||||||
|
let (data, _) = try await session.data(for: req)
|
||||||
let resp = try yfDecodeChart(data)
|
let resp = try yfDecodeChart(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 }
|
||||||
|
|
||||||
var bars: [PriceBar] = []
|
var bars: [PriceBar] = []
|
||||||
let ts = r.timestamp
|
for i in 0..<min(r.timestamp.count, q.close.count) {
|
||||||
let opens = q.open
|
guard let c = q.close[i] else { continue }
|
||||||
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 }
|
|
||||||
bars.append(PriceBar(
|
bars.append(PriceBar(
|
||||||
date: Date(timeIntervalSince1970: Double(ts[i])),
|
date: Date(timeIntervalSince1970: Double(r.timestamp[i])),
|
||||||
open: opens[i] ?? c,
|
open: q.open[i] ?? c,
|
||||||
high: highs[i] ?? c,
|
high: q.high[i] ?? c,
|
||||||
low: lows[i] ?? c,
|
low: q.low[i] ?? c,
|
||||||
close: c
|
close: c
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
guard !bars.isEmpty else { throw YFError.noData }
|
||||||
|
|
||||||
let result = PriceHistory(ticker: ticker, bars: bars)
|
let result = PriceHistory(ticker: ticker, bars: bars)
|
||||||
set(result, key: key)
|
store(result, key: key)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Option expirations
|
// MARK: - Option expirations (requires auth)
|
||||||
|
|
||||||
func expirations(ticker: String) async throws -> [Date] {
|
func expirations(ticker: String) async throws -> [Date] {
|
||||||
let key = "exp_\(ticker)"
|
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:
|
guard let url = URL(string:
|
||||||
"https://query1.finance.yahoo.com/v7/finance/options/\(ticker)")
|
"https://query1.finance.yahoo.com/v7/finance/options/\(ticker)")
|
||||||
else { throw YFError.badURL }
|
else { throw YFError.badURL }
|
||||||
|
|
||||||
let (data, _) = try await session.data(from: url)
|
let data = try await fetch(url)
|
||||||
let resp = try yfDecodeOptions(data)
|
let resp = try yfDecodeOptions(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)) }
|
||||||
set(dates, key: key)
|
guard !dates.isEmpty else { throw YFError.noData }
|
||||||
|
store(dates, key: key)
|
||||||
return dates
|
return dates
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the closest available expiration on or after `target`.
|
|
||||||
func nearestExpiration(ticker: String, on target: Date) async throws -> Date? {
|
func nearestExpiration(ticker: String, on target: Date) async throws -> Date? {
|
||||||
let exps = try await expirations(ticker: ticker)
|
try await expirations(ticker: ticker).first { $0 >= target }
|
||||||
return exps.first { $0 >= target }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns today's expiration if one exists (for 0DTE).
|
|
||||||
func sameDayExpiration(ticker: String) async throws -> Date? {
|
func sameDayExpiration(ticker: String) async throws -> Date? {
|
||||||
let today = Calendar.current.startOfDay(for: Date())
|
let today = Calendar.current.startOfDay(for: Date())
|
||||||
let exps = try await expirations(ticker: ticker)
|
return try await expirations(ticker: ticker)
|
||||||
return exps.first { Calendar.current.isDate($0, inSameDayAs: today) }
|
.first { Calendar.current.isDate($0, inSameDayAs: today) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Option chain
|
// MARK: - Option chain (requires auth)
|
||||||
|
|
||||||
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)
|
||||||
let key = "chain_\(ticker)_\(epoch)"
|
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:
|
guard let url = URL(string:
|
||||||
"https://query1.finance.yahoo.com/v7/finance/options/\(ticker)?date=\(epoch)")
|
"https://query1.finance.yahoo.com/v7/finance/options/\(ticker)?date=\(epoch)")
|
||||||
else { throw YFError.badURL }
|
else { throw YFError.badURL }
|
||||||
|
|
||||||
let (data, _) = try await session.data(from: url)
|
let data = try await fetch(url)
|
||||||
let resp = try yfDecodeOptions(data)
|
let resp = try yfDecodeOptions(data)
|
||||||
|
|
||||||
guard let r = resp.optionChain.result?.first,
|
guard let r = resp.optionChain.result?.first,
|
||||||
@@ -168,7 +242,7 @@ actor YahooFinanceClient {
|
|||||||
let puts = opts.puts .compactMap { yfMakeContract($0) }
|
let puts = opts.puts .compactMap { yfMakeContract($0) }
|
||||||
|
|
||||||
let chain = OptionChain(expiration: expiration, calls: calls, puts: puts)
|
let chain = OptionChain(expiration: expiration, calls: calls, puts: puts)
|
||||||
set(chain, key: key)
|
store(chain, key: key)
|
||||||
return chain
|
return chain
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,13 +250,14 @@ actor YahooFinanceClient {
|
|||||||
|
|
||||||
func nextEarningsDate(ticker: String) async throws -> Date? {
|
func nextEarningsDate(ticker: String) async throws -> Date? {
|
||||||
let key = "earn_\(ticker)"
|
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:
|
guard let url = URL(string:
|
||||||
"https://query1.finance.yahoo.com/v10/finance/quoteSummary/\(ticker)?modules=calendarEvents")
|
"https://query1.finance.yahoo.com/v10/finance/quoteSummary/\(ticker)?modules=calendarEvents")
|
||||||
else { return nil }
|
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 json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
let qs = json["quoteSummary"] as? [String: Any],
|
let qs = json["quoteSummary"] as? [String: Any],
|
||||||
let results = qs["result"] as? [[String: Any]],
|
let results = qs["result"] as? [[String: Any]],
|
||||||
@@ -192,20 +267,20 @@ actor YahooFinanceClient {
|
|||||||
let dates = earn["earningsDate"] as? [[String: Any]],
|
let dates = earn["earningsDate"] as? [[String: Any]],
|
||||||
let rawDate = dates.first?["raw"] as? TimeInterval
|
let rawDate = dates.first?["raw"] as? TimeInterval
|
||||||
else {
|
else {
|
||||||
set(Optional<Date>.none as Any, key: key)
|
store(Optional<Date>.none as Any, key: key)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let date: Date? = Date(timeIntervalSince1970: rawDate) > Date()
|
let date: Date? = Date(timeIntervalSince1970: rawDate) > Date()
|
||||||
? Date(timeIntervalSince1970: rawDate) : nil
|
? Date(timeIntervalSince1970: rawDate) : nil
|
||||||
set(date as Any, key: key)
|
store(date as Any, key: key)
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - File-scope free functions (naturally nonisolated — no actor context)
|
// MARK: - File-scope free functions (naturally nonisolated)
|
||||||
// Swift 6 requires Decodable conformances used in nonisolated code to themselves
|
// Decode helpers live outside the actor so Swift 6 doesn't flag the
|
||||||
// be nonisolated. Placing the decode calls here, outside any actor, satisfies that.
|
// Decodable conformance as being used in an actor-isolated context.
|
||||||
|
|
||||||
private func yfDecodeChart(_ data: Data) throws -> YFChartResponse {
|
private func yfDecodeChart(_ data: Data) throws -> YFChartResponse {
|
||||||
try JSONDecoder().decode(YFChartResponse.self, from: data)
|
try JSONDecoder().decode(YFChartResponse.self, from: data)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import Combine
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class RecommendationsViewModel: ObservableObject {
|
final class RecommendationsViewModel: ObservableObject {
|
||||||
@Published var recommendations: [Recommendation] = []
|
@Published var recommendations: [Recommendation] = []
|
||||||
@Published var isLoading = false
|
|
||||||
@Published var isRefreshing = false
|
@Published var isRefreshing = false
|
||||||
|
@Published var isLoading = false
|
||||||
@Published var error: String? = nil
|
@Published var error: String? = nil
|
||||||
@Published var selectedHorizon: String = TimeHorizon.weekly.rawValue
|
@Published var selectedHorizon: String = TimeHorizon.weekly.rawValue
|
||||||
@Published var selectedStrategy: String = "covered_call"
|
@Published var selectedStrategy: String = "covered_call"
|
||||||
@@ -36,54 +36,82 @@ final class RecommendationsViewModel: ObservableObject {
|
|||||||
|
|
||||||
let tickers = DataStore.shared.tickers
|
let tickers = DataStore.shared.tickers
|
||||||
guard !tickers.isEmpty else {
|
guard !tickers.isEmpty else {
|
||||||
|
error = "Add stocks in the Portfolio tab first."
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var freshRecs: [Recommendation] = []
|
var freshRecs: [Recommendation] = []
|
||||||
|
var tickerErrors: [String] = []
|
||||||
let yfClient = YahooFinanceClient.shared
|
let yfClient = YahooFinanceClient.shared
|
||||||
|
|
||||||
for ticker in tickers {
|
for ticker in tickers {
|
||||||
guard let history = try? await yfClient.priceHistory(ticker: ticker) else { continue }
|
do {
|
||||||
let earningsDate = try? await yfClient.nextEarningsDate(ticker: ticker)
|
let history = try await yfClient.priceHistory(ticker: ticker)
|
||||||
let signal = SignalEngine.compute(ticker: ticker, history: history, earningsDate: earningsDate)
|
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),
|
let expirations = try await yfClient.expirations(ticker: ticker)
|
||||||
!expirations.isEmpty else { continue }
|
guard !expirations.isEmpty else {
|
||||||
|
print("[Recs] \(ticker): no expirations available (no listed options?)")
|
||||||
for horizon in TimeHorizon.allCases {
|
tickerErrors.append("\(ticker): no listed options")
|
||||||
// 0DTE only makes sense during market hours on a trading day
|
continue
|
||||||
if horizon == .zeroDTE {
|
|
||||||
guard let _ = expirations.first(where: {
|
|
||||||
Calendar.current.isDate($0, inSameDayAs: Date())
|
|
||||||
}) else { continue }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let expDate = RecommendationEngine.expiration(for: horizon, available: expirations),
|
for horizon in TimeHorizon.allCases {
|
||||||
let chain = try? await yfClient.optionChain(ticker: ticker, expiration: expDate)
|
if horizon == .zeroDTE {
|
||||||
else { continue }
|
guard expirations.first(where: {
|
||||||
|
Calendar.current.isDate($0, inSameDayAs: Date())
|
||||||
|
}) != nil else { continue }
|
||||||
|
}
|
||||||
|
|
||||||
for strategy in ["covered_call", "cash_secured_put"] {
|
guard let expDate = RecommendationEngine.expiration(
|
||||||
if let rec = RecommendationEngine.recommend(
|
for: horizon, available: expirations
|
||||||
ticker: ticker,
|
) else {
|
||||||
strategy: strategy,
|
print("[Recs] \(ticker)/\(horizon.rawValue): no matching expiration")
|
||||||
horizon: horizon,
|
continue
|
||||||
history: history,
|
}
|
||||||
signal: signal,
|
|
||||||
chain: chain,
|
do {
|
||||||
expiration: expDate
|
let chain = try await yfClient.optionChain(ticker: ticker, expiration: expDate)
|
||||||
) {
|
print("[Recs] \(ticker)/\(horizon.rawValue): \(chain.calls.count) calls, \(chain.puts.count) puts")
|
||||||
freshRecs.append(rec)
|
|
||||||
|
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)
|
DataStore.shared.replaceRecommendations(freshRecs)
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the SignalSnapshot for a ticker (re-computed on demand).
|
|
||||||
func signalSnapshot(for ticker: String) async -> SignalSnapshot? {
|
func signalSnapshot(for ticker: String) async -> SignalSnapshot? {
|
||||||
guard let history = try? await YahooFinanceClient.shared.priceHistory(ticker: ticker) else { return nil }
|
guard let history = try? await YahooFinanceClient.shared.priceHistory(ticker: ticker) else { return nil }
|
||||||
let earningsDate = try? await YahooFinanceClient.shared.nextEarningsDate(ticker: ticker)
|
let earningsDate = try? await YahooFinanceClient.shared.nextEarningsDate(ticker: ticker)
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ struct AddPositionSheet: View {
|
|||||||
Section("Stock") {
|
Section("Stock") {
|
||||||
TextField("Ticker (e.g. AAPL)", text: $ticker)
|
TextField("Ticker (e.g. AAPL)", text: $ticker)
|
||||||
.textInputAutocapitalization(.characters)
|
.textInputAutocapitalization(.characters)
|
||||||
|
.autocorrectionDisabled()
|
||||||
.focused($focusedField, equals: .ticker)
|
.focused($focusedField, equals: .ticker)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ struct LogTradeSheet: View {
|
|||||||
Section("Trade") {
|
Section("Trade") {
|
||||||
TextField("Ticker (e.g. AAPL)", text: $ticker)
|
TextField("Ticker (e.g. AAPL)", text: $ticker)
|
||||||
.textInputAutocapitalization(.characters)
|
.textInputAutocapitalization(.characters)
|
||||||
|
.autocorrectionDisabled()
|
||||||
.focused($focusedField, equals: .ticker)
|
.focused($focusedField, equals: .ticker)
|
||||||
|
|
||||||
Picker("Strategy", selection: $strategy) {
|
Picker("Strategy", selection: $strategy) {
|
||||||
|
|||||||
@@ -32,14 +32,25 @@ struct RecommendationsView: View {
|
|||||||
|
|
||||||
// ─── Content ──────────────────────────────────────────────
|
// ─── Content ──────────────────────────────────────────────
|
||||||
if vm.isRefreshing {
|
if vm.isRefreshing {
|
||||||
// Show a full-screen spinner while fetching
|
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Spacer()
|
Spacer()
|
||||||
ProgressView()
|
ProgressView().scaleEffect(1.4)
|
||||||
.scaleEffect(1.4)
|
|
||||||
Text("Fetching market data…")
|
Text("Fetching market data…")
|
||||||
.font(.subheadline)
|
.font(.subheadline).foregroundStyle(.secondary)
|
||||||
.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()
|
Spacer()
|
||||||
}
|
}
|
||||||
} else if vm.filtered.isEmpty {
|
} else if vm.filtered.isEmpty {
|
||||||
|
|||||||
Reference in New Issue
Block a user