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 UIKit
|
||||||
import UserNotifications
|
import BackgroundTasks
|
||||||
|
|
||||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
|
|
||||||
@@ -7,51 +7,26 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
// Set up local notification delegate
|
||||||
NotificationHandler.shared.setup()
|
NotificationHandler.shared.setup()
|
||||||
|
|
||||||
|
// Register BGAppRefreshTask before app finishes launching (required by Apple)
|
||||||
|
BackgroundRefreshManager.shared.registerBackgroundTask()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called after user grants permission and iOS assigns a device token
|
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
BackgroundRefreshManager.shared.stopForegroundTimer()
|
||||||
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
BackgroundRefreshManager.shared.scheduleBackgroundRefresh()
|
||||||
LocalStore.shared.deviceToken = token
|
|
||||||
|
|
||||||
Task {
|
|
||||||
await registerDevice(token: token)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||||
print("APNs registration failed: \(error.localizedDescription)")
|
BackgroundRefreshManager.shared.startForegroundTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Private ──────────────────────────────────────────────────────────────
|
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||||
|
// Run a signal check immediately when user opens the app
|
||||||
private func registerDevice(token: String) async {
|
Task { await BackgroundRefreshManager.shared.runSignalCheck() }
|
||||||
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)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum Constants {
|
enum Constants {
|
||||||
// ─── API ──────────────────────────────────────────────────────────────────
|
// MARK: - Colors
|
||||||
/// 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 ───────────────────────────────────────────────────────────────
|
|
||||||
enum Color {
|
enum Color {
|
||||||
static let strong = SwiftUI.Color.green
|
static let strong = SwiftUI.Color.green
|
||||||
static let moderate = SwiftUI.Color.yellow
|
static let moderate = SwiftUI.Color.yellow
|
||||||
@@ -19,8 +11,11 @@ enum Constants {
|
|||||||
static let warning = SwiftUI.Color.orange
|
static let warning = SwiftUI.Color.orange
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── App ──────────────────────────────────────────────────────────────────
|
// MARK: - App
|
||||||
static let appName = "Options Sidekick"
|
static let appName = "Options Sidekick"
|
||||||
static let notificationCategory = "POSITION_ALERT"
|
static let notificationCategory = "POSITION_ALERT"
|
||||||
static let notificationActionView = "VIEW_POSITION"
|
static let notificationActionView = "VIEW_POSITION"
|
||||||
|
|
||||||
|
// MARK: - Background Task
|
||||||
|
static let bgRefreshIdentifier = BackgroundRefreshManager.taskIdentifier
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct AppAlert: Codable, Identifiable, Hashable {
|
struct AppAlert: Codable, Identifiable, Hashable {
|
||||||
let id: Int
|
let id: UUID
|
||||||
let ticker: String
|
let ticker: String
|
||||||
let optionPositionId: Int?
|
let optionPositionId: UUID?
|
||||||
let alertType: String // "close_early" | "roll_out" | "roll_up_down" | "earnings_warning"
|
let alertType: String // "close_early" | "roll_out" | "earnings_warning" | "new_rec"
|
||||||
let message: String
|
let message: String
|
||||||
let sentAt: Date
|
let sentAt: Date
|
||||||
var acknowledged: Bool
|
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 {
|
var typeLabel: String {
|
||||||
switch alertType {
|
switch alertType {
|
||||||
case "close_early": return "Close Early"
|
case "close_early": return "Close Early"
|
||||||
|
|||||||
@@ -1,27 +1,18 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct OptionPosition: Codable, Identifiable, Hashable {
|
struct OptionPosition: Codable, Identifiable, Hashable {
|
||||||
let id: Int
|
let id: UUID
|
||||||
let ticker: String
|
let ticker: String
|
||||||
let strategy: String // "covered_call" | "cash_secured_put"
|
let strategy: String // "covered_call" | "cash_secured_put"
|
||||||
let strike: Double
|
let strike: Double
|
||||||
let expiration: String // ISO date string "YYYY-MM-DD"
|
let expiration: String // ISO date "YYYY-MM-DD"
|
||||||
let premiumReceived: Double
|
let premiumReceived: Double
|
||||||
let contracts: Int
|
let contracts: Int
|
||||||
let status: String // "open" | "closed" | "rolled"
|
var status: String // "open" | "closed" | "rolled"
|
||||||
let closeReason: String?
|
var closeReason: String?
|
||||||
let openedAt: Date
|
let openedAt: Date
|
||||||
let closedAt: Date?
|
var closedAt: Date?
|
||||||
let lastSignalHash: String?
|
var 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 strategyLabel: String {
|
var strategyLabel: String {
|
||||||
strategy == "covered_call" ? "Covered Call" : "Cash-Secured Put"
|
strategy == "covered_call" ? "Covered Call" : "Cash-Secured Put"
|
||||||
@@ -35,35 +26,19 @@ struct OptionPosition: Codable, Identifiable, Hashable {
|
|||||||
|
|
||||||
var daysToExpiry: Int? {
|
var daysToExpiry: Int? {
|
||||||
guard let exp = expirationDate else { return nil }
|
guard let exp = expirationDate else { return nil }
|
||||||
let days = Calendar.current.dateComponents([.day], from: Date(), to: exp).day
|
return Calendar.current.dateComponents([.day], from: Date(), to: exp).day
|
||||||
return days
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalCredit: Double {
|
var totalCredit: Double { premiumReceived * Double(contracts) * 100 }
|
||||||
premiumReceived * Double(contracts) * 100
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct OptionPositionCreate: Codable {
|
// MARK: - Create helpers (used by LogTradeSheet)
|
||||||
|
|
||||||
|
struct OptionPositionCreate {
|
||||||
let ticker: String
|
let ticker: String
|
||||||
let strategy: String
|
let strategy: String
|
||||||
let strike: Double
|
let strike: Double
|
||||||
let expiration: String
|
let expiration: String
|
||||||
let premiumReceived: Double
|
let premiumReceived: Double
|
||||||
let contracts: Int
|
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
|
import Foundation
|
||||||
|
|
||||||
struct PortfolioPosition: Codable, Identifiable, Hashable {
|
struct PortfolioPosition: Codable, Identifiable, Hashable {
|
||||||
let id: Int
|
let id: UUID
|
||||||
let ticker: String
|
let ticker: String
|
||||||
let shares: Int
|
let shares: Int
|
||||||
let costBasis: Double?
|
let costBasis: Double?
|
||||||
let createdAt: Date
|
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
|
import Foundation
|
||||||
|
|
||||||
struct Recommendation: Codable, Identifiable, Hashable {
|
struct Recommendation: Codable, Identifiable, Hashable {
|
||||||
let id: Int
|
let id: UUID
|
||||||
let ticker: String
|
let ticker: String
|
||||||
let strategy: String
|
let strategy: String
|
||||||
let timeHorizon: String
|
let timeHorizon: String
|
||||||
let currentPrice: Double
|
let currentPrice: Double
|
||||||
let recommendedStrike: Double
|
let recommendedStrike: Double
|
||||||
let recommendedExpiration: String // ISO date "YYYY-MM-DD"
|
let recommendedExpiration: String // "YYYY-MM-DD"
|
||||||
let estimatedPremium: Double
|
let estimatedPremium: Double
|
||||||
let delta: Double
|
let delta: Double
|
||||||
let theta: Double
|
let theta: Double
|
||||||
@@ -19,22 +19,6 @@ struct Recommendation: Codable, Identifiable, Hashable {
|
|||||||
let signalHash: String
|
let signalHash: String
|
||||||
let createdAt: Date
|
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 {
|
var strategyLabel: String {
|
||||||
strategy == "covered_call" ? "Covered Call" : "Cash-Secured Put"
|
strategy == "covered_call" ? "Covered Call" : "Cash-Secured Put"
|
||||||
}
|
}
|
||||||
@@ -49,21 +33,24 @@ struct Recommendation: Codable, Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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? {
|
var expirationDate: Date? {
|
||||||
let fmt = DateFormatter()
|
let fmt = DateFormatter()
|
||||||
fmt.dateFormat = "yyyy-MM-dd"
|
fmt.dateFormat = "yyyy-MM-dd"
|
||||||
return fmt.date(from: recommendedExpiration)
|
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 ticker: String
|
||||||
let currentPrice: Double
|
let currentPrice: Double
|
||||||
let ivRank: Double
|
let ivRank: Double
|
||||||
@@ -72,24 +59,6 @@ struct SignalSnapshot: Codable {
|
|||||||
let nearestSupport: Double?
|
let nearestSupport: Double?
|
||||||
let nearestResistance: Double?
|
let nearestResistance: Double?
|
||||||
let trend: String
|
let trend: String
|
||||||
let earningsDate: String?
|
let earningsDate: Date?
|
||||||
let computedAt: 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
|
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
|
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 Combine
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
/// Handles incoming push notifications — both foreground and background tap.
|
/// Receives local notifications — both foreground display and tap handling.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||||
static let shared = NotificationHandler()
|
static let shared = NotificationHandler()
|
||||||
|
|
||||||
/// Published so views can react to a deep-link navigation request.
|
/// Published so views can react to a deep-link navigation request.
|
||||||
@Published var navigateToPositionId: Int? = nil
|
@Published var navigateToPositionId: UUID? = nil
|
||||||
@Published var inAppAlertMessage: String? = nil
|
@Published var inAppAlertMessage: String? = nil
|
||||||
|
|
||||||
private override init() {
|
private override init() { super.init() }
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setup() {
|
func setup() {
|
||||||
UNUserNotificationCenter.current().delegate = self
|
UNUserNotificationCenter.current().delegate = self
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Foreground notification ───────────────────────────────────────────────
|
// MARK: - Foreground notification
|
||||||
|
|
||||||
func userNotificationCenter(
|
func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
willPresent notification: UNNotification,
|
willPresent notification: UNNotification,
|
||||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
) {
|
) {
|
||||||
let userInfo = notification.request.content.userInfo
|
inAppAlertMessage = notification.request.content.body
|
||||||
if let message = notification.request.content.body as String? {
|
|
||||||
inAppAlertMessage = message
|
|
||||||
}
|
|
||||||
// Still show the banner even when foregrounded so user doesn't miss it
|
|
||||||
completionHandler([.banner, .sound, .badge])
|
completionHandler([.banner, .sound, .badge])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Background tap / action tap ──────────────────────────────────────────
|
// MARK: - Tap / action handler
|
||||||
|
|
||||||
func userNotificationCenter(
|
func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
didReceive response: UNNotificationResponse,
|
didReceive response: UNNotificationResponse,
|
||||||
withCompletionHandler completionHandler: @escaping () -> Void
|
withCompletionHandler completionHandler: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
let userInfo = response.notification.request.content.userInfo
|
let userInfo = response.notification.request.content.userInfo
|
||||||
if let positionId = userInfo["position_id"] as? Int {
|
if let idStr = userInfo["position_id"] as? String,
|
||||||
navigateToPositionId = positionId
|
let uuid = UUID(uuidString: idStr) {
|
||||||
|
navigateToPositionId = uuid
|
||||||
}
|
}
|
||||||
completionHandler()
|
completionHandler()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -13,7 +12,7 @@ final class NotificationPermissions {
|
|||||||
|
|
||||||
let center = UNUserNotificationCenter.current()
|
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(
|
let viewAction = UNNotificationAction(
|
||||||
identifier: Constants.notificationActionView,
|
identifier: Constants.notificationActionView,
|
||||||
title: "View Position",
|
title: "View Position",
|
||||||
@@ -28,14 +27,9 @@ final class NotificationPermissions {
|
|||||||
center.setNotificationCategories([category])
|
center.setNotificationCategories([category])
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])
|
try await center.requestAuthorization(options: [.alert, .badge, .sound])
|
||||||
if granted {
|
|
||||||
await MainActor.run {
|
|
||||||
UIApplication.shared.registerForRemoteNotifications()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
print("Notification permission error: \(error)")
|
print("[Notifications] Permission request error: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,16 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Lightweight UserDefaults wrapper for device-local state.
|
/// Lightweight UserDefaults wrapper for small non-structural app state.
|
||||||
final class LocalStore {
|
final class LocalStore {
|
||||||
static let shared = LocalStore()
|
static let shared = LocalStore()
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
private let defaults = UserDefaults.standard
|
private let defaults = UserDefaults.standard
|
||||||
|
|
||||||
// ─── APNs device token ────────────────────────────────────────────────────
|
// MARK: - Notification permission
|
||||||
|
|
||||||
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 ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
var notificationPermissionRequested: Bool {
|
var notificationPermissionRequested: Bool {
|
||||||
get { defaults.bool(forKey: "notification_permission_requested") }
|
get { defaults.bool(forKey: "notification_permission_requested") }
|
||||||
set { defaults.set(newValue, 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/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
<!-- Allow Yahoo Finance unofficial HTTPS endpoints -->
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSExceptionDomains</key>
|
||||||
|
<dict>
|
||||||
|
<key>finance.yahoo.com</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSIncludesSubdomains</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</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>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<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>
|
</array>
|
||||||
<key>aps-environment</key>
|
|
||||||
<string>development</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</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 isLoading = false
|
||||||
@Published var error: String? = nil
|
@Published var error: String? = nil
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
var unreadCount: Int { alerts.filter { !$0.acknowledged }.count }
|
var unreadCount: Int { alerts.filter { !$0.acknowledged }.count }
|
||||||
|
|
||||||
func load(unreadOnly: Bool = false) async {
|
init() {
|
||||||
isLoading = true
|
DataStore.shared.$alerts
|
||||||
error = nil
|
.receive(on: DispatchQueue.main)
|
||||||
do {
|
.assign(to: \.alerts, on: self)
|
||||||
alerts = try await APIClient.shared.request(
|
.store(in: &cancellables)
|
||||||
.getAlerts(unreadOnly: unreadOnly),
|
|
||||||
body: nil
|
|
||||||
)
|
|
||||||
LocalStore.shared.unreadAlertCount = unreadCount
|
|
||||||
} catch {
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func acknowledge(_ alert: AppAlert) async {
|
func load() {
|
||||||
do {
|
alerts = DataStore.shared.alerts
|
||||||
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 acknowledgeAll() async {
|
func acknowledge(_ alert: AppAlert) {
|
||||||
for alert in alerts where !alert.acknowledged {
|
DataStore.shared.acknowledgeAlert(id: alert.id)
|
||||||
await acknowledge(alert)
|
Task { await NotificationService.setBadge(DataStore.shared.unreadAlertCount) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func acknowledgeAll() {
|
||||||
|
DataStore.shared.acknowledgeAllAlerts()
|
||||||
|
Task { await NotificationService.setBadge(0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ final class DashboardViewModel: ObservableObject {
|
|||||||
@Published var recommendations: [Recommendation] = []
|
@Published var recommendations: [Recommendation] = []
|
||||||
@Published var unreadAlerts: [AppAlert] = []
|
@Published var unreadAlerts: [AppAlert] = []
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
|
@Published var isRefreshing = false
|
||||||
@Published var error: String? = nil
|
@Published var error: String? = nil
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
var urgentAlerts: [AppAlert] { unreadAlerts.filter { $0.isUrgent } }
|
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] {
|
var topRecommendations: [Recommendation] {
|
||||||
let order = ["strong": 0, "moderate": 1, "weak": 2]
|
let order = ["strong": 0, "moderate": 1, "weak": 2]
|
||||||
var best: [String: Recommendation] = [:]
|
var best: [String: Recommendation] = [:]
|
||||||
@@ -25,38 +28,46 @@ final class DashboardViewModel: ObservableObject {
|
|||||||
return Array(best.values).sorted { $0.ticker < $1.ticker }
|
return Array(best.values).sorted { $0.ticker < $1.ticker }
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadAll() async {
|
init() {
|
||||||
isLoading = true
|
let store = DataStore.shared
|
||||||
error = nil
|
store.$portfolio
|
||||||
|
.map { $0 }
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: \.stockPositions, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
async let stocks: [PortfolioPosition] = loadStocks()
|
store.$positions
|
||||||
async let options: [OptionPosition] = loadOptions()
|
.map { $0.filter { $0.status == "open" } }
|
||||||
async let recs: [Recommendation] = loadRecommendations()
|
.receive(on: DispatchQueue.main)
|
||||||
async let alerts: [AppAlert] = loadAlerts()
|
.assign(to: \.openOptionPositions, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
(stockPositions, openOptionPositions, recommendations, unreadAlerts) = await (stocks, options, recs, alerts)
|
store.$recommendations
|
||||||
isLoading = false
|
.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 {
|
func refresh() async {
|
||||||
await loadAll()
|
isRefreshing = true
|
||||||
}
|
await BackgroundRefreshManager.shared.runSignalCheck()
|
||||||
|
// Recommendations are rebuilt by RecommendationsViewModel; here we just update the signal state
|
||||||
// ─── Private loaders ──────────────────────────────────────────────────────
|
isRefreshing = false
|
||||||
|
loadAll()
|
||||||
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)) ?? []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,62 +7,25 @@ final class PortfolioViewModel: ObservableObject {
|
|||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var error: String? = nil
|
@Published var error: String? = nil
|
||||||
|
|
||||||
func load() async {
|
private var cancellables = Set<AnyCancellable>()
|
||||||
isLoading = true
|
|
||||||
error = nil
|
init() {
|
||||||
do {
|
// Mirror DataStore so views just observe this VM
|
||||||
positions = try await APIClient.shared.request(.getPortfolio, body: nil)
|
DataStore.shared.$portfolio
|
||||||
} catch {
|
.receive(on: DispatchQueue.main)
|
||||||
self.error = error.localizedDescription
|
.assign(to: \.positions, on: self)
|
||||||
}
|
.store(in: &cancellables)
|
||||||
isLoading = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func save(_ newPositions: [PortfolioPosition]) async {
|
func load() {
|
||||||
isLoading = true
|
positions = DataStore.shared.portfolio
|
||||||
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 add(ticker: String, shares: Int, costBasis: Double?) async {
|
func add(ticker: String, shares: Int, costBasis: Double?) {
|
||||||
let updated = positions
|
DataStore.shared.upsertPortfolioPosition(ticker: ticker, shares: shares, costBasis: costBasis)
|
||||||
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 delete(ticker: String) async {
|
func delete(id: UUID) {
|
||||||
isLoading = true
|
DataStore.shared.removePortfolioPosition(id: id)
|
||||||
error = nil
|
|
||||||
do {
|
|
||||||
try await APIClient.shared.requestVoid(.deleteTicker(ticker), body: nil)
|
|
||||||
positions.removeAll { $0.ticker == ticker }
|
|
||||||
} catch {
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,71 +7,39 @@ final class PositionsViewModel: ObservableObject {
|
|||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var error: String? = nil
|
@Published var error: String? = nil
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
var openPositions: [OptionPosition] { positions.filter { $0.status == "open" } }
|
var openPositions: [OptionPosition] { positions.filter { $0.status == "open" } }
|
||||||
var closedPositions: [OptionPosition] { positions.filter { $0.status != "open" } }
|
var closedPositions: [OptionPosition] { positions.filter { $0.status != "open" } }
|
||||||
|
|
||||||
func load() async {
|
init() {
|
||||||
isLoading = true
|
DataStore.shared.$positions
|
||||||
error = nil
|
.receive(on: DispatchQueue.main)
|
||||||
do {
|
.assign(to: \.positions, on: self)
|
||||||
positions = try await APIClient.shared.request(
|
.store(in: &cancellables)
|
||||||
.getPositions(status: nil),
|
|
||||||
body: nil
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func log(create: OptionPositionCreate) async -> Bool {
|
func load() {
|
||||||
isLoading = true
|
positions = DataStore.shared.positions
|
||||||
error = nil
|
}
|
||||||
do {
|
|
||||||
let new: OptionPosition = try await APIClient.shared.request(.logPosition, body: create)
|
func log(create: OptionPositionCreate) -> Bool {
|
||||||
positions.insert(new, at: 0)
|
DataStore.shared.logPosition(
|
||||||
isLoading = false
|
ticker: create.ticker,
|
||||||
|
strategy: create.strategy,
|
||||||
|
strike: create.strike,
|
||||||
|
expiration: create.expiration,
|
||||||
|
premiumReceived: create.premiumReceived,
|
||||||
|
contracts: create.contracts
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
} catch {
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
isLoading = false
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func close(position: OptionPosition, reason: String) async {
|
func close(position: OptionPosition, reason: String) {
|
||||||
isLoading = true
|
DataStore.shared.closePosition(id: position.id, reason: reason)
|
||||||
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 roll(position: OptionPosition) async {
|
func roll(position: OptionPosition) {
|
||||||
let body = OptionPositionClose(status: "rolled", closeReason: "rolled")
|
DataStore.shared.rollPosition(id: position.id)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,52 +7,86 @@ final class RecommendationsViewModel: ObservableObject {
|
|||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var isRefreshing = false
|
@Published var isRefreshing = false
|
||||||
@Published var error: String? = nil
|
@Published var error: String? = nil
|
||||||
@Published var selectedHorizon: String = "weekly"
|
@Published var selectedHorizon: String = TimeHorizon.weekly.rawValue
|
||||||
@Published var selectedStrategy: String = "covered_call"
|
@Published var selectedStrategy: String = "covered_call"
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
var filtered: [Recommendation] {
|
var filtered: [Recommendation] {
|
||||||
recommendations.filter {
|
recommendations.filter {
|
||||||
$0.timeHorizon == selectedHorizon && $0.strategy == selectedStrategy
|
$0.timeHorizon == selectedHorizon && $0.strategy == selectedStrategy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func load() async {
|
init() {
|
||||||
isLoading = true
|
DataStore.shared.$recommendations
|
||||||
error = nil
|
.receive(on: DispatchQueue.main)
|
||||||
do {
|
.assign(to: \.recommendations, on: self)
|
||||||
recommendations = try await APIClient.shared.request(
|
.store(in: &cancellables)
|
||||||
.getRecommendations(timeHorizon: nil),
|
|
||||||
body: nil
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func load() {
|
||||||
|
recommendations = DataStore.shared.recommendations
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches fresh market data for every ticker in the portfolio and rebuilds recommendations.
|
||||||
func refresh() async {
|
func refresh() async {
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
error = nil
|
error = nil
|
||||||
do {
|
|
||||||
recommendations = try await APIClient.shared.request(
|
let tickers = DataStore.shared.tickers
|
||||||
.refreshRecommendations,
|
guard !tickers.isEmpty else {
|
||||||
body: nil
|
isRefreshing = false
|
||||||
)
|
return
|
||||||
} catch {
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDetail(ticker: String) async -> RecommendationWithSignals? {
|
/// Returns the SignalSnapshot for a ticker (re-computed on demand).
|
||||||
do {
|
func signalSnapshot(for ticker: String) async -> SignalSnapshot? {
|
||||||
return try await APIClient.shared.request(
|
guard let history = try? await YahooFinanceClient.shared.priceHistory(ticker: ticker) else { return nil }
|
||||||
.getRecommendation(ticker: ticker, strategy: selectedStrategy, timeHorizon: selectedHorizon),
|
let earningsDate = try? await YahooFinanceClient.shared.nextEarningsDate(ticker: ticker)
|
||||||
body: nil
|
return SignalEngine.compute(ticker: ticker, history: history, earningsDate: earningsDate).snapshot
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,19 @@ struct AlertsView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if vm.isLoading && vm.alerts.isEmpty {
|
if vm.alerts.isEmpty {
|
||||||
LoadingView(message: "Loading alerts...")
|
|
||||||
} else if vm.alerts.isEmpty {
|
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: "bell.slash",
|
icon: "bell.slash",
|
||||||
title: "No alerts",
|
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 {
|
} else {
|
||||||
List(vm.alerts) { alert in
|
List(vm.alerts) { alert in
|
||||||
AlertRowView(alert: alert) {
|
AlertRowView(alert: alert) {
|
||||||
Task { await vm.acknowledge(alert) }
|
vm.acknowledge(alert)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
.refreshable { await vm.load() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Alerts")
|
.navigationTitle("Alerts")
|
||||||
@@ -29,18 +26,17 @@ struct AlertsView: View {
|
|||||||
if vm.unreadCount > 0 {
|
if vm.unreadCount > 0 {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Mark All Read") {
|
Button("Mark All Read") {
|
||||||
Task { await vm.acknowledgeAll() }
|
vm.acknowledgeAll()
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task { await vm.load() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Row ──────────────────────────────────────────────────────────────────────
|
// MARK: - Row
|
||||||
|
|
||||||
struct AlertRowView: View {
|
struct AlertRowView: View {
|
||||||
let alert: AppAlert
|
let alert: AppAlert
|
||||||
@@ -56,8 +52,7 @@ struct AlertRowView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(alert.ticker)
|
Text(alert.ticker).font(.headline)
|
||||||
.font(.headline)
|
|
||||||
AlertTypeBadge(alertType: alert.alertType)
|
AlertTypeBadge(alertType: alert.alertType)
|
||||||
}
|
}
|
||||||
Text(alert.message)
|
Text(alert.message)
|
||||||
|
|||||||
@@ -2,40 +2,30 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@EnvironmentObject private var notificationHandler: NotificationHandler
|
@EnvironmentObject private var notificationHandler: NotificationHandler
|
||||||
|
@StateObject private var store = DataStore.shared
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
@State private var navigateToPositionId: Int? = nil
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
DashboardView()
|
DashboardView()
|
||||||
.tabItem {
|
.tabItem { Label("Dashboard", systemImage: "house.fill") }
|
||||||
Label("Dashboard", systemImage: "house.fill")
|
|
||||||
}
|
|
||||||
.tag(0)
|
.tag(0)
|
||||||
|
|
||||||
RecommendationsView()
|
RecommendationsView()
|
||||||
.tabItem {
|
.tabItem { Label("Setups", systemImage: "lightbulb.fill") }
|
||||||
Label("Setups", systemImage: "lightbulb.fill")
|
|
||||||
}
|
|
||||||
.tag(1)
|
.tag(1)
|
||||||
|
|
||||||
OpenPositionsView()
|
OpenPositionsView()
|
||||||
.tabItem {
|
.tabItem { Label("Trades", systemImage: "doc.text.fill") }
|
||||||
Label("Trades", systemImage: "doc.text.fill")
|
|
||||||
}
|
|
||||||
.tag(2)
|
.tag(2)
|
||||||
|
|
||||||
PortfolioView()
|
PortfolioView()
|
||||||
.tabItem {
|
.tabItem { Label("Portfolio", systemImage: "briefcase.fill") }
|
||||||
Label("Portfolio", systemImage: "briefcase.fill")
|
|
||||||
}
|
|
||||||
.tag(3)
|
.tag(3)
|
||||||
|
|
||||||
AlertsView()
|
AlertsView()
|
||||||
.tabItem {
|
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
||||||
Label("Alerts", systemImage: "bell.fill")
|
.badge(store.unreadAlertCount > 0 ? "\(store.unreadAlertCount)" : nil)
|
||||||
}
|
|
||||||
.badge(LocalStore.shared.unreadAlertCount > 0 ? "\(LocalStore.shared.unreadAlertCount)" : nil)
|
|
||||||
.tag(4)
|
.tag(4)
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
@@ -43,9 +33,8 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: notificationHandler.navigateToPositionId) { _, posId in
|
.onChange(of: notificationHandler.navigateToPositionId) { _, posId in
|
||||||
if posId != nil {
|
if posId != nil {
|
||||||
// Deep link: switch to Trades tab
|
// Deep link from local notification tap → go to Trades tab
|
||||||
selectedTab = 2
|
selectedTab = 2
|
||||||
navigateToPositionId = posId
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ struct DashboardView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if vm.isLoading && vm.stockPositions.isEmpty {
|
if vm.isRefreshing && vm.stockPositions.isEmpty {
|
||||||
LoadingView(message: "Loading your positions...")
|
LoadingView(message: "Fetching signals...")
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
@@ -47,7 +47,7 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task { await vm.loadAll() }
|
.onAppear { vm.loadAll() }
|
||||||
.onChange(of: notificationHandler.inAppAlertMessage) { _, msg in
|
.onChange(of: notificationHandler.inAppAlertMessage) { _, msg in
|
||||||
if msg != nil { Task { await vm.refresh() } }
|
if msg != nil { Task { await vm.refresh() } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ struct PortfolioView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if vm.isLoading && vm.positions.isEmpty {
|
if vm.positions.isEmpty {
|
||||||
LoadingView(message: "Loading portfolio...")
|
|
||||||
} else if let error = vm.error {
|
|
||||||
ErrorView(message: error) { await vm.load() }
|
|
||||||
} else if vm.positions.isEmpty {
|
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: "briefcase",
|
icon: "briefcase",
|
||||||
title: "No stocks yet",
|
title: "No stocks yet",
|
||||||
@@ -23,10 +19,8 @@ struct PortfolioView: View {
|
|||||||
PortfolioRowView(position: position)
|
PortfolioRowView(position: position)
|
||||||
}
|
}
|
||||||
.onDelete { indexSet in
|
.onDelete { indexSet in
|
||||||
Task {
|
|
||||||
for i in indexSet {
|
for i in indexSet {
|
||||||
await vm.delete(ticker: vm.positions[i].ticker)
|
vm.delete(id: vm.positions[i].id)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,11 +41,10 @@ struct PortfolioView: View {
|
|||||||
AddPositionSheet(vm: vm)
|
AddPositionSheet(vm: vm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task { await vm.load() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Row ──────────────────────────────────────────────────────────────────────
|
// MARK: - Row
|
||||||
|
|
||||||
struct PortfolioRowView: View {
|
struct PortfolioRowView: View {
|
||||||
let position: PortfolioPosition
|
let position: PortfolioPosition
|
||||||
@@ -80,7 +73,7 @@ struct PortfolioRowView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Add Sheet ────────────────────────────────────────────────────────────────
|
// MARK: - Add Sheet
|
||||||
|
|
||||||
struct AddPositionSheet: View {
|
struct AddPositionSheet: View {
|
||||||
@ObservedObject var vm: PortfolioViewModel
|
@ObservedObject var vm: PortfolioViewModel
|
||||||
@@ -136,11 +129,9 @@ struct AddPositionSheet: View {
|
|||||||
}
|
}
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("Add") {
|
Button("Add") {
|
||||||
Task {
|
vm.add(ticker: ticker, shares: sharesInt!, costBasis: costDouble)
|
||||||
await vm.add(ticker: ticker, shares: sharesInt!, costBasis: costDouble)
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.disabled(!isValid)
|
.disabled(!isValid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ struct LogTradeSheet: View {
|
|||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("Log") {
|
Button("Log") {
|
||||||
guard isValid else { return }
|
guard isValid else { return }
|
||||||
Task {
|
|
||||||
let create = OptionPositionCreate(
|
let create = OptionPositionCreate(
|
||||||
ticker: ticker.uppercased(),
|
ticker: ticker.uppercased(),
|
||||||
strategy: strategy,
|
strategy: strategy,
|
||||||
@@ -91,9 +90,7 @@ struct LogTradeSheet: View {
|
|||||||
premiumReceived: premium!,
|
premiumReceived: premium!,
|
||||||
contracts: contracts
|
contracts: contracts
|
||||||
)
|
)
|
||||||
let success = await vm.log(create: create)
|
if vm.log(create: create) { dismiss() }
|
||||||
if success { dismiss() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.disabled(!isValid)
|
.disabled(!isValid)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ struct OpenPositionsView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if vm.isLoading && vm.positions.isEmpty {
|
if vm.positions.isEmpty {
|
||||||
LoadingView(message: "Loading positions...")
|
|
||||||
} else if let error = vm.error {
|
|
||||||
ErrorView(message: error) { await vm.load() }
|
|
||||||
} else if vm.positions.isEmpty {
|
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: "doc.text",
|
icon: "doc.text",
|
||||||
title: "No logged trades",
|
title: "No logged trades",
|
||||||
@@ -38,7 +34,6 @@ struct OpenPositionsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
.refreshable { await vm.load() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("My Trades")
|
.navigationTitle("My Trades")
|
||||||
@@ -55,11 +50,10 @@ struct OpenPositionsView: View {
|
|||||||
LogTradeSheet(vm: vm)
|
LogTradeSheet(vm: vm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task { await vm.load() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Row ──────────────────────────────────────────────────────────────────────
|
// MARK: - Row
|
||||||
|
|
||||||
struct LoggedPositionRow: View {
|
struct LoggedPositionRow: View {
|
||||||
let position: OptionPosition
|
let position: OptionPosition
|
||||||
@@ -94,8 +88,7 @@ struct LoggedPositionRow: View {
|
|||||||
Text(String(format: "$%.2f", position.premiumReceived))
|
Text(String(format: "$%.2f", position.premiumReceived))
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
Text(String(format: "×%d", position.contracts))
|
Text(String(format: "×%d", position.contracts))
|
||||||
.font(.caption)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import SwiftUI
|
|||||||
struct PositionDetailView: View {
|
struct PositionDetailView: View {
|
||||||
let position: OptionPosition
|
let position: OptionPosition
|
||||||
/// Pass in from parent (OpenPositionsView) so close/roll updates the parent list.
|
/// 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
|
var parentVM: PositionsViewModel? = nil
|
||||||
@StateObject private var localVM = PositionsViewModel()
|
@StateObject private var localVM = PositionsViewModel()
|
||||||
@State private var signals: SignalSnapshot? = nil
|
@State private var signals: SignalSnapshot? = nil
|
||||||
@@ -14,6 +13,12 @@ struct PositionDetailView: View {
|
|||||||
|
|
||||||
private var vm: PositionsViewModel { parentVM ?? localVM }
|
private var vm: PositionsViewModel { parentVM ?? localVM }
|
||||||
|
|
||||||
|
private let earningsDateFmt: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateStyle = .medium
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
@@ -47,23 +52,25 @@ struct PositionDetailView: View {
|
|||||||
.task { await loadSignals() }
|
.task { await loadSignals() }
|
||||||
.confirmationDialog("Close Position", isPresented: $showCloseConfirm) {
|
.confirmationDialog("Close Position", isPresented: $showCloseConfirm) {
|
||||||
Button("Bought Back", role: .destructive) {
|
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") {
|
Button("Expired Worthless") {
|
||||||
Task { await vm.close(position: position, reason: "expired"); dismiss() }
|
vm.close(position: position, reason: "expired")
|
||||||
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confirmationDialog("Roll Position", isPresented: $showRollConfirm) {
|
.confirmationDialog("Roll Position", isPresented: $showRollConfirm) {
|
||||||
Button("Mark as Rolled", role: .destructive) {
|
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 {
|
private var summarySection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
||||||
metricCard("Strike", String(format: "$%.2f", position.strike))
|
metricCard("Strike", String(format: "$%.2f", position.strike))
|
||||||
metricCard("Expiration", position.expiration)
|
metricCard("Expiration", position.expiration)
|
||||||
@@ -75,15 +82,15 @@ struct PositionDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func signalsSection(_ snap: SignalSnapshot) -> some View {
|
private func signalsSection(_ snap: SignalSnapshot) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Current Signals")
|
Text("Current Signals").font(.headline)
|
||||||
.font(.headline)
|
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
||||||
metricCard("IV Rank", String(format: "%.0f%%", snap.ivRank), highlight: snap.ivRank >= 50)
|
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 {
|
if let support = snap.nearestSupport {
|
||||||
metricCard("Support", String(format: "$%.2f", support))
|
metricCard("Support", String(format: "$%.2f", support))
|
||||||
}
|
}
|
||||||
@@ -95,7 +102,7 @@ struct PositionDetailView: View {
|
|||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "calendar.badge.exclamationmark")
|
Image(systemName: "calendar.badge.exclamationmark")
|
||||||
.foregroundStyle(Constants.Color.warning)
|
.foregroundStyle(Constants.Color.warning)
|
||||||
Text("Earnings: \(earnings)")
|
Text("Earnings: \(earningsDateFmt.string(from: earnings))")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Constants.Color.warning)
|
.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 {
|
private func metricCard(_ label: String, _ value: String, highlight: Bool = false) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(label)
|
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
Text(value).font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(value)
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.foregroundStyle(highlight ? Constants.Color.strong : .primary)
|
.foregroundStyle(highlight ? Constants.Color.strong : .primary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -143,13 +147,13 @@ struct PositionDetailView: View {
|
|||||||
|
|
||||||
private func loadSignals() async {
|
private func loadSignals() async {
|
||||||
isLoadingSignals = true
|
isLoadingSignals = true
|
||||||
do {
|
if let history = try? await YahooFinanceClient.shared.priceHistory(ticker: position.ticker) {
|
||||||
signals = try await APIClient.shared.request(
|
let earningsDate = try? await YahooFinanceClient.shared.nextEarningsDate(ticker: position.ticker)
|
||||||
.getSignals(position.ticker),
|
signals = SignalEngine.compute(
|
||||||
body: nil
|
ticker: position.ticker,
|
||||||
)
|
history: history,
|
||||||
} catch {
|
earningsDate: earningsDate
|
||||||
// Non-critical — just don't show signals
|
).snapshot
|
||||||
}
|
}
|
||||||
isLoadingSignals = false
|
isLoadingSignals = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,36 +4,37 @@ struct RecommendationDetailView: View {
|
|||||||
let ticker: String
|
let ticker: String
|
||||||
var initialRec: Recommendation? = nil
|
var initialRec: Recommendation? = nil
|
||||||
|
|
||||||
@StateObject private var vm = RecommendationsViewModel()
|
@State private var signals: SignalSnapshot? = nil
|
||||||
@State private var detail: RecommendationWithSignals? = nil
|
@State private var isLoadingSignals = false
|
||||||
@State private var isLoading = false
|
|
||||||
|
|
||||||
var rec: Recommendation? { detail?.recommendation ?? initialRec }
|
private let earningsDateFmt: DateFormatter = {
|
||||||
var signals: SignalSnapshot? { detail?.signals }
|
let f = DateFormatter()
|
||||||
|
f.dateStyle = .medium
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
if isLoading {
|
if let rec = initialRec {
|
||||||
LoadingView()
|
// ─── Header ────────────────────────────────────────────
|
||||||
} else if let rec {
|
|
||||||
// ─── Header ───────────────────────────────────────────
|
|
||||||
headerSection(rec)
|
headerSection(rec)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// ─── Trade Setup ──────────────────────────────────────
|
// ─── Trade Setup ───────────────────────────────────────
|
||||||
tradeSetupSection(rec)
|
tradeSetupSection(rec)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// ─── Signal Detail ────────────────────────────────────
|
// ─── Signal Detail ─────────────────────────────────────
|
||||||
if let signals {
|
if isLoadingSignals {
|
||||||
|
HStack { ProgressView(); Text("Loading signals…").font(.caption).foregroundStyle(.secondary) }
|
||||||
|
.padding()
|
||||||
|
} else if let signals {
|
||||||
signalDetailSection(signals)
|
signalDetailSection(signals)
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Rationale ────────────────────────────────────────
|
// ─── Rationale ─────────────────────────────────────────
|
||||||
rationaleSection(rec)
|
rationaleSection(rec)
|
||||||
|
|
||||||
if rec.earningsWarning {
|
if rec.earningsWarning {
|
||||||
@@ -45,35 +46,29 @@ struct RecommendationDetailView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(ticker)
|
.navigationTitle(ticker)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.task { await loadDetail() }
|
.task { await loadSignals() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Sections ─────────────────────────────────────────────────────────────
|
// MARK: - Sections
|
||||||
|
|
||||||
private func headerSection(_ rec: Recommendation) -> some View {
|
private func headerSection(_ rec: Recommendation) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(rec.strategyLabel)
|
Text(rec.strategyLabel).font(.title3.weight(.semibold))
|
||||||
.font(.title3.weight(.semibold))
|
Text(rec.horizonLabel).font(.subheadline).foregroundStyle(.secondary)
|
||||||
Text(rec.horizonLabel)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
VStack(alignment: .trailing, spacing: 4) {
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
SignalBadge(strength: rec.signalStrength)
|
SignalBadge(strength: rec.signalStrength)
|
||||||
Text(String(format: "$%.2f", rec.currentPrice))
|
Text(String(format: "$%.2f", rec.currentPrice))
|
||||||
.font(.caption)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tradeSetupSection(_ rec: Recommendation) -> some View {
|
private func tradeSetupSection(_ rec: Recommendation) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Trade Setup")
|
Text("Trade Setup").font(.headline)
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
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("Expiration", rec.recommendedExpiration)
|
||||||
@@ -85,30 +80,34 @@ struct RecommendationDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func signalDetailSection(_ signals: SignalSnapshot) -> some View {
|
private func signalDetailSection(_ snap: SignalSnapshot) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Market Signals")
|
Text("Market Signals").font(.headline)
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
||||||
metricCard("IV Rank", String(format: "%.0f%%", signals.ivRank), highlight: signals.ivRank >= 50)
|
metricCard("IV Rank", String(format: "%.0f%%", snap.ivRank), highlight: snap.ivRank >= 50)
|
||||||
metricCard("Trend", signals.trend.capitalized)
|
metricCard("Trend", snap.trend.capitalized)
|
||||||
metricCard("SMA-50", String(format: "$%.2f", signals.sma50))
|
metricCard("SMA-50", String(format: "$%.2f", snap.sma50))
|
||||||
metricCard("SMA-200", String(format: "$%.2f", signals.sma200))
|
metricCard("SMA-200", String(format: "$%.2f", snap.sma200))
|
||||||
if let support = signals.nearestSupport {
|
if let support = snap.nearestSupport {
|
||||||
metricCard("Support", String(format: "$%.2f", support))
|
metricCard("Support", String(format: "$%.2f", support))
|
||||||
}
|
}
|
||||||
if let resistance = signals.nearestResistance {
|
if let resistance = snap.nearestResistance {
|
||||||
metricCard("Resistance", String(format: "$%.2f", resistance))
|
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 {
|
private func rationaleSection(_ rec: Recommendation) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Rationale")
|
Text("Rationale").font(.headline)
|
||||||
.font(.headline)
|
|
||||||
Text(rec.rationale)
|
Text(rec.rationale)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -118,32 +117,27 @@ struct RecommendationDetailView: View {
|
|||||||
|
|
||||||
private func earningsWarningBanner(_ rec: Recommendation) -> some View {
|
private func earningsWarningBanner(_ rec: Recommendation) -> some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Constants.Color.warning)
|
||||||
.foregroundStyle(Constants.Color.warning)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Earnings Warning")
|
Text("Earnings Warning").font(.subheadline.weight(.semibold))
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
if let earningsDate = rec.earningsDate {
|
if let earningsDate = rec.earningsDate {
|
||||||
Text("Earnings on \(earningsDate) fall within this expiry window.")
|
Text("Earnings on \(earningsDate) fall within this expiry window.")
|
||||||
.font(.caption)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Constants.Color.warning.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
.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 {
|
private func metricCard(_ label: String, _ value: String, highlight: Bool = false) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(label)
|
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
Text(value).font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(value)
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.foregroundStyle(highlight ? Constants.Color.strong : .primary)
|
.foregroundStyle(highlight ? Constants.Color.strong : .primary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -151,11 +145,16 @@ struct RecommendationDetailView: View {
|
|||||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8))
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadDetail() async {
|
private func loadSignals() async {
|
||||||
isLoading = true
|
isLoadingSignals = true
|
||||||
vm.selectedStrategy = initialRec?.strategy ?? "covered_call"
|
if let history = try? await YahooFinanceClient.shared.priceHistory(ticker: ticker) {
|
||||||
vm.selectedHorizon = initialRec?.timeHorizon ?? "weekly"
|
let earningsDate = try? await YahooFinanceClient.shared.nextEarningsDate(ticker: ticker)
|
||||||
detail = await vm.getDetail(ticker: ticker)
|
signals = SignalEngine.compute(
|
||||||
isLoading = false
|
ticker: ticker,
|
||||||
|
history: history,
|
||||||
|
earningsDate: earningsDate
|
||||||
|
).snapshot
|
||||||
|
}
|
||||||
|
isLoadingSignals = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ struct RecommendationsView: View {
|
|||||||
// ─── Content ──────────────────────────────────────────────
|
// ─── Content ──────────────────────────────────────────────
|
||||||
if vm.isLoading {
|
if vm.isLoading {
|
||||||
LoadingView(message: "Getting recommendations...")
|
LoadingView(message: "Getting recommendations...")
|
||||||
} else if let error = vm.error {
|
|
||||||
ErrorView(message: error) { await vm.load() }
|
|
||||||
} else if vm.filtered.isEmpty {
|
} else if vm.filtered.isEmpty {
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: "chart.xyaxis.line",
|
icon: "chart.xyaxis.line",
|
||||||
@@ -66,7 +64,7 @@ struct RecommendationsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task { await vm.load() }
|
.onAppear { vm.load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func horizonLabel(_ h: String) -> String {
|
private func horizonLabel(_ h: String) -> String {
|
||||||
|
|||||||
Reference in New Issue
Block a user