diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/AppDelegate.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/AppDelegate.swift index af54bf0..37490f3 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/AppDelegate.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/AppDelegate.swift @@ -1,5 +1,5 @@ import UIKit -import UserNotifications +import BackgroundTasks final class AppDelegate: NSObject, UIApplicationDelegate { @@ -7,51 +7,26 @@ final class AppDelegate: NSObject, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + // Set up local notification delegate NotificationHandler.shared.setup() + + // Register BGAppRefreshTask before app finishes launching (required by Apple) + BackgroundRefreshManager.shared.registerBackgroundTask() + return true } - // Called after user grants permission and iOS assigns a device token - func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - let token = deviceToken.map { String(format: "%02x", $0) }.joined() - LocalStore.shared.deviceToken = token - - Task { - await registerDevice(token: token) - } + func applicationDidEnterBackground(_ application: UIApplication) { + BackgroundRefreshManager.shared.stopForegroundTimer() + BackgroundRefreshManager.shared.scheduleBackgroundRefresh() } - func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - print("APNs registration failed: \(error.localizedDescription)") + func applicationWillEnterForeground(_ application: UIApplication) { + BackgroundRefreshManager.shared.startForegroundTimer() } - // ─── Private ────────────────────────────────────────────────────────────── - - private func registerDevice(token: String) async { - struct DeviceRegisterBody: Encodable { - let apns_token: String - let device_name: String? - } - - struct DeviceResponse: Decodable { - let id: Int - } - - do { - let body = DeviceRegisterBody( - apns_token: token, - device_name: UIDevice.current.name - ) - // Ensure token is stored before the request so APIClient can use it - LocalStore.shared.deviceToken = token - let response: DeviceResponse = try await APIClient.shared.requestNoAuth( - .registerDevice, - body: body - ) - LocalStore.shared.deviceId = response.id - print("Device registered with id: \(response.id)") - } catch { - print("Device registration failed: \(error.localizedDescription)") - } + func applicationDidBecomeActive(_ application: UIApplication) { + // Run a signal check immediately when user opens the app + Task { await BackgroundRefreshManager.shared.runSignalCheck() } } } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Config/Constants.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Config/Constants.swift index 2ee86d3..37018e4 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Config/Constants.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Config/Constants.swift @@ -1,26 +1,21 @@ import SwiftUI enum Constants { - // ─── API ────────────────────────────────────────────────────────────────── - /// Change this to your deployed Railway/Render URL before shipping. - /// For local dev, use your Mac's LAN IP so the simulator can reach it. - static let baseURL = "http://localhost:8000" - static let apiPrefix = "/api/v1" - - static var apiBaseURL: String { baseURL + apiPrefix } - - // ─── Colors ─────────────────────────────────────────────────────────────── + // MARK: - Colors enum Color { - static let strong = SwiftUI.Color.green - static let moderate = SwiftUI.Color.yellow - static let weak = SwiftUI.Color(red: 0.9, green: 0.4, blue: 0.2) - static let accent = SwiftUI.Color.blue + static let strong = SwiftUI.Color.green + static let moderate = SwiftUI.Color.yellow + static let weak = SwiftUI.Color(red: 0.9, green: 0.4, blue: 0.2) + static let accent = SwiftUI.Color.blue static let destructive = SwiftUI.Color.red - static let warning = SwiftUI.Color.orange + static let warning = SwiftUI.Color.orange } - // ─── App ────────────────────────────────────────────────────────────────── - static let appName = "Options Sidekick" - static let notificationCategory = "POSITION_ALERT" + // MARK: - App + static let appName = "Options Sidekick" + static let notificationCategory = "POSITION_ALERT" static let notificationActionView = "VIEW_POSITION" + + // MARK: - Background Task + static let bgRefreshIdentifier = BackgroundRefreshManager.taskIdentifier } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/AppAlert.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/AppAlert.swift index c980d48..626906a 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/AppAlert.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/AppAlert.swift @@ -1,28 +1,21 @@ import Foundation struct AppAlert: Codable, Identifiable, Hashable { - let id: Int + let id: UUID let ticker: String - let optionPositionId: Int? - let alertType: String // "close_early" | "roll_out" | "roll_up_down" | "earnings_warning" + let optionPositionId: UUID? + let alertType: String // "close_early" | "roll_out" | "earnings_warning" | "new_rec" let message: String let sentAt: Date var acknowledged: Bool - enum CodingKeys: String, CodingKey { - case id, ticker, message, acknowledged - case optionPositionId = "option_position_id" - case alertType = "alert_type" - case sentAt = "sent_at" - } - var typeLabel: String { switch alertType { - case "close_early": return "Close Early" - case "roll_out": return "Roll Out" - case "roll_up_down": return "Roll Strike" + case "close_early": return "Close Early" + case "roll_out": return "Roll Out" + case "roll_up_down": return "Roll Strike" case "earnings_warning": return "Earnings Warning" - case "new_rec": return "New Recommendation" + case "new_rec": return "New Recommendation" default: return alertType.replacingOccurrences(of: "_", with: " ").capitalized } } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/OptionPosition.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/OptionPosition.swift index 31a9e5a..682715e 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/OptionPosition.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/OptionPosition.swift @@ -1,27 +1,18 @@ import Foundation struct OptionPosition: Codable, Identifiable, Hashable { - let id: Int + let id: UUID let ticker: String let strategy: String // "covered_call" | "cash_secured_put" let strike: Double - let expiration: String // ISO date string "YYYY-MM-DD" + let expiration: String // ISO date "YYYY-MM-DD" let premiumReceived: Double let contracts: Int - let status: String // "open" | "closed" | "rolled" - let closeReason: String? + var status: String // "open" | "closed" | "rolled" + var closeReason: String? let openedAt: Date - let closedAt: Date? - let lastSignalHash: String? - - enum CodingKeys: String, CodingKey { - case id, ticker, strategy, strike, expiration, contracts, status - case premiumReceived = "premium_received" - case closeReason = "close_reason" - case openedAt = "opened_at" - case closedAt = "closed_at" - case lastSignalHash = "last_signal_hash" - } + var closedAt: Date? + var lastSignalHash: String? var strategyLabel: String { strategy == "covered_call" ? "Covered Call" : "Cash-Secured Put" @@ -35,35 +26,19 @@ struct OptionPosition: Codable, Identifiable, Hashable { var daysToExpiry: Int? { guard let exp = expirationDate else { return nil } - let days = Calendar.current.dateComponents([.day], from: Date(), to: exp).day - return days + return Calendar.current.dateComponents([.day], from: Date(), to: exp).day } - var totalCredit: Double { - premiumReceived * Double(contracts) * 100 - } + var totalCredit: Double { premiumReceived * Double(contracts) * 100 } } -struct OptionPositionCreate: Codable { +// MARK: - Create helpers (used by LogTradeSheet) + +struct OptionPositionCreate { let ticker: String let strategy: String let strike: Double let expiration: String let premiumReceived: Double let contracts: Int - - enum CodingKeys: String, CodingKey { - case ticker, strategy, strike, expiration, contracts - case premiumReceived = "premium_received" - } -} - -struct OptionPositionClose: Codable { - let status: String - let closeReason: String? - - enum CodingKeys: String, CodingKey { - case status - case closeReason = "close_reason" - } } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/PortfolioPosition.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/PortfolioPosition.swift index c0daf59..c8af2c8 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/PortfolioPosition.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/PortfolioPosition.swift @@ -1,15 +1,9 @@ import Foundation struct PortfolioPosition: Codable, Identifiable, Hashable { - let id: Int + let id: UUID let ticker: String let shares: Int let costBasis: Double? let createdAt: Date - - enum CodingKeys: String, CodingKey { - case id, ticker, shares - case costBasis = "cost_basis" - case createdAt = "created_at" - } } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/Recommendation.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/Recommendation.swift index b9f09cb..a1b3fc1 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/Recommendation.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/Recommendation.swift @@ -1,13 +1,13 @@ import Foundation struct Recommendation: Codable, Identifiable, Hashable { - let id: Int + let id: UUID let ticker: String let strategy: String let timeHorizon: String let currentPrice: Double let recommendedStrike: Double - let recommendedExpiration: String // ISO date "YYYY-MM-DD" + let recommendedExpiration: String // "YYYY-MM-DD" let estimatedPremium: Double let delta: Double let theta: Double @@ -19,51 +19,38 @@ struct Recommendation: Codable, Identifiable, Hashable { let signalHash: String let createdAt: Date - enum CodingKeys: String, CodingKey { - case id, ticker, strategy, rationale - case timeHorizon = "time_horizon" - case currentPrice = "current_price" - case recommendedStrike = "recommended_strike" - case recommendedExpiration = "recommended_expiration" - case estimatedPremium = "estimated_premium" - case delta, theta - case ivRank = "iv_rank" - case signalStrength = "signal_strength" - case earningsWarning = "earnings_warning" - case earningsDate = "earnings_date" - case signalHash = "signal_hash" - case createdAt = "created_at" - } - var strategyLabel: String { strategy == "covered_call" ? "Covered Call" : "Cash-Secured Put" } var horizonLabel: String { switch timeHorizon { - case "0dte": return "0DTE" - case "1dte": return "1DTE" + case "0dte": return "0DTE" + case "1dte": return "1DTE" case "weekly": return "Weekly" case "monthly": return "Monthly" - default: return timeHorizon.capitalized + default: return timeHorizon.capitalized } } - var annualizedPremiumPct: Double { - guard currentPrice > 0 else { return 0 } - let daysToExpiry = expirationDate.map { Calendar.current.dateComponents([.day], from: Date(), to: $0).day ?? 30 } ?? 30 - let dailyReturn = estimatedPremium / currentPrice - return dailyReturn * (365.0 / max(1, Double(daysToExpiry))) * 100 - } - var expirationDate: Date? { let fmt = DateFormatter() fmt.dateFormat = "yyyy-MM-dd" return fmt.date(from: recommendedExpiration) } + + var annualizedPremiumPct: Double { + guard currentPrice > 0 else { return 0 } + let dte = expirationDate.map { + Calendar.current.dateComponents([.day], from: Date(), to: $0).day ?? 30 + } ?? 30 + return (estimatedPremium / currentPrice) * (365.0 / max(1, Double(dte))) * 100 + } } -struct SignalSnapshot: Codable { +// MARK: - Signal snapshot (on-device, not Codable-over-network) + +struct SignalSnapshotView { let ticker: String let currentPrice: Double let ivRank: Double @@ -72,24 +59,6 @@ struct SignalSnapshot: Codable { let nearestSupport: Double? let nearestResistance: Double? let trend: String - let earningsDate: String? + let earningsDate: Date? let computedAt: Date - - enum CodingKeys: String, CodingKey { - case ticker - case currentPrice = "current_price" - case ivRank = "iv_rank" - case sma50 = "sma_50" - case sma200 = "sma_200" - case nearestSupport = "nearest_support" - case nearestResistance = "nearest_resistance" - case trend - case earningsDate = "earnings_date" - case computedAt = "computed_at" - } -} - -struct RecommendationWithSignals: Codable { - let recommendation: Recommendation - let signals: SignalSnapshot } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Networking/APIClient.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Networking/APIClient.swift index b7d78fa..ce896d9 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Networking/APIClient.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Networking/APIClient.swift @@ -1,157 +1,4 @@ +// APIClient.swift — no longer used (backend removed; all data is on-device) +// File retained to avoid Xcode "file not in project" warnings if still referenced +// in any build phase. Safe to delete from the Xcode project target. import Foundation - -/// Central networking client. -/// Adds X-Device-Token header automatically from LocalStore. -/// All requests are async/await. -final class APIClient { - static let shared = APIClient() - private init() {} - - private let session: URLSession = { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 15 - config.timeoutIntervalForResource = 30 - return URLSession(configuration: config) - }() - - private var decoder: JSONDecoder = { - let d = JSONDecoder() - d.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let str = try container.decode(String.self) - let formatters: [ISO8601DateFormatter] = [ - { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return f - }(), - { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime] - return f - }(), - ] - for fmt in formatters { - if let date = fmt.date(from: str) { return date } - } - let df = DateFormatter() - df.dateFormat = "yyyy-MM-dd" - if let date = df.date(from: str) { return date } - throw DecodingError.dataCorrupted( - .init(codingPath: decoder.codingPath, - debugDescription: "Cannot parse date: \(str)")) - } - return d - }() - - private var encoder: JSONEncoder = { - let e = JSONEncoder() - e.dateEncodingStrategy = .iso8601 - return e - }() - - // ─── Core request builder ───────────────────────────────────────────────── - - func request( - _ endpoint: Endpoint, - body: (any Encodable)? = nil - ) async throws -> T { - guard let token = LocalStore.shared.deviceToken else { - throw APIError.noDeviceToken - } - let req = try buildRequest(endpoint, deviceToken: token, body: body) - let (data, response) = try await session.data(for: req) - try validateResponse(response, data: data) - return try decoder.decode(T.self, from: data) - } - - /// Version that doesn't return a body (e.g. DELETE 204) - func requestVoid(_ endpoint: Endpoint, body: (any Encodable)? = nil) async throws { - guard let token = LocalStore.shared.deviceToken else { - throw APIError.noDeviceToken - } - let req = try buildRequest(endpoint, deviceToken: token, body: body) - let (data, response) = try await session.data(for: req) - try validateResponse(response, data: data) - } - - /// For device registration (no token yet) - func requestNoAuth( - _ endpoint: Endpoint, - body: (any Encodable)? = nil - ) async throws -> T { - let req = try buildRequest(endpoint, deviceToken: nil, body: body) - let (data, response) = try await session.data(for: req) - try validateResponse(response, data: data) - return try decoder.decode(T.self, from: data) - } - - // ─── Helpers ────────────────────────────────────────────────────────────── - - private func buildRequest( - _ endpoint: Endpoint, - deviceToken: String?, - body: (any Encodable)? - ) throws -> URLRequest { - guard let url = URL(string: Constants.apiBaseURL + endpoint.path) else { - throw APIError.invalidURL - } - var request = URLRequest(url: url) - request.httpMethod = endpoint.method - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - if let token = deviceToken { - request.setValue(token, forHTTPHeaderField: "X-Device-Token") - } - if let body { - request.httpBody = try encodeAny(body) - } - return request - } - - /// Opens the `any Encodable` existential so JSONEncoder can encode it. - private func encodeAny(_ value: any Encodable) throws -> Data { - func encode(_ v: T) throws -> Data { - try encoder.encode(v) - } - return try encode(value) - } - - private func validateResponse(_ response: URLResponse, data: Data) throws { - guard let httpResponse = response as? HTTPURLResponse else { - throw APIError.invalidResponse - } - switch httpResponse.statusCode { - case 200...299: - break - case 404: - throw APIError.notFound - case 503: - throw APIError.serviceUnavailable - default: - let message = String(data: data, encoding: .utf8) ?? "Unknown error" - throw APIError.serverError(httpResponse.statusCode, message) - } - } -} - -// ─── Supporting types ────────────────────────────────────────────────────────── - -enum APIError: LocalizedError { - case noDeviceToken - case invalidURL - case invalidResponse - case notFound - case serviceUnavailable - case serverError(Int, String) - - var errorDescription: String? { - switch self { - case .noDeviceToken: return "Device not registered. Please restart the app." - case .invalidURL: return "Invalid API URL." - case .invalidResponse: return "Invalid response from server." - case .notFound: return "Resource not found." - case .serviceUnavailable: return "Market data unavailable. Try again shortly." - case .serverError(let code, let msg): return "Server error \(code): \(msg)" - } - } -} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Networking/Endpoints.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Networking/Endpoints.swift index e11257a..2ae82cf 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Networking/Endpoints.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Networking/Endpoints.swift @@ -1,53 +1,4 @@ +// Endpoints.swift — no longer used (backend removed; all data is on-device) +// File retained to avoid Xcode "file not in project" warnings. +// Safe to delete from the Xcode project target. import Foundation - -struct Endpoint { - let path: String - let method: String - - // ─── Devices ────────────────────────────────────────────────────────────── - static let registerDevice = Endpoint(path: "/devices/register", method: "POST") - - // ─── Portfolio ──────────────────────────────────────────────────────────── - static let getPortfolio = Endpoint(path: "/portfolio", method: "GET") - static let setPortfolio = Endpoint(path: "/portfolio", method: "POST") - static func deleteTicker(_ ticker: String) -> Endpoint { - Endpoint(path: "/portfolio/\(ticker)", method: "DELETE") - } - - // ─── Recommendations ────────────────────────────────────────────────────── - static func getRecommendations(timeHorizon: String? = nil) -> Endpoint { - let query = timeHorizon.map { "?time_horizon=\($0)" } ?? "" - return Endpoint(path: "/recommendations\(query)", method: "GET") - } - static func getRecommendation(ticker: String, strategy: String, timeHorizon: String) -> Endpoint { - Endpoint(path: "/recommendations/\(ticker)?strategy=\(strategy)&time_horizon=\(timeHorizon)", method: "GET") - } - static let refreshRecommendations = Endpoint(path: "/recommendations/refresh", method: "POST") - - // ─── Positions ──────────────────────────────────────────────────────────── - static func getPositions(status: String? = nil) -> Endpoint { - let query = status.map { "?status=\($0)" } ?? "" - return Endpoint(path: "/positions\(query)", method: "GET") - } - static let logPosition = Endpoint(path: "/positions", method: "POST") - static func closePosition(_ id: Int) -> Endpoint { - Endpoint(path: "/positions/\(id)", method: "PATCH") - } - - // ─── Signals ────────────────────────────────────────────────────────────── - static func getSignals(_ ticker: String) -> Endpoint { - Endpoint(path: "/signals/\(ticker)", method: "GET") - } - - // ─── Alerts ─────────────────────────────────────────────────────────────── - static func getAlerts(unreadOnly: Bool = false) -> Endpoint { - let query = unreadOnly ? "?unread_only=true" : "" - return Endpoint(path: "/alerts\(query)", method: "GET") - } - static func acknowledgeAlert(_ id: Int) -> Endpoint { - Endpoint(path: "/alerts/\(id)/acknowledge", method: "PATCH") - } - - // ─── Health ─────────────────────────────────────────────────────────────── - static let health = Endpoint(path: "/health", method: "GET") -} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Notifications/NotificationHandler.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Notifications/NotificationHandler.swift index 9e335ce..d73b918 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Notifications/NotificationHandler.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Notifications/NotificationHandler.swift @@ -2,46 +2,43 @@ import Foundation import Combine import UserNotifications -/// Handles incoming push notifications — both foreground and background tap. +/// Receives local notifications — both foreground display and tap handling. @MainActor final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate, ObservableObject { static let shared = NotificationHandler() /// Published so views can react to a deep-link navigation request. - @Published var navigateToPositionId: Int? = nil - @Published var inAppAlertMessage: String? = nil + @Published var navigateToPositionId: UUID? = nil + @Published var inAppAlertMessage: String? = nil - private override init() { - super.init() - } + private override init() { super.init() } func setup() { UNUserNotificationCenter.current().delegate = self } - // ─── Foreground notification ─────────────────────────────────────────────── + // MARK: - Foreground notification + func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { - let userInfo = notification.request.content.userInfo - if let message = notification.request.content.body as String? { - inAppAlertMessage = message - } - // Still show the banner even when foregrounded so user doesn't miss it + inAppAlertMessage = notification.request.content.body completionHandler([.banner, .sound, .badge]) } - // ─── Background tap / action tap ────────────────────────────────────────── + // MARK: - Tap / action handler + func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { let userInfo = response.notification.request.content.userInfo - if let positionId = userInfo["position_id"] as? Int { - navigateToPositionId = positionId + if let idStr = userInfo["position_id"] as? String, + let uuid = UUID(uuidString: idStr) { + navigateToPositionId = uuid } completionHandler() } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Notifications/NotificationPermissions.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Notifications/NotificationPermissions.swift index 4e74408..615218c 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Notifications/NotificationPermissions.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Notifications/NotificationPermissions.swift @@ -1,5 +1,4 @@ import Foundation -import UIKit import UserNotifications @MainActor @@ -13,7 +12,7 @@ final class NotificationPermissions { let center = UNUserNotificationCenter.current() - // Register a custom category with a "View" action for deep linking + // Register a category with a "View" action for deep-linking let viewAction = UNNotificationAction( identifier: Constants.notificationActionView, title: "View Position", @@ -28,14 +27,9 @@ final class NotificationPermissions { center.setNotificationCategories([category]) do { - let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound]) - if granted { - await MainActor.run { - UIApplication.shared.registerForRemoteNotifications() - } - } + try await center.requestAuthorization(options: [.alert, .badge, .sound]) } catch { - print("Notification permission error: \(error)") + print("[Notifications] Permission request error: \(error)") } } } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Persistence/LocalStore.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Persistence/LocalStore.swift index bbd7be5..9535e37 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Persistence/LocalStore.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Persistence/LocalStore.swift @@ -1,38 +1,16 @@ import Foundation -/// Lightweight UserDefaults wrapper for device-local state. +/// Lightweight UserDefaults wrapper for small non-structural app state. final class LocalStore { static let shared = LocalStore() private init() {} private let defaults = UserDefaults.standard - // ─── APNs device token ──────────────────────────────────────────────────── - - var deviceToken: String? { - get { defaults.string(forKey: "apns_device_token") } - set { defaults.set(newValue, forKey: "apns_device_token") } - } - - var deviceId: Int? { - get { - let v = defaults.integer(forKey: "device_id") - return v == 0 ? nil : v - } - set { defaults.set(newValue, forKey: "device_id") } - } - - // ─── Notification permission ────────────────────────────────────────────── + // MARK: - Notification permission var notificationPermissionRequested: Bool { get { defaults.bool(forKey: "notification_permission_requested") } set { defaults.set(newValue, forKey: "notification_permission_requested") } } - - // ─── Unread alert badge count ───────────────────────────────────────────── - - var unreadAlertCount: Int { - get { defaults.integer(forKey: "unread_alert_count") } - set { defaults.set(newValue, forKey: "unread_alert_count") } - } } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Resources/Info.plist b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Resources/Info.plist index 9aad648..862bd3b 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Resources/Info.plist +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Resources/Info.plist @@ -34,14 +34,37 @@ NSAppTransportSecurity + NSAllowsArbitraryLoads - + + NSExceptionDomains + + finance.yahoo.com + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + query1.finance.yahoo.com + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + UIBackgroundModes - remote-notification + fetch + processing + + + BGTaskSchedulerPermittedIdentifiers + + com.optionssidekick.refresh - aps-environment - development diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/BackgroundRefreshManager.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/BackgroundRefreshManager.swift new file mode 100644 index 0000000..a09f776 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/BackgroundRefreshManager.swift @@ -0,0 +1,162 @@ +import Foundation +import BackgroundTasks + +/// Manages both foreground (Timer) and background (BGAppRefreshTask) signal monitoring. +/// Call `startForegroundTimer()` from the app's `scenePhase == .active` handler +/// and `stopForegroundTimer()` when the scene goes inactive/background. +final class BackgroundRefreshManager { + static let shared = BackgroundRefreshManager() + static let taskIdentifier = "com.optionssidekick.refresh" + + private var foregroundTimer: Timer? + private let refreshInterval: TimeInterval = 15 * 60 // 15 minutes + + private init() {} + + // MARK: - BGTaskScheduler registration (call once at launch) + + func registerBackgroundTask() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: Self.taskIdentifier, + using: nil + ) { [weak self] task in + guard let task = task as? BGAppRefreshTask else { return } + self?.handleBackgroundRefresh(task: task) + } + } + + // MARK: - Schedule the next background fetch + + func scheduleBackgroundRefresh() { + let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier) + // Earliest allowed execution = now + 15 min + request.earliestBeginDate = Date(timeIntervalSinceNow: refreshInterval) + do { + try BGTaskScheduler.shared.submit(request) + } catch { + print("[BGRefresh] Could not schedule: \(error)") + } + } + + // MARK: - Foreground timer + + func startForegroundTimer() { + stopForegroundTimer() + foregroundTimer = Timer.scheduledTimer( + withTimeInterval: refreshInterval, + repeats: true + ) { [weak self] _ in + Task { await self?.runSignalCheck() } + } + } + + func stopForegroundTimer() { + foregroundTimer?.invalidate() + foregroundTimer = nil + } + + // MARK: - Background task handler + + private func handleBackgroundRefresh(task: BGAppRefreshTask) { + // Reschedule immediately so the next window is already queued + scheduleBackgroundRefresh() + + let refreshTask = Task { await runSignalCheck() } + + task.expirationHandler = { + refreshTask.cancel() + } + + Task { + await refreshTask.value + task.setTaskCompleted(success: true) + } + } + + // MARK: - Core signal check + + @MainActor + func runSignalCheck() async { + let store = DataStore.shared + let openPositions = store.openPositions + guard !openPositions.isEmpty else { return } + + // Deduplicate tickers to avoid redundant network calls + let tickers = Array(Set(openPositions.map(\.ticker))) + + for ticker in tickers { + await checkTicker(ticker, positions: openPositions.filter { $0.ticker == ticker }) + } + + // Update badge + await NotificationService.setBadge(store.unreadAlertCount) + } + + // MARK: - Per-ticker check + + @MainActor + private func checkTicker(_ ticker: String, positions: [OptionPosition]) async { + let store = DataStore.shared + let yfClient = YahooFinanceClient.shared + + // Fetch price history + guard let history = try? await yfClient.priceHistory(ticker: ticker) else { return } + let earningsDate = try? await yfClient.nextEarningsDate(ticker: ticker) + let signal = SignalEngine.compute(ticker: ticker, history: history, earningsDate: earningsDate) + + for position in positions { + let newHash = signal.signalHash + + // Skip if signal hasn't changed + if position.lastSignalHash == newHash { continue } + + // Fetch option chain for this position's expiration + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + guard let expDate = fmt.date(from: position.expiration) else { continue } + + // Delta check for close-early / roll alerts + if let chain = try? await yfClient.optionChain(ticker: ticker, expiration: expDate) { + let contracts = position.strategy == "covered_call" ? chain.calls : chain.puts + if let contract = contracts.first(where: { $0.strike == position.strike }) { + let isCall = position.strategy == "covered_call" + let daysLeft = Calendar.current.dateComponents([.day], from: Date(), to: expDate).day ?? 0 + let T = Double(max(daysLeft, 1)) / 365.0 + let delta = abs(SignalEngine.bsDelta( + S: history.currentPrice, + K: contract.strike, + T: T, + sigma: contract.impliedVolatility > 0 ? contract.impliedVolatility : 0.3, + isCall: isCall + )) + + // Close-early: deep ITM + if delta > 0.45 { + let msg = "\(ticker) \(position.strategyLabel) $\(String(format: "%.0f", position.strike)) strike — delta \(String(format: "%.2f", delta)) is deep ITM. Consider closing early." + store.addAlert(ticker: ticker, positionId: position.id, alertType: "close_early", message: msg) + await NotificationService.send(title: "\(ticker) Close Early Signal", body: msg, positionId: position.id, alertType: "close_early") + } + + // Profit target: > 50% of premium with > 5 DTE + let currentMid = contract.mid + let profitPct = (position.premiumReceived - currentMid) / position.premiumReceived + if profitPct > 0.50 && daysLeft > 5 { + let msg = "\(ticker) position has captured \(String(format: "%.0f%%", profitPct * 100)) of max profit with \(daysLeft) days left. Consider closing early." + store.addAlert(ticker: ticker, positionId: position.id, alertType: "close_early", message: msg) + await NotificationService.send(title: "\(ticker) Profit Target Hit", body: msg, positionId: position.id, alertType: "close_early") + } + } + } + + // Earnings warning + if signal.snapshot.earningsWithinWindow && !(position.lastSignalHash?.contains("ew1") ?? false) { + let msg = "\(ticker) earnings expected within 30 days — review your \(position.strategyLabel) position before expiration." + store.addAlert(ticker: ticker, positionId: position.id, alertType: "earnings_warning", message: msg) + await NotificationService.send(title: "\(ticker) Earnings Warning", body: msg, positionId: position.id, alertType: "earnings_warning") + } + + // Update stored hash so we don't re-alert for the same state + store.updateSignalHash(id: position.id, hash: newHash) + } + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/DataStore.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/DataStore.swift new file mode 100644 index 0000000..34bd3c7 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/DataStore.swift @@ -0,0 +1,185 @@ +import Foundation +import Combine + +/// Single source of truth for all persisted app data. +/// Thread-safe via @MainActor. Publishes changes for SwiftUI. +@MainActor +final class DataStore: ObservableObject { + static let shared = DataStore() + + @Published private(set) var portfolio: [PortfolioPosition] = [] + @Published private(set) var positions: [OptionPosition] = [] + @Published private(set) var recommendations: [Recommendation] = [] + @Published private(set) var alerts: [AppAlert] = [] + + private let encoder: JSONEncoder = { + let e = JSONEncoder() + e.dateEncodingStrategy = .iso8601 + e.outputFormatting = .prettyPrinted + return e + }() + private let decoder: JSONDecoder = { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + }() + + private init() { loadAll() } + + // MARK: - Portfolio + + func upsertPortfolioPosition(ticker: String, shares: Int, costBasis: Double?) { + let t = ticker.uppercased() + if let idx = portfolio.firstIndex(where: { $0.ticker == t }) { + portfolio[idx] = PortfolioPosition( + id: portfolio[idx].id, + ticker: t, + shares: shares, + costBasis: costBasis, + createdAt: portfolio[idx].createdAt + ) + } else { + portfolio.append(PortfolioPosition( + id: UUID(), ticker: t, shares: shares, + costBasis: costBasis, createdAt: Date() + )) + } + savePortfolio() + } + + func removePortfolioPosition(id: UUID) { + portfolio.removeAll { $0.id == id } + savePortfolio() + } + + var tickers: [String] { portfolio.map(\.ticker) } + + // MARK: - Option positions + + @discardableResult + func logPosition( + ticker: String, strategy: String, strike: Double, + expiration: String, premiumReceived: Double, contracts: Int + ) -> OptionPosition { + let pos = OptionPosition( + id: UUID(), + ticker: ticker.uppercased(), + strategy: strategy, + strike: strike, + expiration: expiration, + premiumReceived: premiumReceived, + contracts: contracts, + status: "open", + closeReason: nil, + openedAt: Date(), + closedAt: nil, + lastSignalHash: nil + ) + positions.insert(pos, at: 0) + savePositions() + return pos + } + + func closePosition(id: UUID, reason: String) { + guard let idx = positions.firstIndex(where: { $0.id == id }) else { return } + var p = positions[idx] + p.status = "closed" + p.closeReason = reason + p.closedAt = Date() + positions[idx] = p + savePositions() + } + + func rollPosition(id: UUID) { + guard let idx = positions.firstIndex(where: { $0.id == id }) else { return } + var p = positions[idx] + p.status = "rolled" + p.closeReason = "rolled" + p.closedAt = Date() + positions[idx] = p + savePositions() + } + + func updateSignalHash(id: UUID, hash: String) { + guard let idx = positions.firstIndex(where: { $0.id == id }) else { return } + positions[idx].lastSignalHash = hash + savePositions() + } + + var openPositions: [OptionPosition] { positions.filter { $0.status == "open" } } + var closedPositions: [OptionPosition] { positions.filter { $0.status != "open" } } + + // MARK: - Recommendations + + func replaceRecommendations(_ recs: [Recommendation]) { + recommendations = recs + saveRecommendations() + } + + // MARK: - Alerts + + func addAlert( + ticker: String, + positionId: UUID?, + alertType: String, + message: String + ) { + let alert = AppAlert( + id: UUID(), + ticker: ticker, + optionPositionId: positionId, + alertType: alertType, + message: message, + sentAt: Date(), + acknowledged: false + ) + alerts.insert(alert, at: 0) + // Trim to most recent 200 + if alerts.count > 200 { alerts = Array(alerts.prefix(200)) } + saveAlerts() + } + + func acknowledgeAlert(id: UUID) { + guard let idx = alerts.firstIndex(where: { $0.id == id }) else { return } + alerts[idx].acknowledged = true + saveAlerts() + } + + func acknowledgeAllAlerts() { + for idx in alerts.indices { alerts[idx].acknowledged = true } + saveAlerts() + } + + var unreadAlertCount: Int { alerts.filter { !$0.acknowledged }.count } + + // MARK: - Persistence + + private func loadAll() { + portfolio = load(file: "portfolio.json") ?? [] + positions = load(file: "positions.json") ?? [] + recommendations = load(file: "recommendations.json") ?? [] + alerts = load(file: "alerts.json") ?? [] + } + + private func savePortfolio() { save(portfolio, file: "portfolio.json") } + private func savePositions() { save(positions, file: "positions.json") } + private func saveRecommendations() { save(recommendations, file: "recommendations.json") } + private func saveAlerts() { save(alerts, file: "alerts.json") } + + private func load(file: String) -> T? { + guard let url = fileURL(for: file), + let data = try? Data(contentsOf: url) else { return nil } + return try? decoder.decode(T.self, from: data) + } + + private func save(_ value: T, file: String) { + guard let url = fileURL(for: file), + let data = try? encoder.encode(value) else { return } + try? data.write(to: url, options: .atomic) + } + + private func fileURL(for name: String) -> URL? { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + .first?.appendingPathComponent(name) + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/NotificationService.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/NotificationService.swift new file mode 100644 index 0000000..6c3cfcc --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/NotificationService.swift @@ -0,0 +1,43 @@ +import Foundation +import UserNotifications + +/// Thin wrapper around UNUserNotificationCenter for local notifications. +enum NotificationService { + + /// Fire an immediate local notification. + static func send( + title: String, + body: String, + positionId: UUID? = nil, + alertType: String = "info" + ) async { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.categoryIdentifier = Constants.notificationCategory + + // Attach metadata for deep-link handling + var userInfo: [AnyHashable: Any] = ["alert_type": alertType] + if let pid = positionId { userInfo["position_id"] = pid.uuidString } + content.userInfo = userInfo + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil // deliver immediately + ) + + try? await UNUserNotificationCenter.current().add(request) + } + + /// Update the app badge count. + static func setBadge(_ count: Int) async { + let center = UNUserNotificationCenter.current() + do { + try await center.setBadgeCount(count) + } catch { + // Silently ignore; badge is non-critical. + } + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/RecommendationEngine.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/RecommendationEngine.swift new file mode 100644 index 0000000..4b0f6e7 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/RecommendationEngine.swift @@ -0,0 +1,264 @@ +import Foundation + +// MARK: - Time Horizons + +enum TimeHorizon: String, CaseIterable, Identifiable { + case zeroDTE = "0dte" + case oneDTE = "1dte" + case weekly = "weekly" + case monthly = "monthly" + + var id: String { rawValue } + + var label: String { + switch self { + case .zeroDTE: return "0DTE" + case .oneDTE: return "1DTE" + case .weekly: return "Weekly" + case .monthly: return "Monthly" + } + } + + /// Minimum T in years for this horizon to make sense. + var minT: Double { + switch self { + case .zeroDTE: return 0 + case .oneDTE: return 1/365 + case .weekly: return 3/365 + case .monthly: return 20/365 + } + } +} + +// MARK: - Engine + +enum RecommendationEngine { + + // MARK: Main entry + + /// Returns a `Recommendation` or `nil` if no suitable chain is available. + static func recommend( + ticker: String, + strategy: String, + horizon: TimeHorizon, + history: PriceHistory, + signal: SignalResult, + chain: OptionChain, + expiration: Date + ) -> Recommendation? { + let currentPrice = history.currentPrice + guard currentPrice > 0 else { return nil } + + let contracts = strategy == "covered_call" ? chain.calls : chain.puts + guard let best = selectBestContract( + contracts: contracts, + strategy: strategy, + currentPrice: currentPrice, + signal: signal + ) else { return nil } + + // T in years + let daysToExp = max( + Calendar.current.dateComponents([.day], from: Date(), to: expiration).day ?? 1, + 1 + ) + let T = Double(daysToExp) / 365.0 + let isCall = strategy == "covered_call" + + let delta = SignalEngine.bsDelta( + S: currentPrice, + K: best.strike, + T: T, + sigma: best.impliedVolatility > 0 ? best.impliedVolatility : 0.3, + isCall: isCall + ) + + let theta = SignalEngine.bsTheta( + S: currentPrice, + K: best.strike, + T: T, + sigma: best.impliedVolatility > 0 ? best.impliedVolatility : 0.3, + isCall: isCall + ) + + let earningsWarning = signal.snapshot.earningsWithinWindow + + // Fine-grained score including strike context + let baseScore = signal.score(for: strategy) + let finalScore = SignalEngine.strikeScore( + base: baseScore, + strategy: strategy, + strike: best.strike, + nearestSupport: signal.snapshot.nearestSupport, + nearestResistance: signal.snapshot.nearestResistance + ) + let strength: String + if finalScore >= 4 { strength = "strong" } + else if finalScore >= 2 { strength = "moderate" } + else { strength = "weak" } + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let expirationStr = dateFormatter.string(from: expiration) + + let earningsStr = signal.snapshot.earningsDate.map { dateFormatter.string(from: $0) } + + let rationale = buildRationale( + strategy: strategy, + horizon: horizon, + signal: signal, + strike: best.strike, + currentPrice: currentPrice, + earningsWarning: earningsWarning + ) + + return Recommendation( + id: UUID(), + ticker: ticker, + strategy: strategy, + timeHorizon: horizon.rawValue, + currentPrice: currentPrice, + recommendedStrike: best.strike, + recommendedExpiration: expirationStr, + estimatedPremium: best.mid, + delta: abs(delta), + theta: theta, + ivRank: signal.snapshot.ivRank, + signalStrength: strength, + earningsWarning: earningsWarning, + earningsDate: earningsStr, + rationale: rationale, + signalHash: signal.signalHash, + createdAt: Date() + ) + } + + // MARK: Strike selection + + /// Picks the contract with delta closest to 0.25 (target range 0.15–0.35). + static func selectBestContract( + contracts: [OptionContract], + strategy: String, + currentPrice: Double, + signal: SignalResult + ) -> OptionContract? { + let isCall = strategy == "covered_call" + + // Only OTM contracts with reasonable premium + let candidates = contracts.filter { c in + let otm = isCall ? c.strike > currentPrice : c.strike < currentPrice + return otm && c.bid > 0 && c.ask > 0 && c.impliedVolatility > 0 + } + guard !candidates.isEmpty else { return nil } + + // Score each contract: prefer delta near 0.25, above support / below resistance + let scored: [(OptionContract, Double)] = candidates.compactMap { c in + let T = 30.0/365 // approximate; exact value used in outer function + let delta = abs(SignalEngine.bsDelta( + S: currentPrice, + K: c.strike, + T: T, + sigma: c.impliedVolatility, + isCall: isCall + )) + // Hard filter: keep delta 0.10–0.40 + guard delta >= 0.10, delta <= 0.40 else { return nil } + + // Penalty for deviation from target 0.25 + let targetDelta = 0.25 + var score = -abs(delta - targetDelta) + + // Bonus for strike above support (CSP) or below resistance (CC) + if strategy == "cash_secured_put", + let sup = signal.snapshot.nearestSupport, + c.strike > sup { + score += 0.05 + } + if strategy == "covered_call", + let res = signal.snapshot.nearestResistance, + c.strike < res { + score += 0.05 + } + return (c, score) + } + + return scored.max(by: { $0.1 < $1.1 })?.0 + } + + // MARK: Expiration helpers + + /// Picks the correct expiration date from `available` based on the requested horizon. + static func expiration(for horizon: TimeHorizon, available: [Date], referenceDate: Date = Date()) -> Date? { + let cal = Calendar.current + + switch horizon { + case .zeroDTE: + // Same calendar day + return available.first { cal.isDate($0, inSameDayAs: referenceDate) } + + case .oneDTE: + // Next trading session (tomorrow or next Monday if Friday) + let tomorrow = cal.date(byAdding: .day, value: 1, to: referenceDate)! + return available.first { $0 >= tomorrow } + + case .weekly: + // Closest Friday >= tomorrow + let tomorrow = cal.date(byAdding: .day, value: 1, to: referenceDate)! + let fridays = available.filter { + $0 >= tomorrow && cal.component(.weekday, from: $0) == 6 // 6 = Friday + } + return fridays.first ?? available.first { $0 >= tomorrow } + + case .monthly: + // Expiry closest to 30 DTE from today + let target = cal.date(byAdding: .day, value: 30, to: referenceDate)! + return available + .filter { $0 > referenceDate } + .min(by: { abs($0.timeIntervalSince(target)) < abs($1.timeIntervalSince(target)) }) + } + } + + // MARK: Rationale builder + + private static func buildRationale( + strategy: String, + horizon: TimeHorizon, + signal: SignalResult, + strike: Double, + currentPrice: Double, + earningsWarning: Bool + ) -> String { + var parts: [String] = [] + let snap = signal.snapshot + + let ivrStr = String(format: "%.0f", snap.ivRank) + if snap.ivRank >= 50 { + parts.append("IV Rank \(ivrStr) — elevated premium environment.") + } else if snap.ivRank >= 30 { + parts.append("IV Rank \(ivrStr) — moderate premium.") + } else { + parts.append("IV Rank \(ivrStr) — low premium; consider waiting.") + } + + if snap.trend == "bullish" { + parts.append("Price above SMA-50 and SMA-200 — bullish trend.") + } else if snap.trend == "bearish" { + parts.append("Price below SMA-50 and SMA-200 — bearish pressure.") + } + + if strategy == "cash_secured_put", let sup = snap.nearestSupport { + let dist = String(format: "%.1f%%", (currentPrice - sup) / currentPrice * 100) + parts.append("Strike above nearest support $\(String(format: "%.2f", sup)) (\(dist) cushion).") + } + if strategy == "covered_call", let res = snap.nearestResistance { + let dist = String(format: "%.1f%%", (res - currentPrice) / currentPrice * 100) + parts.append("Strike below resistance $\(String(format: "%.2f", res)) (\(dist) buffer).") + } + + if earningsWarning { + parts.append("⚠️ Earnings expected within 30 days — elevated gap risk.") + } + + return parts.joined(separator: " ") + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/SignalEngine.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/SignalEngine.swift new file mode 100644 index 0000000..157c5bd --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/SignalEngine.swift @@ -0,0 +1,295 @@ +import Foundation +import CryptoKit + +// MARK: - Output types + +struct SignalSnapshot { + let ticker: String + let currentPrice: Double + let ivRank: Double + let sma50: Double + let sma200: Double + let nearestSupport: Double? + let nearestResistance: Double? + let trend: String // "bullish" | "bearish" | "neutral" + let earningsDate: Date? + let computedAt: Date + + var earningsWithinWindow: Bool { + guard let ed = earningsDate else { return false } + let days = Calendar.current.dateComponents([.day], from: Date(), to: ed).day ?? 999 + return days >= 0 && days <= 30 + } +} + +struct SignalResult { + let snapshot: SignalSnapshot + let signalStrength: String // "strong" | "moderate" | "weak" + let signalHash: String + + func score(for strategy: String) -> Int { + SignalEngine.score( + ivRank: snapshot.ivRank, + sma50: snapshot.sma50, + sma200: snapshot.sma200, + currentPrice: snapshot.currentPrice, + strategy: strategy, + nearestSupport: snapshot.nearestSupport, + nearestResistance: snapshot.nearestResistance, + earningsWarning: snapshot.earningsWithinWindow + ) + } +} + +// MARK: - Engine + +enum SignalEngine { + + // MARK: Top-level compute + + static func compute( + ticker: String, + history: PriceHistory, + earningsDate: Date? + ) -> SignalResult { + let closes = history.closes + let currentPrice = history.currentPrice + + let ivr = ivRank(closes: closes) + let s50 = sma(50, closes: closes) + let s200 = sma(200, closes: closes) + let (support, resistance) = swingLevels(history: history) + + let trend: String + if s50 > s200 && currentPrice > s50 { + trend = "bullish" + } else if s50 < s200 && currentPrice < s50 { + trend = "bearish" + } else { + trend = "neutral" + } + + let earningsWarning: Bool + if let ed = earningsDate { + let days = Calendar.current.dateComponents([.day], from: Date(), to: ed).day ?? 999 + earningsWarning = days >= 0 && days <= 30 + } else { + earningsWarning = false + } + + let rawScore = score( + ivRank: ivr, + sma50: s50, + sma200: s200, + currentPrice: currentPrice, + strategy: "covered_call", // generic score without strike direction + nearestSupport: support, + nearestResistance: resistance, + earningsWarning: earningsWarning + ) + + let strength: String + if rawScore >= 4 { strength = "strong" } + else if rawScore >= 2 { strength = "moderate" } + else { strength = "weak" } + + let hash = signalHash( + ivRank: ivr, + sma50: s50, + sma200: s200, + support: support, + resistance: resistance, + earningsWarning: earningsWarning + ) + + let snapshot = SignalSnapshot( + ticker: ticker, + currentPrice: currentPrice, + ivRank: ivr, + sma50: s50, + sma200: s200, + nearestSupport: support, + nearestResistance: resistance, + trend: trend, + earningsDate: earningsDate, + computedAt: Date() + ) + + return SignalResult(snapshot: snapshot, signalStrength: strength, signalHash: hash) + } + + // MARK: IV Rank (rolling 30-day HV as proxy) + + static func ivRank(closes: [Double]) -> Double { + guard closes.count >= 32 else { return 30 } + + // Log returns + var logReturns: [Double] = [] + for i in 1.. 0, closes[i] > 0 else { continue } + logReturns.append(log(closes[i] / closes[i-1])) + } + + guard logReturns.count >= 30 else { return 30 } + + // Rolling 30-day HV (annualised) + var hvs: [Double] = [] + for i in 29.. minHV else { return 50 } + + return ((currentHV - minHV) / (maxHV - minHV) * 100).clamped(to: 0...100) + } + + // MARK: Simple moving average + + static func sma(_ n: Int, closes: [Double]) -> Double { + guard !closes.isEmpty else { return 0 } + let window = Array(closes.suffix(n)) + return window.reduce(0, +) / Double(window.count) + } + + // MARK: Swing high / low pivot levels (5-bar rule, last 60 bars) + + static func swingLevels(history: PriceHistory) -> (support: Double?, resistance: Double?) { + let bars = Array(history.bars.suffix(60)) + guard bars.count >= 5 else { return (nil, nil) } + + var pivotHighs: [Double] = [] + var pivotLows: [Double] = [] + + for i in 2..<(bars.count - 2) { + let windowHighs = (i-2...i+2).map { bars[$0].high } + let windowLows = (i-2...i+2).map { bars[$0].low } + if bars[i].high == windowHighs.max()! { pivotHighs.append(bars[i].high) } + if bars[i].low == windowLows.min()! { pivotLows.append(bars[i].low) } + } + + let price = history.currentPrice + let support = pivotLows.filter { $0 < price }.max() + let resistance = pivotHighs.filter { $0 > price }.min() + return (support, resistance) + } + + // MARK: Black-Scholes Greeks + + /// Delta for a European option. T in years, sigma annualised fraction. + static func bsDelta(S: Double, K: Double, T: Double, sigma: Double, isCall: Bool) -> Double { + guard T > 1e-6, sigma > 0, S > 0, K > 0 else { + if isCall { return S >= K ? 1 : 0 } + return S <= K ? -1 : 0 + } + let r = 0.045 + let d1 = (log(S/K) + (r + 0.5 * sigma * sigma) * T) / (sigma * sqrt(T)) + return isCall ? normalCDF(d1) : normalCDF(d1) - 1 + } + + /// Theta per calendar day (negative for long options, positive for short). + static func bsTheta(S: Double, K: Double, T: Double, sigma: Double, isCall: Bool) -> Double { + guard T > 1e-6, sigma > 0, S > 0, K > 0 else { return 0 } + let r = 0.045 + let d1 = (log(S/K) + (r + 0.5 * sigma * sigma) * T) / (sigma * sqrt(T)) + let d2 = d1 - sigma * sqrt(T) + let nd1 = normalPDF(d1) + + var theta: Double + if isCall { + theta = -S * nd1 * sigma / (2 * sqrt(T)) - r * K * exp(-r * T) * normalCDF(d2) + } else { + theta = -S * nd1 * sigma / (2 * sqrt(T)) + r * K * exp(-r * T) * normalCDF(-d2) + } + return theta / 365 // per calendar day + } + + // MARK: Signal score (no strike context) + + static func score( + ivRank: Double, + sma50: Double, + sma200: Double, + currentPrice: Double, + strategy: String, + nearestSupport: Double?, + nearestResistance: Double?, + earningsWarning: Bool + ) -> Int { + var s = 0 + if ivRank >= 50 { s += 2 } else if ivRank >= 30 { s += 1 } + + if strategy == "covered_call" { + if currentPrice > sma50 { s += 1 } + if sma50 > sma200 { s += 1 } + } else { // cash_secured_put + if currentPrice > sma200 { s += 1 } // above long-term trend + } + + if earningsWarning { s -= 2 } + return s + } + + // MARK: Strike-aware score (used post strike-selection) + + static func strikeScore( + base: Int, + strategy: String, + strike: Double, + nearestSupport: Double?, + nearestResistance: Double? + ) -> Int { + var s = base + if strategy == "covered_call", let res = nearestResistance, strike < res { s += 1 } + if strategy == "cash_secured_put", let sup = nearestSupport, strike > sup { s += 1 } + return s + } + + // MARK: Signal hash (noise-resistant change detection) + + static func signalHash( + ivRank: Double, + sma50: Double, + sma200: Double, + support: Double?, + resistance: Double?, + earningsWarning: Bool + ) -> String { + let components = [ + "\(Int(ivRank.rounded()))", + String(format: "%.2f", sma50), + String(format: "%.2f", sma200), + support.map { String(format: "%.2f", $0) } ?? "nil", + resistance.map { String(format: "%.2f", $0) } ?? "nil", + earningsWarning ? "1" : "0" + ].joined(separator: "_") + + let data = Data(components.utf8) + let digest = SHA256.hash(data: data) + return digest.compactMap { String(format: "%02x", $0) }.joined().prefix(16).description + } + + // MARK: Private math helpers + + static func normalCDF(_ x: Double) -> Double { + return 0.5 * (1 + erf(x / 2.squareRoot())) + } + + private static func normalPDF(_ x: Double) -> Double { + return exp(-0.5 * x * x) / (2 * Double.pi).squareRoot() + } +} + +// MARK: - Helpers + +private extension Comparable { + func clamped(to range: ClosedRange) -> Self { + min(max(self, range.lowerBound), range.upperBound) + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/YahooFinanceClient.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/YahooFinanceClient.swift new file mode 100644 index 0000000..77ec74a --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/YahooFinanceClient.swift @@ -0,0 +1,257 @@ +import Foundation + +// MARK: - Data Types + +struct PriceBar { + let date: Date + let open: Double + let high: Double + let low: Double + let close: Double +} + +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 } +} + +struct OptionContract { + let strike: Double + let bid: Double + let ask: Double + let impliedVolatility: Double // annualised fraction (e.g. 0.30 = 30%) + let volume: Int + let openInterest: Int + var mid: Double { (bid + ask) / 2 } +} + +struct OptionChain { + let expiration: Date + let calls: [OptionContract] + let puts: [OptionContract] +} + +enum YFError: LocalizedError { + case noData + 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." + } + } +} + +// MARK: - Client + +/// Direct HTTP client for Yahoo Finance unofficial endpoints. +/// Thread-safe via Swift actor. Results are cached for 15 minutes. +actor YahooFinanceClient { + static let shared = YahooFinanceClient() + + 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" + ] + return URLSession(configuration: cfg) + }() + + private var cache: [String: (value: Any, storedAt: Date)] = [:] + private let ttl: TimeInterval = 900 // 15 min + + private func get(_ key: String) -> T? { + guard let entry = cache[key], + Date().timeIntervalSince(entry.storedAt) < ttl, + 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() } + + // MARK: Price history + + func priceHistory(ticker: String) async throws -> PriceHistory { + let key = "hist_\(ticker)" + if let hit: PriceHistory = get(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) + let resp = try JSONDecoder().decode(ChartResponse.self, from: 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 } + + 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 resp = try JSONDecoder().decode(OptionsResponse.self, from: 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) + 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 } + } + + /// 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) } + } + + // MARK: Option chain + + 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 } + + 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 resp = try JSONDecoder().decode(OptionsResponse.self, from: 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 chain = OptionChain(expiration: expiration, calls: calls, puts: puts) + set(chain, key: key) + return chain + } + + // MARK: Earnings date + + func nextEarningsDate(ticker: String) async throws -> Date? { + let key = "earn_\(ticker)" + if let hit: Date? = get(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), + 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.none as Any, key: key) + return nil + } + + let date: Date? = Date(timeIntervalSince1970: rawDate) > Date() + ? Date(timeIntervalSince1970: rawDate) + : nil + set(date as Any, key: key) + return date + } +} + +// MARK: - Private JSON models + +private struct ChartResponse: Decodable { + let chart: Wrapper + struct Wrapper: Decodable { let result: [Result]? } + struct Result: Decodable { + let timestamp: [Int] + let indicators: Indicators + } + struct Indicators: Decodable { let quote: [Quote] } + struct Quote: Decodable { + let open: [Double?] + let high: [Double?] + let low: [Double?] + let close: [Double?] + } +} + +private struct OptionsResponse: Decodable { + let optionChain: Wrapper + struct Wrapper: Decodable { let result: [Result]? } + struct Result: Decodable { + let expirationDates: [Int] + let options: [OptionData] + } + struct OptionData: Decodable { + let calls: [YFOption] + let puts: [YFOption] + } + struct YFOption: Decodable { + let strike: Double + let bid: Double? + let ask: Double? + let impliedVolatility: Double? + let volume: Int? + 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 + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/AlertsViewModel.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/AlertsViewModel.swift index 0a2b8b3..d1e3ede 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/AlertsViewModel.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/AlertsViewModel.swift @@ -7,41 +7,28 @@ final class AlertsViewModel: ObservableObject { @Published var isLoading = false @Published var error: String? = nil + private var cancellables = Set() + var unreadCount: Int { alerts.filter { !$0.acknowledged }.count } - func load(unreadOnly: Bool = false) async { - isLoading = true - error = nil - do { - alerts = try await APIClient.shared.request( - .getAlerts(unreadOnly: unreadOnly), - body: nil - ) - LocalStore.shared.unreadAlertCount = unreadCount - } catch { - self.error = error.localizedDescription - } - isLoading = false + init() { + DataStore.shared.$alerts + .receive(on: DispatchQueue.main) + .assign(to: \.alerts, on: self) + .store(in: &cancellables) } - func acknowledge(_ alert: AppAlert) async { - do { - let updated: AppAlert = try await APIClient.shared.request( - .acknowledgeAlert(alert.id), - body: nil - ) - if let idx = alerts.firstIndex(where: { $0.id == alert.id }) { - alerts[idx] = updated - } - LocalStore.shared.unreadAlertCount = unreadCount - } catch { - self.error = error.localizedDescription - } + func load() { + alerts = DataStore.shared.alerts } - func acknowledgeAll() async { - for alert in alerts where !alert.acknowledged { - await acknowledge(alert) - } + func acknowledge(_ alert: AppAlert) { + DataStore.shared.acknowledgeAlert(id: alert.id) + Task { await NotificationService.setBadge(DataStore.shared.unreadAlertCount) } + } + + func acknowledgeAll() { + DataStore.shared.acknowledgeAllAlerts() + Task { await NotificationService.setBadge(0) } } } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/DashboardViewModel.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/DashboardViewModel.swift index e16d805..5816c68 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/DashboardViewModel.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/DashboardViewModel.swift @@ -3,16 +3,19 @@ import Combine @MainActor final class DashboardViewModel: ObservableObject { - @Published var stockPositions: [PortfolioPosition] = [] - @Published var openOptionPositions: [OptionPosition] = [] - @Published var recommendations: [Recommendation] = [] - @Published var unreadAlerts: [AppAlert] = [] - @Published var isLoading = false + @Published var stockPositions: [PortfolioPosition] = [] + @Published var openOptionPositions: [OptionPosition] = [] + @Published var recommendations: [Recommendation] = [] + @Published var unreadAlerts: [AppAlert] = [] + @Published var isLoading = false + @Published var isRefreshing = false @Published var error: String? = nil + private var cancellables = Set() + var urgentAlerts: [AppAlert] { unreadAlerts.filter { $0.isUrgent } } - /// Top-level recommendation per ticker (best signal strength) + /// Best recommendation per ticker (highest signal strength). var topRecommendations: [Recommendation] { let order = ["strong": 0, "moderate": 1, "weak": 2] var best: [String: Recommendation] = [:] @@ -25,38 +28,46 @@ final class DashboardViewModel: ObservableObject { return Array(best.values).sorted { $0.ticker < $1.ticker } } - func loadAll() async { - isLoading = true - error = nil + init() { + let store = DataStore.shared + store.$portfolio + .map { $0 } + .receive(on: DispatchQueue.main) + .assign(to: \.stockPositions, on: self) + .store(in: &cancellables) - async let stocks: [PortfolioPosition] = loadStocks() - async let options: [OptionPosition] = loadOptions() - async let recs: [Recommendation] = loadRecommendations() - async let alerts: [AppAlert] = loadAlerts() + store.$positions + .map { $0.filter { $0.status == "open" } } + .receive(on: DispatchQueue.main) + .assign(to: \.openOptionPositions, on: self) + .store(in: &cancellables) - (stockPositions, openOptionPositions, recommendations, unreadAlerts) = await (stocks, options, recs, alerts) - isLoading = false + store.$recommendations + .receive(on: DispatchQueue.main) + .assign(to: \.recommendations, on: self) + .store(in: &cancellables) + + store.$alerts + .map { $0.filter { !$0.acknowledged } } + .receive(on: DispatchQueue.main) + .assign(to: \.unreadAlerts, on: self) + .store(in: &cancellables) } + func loadAll() { + let store = DataStore.shared + stockPositions = store.portfolio + openOptionPositions = store.openPositions + recommendations = store.recommendations + unreadAlerts = store.alerts.filter { !$0.acknowledged } + } + + /// Refresh: re-run signal check and rebuild recommendations in the background. func refresh() async { - await loadAll() - } - - // ─── Private loaders ────────────────────────────────────────────────────── - - private func loadStocks() async -> [PortfolioPosition] { - (try? await APIClient.shared.request(.getPortfolio, body: nil)) ?? [] - } - - private func loadOptions() async -> [OptionPosition] { - (try? await APIClient.shared.request(.getPositions(status: "open"), body: nil)) ?? [] - } - - private func loadRecommendations() async -> [Recommendation] { - (try? await APIClient.shared.request(.getRecommendations(timeHorizon: nil), body: nil)) ?? [] - } - - private func loadAlerts() async -> [AppAlert] { - (try? await APIClient.shared.request(.getAlerts(unreadOnly: true), body: nil)) ?? [] + isRefreshing = true + await BackgroundRefreshManager.shared.runSignalCheck() + // Recommendations are rebuilt by RecommendationsViewModel; here we just update the signal state + isRefreshing = false + loadAll() } } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PortfolioViewModel.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PortfolioViewModel.swift index 3f0dc54..0bc9751 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PortfolioViewModel.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PortfolioViewModel.swift @@ -7,62 +7,25 @@ final class PortfolioViewModel: ObservableObject { @Published var isLoading = false @Published var error: String? = nil - func load() async { - isLoading = true - error = nil - do { - positions = try await APIClient.shared.request(.getPortfolio, body: nil) - } catch { - self.error = error.localizedDescription - } - isLoading = false + private var cancellables = Set() + + init() { + // Mirror DataStore so views just observe this VM + DataStore.shared.$portfolio + .receive(on: DispatchQueue.main) + .assign(to: \.positions, on: self) + .store(in: &cancellables) } - func save(_ newPositions: [PortfolioPosition]) async { - isLoading = true - error = nil - struct PositionBody: Encodable { - let ticker: String - let shares: Int - let cost_basis: Double? - } - let body = newPositions.map { PositionBody(ticker: $0.ticker, shares: $0.shares, cost_basis: $0.costBasis) } - do { - positions = try await APIClient.shared.request(.setPortfolio, body: body) - } catch { - self.error = error.localizedDescription - } - isLoading = false + func load() { + positions = DataStore.shared.portfolio } - func add(ticker: String, shares: Int, costBasis: Double?) async { - let updated = positions - struct AddBody: Encodable { - let ticker: String - let shares: Int - let cost_basis: Double? - } - let allBody = updated.map { AddBody(ticker: $0.ticker, shares: $0.shares, cost_basis: $0.costBasis) } - + [AddBody(ticker: ticker.uppercased(), shares: shares, cost_basis: costBasis)] - isLoading = true - error = nil - do { - positions = try await APIClient.shared.request(.setPortfolio, body: allBody) - } catch { - self.error = error.localizedDescription - } - isLoading = false + func add(ticker: String, shares: Int, costBasis: Double?) { + DataStore.shared.upsertPortfolioPosition(ticker: ticker, shares: shares, costBasis: costBasis) } - func delete(ticker: String) async { - isLoading = true - error = nil - do { - try await APIClient.shared.requestVoid(.deleteTicker(ticker), body: nil) - positions.removeAll { $0.ticker == ticker } - } catch { - self.error = error.localizedDescription - } - isLoading = false + func delete(id: UUID) { + DataStore.shared.removePortfolioPosition(id: id) } } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PositionsViewModel.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PositionsViewModel.swift index cf29434..9ac3c5b 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PositionsViewModel.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PositionsViewModel.swift @@ -7,71 +7,39 @@ final class PositionsViewModel: ObservableObject { @Published var isLoading = false @Published var error: String? = nil - var openPositions: [OptionPosition] { positions.filter { $0.status == "open" } } + private var cancellables = Set() + + var openPositions: [OptionPosition] { positions.filter { $0.status == "open" } } var closedPositions: [OptionPosition] { positions.filter { $0.status != "open" } } - func load() async { - isLoading = true - error = nil - do { - positions = try await APIClient.shared.request( - .getPositions(status: nil), - body: nil - ) - } catch { - self.error = error.localizedDescription - } - isLoading = false + init() { + DataStore.shared.$positions + .receive(on: DispatchQueue.main) + .assign(to: \.positions, on: self) + .store(in: &cancellables) } - func log(create: OptionPositionCreate) async -> Bool { - isLoading = true - error = nil - do { - let new: OptionPosition = try await APIClient.shared.request(.logPosition, body: create) - positions.insert(new, at: 0) - isLoading = false - return true - } catch { - self.error = error.localizedDescription - isLoading = false - return false - } + func load() { + positions = DataStore.shared.positions } - func close(position: OptionPosition, reason: String) async { - isLoading = true - error = nil - let body = OptionPositionClose(status: "closed", closeReason: reason) - do { - let updated: OptionPosition = try await APIClient.shared.request( - .closePosition(position.id), - body: body - ) - if let idx = positions.firstIndex(where: { $0.id == position.id }) { - positions[idx] = updated - } - } catch { - self.error = error.localizedDescription - } - isLoading = false + func log(create: OptionPositionCreate) -> Bool { + DataStore.shared.logPosition( + ticker: create.ticker, + strategy: create.strategy, + strike: create.strike, + expiration: create.expiration, + premiumReceived: create.premiumReceived, + contracts: create.contracts + ) + return true } - func roll(position: OptionPosition) async { - let body = OptionPositionClose(status: "rolled", closeReason: "rolled") - isLoading = true - error = nil - do { - let updated: OptionPosition = try await APIClient.shared.request( - .closePosition(position.id), - body: body - ) - if let idx = positions.firstIndex(where: { $0.id == position.id }) { - positions[idx] = updated - } - } catch { - self.error = error.localizedDescription - } - isLoading = false + func close(position: OptionPosition, reason: String) { + DataStore.shared.closePosition(id: position.id, reason: reason) + } + + func roll(position: OptionPosition) { + DataStore.shared.rollPosition(id: position.id) } } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/RecommendationsViewModel.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/RecommendationsViewModel.swift index b533fe0..3864440 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/RecommendationsViewModel.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/RecommendationsViewModel.swift @@ -4,55 +4,89 @@ import Combine @MainActor final class RecommendationsViewModel: ObservableObject { @Published var recommendations: [Recommendation] = [] - @Published var isLoading = false + @Published var isLoading = false @Published var isRefreshing = false @Published var error: String? = nil - @Published var selectedHorizon: String = "weekly" + @Published var selectedHorizon: String = TimeHorizon.weekly.rawValue @Published var selectedStrategy: String = "covered_call" + private var cancellables = Set() + var filtered: [Recommendation] { recommendations.filter { $0.timeHorizon == selectedHorizon && $0.strategy == selectedStrategy } } - func load() async { - isLoading = true - error = nil - do { - recommendations = try await APIClient.shared.request( - .getRecommendations(timeHorizon: nil), - body: nil - ) - } catch { - self.error = error.localizedDescription - } - isLoading = false + init() { + DataStore.shared.$recommendations + .receive(on: DispatchQueue.main) + .assign(to: \.recommendations, on: self) + .store(in: &cancellables) } + func load() { + recommendations = DataStore.shared.recommendations + } + + /// Fetches fresh market data for every ticker in the portfolio and rebuilds recommendations. func refresh() async { isRefreshing = true error = nil - do { - recommendations = try await APIClient.shared.request( - .refreshRecommendations, - body: nil - ) - } catch { - self.error = error.localizedDescription + + let tickers = DataStore.shared.tickers + guard !tickers.isEmpty else { + isRefreshing = false + return } + + var freshRecs: [Recommendation] = [] + 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) + + 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 } + } + + guard let expDate = RecommendationEngine.expiration(for: horizon, available: expirations), + let chain = try? await yfClient.optionChain(ticker: ticker, expiration: expDate) + 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) + } + } + } + } + + DataStore.shared.replaceRecommendations(freshRecs) isRefreshing = false } - func getDetail(ticker: String) async -> RecommendationWithSignals? { - do { - return try await APIClient.shared.request( - .getRecommendation(ticker: ticker, strategy: selectedStrategy, timeHorizon: selectedHorizon), - body: nil - ) - } catch { - self.error = error.localizedDescription - return nil - } + /// 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) + return SignalEngine.compute(ticker: ticker, history: history, earningsDate: earningsDate).snapshot } } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Alerts/AlertsView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Alerts/AlertsView.swift index 44aeec8..172bfa5 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Alerts/AlertsView.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Alerts/AlertsView.swift @@ -6,22 +6,19 @@ struct AlertsView: View { var body: some View { NavigationStack { Group { - if vm.isLoading && vm.alerts.isEmpty { - LoadingView(message: "Loading alerts...") - } else if vm.alerts.isEmpty { + if vm.alerts.isEmpty { EmptyStateView( icon: "bell.slash", title: "No alerts", - subtitle: "Alerts appear here when signal changes on your open positions." + subtitle: "Alerts appear here when signals change on your open positions." ) } else { List(vm.alerts) { alert in AlertRowView(alert: alert) { - Task { await vm.acknowledge(alert) } + vm.acknowledge(alert) } } .listStyle(.insetGrouped) - .refreshable { await vm.load() } } } .navigationTitle("Alerts") @@ -29,18 +26,17 @@ struct AlertsView: View { if vm.unreadCount > 0 { ToolbarItem(placement: .navigationBarTrailing) { Button("Mark All Read") { - Task { await vm.acknowledgeAll() } + vm.acknowledgeAll() } .font(.caption) } } } } - .task { await vm.load() } } } -// ─── Row ────────────────────────────────────────────────────────────────────── +// MARK: - Row struct AlertRowView: View { let alert: AppAlert @@ -56,8 +52,7 @@ struct AlertRowView: View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { - Text(alert.ticker) - .font(.headline) + Text(alert.ticker).font(.headline) AlertTypeBadge(alertType: alert.alertType) } Text(alert.message) diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/ContentView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/ContentView.swift index 34bb04c..ab45242 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/ContentView.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/ContentView.swift @@ -2,40 +2,30 @@ import SwiftUI struct ContentView: View { @EnvironmentObject private var notificationHandler: NotificationHandler + @StateObject private var store = DataStore.shared @State private var selectedTab = 0 - @State private var navigateToPositionId: Int? = nil var body: some View { TabView(selection: $selectedTab) { DashboardView() - .tabItem { - Label("Dashboard", systemImage: "house.fill") - } + .tabItem { Label("Dashboard", systemImage: "house.fill") } .tag(0) RecommendationsView() - .tabItem { - Label("Setups", systemImage: "lightbulb.fill") - } + .tabItem { Label("Setups", systemImage: "lightbulb.fill") } .tag(1) OpenPositionsView() - .tabItem { - Label("Trades", systemImage: "doc.text.fill") - } + .tabItem { Label("Trades", systemImage: "doc.text.fill") } .tag(2) PortfolioView() - .tabItem { - Label("Portfolio", systemImage: "briefcase.fill") - } + .tabItem { Label("Portfolio", systemImage: "briefcase.fill") } .tag(3) AlertsView() - .tabItem { - Label("Alerts", systemImage: "bell.fill") - } - .badge(LocalStore.shared.unreadAlertCount > 0 ? "\(LocalStore.shared.unreadAlertCount)" : nil) + .tabItem { Label("Alerts", systemImage: "bell.fill") } + .badge(store.unreadAlertCount > 0 ? "\(store.unreadAlertCount)" : nil) .tag(4) } .task { @@ -43,9 +33,8 @@ struct ContentView: View { } .onChange(of: notificationHandler.navigateToPositionId) { _, posId in if posId != nil { - // Deep link: switch to Trades tab + // Deep link from local notification tap → go to Trades tab selectedTab = 2 - navigateToPositionId = posId } } } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Dashboard/DashboardView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Dashboard/DashboardView.swift index 06be6dc..b1082f8 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Dashboard/DashboardView.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Dashboard/DashboardView.swift @@ -7,8 +7,8 @@ struct DashboardView: View { var body: some View { NavigationStack { Group { - if vm.isLoading && vm.stockPositions.isEmpty { - LoadingView(message: "Loading your positions...") + if vm.isRefreshing && vm.stockPositions.isEmpty { + LoadingView(message: "Fetching signals...") } else { ScrollView { LazyVStack(spacing: 0) { @@ -47,7 +47,7 @@ struct DashboardView: View { } } } - .task { await vm.loadAll() } + .onAppear { vm.loadAll() } .onChange(of: notificationHandler.inAppAlertMessage) { _, msg in if msg != nil { Task { await vm.refresh() } } } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Portfolio/PortfolioView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Portfolio/PortfolioView.swift index 417a7fb..b20636c 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Portfolio/PortfolioView.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Portfolio/PortfolioView.swift @@ -7,11 +7,7 @@ struct PortfolioView: View { var body: some View { NavigationStack { Group { - if vm.isLoading && vm.positions.isEmpty { - LoadingView(message: "Loading portfolio...") - } else if let error = vm.error { - ErrorView(message: error) { await vm.load() } - } else if vm.positions.isEmpty { + if vm.positions.isEmpty { EmptyStateView( icon: "briefcase", title: "No stocks yet", @@ -23,10 +19,8 @@ struct PortfolioView: View { PortfolioRowView(position: position) } .onDelete { indexSet in - Task { - for i in indexSet { - await vm.delete(ticker: vm.positions[i].ticker) - } + for i in indexSet { + vm.delete(id: vm.positions[i].id) } } } @@ -47,11 +41,10 @@ struct PortfolioView: View { AddPositionSheet(vm: vm) } } - .task { await vm.load() } } } -// ─── Row ────────────────────────────────────────────────────────────────────── +// MARK: - Row struct PortfolioRowView: View { let position: PortfolioPosition @@ -80,7 +73,7 @@ struct PortfolioRowView: View { } } -// ─── Add Sheet ──────────────────────────────────────────────────────────────── +// MARK: - Add Sheet struct AddPositionSheet: View { @ObservedObject var vm: PortfolioViewModel @@ -93,7 +86,7 @@ struct AddPositionSheet: View { enum Field { case ticker, shares, cost } - var sharesInt: Int? { Int(sharesText) } + var sharesInt: Int? { Int(sharesText) } var costDouble: Double? { costBasisText.isEmpty ? nil : Double(costBasisText) } var isValid: Bool { !ticker.isEmpty && (sharesInt ?? 0) > 0 } @@ -136,10 +129,8 @@ struct AddPositionSheet: View { } ToolbarItem(placement: .confirmationAction) { Button("Add") { - Task { - await vm.add(ticker: ticker, shares: sharesInt!, costBasis: costDouble) - dismiss() - } + vm.add(ticker: ticker, shares: sharesInt!, costBasis: costDouble) + dismiss() } .disabled(!isValid) } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/LogTradeSheet.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/LogTradeSheet.swift index 0b6e2cd..5fce272 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/LogTradeSheet.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/LogTradeSheet.swift @@ -82,18 +82,15 @@ struct LogTradeSheet: View { ToolbarItem(placement: .confirmationAction) { Button("Log") { guard isValid else { return } - Task { - let create = OptionPositionCreate( - ticker: ticker.uppercased(), - strategy: strategy, - strike: strike!, - expiration: dateFormatter.string(from: expiration), - premiumReceived: premium!, - contracts: contracts - ) - let success = await vm.log(create: create) - if success { dismiss() } - } + let create = OptionPositionCreate( + ticker: ticker.uppercased(), + strategy: strategy, + strike: strike!, + expiration: dateFormatter.string(from: expiration), + premiumReceived: premium!, + contracts: contracts + ) + if vm.log(create: create) { dismiss() } } .disabled(!isValid) } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/OpenPositionsView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/OpenPositionsView.swift index 095fa5a..af7e9a5 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/OpenPositionsView.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/OpenPositionsView.swift @@ -7,11 +7,7 @@ struct OpenPositionsView: View { var body: some View { NavigationStack { Group { - if vm.isLoading && vm.positions.isEmpty { - LoadingView(message: "Loading positions...") - } else if let error = vm.error { - ErrorView(message: error) { await vm.load() } - } else if vm.positions.isEmpty { + if vm.positions.isEmpty { EmptyStateView( icon: "doc.text", title: "No logged trades", @@ -38,7 +34,6 @@ struct OpenPositionsView: View { } } .listStyle(.insetGrouped) - .refreshable { await vm.load() } } } .navigationTitle("My Trades") @@ -55,20 +50,19 @@ struct OpenPositionsView: View { LogTradeSheet(vm: vm) } } - .task { await vm.load() } } } -// ─── Row ────────────────────────────────────────────────────────────────────── +// MARK: - Row struct LoggedPositionRow: View { let position: OptionPosition var statusColor: Color { switch position.status { - case "open": return Constants.Color.strong + case "open": return Constants.Color.strong case "rolled": return Constants.Color.moderate - default: return .secondary + default: return .secondary } } @@ -94,8 +88,7 @@ struct LoggedPositionRow: View { Text(String(format: "$%.2f", position.premiumReceived)) .font(.subheadline.weight(.semibold)) Text(String(format: "×%d", position.contracts)) - .font(.caption) - .foregroundStyle(.secondary) + .font(.caption).foregroundStyle(.secondary) } } .padding(.vertical, 3) diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/PositionDetailView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/PositionDetailView.swift index 9f71824..20560e9 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/PositionDetailView.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/PositionDetailView.swift @@ -3,17 +3,22 @@ import SwiftUI struct PositionDetailView: View { let position: OptionPosition /// Pass in from parent (OpenPositionsView) so close/roll updates the parent list. - /// If nil, a local vm is used (e.g. when navigating from DashboardView). var parentVM: PositionsViewModel? = nil - @StateObject private var localVM = PositionsViewModel() + @StateObject private var localVM = PositionsViewModel() @State private var signals: SignalSnapshot? = nil - @State private var isLoadingSignals = false - @State private var showCloseConfirm = false - @State private var showRollConfirm = false + @State private var isLoadingSignals = false + @State private var showCloseConfirm = false + @State private var showRollConfirm = false @Environment(\.dismiss) var dismiss private var vm: PositionsViewModel { parentVM ?? localVM } + private let earningsDateFmt: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + return f + }() + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { @@ -47,43 +52,45 @@ struct PositionDetailView: View { .task { await loadSignals() } .confirmationDialog("Close Position", isPresented: $showCloseConfirm) { Button("Bought Back", role: .destructive) { - Task { await vm.close(position: position, reason: "bought_back"); dismiss() } + vm.close(position: position, reason: "bought_back") + dismiss() } Button("Expired Worthless") { - Task { await vm.close(position: position, reason: "expired"); dismiss() } + vm.close(position: position, reason: "expired") + dismiss() } } .confirmationDialog("Roll Position", isPresented: $showRollConfirm) { Button("Mark as Rolled", role: .destructive) { - Task { await vm.roll(position: position); dismiss() } + vm.roll(position: position) + dismiss() } } } - // ─── Sections ────────────────────────────────────────────────────────────── + // MARK: - Sections private var summarySection: some View { - VStack(alignment: .leading, spacing: 10) { - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { - metricCard("Strike", String(format: "$%.2f", position.strike)) - metricCard("Expiration", position.expiration) - metricCard("Premium", String(format: "$%.2f", position.premiumReceived)) - metricCard("Contracts", "\(position.contracts)") - metricCard("Total Credit", String(format: "$%.2f", position.totalCredit)) - if let dte = position.daysToExpiry { - metricCard("Days Left", "\(dte)d", highlight: dte <= 5) - } + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { + metricCard("Strike", String(format: "$%.2f", position.strike)) + metricCard("Expiration", position.expiration) + metricCard("Premium", String(format: "$%.2f", position.premiumReceived)) + metricCard("Contracts", "\(position.contracts)") + metricCard("Total Credit", String(format: "$%.2f", position.totalCredit)) + if let dte = position.daysToExpiry { + metricCard("Days Left", "\(dte)d", highlight: dte <= 5) } } } private func signalsSection(_ snap: SignalSnapshot) -> some View { VStack(alignment: .leading, spacing: 10) { - Text("Current Signals") - .font(.headline) + Text("Current Signals").font(.headline) LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { metricCard("IV Rank", String(format: "%.0f%%", snap.ivRank), highlight: snap.ivRank >= 50) - metricCard("Trend", snap.trend.capitalized) + metricCard("Trend", snap.trend.capitalized) + metricCard("SMA-50", String(format: "$%.2f", snap.sma50)) + metricCard("SMA-200", String(format: "$%.2f", snap.sma200)) if let support = snap.nearestSupport { metricCard("Support", String(format: "$%.2f", support)) } @@ -95,7 +102,7 @@ struct PositionDetailView: View { HStack(spacing: 6) { Image(systemName: "calendar.badge.exclamationmark") .foregroundStyle(Constants.Color.warning) - Text("Earnings: \(earnings)") + Text("Earnings: \(earningsDateFmt.string(from: earnings))") .font(.caption) .foregroundStyle(Constants.Color.warning) } @@ -125,15 +132,12 @@ struct PositionDetailView: View { } } - // ─── Helpers ────────────────────────────────────────────────────────────── + // MARK: - Helpers private func metricCard(_ label: String, _ value: String, highlight: Bool = false) -> some View { VStack(alignment: .leading, spacing: 4) { - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - Text(value) - .font(.subheadline.weight(.semibold)) + Text(label).font(.caption).foregroundStyle(.secondary) + Text(value).font(.subheadline.weight(.semibold)) .foregroundStyle(highlight ? Constants.Color.strong : .primary) } .frame(maxWidth: .infinity, alignment: .leading) @@ -143,13 +147,13 @@ struct PositionDetailView: View { private func loadSignals() async { isLoadingSignals = true - do { - signals = try await APIClient.shared.request( - .getSignals(position.ticker), - body: nil - ) - } catch { - // Non-critical — just don't show signals + if let history = try? await YahooFinanceClient.shared.priceHistory(ticker: position.ticker) { + let earningsDate = try? await YahooFinanceClient.shared.nextEarningsDate(ticker: position.ticker) + signals = SignalEngine.compute( + ticker: position.ticker, + history: history, + earningsDate: earningsDate + ).snapshot } isLoadingSignals = false } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationDetailView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationDetailView.swift index 9a0d67b..9906a19 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationDetailView.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationDetailView.swift @@ -4,36 +4,37 @@ struct RecommendationDetailView: View { let ticker: String var initialRec: Recommendation? = nil - @StateObject private var vm = RecommendationsViewModel() - @State private var detail: RecommendationWithSignals? = nil - @State private var isLoading = false + @State private var signals: SignalSnapshot? = nil + @State private var isLoadingSignals = false - var rec: Recommendation? { detail?.recommendation ?? initialRec } - var signals: SignalSnapshot? { detail?.signals } + private let earningsDateFmt: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + return f + }() var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { - if isLoading { - LoadingView() - } else if let rec { - // ─── Header ─────────────────────────────────────────── + if let rec = initialRec { + // ─── Header ──────────────────────────────────────────── headerSection(rec) - Divider() - // ─── Trade Setup ────────────────────────────────────── + // ─── Trade Setup ─────────────────────────────────────── tradeSetupSection(rec) - Divider() - // ─── Signal Detail ──────────────────────────────────── - if let signals { + // ─── Signal Detail ───────────────────────────────────── + if isLoadingSignals { + HStack { ProgressView(); Text("Loading signals…").font(.caption).foregroundStyle(.secondary) } + .padding() + } else if let signals { signalDetailSection(signals) Divider() } - // ─── Rationale ──────────────────────────────────────── + // ─── Rationale ───────────────────────────────────────── rationaleSection(rec) if rec.earningsWarning { @@ -45,70 +46,68 @@ struct RecommendationDetailView: View { } .navigationTitle(ticker) .navigationBarTitleDisplayMode(.inline) - .task { await loadDetail() } + .task { await loadSignals() } } - // ─── Sections ───────────────────────────────────────────────────────────── + // MARK: - Sections private func headerSection(_ rec: Recommendation) -> some View { HStack { VStack(alignment: .leading, spacing: 4) { - Text(rec.strategyLabel) - .font(.title3.weight(.semibold)) - Text(rec.horizonLabel) - .font(.subheadline) - .foregroundStyle(.secondary) + Text(rec.strategyLabel).font(.title3.weight(.semibold)) + Text(rec.horizonLabel).font(.subheadline).foregroundStyle(.secondary) } Spacer() VStack(alignment: .trailing, spacing: 4) { SignalBadge(strength: rec.signalStrength) Text(String(format: "$%.2f", rec.currentPrice)) - .font(.caption) - .foregroundStyle(.secondary) + .font(.caption).foregroundStyle(.secondary) } } } private func tradeSetupSection(_ rec: Recommendation) -> some View { VStack(alignment: .leading, spacing: 10) { - Text("Trade Setup") - .font(.headline) - + Text("Trade Setup").font(.headline) LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { - metricCard("Strike", String(format: "$%.2f", rec.recommendedStrike)) + metricCard("Strike", String(format: "$%.2f", rec.recommendedStrike)) metricCard("Expiration", rec.recommendedExpiration) metricCard("Est. Premium", String(format: "$%.2f", rec.estimatedPremium)) - metricCard("Ann. Return", String(format: "%.1f%%", rec.annualizedPremiumPct)) - metricCard("Delta", String(format: "%.3f", rec.delta)) - metricCard("Theta", String(format: "%.3f", rec.theta)) + metricCard("Ann. Return", String(format: "%.1f%%", rec.annualizedPremiumPct)) + metricCard("Delta", String(format: "%.3f", rec.delta)) + metricCard("Theta", String(format: "%.3f", rec.theta)) } } } - private func signalDetailSection(_ signals: SignalSnapshot) -> some View { + private func signalDetailSection(_ snap: SignalSnapshot) -> some View { VStack(alignment: .leading, spacing: 10) { - Text("Market Signals") - .font(.headline) - + Text("Market Signals").font(.headline) LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { - metricCard("IV Rank", String(format: "%.0f%%", signals.ivRank), highlight: signals.ivRank >= 50) - metricCard("Trend", signals.trend.capitalized) - metricCard("SMA-50", String(format: "$%.2f", signals.sma50)) - metricCard("SMA-200", String(format: "$%.2f", signals.sma200)) - if let support = signals.nearestSupport { - metricCard("Support", String(format: "$%.2f", support)) + metricCard("IV Rank", String(format: "%.0f%%", snap.ivRank), highlight: snap.ivRank >= 50) + metricCard("Trend", snap.trend.capitalized) + metricCard("SMA-50", String(format: "$%.2f", snap.sma50)) + metricCard("SMA-200", String(format: "$%.2f", snap.sma200)) + if let support = snap.nearestSupport { + metricCard("Support", String(format: "$%.2f", support)) } - if let resistance = signals.nearestResistance { + if let resistance = snap.nearestResistance { metricCard("Resistance", String(format: "$%.2f", resistance)) } } + if let earnings = snap.earningsDate { + HStack(spacing: 6) { + Image(systemName: "calendar.badge.exclamationmark").foregroundStyle(Constants.Color.warning) + Text("Earnings: \(earningsDateFmt.string(from: earnings))") + .font(.caption).foregroundStyle(Constants.Color.warning) + } + } } } private func rationaleSection(_ rec: Recommendation) -> some View { VStack(alignment: .leading, spacing: 6) { - Text("Rationale") - .font(.headline) + Text("Rationale").font(.headline) Text(rec.rationale) .font(.subheadline) .foregroundStyle(.secondary) @@ -118,32 +117,27 @@ struct RecommendationDetailView: View { private func earningsWarningBanner(_ rec: Recommendation) -> some View { HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(Constants.Color.warning) + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Constants.Color.warning) VStack(alignment: .leading, spacing: 2) { - Text("Earnings Warning") - .font(.subheadline.weight(.semibold)) + Text("Earnings Warning").font(.subheadline.weight(.semibold)) if let earningsDate = rec.earningsDate { Text("Earnings on \(earningsDate) fall within this expiry window.") - .font(.caption) - .foregroundStyle(.secondary) + .font(.caption).foregroundStyle(.secondary) } } } .padding() .background(Constants.Color.warning.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(Constants.Color.warning.opacity(0.3), lineWidth: 1)) + .overlay(RoundedRectangle(cornerRadius: 10) + .stroke(Constants.Color.warning.opacity(0.3), lineWidth: 1)) } - // ─── Helper views ────────────────────────────────────────────────────────── + // MARK: - Helpers private func metricCard(_ label: String, _ value: String, highlight: Bool = false) -> some View { VStack(alignment: .leading, spacing: 4) { - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - Text(value) - .font(.subheadline.weight(.semibold)) + Text(label).font(.caption).foregroundStyle(.secondary) + Text(value).font(.subheadline.weight(.semibold)) .foregroundStyle(highlight ? Constants.Color.strong : .primary) } .frame(maxWidth: .infinity, alignment: .leading) @@ -151,11 +145,16 @@ struct RecommendationDetailView: View { .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) } - private func loadDetail() async { - isLoading = true - vm.selectedStrategy = initialRec?.strategy ?? "covered_call" - vm.selectedHorizon = initialRec?.timeHorizon ?? "weekly" - detail = await vm.getDetail(ticker: ticker) - isLoading = false + private func loadSignals() async { + isLoadingSignals = true + if let history = try? await YahooFinanceClient.shared.priceHistory(ticker: ticker) { + let earningsDate = try? await YahooFinanceClient.shared.nextEarningsDate(ticker: ticker) + signals = SignalEngine.compute( + ticker: ticker, + history: history, + earningsDate: earningsDate + ).snapshot + } + isLoadingSignals = false } } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationsView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationsView.swift index a39449e..f790aea 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationsView.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationsView.swift @@ -33,8 +33,6 @@ struct RecommendationsView: View { // ─── Content ────────────────────────────────────────────── if vm.isLoading { LoadingView(message: "Getting recommendations...") - } else if let error = vm.error { - ErrorView(message: error) { await vm.load() } } else if vm.filtered.isEmpty { EmptyStateView( icon: "chart.xyaxis.line", @@ -66,7 +64,7 @@ struct RecommendationsView: View { } } } - .task { await vm.load() } + .onAppear { vm.load() } } private func horizonLabel(_ h: String) -> String {