Pivot to fully client-side architecture — remove backend dependency
- Add Services/YahooFinanceClient.swift: Swift actor wrapping Yahoo Finance unofficial APIs (price history, option chain, expirations, earnings date) with 15-min in-memory cache - Add Services/SignalEngine.swift: on-device IV Rank (rolling HV), SMA-50/200, swing-level pivot support/resistance, Black-Scholes delta/theta, signal strength scoring, and SHA-256 signal hash for change detection - Add Services/RecommendationEngine.swift: strike selection (delta 0.15–0.40 target 0.25), expiration mapping per horizon (0DTE/1DTE/weekly/monthly), rationale builder - Add Services/DataStore.swift: @MainActor ObservableObject persisting all app state as JSON files in the documents directory (UUID-keyed, no server IDs) - Add Services/BackgroundRefreshManager.swift: BGAppRefreshTask registration + foreground 15-min Timer; runs signal check per open position and fires local notifications when hash changes or delta/profit thresholds are crossed - Add Services/NotificationService.swift: UNUserNotificationCenter local notification helper + badge count management - Rewrite all 5 ViewModels to use DataStore + local services (no APIClient) - Update Models to use UUID ids; remove snake_case CodingKeys (no network layer) - Simplify AppDelegate: remove APNs, register BGTaskScheduler, start/stop timer - Simplify NotificationPermissions/Handler: local notifications only, UUID position deep-link - Update Info.plist: replace remote-notification with fetch+processing background modes, add BGTaskSchedulerPermittedIdentifiers - Gut APIClient.swift and Endpoints.swift (no longer used) - Update all Views to match new sync/async patterns and UUID-based models Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<T: Decodable>(
|
||||
_ 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<T: Decodable>(
|
||||
_ 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<T: Encodable>(_ 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,14 +34,37 @@
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<!-- Allow Yahoo Finance unofficial HTTPS endpoints -->
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<false/>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>finance.yahoo.com</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<false/>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>query1.finance.yahoo.com</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<false/>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<!-- Background App Refresh -->
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<!-- BGTaskScheduler task identifiers -->
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.optionssidekick.refresh</string>
|
||||
</array>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<T: Decodable>(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<T: Encodable>(_ 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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: " ")
|
||||
}
|
||||
}
|
||||
@@ -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..<closes.count {
|
||||
guard closes[i-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..<logReturns.count {
|
||||
let window = Array(logReturns[(i-29)...i])
|
||||
let mean = window.reduce(0, +) / Double(window.count)
|
||||
let variance = window.map { pow($0 - mean, 2) }.reduce(0, +) / Double(window.count - 1)
|
||||
let hv = sqrt(variance) * sqrt(252)
|
||||
hvs.append(hv)
|
||||
}
|
||||
|
||||
guard let minHV = hvs.min(),
|
||||
let maxHV = hvs.max(),
|
||||
let currentHV = hvs.last,
|
||||
maxHV > 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>) -> Self {
|
||||
min(max(self, range.lowerBound), range.upperBound)
|
||||
}
|
||||
}
|
||||
@@ -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<T>(_ 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..<min(ts.count, closes.count) {
|
||||
guard let c = closes[i] else { continue }
|
||||
bars.append(PriceBar(
|
||||
date: Date(timeIntervalSince1970: Double(ts[i])),
|
||||
open: opens[i] ?? c,
|
||||
high: highs[i] ?? c,
|
||||
low: lows[i] ?? c,
|
||||
close: c
|
||||
))
|
||||
}
|
||||
|
||||
let result = PriceHistory(ticker: ticker, bars: bars)
|
||||
set(result, key: key)
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: Option expirations
|
||||
|
||||
func expirations(ticker: String) async throws -> [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<Date>.none as Any, key: key)
|
||||
return nil
|
||||
}
|
||||
|
||||
let date: Date? = Date(timeIntervalSince1970: rawDate) > Date()
|
||||
? Date(timeIntervalSince1970: rawDate)
|
||||
: nil
|
||||
set(date as Any, key: key)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -7,41 +7,28 @@ final class AlertsViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var error: String? = nil
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AnyCancellable>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AnyCancellable>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AnyCancellable>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AnyCancellable>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() } }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user