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:
2026-04-09 16:13:20 -04:00
parent 080f10f1c5
commit 2c595876f1
32 changed files with 1627 additions and 801 deletions

View File

@@ -1,5 +1,5 @@
import UIKit
import UserNotifications
import BackgroundTasks
final class AppDelegate: NSObject, UIApplicationDelegate {
@@ -7,51 +7,26 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// Set up local notification delegate
NotificationHandler.shared.setup()
// Register BGAppRefreshTask before app finishes launching (required by Apple)
BackgroundRefreshManager.shared.registerBackgroundTask()
return true
}
// Called after user grants permission and iOS assigns a device token
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
LocalStore.shared.deviceToken = token
Task {
await registerDevice(token: token)
}
func applicationDidEnterBackground(_ application: UIApplication) {
BackgroundRefreshManager.shared.stopForegroundTimer()
BackgroundRefreshManager.shared.scheduleBackgroundRefresh()
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
print("APNs registration failed: \(error.localizedDescription)")
func applicationWillEnterForeground(_ application: UIApplication) {
BackgroundRefreshManager.shared.startForegroundTimer()
}
// Private
private func registerDevice(token: String) async {
struct DeviceRegisterBody: Encodable {
let apns_token: String
let device_name: String?
}
struct DeviceResponse: Decodable {
let id: Int
}
do {
let body = DeviceRegisterBody(
apns_token: token,
device_name: UIDevice.current.name
)
// Ensure token is stored before the request so APIClient can use it
LocalStore.shared.deviceToken = token
let response: DeviceResponse = try await APIClient.shared.requestNoAuth(
.registerDevice,
body: body
)
LocalStore.shared.deviceId = response.id
print("Device registered with id: \(response.id)")
} catch {
print("Device registration failed: \(error.localizedDescription)")
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Run a signal check immediately when user opens the app
Task { await BackgroundRefreshManager.shared.runSignalCheck() }
}
}

View File

@@ -1,15 +1,7 @@
import SwiftUI
enum Constants {
// API
/// Change this to your deployed Railway/Render URL before shipping.
/// For local dev, use your Mac's LAN IP so the simulator can reach it.
static let baseURL = "http://localhost:8000"
static let apiPrefix = "/api/v1"
static var apiBaseURL: String { baseURL + apiPrefix }
// Colors
// MARK: - Colors
enum Color {
static let strong = SwiftUI.Color.green
static let moderate = SwiftUI.Color.yellow
@@ -19,8 +11,11 @@ enum Constants {
static let warning = SwiftUI.Color.orange
}
// App
// MARK: - App
static let appName = "Options Sidekick"
static let notificationCategory = "POSITION_ALERT"
static let notificationActionView = "VIEW_POSITION"
// MARK: - Background Task
static let bgRefreshIdentifier = BackgroundRefreshManager.taskIdentifier
}

View File

@@ -1,21 +1,14 @@
import Foundation
struct AppAlert: Codable, Identifiable, Hashable {
let id: Int
let id: UUID
let ticker: String
let optionPositionId: Int?
let alertType: String // "close_early" | "roll_out" | "roll_up_down" | "earnings_warning"
let optionPositionId: UUID?
let alertType: String // "close_early" | "roll_out" | "earnings_warning" | "new_rec"
let message: String
let sentAt: Date
var acknowledged: Bool
enum CodingKeys: String, CodingKey {
case id, ticker, message, acknowledged
case optionPositionId = "option_position_id"
case alertType = "alert_type"
case sentAt = "sent_at"
}
var typeLabel: String {
switch alertType {
case "close_early": return "Close Early"

View File

@@ -1,27 +1,18 @@
import Foundation
struct OptionPosition: Codable, Identifiable, Hashable {
let id: Int
let id: UUID
let ticker: String
let strategy: String // "covered_call" | "cash_secured_put"
let strike: Double
let expiration: String // ISO date string "YYYY-MM-DD"
let expiration: String // ISO date "YYYY-MM-DD"
let premiumReceived: Double
let contracts: Int
let status: String // "open" | "closed" | "rolled"
let closeReason: String?
var status: String // "open" | "closed" | "rolled"
var closeReason: String?
let openedAt: Date
let closedAt: Date?
let lastSignalHash: String?
enum CodingKeys: String, CodingKey {
case id, ticker, strategy, strike, expiration, contracts, status
case premiumReceived = "premium_received"
case closeReason = "close_reason"
case openedAt = "opened_at"
case closedAt = "closed_at"
case lastSignalHash = "last_signal_hash"
}
var closedAt: Date?
var lastSignalHash: String?
var strategyLabel: String {
strategy == "covered_call" ? "Covered Call" : "Cash-Secured Put"
@@ -35,35 +26,19 @@ struct OptionPosition: Codable, Identifiable, Hashable {
var daysToExpiry: Int? {
guard let exp = expirationDate else { return nil }
let days = Calendar.current.dateComponents([.day], from: Date(), to: exp).day
return days
return Calendar.current.dateComponents([.day], from: Date(), to: exp).day
}
var totalCredit: Double {
premiumReceived * Double(contracts) * 100
}
var totalCredit: Double { premiumReceived * Double(contracts) * 100 }
}
struct OptionPositionCreate: Codable {
// MARK: - Create helpers (used by LogTradeSheet)
struct OptionPositionCreate {
let ticker: String
let strategy: String
let strike: Double
let expiration: String
let premiumReceived: Double
let contracts: Int
enum CodingKeys: String, CodingKey {
case ticker, strategy, strike, expiration, contracts
case premiumReceived = "premium_received"
}
}
struct OptionPositionClose: Codable {
let status: String
let closeReason: String?
enum CodingKeys: String, CodingKey {
case status
case closeReason = "close_reason"
}
}

View File

@@ -1,15 +1,9 @@
import Foundation
struct PortfolioPosition: Codable, Identifiable, Hashable {
let id: Int
let id: UUID
let ticker: String
let shares: Int
let costBasis: Double?
let createdAt: Date
enum CodingKeys: String, CodingKey {
case id, ticker, shares
case costBasis = "cost_basis"
case createdAt = "created_at"
}
}

View File

@@ -1,13 +1,13 @@
import Foundation
struct Recommendation: Codable, Identifiable, Hashable {
let id: Int
let id: UUID
let ticker: String
let strategy: String
let timeHorizon: String
let currentPrice: Double
let recommendedStrike: Double
let recommendedExpiration: String // ISO date "YYYY-MM-DD"
let recommendedExpiration: String // "YYYY-MM-DD"
let estimatedPremium: Double
let delta: Double
let theta: Double
@@ -19,22 +19,6 @@ struct Recommendation: Codable, Identifiable, Hashable {
let signalHash: String
let createdAt: Date
enum CodingKeys: String, CodingKey {
case id, ticker, strategy, rationale
case timeHorizon = "time_horizon"
case currentPrice = "current_price"
case recommendedStrike = "recommended_strike"
case recommendedExpiration = "recommended_expiration"
case estimatedPremium = "estimated_premium"
case delta, theta
case ivRank = "iv_rank"
case signalStrength = "signal_strength"
case earningsWarning = "earnings_warning"
case earningsDate = "earnings_date"
case signalHash = "signal_hash"
case createdAt = "created_at"
}
var strategyLabel: String {
strategy == "covered_call" ? "Covered Call" : "Cash-Secured Put"
}
@@ -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? {
let fmt = DateFormatter()
fmt.dateFormat = "yyyy-MM-dd"
return fmt.date(from: recommendedExpiration)
}
var annualizedPremiumPct: Double {
guard currentPrice > 0 else { return 0 }
let dte = expirationDate.map {
Calendar.current.dateComponents([.day], from: Date(), to: $0).day ?? 30
} ?? 30
return (estimatedPremium / currentPrice) * (365.0 / max(1, Double(dte))) * 100
}
}
struct SignalSnapshot: Codable {
// MARK: - Signal snapshot (on-device, not Codable-over-network)
struct SignalSnapshotView {
let ticker: String
let currentPrice: Double
let ivRank: Double
@@ -72,24 +59,6 @@ struct SignalSnapshot: Codable {
let nearestSupport: Double?
let nearestResistance: Double?
let trend: String
let earningsDate: String?
let earningsDate: Date?
let computedAt: Date
enum CodingKeys: String, CodingKey {
case ticker
case currentPrice = "current_price"
case ivRank = "iv_rank"
case sma50 = "sma_50"
case sma200 = "sma_200"
case nearestSupport = "nearest_support"
case nearestResistance = "nearest_resistance"
case trend
case earningsDate = "earnings_date"
case computedAt = "computed_at"
}
}
struct RecommendationWithSignals: Codable {
let recommendation: Recommendation
let signals: SignalSnapshot
}

View File

@@ -1,157 +1,4 @@
// APIClient.swift no longer used (backend removed; all data is on-device)
// File retained to avoid Xcode "file not in project" warnings if still referenced
// in any build phase. Safe to delete from the Xcode project target.
import Foundation
/// Central networking client.
/// Adds X-Device-Token header automatically from LocalStore.
/// All requests are async/await.
final class APIClient {
static let shared = APIClient()
private init() {}
private let session: URLSession = {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 30
return URLSession(configuration: config)
}()
private var decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let str = try container.decode(String.self)
let formatters: [ISO8601DateFormatter] = [
{
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}(),
{
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}(),
]
for fmt in formatters {
if let date = fmt.date(from: str) { return date }
}
let df = DateFormatter()
df.dateFormat = "yyyy-MM-dd"
if let date = df.date(from: str) { return date }
throw DecodingError.dataCorrupted(
.init(codingPath: decoder.codingPath,
debugDescription: "Cannot parse date: \(str)"))
}
return d
}()
private var encoder: JSONEncoder = {
let e = JSONEncoder()
e.dateEncodingStrategy = .iso8601
return e
}()
// Core request builder
func request<T: Decodable>(
_ endpoint: Endpoint,
body: (any Encodable)? = nil
) async throws -> T {
guard let token = LocalStore.shared.deviceToken else {
throw APIError.noDeviceToken
}
let req = try buildRequest(endpoint, deviceToken: token, body: body)
let (data, response) = try await session.data(for: req)
try validateResponse(response, data: data)
return try decoder.decode(T.self, from: data)
}
/// Version that doesn't return a body (e.g. DELETE 204)
func requestVoid(_ endpoint: Endpoint, body: (any Encodable)? = nil) async throws {
guard let token = LocalStore.shared.deviceToken else {
throw APIError.noDeviceToken
}
let req = try buildRequest(endpoint, deviceToken: token, body: body)
let (data, response) = try await session.data(for: req)
try validateResponse(response, data: data)
}
/// For device registration (no token yet)
func requestNoAuth<T: Decodable>(
_ endpoint: Endpoint,
body: (any Encodable)? = nil
) async throws -> T {
let req = try buildRequest(endpoint, deviceToken: nil, body: body)
let (data, response) = try await session.data(for: req)
try validateResponse(response, data: data)
return try decoder.decode(T.self, from: data)
}
// Helpers
private func buildRequest(
_ endpoint: Endpoint,
deviceToken: String?,
body: (any Encodable)?
) throws -> URLRequest {
guard let url = URL(string: Constants.apiBaseURL + endpoint.path) else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = endpoint.method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = deviceToken {
request.setValue(token, forHTTPHeaderField: "X-Device-Token")
}
if let body {
request.httpBody = try encodeAny(body)
}
return request
}
/// Opens the `any Encodable` existential so JSONEncoder can encode it.
private func encodeAny(_ value: any Encodable) throws -> Data {
func encode<T: Encodable>(_ v: T) throws -> Data {
try encoder.encode(v)
}
return try encode(value)
}
private func validateResponse(_ response: URLResponse, data: Data) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
switch httpResponse.statusCode {
case 200...299:
break
case 404:
throw APIError.notFound
case 503:
throw APIError.serviceUnavailable
default:
let message = String(data: data, encoding: .utf8) ?? "Unknown error"
throw APIError.serverError(httpResponse.statusCode, message)
}
}
}
// Supporting types
enum APIError: LocalizedError {
case noDeviceToken
case invalidURL
case invalidResponse
case notFound
case serviceUnavailable
case serverError(Int, String)
var errorDescription: String? {
switch self {
case .noDeviceToken: return "Device not registered. Please restart the app."
case .invalidURL: return "Invalid API URL."
case .invalidResponse: return "Invalid response from server."
case .notFound: return "Resource not found."
case .serviceUnavailable: return "Market data unavailable. Try again shortly."
case .serverError(let code, let msg): return "Server error \(code): \(msg)"
}
}
}

View File

@@ -1,53 +1,4 @@
// Endpoints.swift no longer used (backend removed; all data is on-device)
// File retained to avoid Xcode "file not in project" warnings.
// Safe to delete from the Xcode project target.
import Foundation
struct Endpoint {
let path: String
let method: String
// Devices
static let registerDevice = Endpoint(path: "/devices/register", method: "POST")
// Portfolio
static let getPortfolio = Endpoint(path: "/portfolio", method: "GET")
static let setPortfolio = Endpoint(path: "/portfolio", method: "POST")
static func deleteTicker(_ ticker: String) -> Endpoint {
Endpoint(path: "/portfolio/\(ticker)", method: "DELETE")
}
// Recommendations
static func getRecommendations(timeHorizon: String? = nil) -> Endpoint {
let query = timeHorizon.map { "?time_horizon=\($0)" } ?? ""
return Endpoint(path: "/recommendations\(query)", method: "GET")
}
static func getRecommendation(ticker: String, strategy: String, timeHorizon: String) -> Endpoint {
Endpoint(path: "/recommendations/\(ticker)?strategy=\(strategy)&time_horizon=\(timeHorizon)", method: "GET")
}
static let refreshRecommendations = Endpoint(path: "/recommendations/refresh", method: "POST")
// Positions
static func getPositions(status: String? = nil) -> Endpoint {
let query = status.map { "?status=\($0)" } ?? ""
return Endpoint(path: "/positions\(query)", method: "GET")
}
static let logPosition = Endpoint(path: "/positions", method: "POST")
static func closePosition(_ id: Int) -> Endpoint {
Endpoint(path: "/positions/\(id)", method: "PATCH")
}
// Signals
static func getSignals(_ ticker: String) -> Endpoint {
Endpoint(path: "/signals/\(ticker)", method: "GET")
}
// Alerts
static func getAlerts(unreadOnly: Bool = false) -> Endpoint {
let query = unreadOnly ? "?unread_only=true" : ""
return Endpoint(path: "/alerts\(query)", method: "GET")
}
static func acknowledgeAlert(_ id: Int) -> Endpoint {
Endpoint(path: "/alerts/\(id)/acknowledge", method: "PATCH")
}
// Health
static let health = Endpoint(path: "/health", method: "GET")
}

View File

@@ -2,46 +2,43 @@ import Foundation
import Combine
import UserNotifications
/// Handles incoming push notifications both foreground and background tap.
/// Receives local notifications both foreground display and tap handling.
@MainActor
final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
static let shared = NotificationHandler()
/// Published so views can react to a deep-link navigation request.
@Published var navigateToPositionId: Int? = nil
@Published var navigateToPositionId: UUID? = nil
@Published var inAppAlertMessage: String? = nil
private override init() {
super.init()
}
private override init() { super.init() }
func setup() {
UNUserNotificationCenter.current().delegate = self
}
// Foreground notification
// MARK: - Foreground notification
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
let userInfo = notification.request.content.userInfo
if let message = notification.request.content.body as String? {
inAppAlertMessage = message
}
// Still show the banner even when foregrounded so user doesn't miss it
inAppAlertMessage = notification.request.content.body
completionHandler([.banner, .sound, .badge])
}
// Background tap / action tap
// MARK: - Tap / action handler
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
if let positionId = userInfo["position_id"] as? Int {
navigateToPositionId = positionId
if let idStr = userInfo["position_id"] as? String,
let uuid = UUID(uuidString: idStr) {
navigateToPositionId = uuid
}
completionHandler()
}

View File

@@ -1,5 +1,4 @@
import Foundation
import UIKit
import UserNotifications
@MainActor
@@ -13,7 +12,7 @@ final class NotificationPermissions {
let center = UNUserNotificationCenter.current()
// Register a custom category with a "View" action for deep linking
// Register a category with a "View" action for deep-linking
let viewAction = UNNotificationAction(
identifier: Constants.notificationActionView,
title: "View Position",
@@ -28,14 +27,9 @@ final class NotificationPermissions {
center.setNotificationCategories([category])
do {
let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])
if granted {
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
try await center.requestAuthorization(options: [.alert, .badge, .sound])
} catch {
print("Notification permission error: \(error)")
print("[Notifications] Permission request error: \(error)")
}
}
}

View File

@@ -1,38 +1,16 @@
import Foundation
/// Lightweight UserDefaults wrapper for device-local state.
/// Lightweight UserDefaults wrapper for small non-structural app state.
final class LocalStore {
static let shared = LocalStore()
private init() {}
private let defaults = UserDefaults.standard
// APNs device token
var deviceToken: String? {
get { defaults.string(forKey: "apns_device_token") }
set { defaults.set(newValue, forKey: "apns_device_token") }
}
var deviceId: Int? {
get {
let v = defaults.integer(forKey: "device_id")
return v == 0 ? nil : v
}
set { defaults.set(newValue, forKey: "device_id") }
}
// Notification permission
// MARK: - Notification permission
var notificationPermissionRequested: Bool {
get { defaults.bool(forKey: "notification_permission_requested") }
set { defaults.set(newValue, forKey: "notification_permission_requested") }
}
// Unread alert badge count
var unreadAlertCount: Int {
get { defaults.integer(forKey: "unread_alert_count") }
set { defaults.set(newValue, forKey: "unread_alert_count") }
}
}

View File

@@ -34,14 +34,37 @@
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<!-- Allow Yahoo Finance unofficial HTTPS endpoints -->
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSExceptionDomains</key>
<dict>
<key>finance.yahoo.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<false/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>query1.finance.yahoo.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<false/>
<key>NSIncludesSubdomains</key>
<false/>
</dict>
</dict>
</dict>
<!-- Background App Refresh -->
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
<string>fetch</string>
<string>processing</string>
</array>
<!-- BGTaskScheduler task identifiers -->
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.optionssidekick.refresh</string>
</array>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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.
}
}
}

View File

@@ -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.150.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.100.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: " ")
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -7,41 +7,28 @@ final class AlertsViewModel: ObservableObject {
@Published var isLoading = false
@Published var error: String? = nil
private var cancellables = Set<AnyCancellable>()
var unreadCount: Int { alerts.filter { !$0.acknowledged }.count }
func load(unreadOnly: Bool = false) async {
isLoading = true
error = nil
do {
alerts = try await APIClient.shared.request(
.getAlerts(unreadOnly: unreadOnly),
body: nil
)
LocalStore.shared.unreadAlertCount = unreadCount
} catch {
self.error = error.localizedDescription
}
isLoading = false
init() {
DataStore.shared.$alerts
.receive(on: DispatchQueue.main)
.assign(to: \.alerts, on: self)
.store(in: &cancellables)
}
func acknowledge(_ alert: AppAlert) async {
do {
let updated: AppAlert = try await APIClient.shared.request(
.acknowledgeAlert(alert.id),
body: nil
)
if let idx = alerts.firstIndex(where: { $0.id == alert.id }) {
alerts[idx] = updated
}
LocalStore.shared.unreadAlertCount = unreadCount
} catch {
self.error = error.localizedDescription
}
func load() {
alerts = DataStore.shared.alerts
}
func acknowledgeAll() async {
for alert in alerts where !alert.acknowledged {
await acknowledge(alert)
func acknowledge(_ alert: AppAlert) {
DataStore.shared.acknowledgeAlert(id: alert.id)
Task { await NotificationService.setBadge(DataStore.shared.unreadAlertCount) }
}
func acknowledgeAll() {
DataStore.shared.acknowledgeAllAlerts()
Task { await NotificationService.setBadge(0) }
}
}

View File

@@ -8,11 +8,14 @@ final class DashboardViewModel: ObservableObject {
@Published var recommendations: [Recommendation] = []
@Published var unreadAlerts: [AppAlert] = []
@Published var isLoading = false
@Published var isRefreshing = false
@Published var error: String? = nil
private var cancellables = Set<AnyCancellable>()
var urgentAlerts: [AppAlert] { unreadAlerts.filter { $0.isUrgent } }
/// Top-level recommendation per ticker (best signal strength)
/// Best recommendation per ticker (highest signal strength).
var topRecommendations: [Recommendation] {
let order = ["strong": 0, "moderate": 1, "weak": 2]
var best: [String: Recommendation] = [:]
@@ -25,38 +28,46 @@ final class DashboardViewModel: ObservableObject {
return Array(best.values).sorted { $0.ticker < $1.ticker }
}
func loadAll() async {
isLoading = true
error = nil
init() {
let store = DataStore.shared
store.$portfolio
.map { $0 }
.receive(on: DispatchQueue.main)
.assign(to: \.stockPositions, on: self)
.store(in: &cancellables)
async let stocks: [PortfolioPosition] = loadStocks()
async let options: [OptionPosition] = loadOptions()
async let recs: [Recommendation] = loadRecommendations()
async let alerts: [AppAlert] = loadAlerts()
store.$positions
.map { $0.filter { $0.status == "open" } }
.receive(on: DispatchQueue.main)
.assign(to: \.openOptionPositions, on: self)
.store(in: &cancellables)
(stockPositions, openOptionPositions, recommendations, unreadAlerts) = await (stocks, options, recs, alerts)
isLoading = false
store.$recommendations
.receive(on: DispatchQueue.main)
.assign(to: \.recommendations, on: self)
.store(in: &cancellables)
store.$alerts
.map { $0.filter { !$0.acknowledged } }
.receive(on: DispatchQueue.main)
.assign(to: \.unreadAlerts, on: self)
.store(in: &cancellables)
}
func loadAll() {
let store = DataStore.shared
stockPositions = store.portfolio
openOptionPositions = store.openPositions
recommendations = store.recommendations
unreadAlerts = store.alerts.filter { !$0.acknowledged }
}
/// Refresh: re-run signal check and rebuild recommendations in the background.
func refresh() async {
await loadAll()
}
// Private loaders
private func loadStocks() async -> [PortfolioPosition] {
(try? await APIClient.shared.request(.getPortfolio, body: nil)) ?? []
}
private func loadOptions() async -> [OptionPosition] {
(try? await APIClient.shared.request(.getPositions(status: "open"), body: nil)) ?? []
}
private func loadRecommendations() async -> [Recommendation] {
(try? await APIClient.shared.request(.getRecommendations(timeHorizon: nil), body: nil)) ?? []
}
private func loadAlerts() async -> [AppAlert] {
(try? await APIClient.shared.request(.getAlerts(unreadOnly: true), body: nil)) ?? []
isRefreshing = true
await BackgroundRefreshManager.shared.runSignalCheck()
// Recommendations are rebuilt by RecommendationsViewModel; here we just update the signal state
isRefreshing = false
loadAll()
}
}

View File

@@ -7,62 +7,25 @@ final class PortfolioViewModel: ObservableObject {
@Published var isLoading = false
@Published var error: String? = nil
func load() async {
isLoading = true
error = nil
do {
positions = try await APIClient.shared.request(.getPortfolio, body: nil)
} catch {
self.error = error.localizedDescription
}
isLoading = false
private var cancellables = Set<AnyCancellable>()
init() {
// Mirror DataStore so views just observe this VM
DataStore.shared.$portfolio
.receive(on: DispatchQueue.main)
.assign(to: \.positions, on: self)
.store(in: &cancellables)
}
func save(_ newPositions: [PortfolioPosition]) async {
isLoading = true
error = nil
struct PositionBody: Encodable {
let ticker: String
let shares: Int
let cost_basis: Double?
}
let body = newPositions.map { PositionBody(ticker: $0.ticker, shares: $0.shares, cost_basis: $0.costBasis) }
do {
positions = try await APIClient.shared.request(.setPortfolio, body: body)
} catch {
self.error = error.localizedDescription
}
isLoading = false
func load() {
positions = DataStore.shared.portfolio
}
func add(ticker: String, shares: Int, costBasis: Double?) async {
let updated = positions
struct AddBody: Encodable {
let ticker: String
let shares: Int
let cost_basis: Double?
}
let allBody = updated.map { AddBody(ticker: $0.ticker, shares: $0.shares, cost_basis: $0.costBasis) }
+ [AddBody(ticker: ticker.uppercased(), shares: shares, cost_basis: costBasis)]
isLoading = true
error = nil
do {
positions = try await APIClient.shared.request(.setPortfolio, body: allBody)
} catch {
self.error = error.localizedDescription
}
isLoading = false
func add(ticker: String, shares: Int, costBasis: Double?) {
DataStore.shared.upsertPortfolioPosition(ticker: ticker, shares: shares, costBasis: costBasis)
}
func delete(ticker: String) async {
isLoading = true
error = nil
do {
try await APIClient.shared.requestVoid(.deleteTicker(ticker), body: nil)
positions.removeAll { $0.ticker == ticker }
} catch {
self.error = error.localizedDescription
}
isLoading = false
func delete(id: UUID) {
DataStore.shared.removePortfolioPosition(id: id)
}
}

View File

@@ -7,71 +7,39 @@ final class PositionsViewModel: ObservableObject {
@Published var isLoading = false
@Published var error: String? = nil
private var cancellables = Set<AnyCancellable>()
var openPositions: [OptionPosition] { positions.filter { $0.status == "open" } }
var closedPositions: [OptionPosition] { positions.filter { $0.status != "open" } }
func load() async {
isLoading = true
error = nil
do {
positions = try await APIClient.shared.request(
.getPositions(status: nil),
body: nil
)
} catch {
self.error = error.localizedDescription
}
isLoading = false
init() {
DataStore.shared.$positions
.receive(on: DispatchQueue.main)
.assign(to: \.positions, on: self)
.store(in: &cancellables)
}
func log(create: OptionPositionCreate) async -> Bool {
isLoading = true
error = nil
do {
let new: OptionPosition = try await APIClient.shared.request(.logPosition, body: create)
positions.insert(new, at: 0)
isLoading = false
func load() {
positions = DataStore.shared.positions
}
func log(create: OptionPositionCreate) -> Bool {
DataStore.shared.logPosition(
ticker: create.ticker,
strategy: create.strategy,
strike: create.strike,
expiration: create.expiration,
premiumReceived: create.premiumReceived,
contracts: create.contracts
)
return true
} catch {
self.error = error.localizedDescription
isLoading = false
return false
}
}
func close(position: OptionPosition, reason: String) async {
isLoading = true
error = nil
let body = OptionPositionClose(status: "closed", closeReason: reason)
do {
let updated: OptionPosition = try await APIClient.shared.request(
.closePosition(position.id),
body: body
)
if let idx = positions.firstIndex(where: { $0.id == position.id }) {
positions[idx] = updated
}
} catch {
self.error = error.localizedDescription
}
isLoading = false
func close(position: OptionPosition, reason: String) {
DataStore.shared.closePosition(id: position.id, reason: reason)
}
func roll(position: OptionPosition) async {
let body = OptionPositionClose(status: "rolled", closeReason: "rolled")
isLoading = true
error = nil
do {
let updated: OptionPosition = try await APIClient.shared.request(
.closePosition(position.id),
body: body
)
if let idx = positions.firstIndex(where: { $0.id == position.id }) {
positions[idx] = updated
}
} catch {
self.error = error.localizedDescription
}
isLoading = false
func roll(position: OptionPosition) {
DataStore.shared.rollPosition(id: position.id)
}
}

View File

@@ -7,52 +7,86 @@ final class RecommendationsViewModel: ObservableObject {
@Published var isLoading = false
@Published var isRefreshing = false
@Published var error: String? = nil
@Published var selectedHorizon: String = "weekly"
@Published var selectedHorizon: String = TimeHorizon.weekly.rawValue
@Published var selectedStrategy: String = "covered_call"
private var cancellables = Set<AnyCancellable>()
var filtered: [Recommendation] {
recommendations.filter {
$0.timeHorizon == selectedHorizon && $0.strategy == selectedStrategy
}
}
func load() async {
isLoading = true
error = nil
do {
recommendations = try await APIClient.shared.request(
.getRecommendations(timeHorizon: nil),
body: nil
)
} catch {
self.error = error.localizedDescription
}
isLoading = false
init() {
DataStore.shared.$recommendations
.receive(on: DispatchQueue.main)
.assign(to: \.recommendations, on: self)
.store(in: &cancellables)
}
func load() {
recommendations = DataStore.shared.recommendations
}
/// Fetches fresh market data for every ticker in the portfolio and rebuilds recommendations.
func refresh() async {
isRefreshing = true
error = nil
do {
recommendations = try await APIClient.shared.request(
.refreshRecommendations,
body: nil
)
} catch {
self.error = error.localizedDescription
let tickers = DataStore.shared.tickers
guard !tickers.isEmpty else {
isRefreshing = false
return
}
var freshRecs: [Recommendation] = []
let yfClient = YahooFinanceClient.shared
for ticker in tickers {
guard let history = try? await yfClient.priceHistory(ticker: ticker) else { continue }
let earningsDate = try? await yfClient.nextEarningsDate(ticker: ticker)
let signal = SignalEngine.compute(ticker: ticker, history: history, earningsDate: earningsDate)
guard let expirations = try? await yfClient.expirations(ticker: ticker),
!expirations.isEmpty else { continue }
for horizon in TimeHorizon.allCases {
// 0DTE only makes sense during market hours on a trading day
if horizon == .zeroDTE {
guard let _ = expirations.first(where: {
Calendar.current.isDate($0, inSameDayAs: Date())
}) else { continue }
}
guard let expDate = RecommendationEngine.expiration(for: horizon, available: expirations),
let chain = try? await yfClient.optionChain(ticker: ticker, expiration: expDate)
else { continue }
for strategy in ["covered_call", "cash_secured_put"] {
if let rec = RecommendationEngine.recommend(
ticker: ticker,
strategy: strategy,
horizon: horizon,
history: history,
signal: signal,
chain: chain,
expiration: expDate
) {
freshRecs.append(rec)
}
}
}
}
DataStore.shared.replaceRecommendations(freshRecs)
isRefreshing = false
}
func getDetail(ticker: String) async -> RecommendationWithSignals? {
do {
return try await APIClient.shared.request(
.getRecommendation(ticker: ticker, strategy: selectedStrategy, timeHorizon: selectedHorizon),
body: nil
)
} catch {
self.error = error.localizedDescription
return nil
}
/// Returns the SignalSnapshot for a ticker (re-computed on demand).
func signalSnapshot(for ticker: String) async -> SignalSnapshot? {
guard let history = try? await YahooFinanceClient.shared.priceHistory(ticker: ticker) else { return nil }
let earningsDate = try? await YahooFinanceClient.shared.nextEarningsDate(ticker: ticker)
return SignalEngine.compute(ticker: ticker, history: history, earningsDate: earningsDate).snapshot
}
}

View File

@@ -6,22 +6,19 @@ struct AlertsView: View {
var body: some View {
NavigationStack {
Group {
if vm.isLoading && vm.alerts.isEmpty {
LoadingView(message: "Loading alerts...")
} else if vm.alerts.isEmpty {
if vm.alerts.isEmpty {
EmptyStateView(
icon: "bell.slash",
title: "No alerts",
subtitle: "Alerts appear here when signal changes on your open positions."
subtitle: "Alerts appear here when signals change on your open positions."
)
} else {
List(vm.alerts) { alert in
AlertRowView(alert: alert) {
Task { await vm.acknowledge(alert) }
vm.acknowledge(alert)
}
}
.listStyle(.insetGrouped)
.refreshable { await vm.load() }
}
}
.navigationTitle("Alerts")
@@ -29,18 +26,17 @@ struct AlertsView: View {
if vm.unreadCount > 0 {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Mark All Read") {
Task { await vm.acknowledgeAll() }
vm.acknowledgeAll()
}
.font(.caption)
}
}
}
}
.task { await vm.load() }
}
}
// Row
// MARK: - Row
struct AlertRowView: View {
let alert: AppAlert
@@ -56,8 +52,7 @@ struct AlertRowView: View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(alert.ticker)
.font(.headline)
Text(alert.ticker).font(.headline)
AlertTypeBadge(alertType: alert.alertType)
}
Text(alert.message)

View File

@@ -2,40 +2,30 @@ import SwiftUI
struct ContentView: View {
@EnvironmentObject private var notificationHandler: NotificationHandler
@StateObject private var store = DataStore.shared
@State private var selectedTab = 0
@State private var navigateToPositionId: Int? = nil
var body: some View {
TabView(selection: $selectedTab) {
DashboardView()
.tabItem {
Label("Dashboard", systemImage: "house.fill")
}
.tabItem { Label("Dashboard", systemImage: "house.fill") }
.tag(0)
RecommendationsView()
.tabItem {
Label("Setups", systemImage: "lightbulb.fill")
}
.tabItem { Label("Setups", systemImage: "lightbulb.fill") }
.tag(1)
OpenPositionsView()
.tabItem {
Label("Trades", systemImage: "doc.text.fill")
}
.tabItem { Label("Trades", systemImage: "doc.text.fill") }
.tag(2)
PortfolioView()
.tabItem {
Label("Portfolio", systemImage: "briefcase.fill")
}
.tabItem { Label("Portfolio", systemImage: "briefcase.fill") }
.tag(3)
AlertsView()
.tabItem {
Label("Alerts", systemImage: "bell.fill")
}
.badge(LocalStore.shared.unreadAlertCount > 0 ? "\(LocalStore.shared.unreadAlertCount)" : nil)
.tabItem { Label("Alerts", systemImage: "bell.fill") }
.badge(store.unreadAlertCount > 0 ? "\(store.unreadAlertCount)" : nil)
.tag(4)
}
.task {
@@ -43,9 +33,8 @@ struct ContentView: View {
}
.onChange(of: notificationHandler.navigateToPositionId) { _, posId in
if posId != nil {
// Deep link: switch to Trades tab
// Deep link from local notification tap go to Trades tab
selectedTab = 2
navigateToPositionId = posId
}
}
}

View File

@@ -7,8 +7,8 @@ struct DashboardView: View {
var body: some View {
NavigationStack {
Group {
if vm.isLoading && vm.stockPositions.isEmpty {
LoadingView(message: "Loading your positions...")
if vm.isRefreshing && vm.stockPositions.isEmpty {
LoadingView(message: "Fetching signals...")
} else {
ScrollView {
LazyVStack(spacing: 0) {
@@ -47,7 +47,7 @@ struct DashboardView: View {
}
}
}
.task { await vm.loadAll() }
.onAppear { vm.loadAll() }
.onChange(of: notificationHandler.inAppAlertMessage) { _, msg in
if msg != nil { Task { await vm.refresh() } }
}

View File

@@ -7,11 +7,7 @@ struct PortfolioView: View {
var body: some View {
NavigationStack {
Group {
if vm.isLoading && vm.positions.isEmpty {
LoadingView(message: "Loading portfolio...")
} else if let error = vm.error {
ErrorView(message: error) { await vm.load() }
} else if vm.positions.isEmpty {
if vm.positions.isEmpty {
EmptyStateView(
icon: "briefcase",
title: "No stocks yet",
@@ -23,10 +19,8 @@ struct PortfolioView: View {
PortfolioRowView(position: position)
}
.onDelete { indexSet in
Task {
for i in indexSet {
await vm.delete(ticker: vm.positions[i].ticker)
}
vm.delete(id: vm.positions[i].id)
}
}
}
@@ -47,11 +41,10 @@ struct PortfolioView: View {
AddPositionSheet(vm: vm)
}
}
.task { await vm.load() }
}
}
// Row
// MARK: - Row
struct PortfolioRowView: View {
let position: PortfolioPosition
@@ -80,7 +73,7 @@ struct PortfolioRowView: View {
}
}
// Add Sheet
// MARK: - Add Sheet
struct AddPositionSheet: View {
@ObservedObject var vm: PortfolioViewModel
@@ -136,11 +129,9 @@ struct AddPositionSheet: View {
}
ToolbarItem(placement: .confirmationAction) {
Button("Add") {
Task {
await vm.add(ticker: ticker, shares: sharesInt!, costBasis: costDouble)
vm.add(ticker: ticker, shares: sharesInt!, costBasis: costDouble)
dismiss()
}
}
.disabled(!isValid)
}
}

View File

@@ -82,7 +82,6 @@ struct LogTradeSheet: View {
ToolbarItem(placement: .confirmationAction) {
Button("Log") {
guard isValid else { return }
Task {
let create = OptionPositionCreate(
ticker: ticker.uppercased(),
strategy: strategy,
@@ -91,9 +90,7 @@ struct LogTradeSheet: View {
premiumReceived: premium!,
contracts: contracts
)
let success = await vm.log(create: create)
if success { dismiss() }
}
if vm.log(create: create) { dismiss() }
}
.disabled(!isValid)
}

View File

@@ -7,11 +7,7 @@ struct OpenPositionsView: View {
var body: some View {
NavigationStack {
Group {
if vm.isLoading && vm.positions.isEmpty {
LoadingView(message: "Loading positions...")
} else if let error = vm.error {
ErrorView(message: error) { await vm.load() }
} else if vm.positions.isEmpty {
if vm.positions.isEmpty {
EmptyStateView(
icon: "doc.text",
title: "No logged trades",
@@ -38,7 +34,6 @@ struct OpenPositionsView: View {
}
}
.listStyle(.insetGrouped)
.refreshable { await vm.load() }
}
}
.navigationTitle("My Trades")
@@ -55,11 +50,10 @@ struct OpenPositionsView: View {
LogTradeSheet(vm: vm)
}
}
.task { await vm.load() }
}
}
// Row
// MARK: - Row
struct LoggedPositionRow: View {
let position: OptionPosition
@@ -94,8 +88,7 @@ struct LoggedPositionRow: View {
Text(String(format: "$%.2f", position.premiumReceived))
.font(.subheadline.weight(.semibold))
Text(String(format: "×%d", position.contracts))
.font(.caption)
.foregroundStyle(.secondary)
.font(.caption).foregroundStyle(.secondary)
}
}
.padding(.vertical, 3)

View File

@@ -3,7 +3,6 @@ import SwiftUI
struct PositionDetailView: View {
let position: OptionPosition
/// Pass in from parent (OpenPositionsView) so close/roll updates the parent list.
/// If nil, a local vm is used (e.g. when navigating from DashboardView).
var parentVM: PositionsViewModel? = nil
@StateObject private var localVM = PositionsViewModel()
@State private var signals: SignalSnapshot? = nil
@@ -14,6 +13,12 @@ struct PositionDetailView: View {
private var vm: PositionsViewModel { parentVM ?? localVM }
private let earningsDateFmt: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
return f
}()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
@@ -47,23 +52,25 @@ struct PositionDetailView: View {
.task { await loadSignals() }
.confirmationDialog("Close Position", isPresented: $showCloseConfirm) {
Button("Bought Back", role: .destructive) {
Task { await vm.close(position: position, reason: "bought_back"); dismiss() }
vm.close(position: position, reason: "bought_back")
dismiss()
}
Button("Expired Worthless") {
Task { await vm.close(position: position, reason: "expired"); dismiss() }
vm.close(position: position, reason: "expired")
dismiss()
}
}
.confirmationDialog("Roll Position", isPresented: $showRollConfirm) {
Button("Mark as Rolled", role: .destructive) {
Task { await vm.roll(position: position); dismiss() }
vm.roll(position: position)
dismiss()
}
}
}
// Sections
// MARK: - Sections
private var summarySection: some View {
VStack(alignment: .leading, spacing: 10) {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
metricCard("Strike", String(format: "$%.2f", position.strike))
metricCard("Expiration", position.expiration)
@@ -75,15 +82,15 @@ struct PositionDetailView: View {
}
}
}
}
private func signalsSection(_ snap: SignalSnapshot) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Current Signals")
.font(.headline)
Text("Current Signals").font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
metricCard("IV Rank", String(format: "%.0f%%", snap.ivRank), highlight: snap.ivRank >= 50)
metricCard("Trend", snap.trend.capitalized)
metricCard("SMA-50", String(format: "$%.2f", snap.sma50))
metricCard("SMA-200", String(format: "$%.2f", snap.sma200))
if let support = snap.nearestSupport {
metricCard("Support", String(format: "$%.2f", support))
}
@@ -95,7 +102,7 @@ struct PositionDetailView: View {
HStack(spacing: 6) {
Image(systemName: "calendar.badge.exclamationmark")
.foregroundStyle(Constants.Color.warning)
Text("Earnings: \(earnings)")
Text("Earnings: \(earningsDateFmt.string(from: earnings))")
.font(.caption)
.foregroundStyle(Constants.Color.warning)
}
@@ -125,15 +132,12 @@ struct PositionDetailView: View {
}
}
// Helpers
// MARK: - Helpers
private func metricCard(_ label: String, _ value: String, highlight: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
Text(label).font(.caption).foregroundStyle(.secondary)
Text(value).font(.subheadline.weight(.semibold))
.foregroundStyle(highlight ? Constants.Color.strong : .primary)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -143,13 +147,13 @@ struct PositionDetailView: View {
private func loadSignals() async {
isLoadingSignals = true
do {
signals = try await APIClient.shared.request(
.getSignals(position.ticker),
body: nil
)
} catch {
// Non-critical just don't show signals
if let history = try? await YahooFinanceClient.shared.priceHistory(ticker: position.ticker) {
let earningsDate = try? await YahooFinanceClient.shared.nextEarningsDate(ticker: position.ticker)
signals = SignalEngine.compute(
ticker: position.ticker,
history: history,
earningsDate: earningsDate
).snapshot
}
isLoadingSignals = false
}

View File

@@ -4,36 +4,37 @@ struct RecommendationDetailView: View {
let ticker: String
var initialRec: Recommendation? = nil
@StateObject private var vm = RecommendationsViewModel()
@State private var detail: RecommendationWithSignals? = nil
@State private var isLoading = false
@State private var signals: SignalSnapshot? = nil
@State private var isLoadingSignals = false
var rec: Recommendation? { detail?.recommendation ?? initialRec }
var signals: SignalSnapshot? { detail?.signals }
private let earningsDateFmt: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
return f
}()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if isLoading {
LoadingView()
} else if let rec {
// Header
if let rec = initialRec {
// Header
headerSection(rec)
Divider()
// Trade Setup
// Trade Setup
tradeSetupSection(rec)
Divider()
// Signal Detail
if let signals {
// Signal Detail
if isLoadingSignals {
HStack { ProgressView(); Text("Loading signals…").font(.caption).foregroundStyle(.secondary) }
.padding()
} else if let signals {
signalDetailSection(signals)
Divider()
}
// Rationale
// Rationale
rationaleSection(rec)
if rec.earningsWarning {
@@ -45,35 +46,29 @@ struct RecommendationDetailView: View {
}
.navigationTitle(ticker)
.navigationBarTitleDisplayMode(.inline)
.task { await loadDetail() }
.task { await loadSignals() }
}
// Sections
// MARK: - Sections
private func headerSection(_ rec: Recommendation) -> some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(rec.strategyLabel)
.font(.title3.weight(.semibold))
Text(rec.horizonLabel)
.font(.subheadline)
.foregroundStyle(.secondary)
Text(rec.strategyLabel).font(.title3.weight(.semibold))
Text(rec.horizonLabel).font(.subheadline).foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
SignalBadge(strength: rec.signalStrength)
Text(String(format: "$%.2f", rec.currentPrice))
.font(.caption)
.foregroundStyle(.secondary)
.font(.caption).foregroundStyle(.secondary)
}
}
}
private func tradeSetupSection(_ rec: Recommendation) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Trade Setup")
.font(.headline)
Text("Trade Setup").font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
metricCard("Strike", String(format: "$%.2f", rec.recommendedStrike))
metricCard("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) {
Text("Market Signals")
.font(.headline)
Text("Market Signals").font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
metricCard("IV Rank", String(format: "%.0f%%", signals.ivRank), highlight: signals.ivRank >= 50)
metricCard("Trend", signals.trend.capitalized)
metricCard("SMA-50", String(format: "$%.2f", signals.sma50))
metricCard("SMA-200", String(format: "$%.2f", signals.sma200))
if let support = signals.nearestSupport {
metricCard("IV Rank", String(format: "%.0f%%", snap.ivRank), highlight: snap.ivRank >= 50)
metricCard("Trend", snap.trend.capitalized)
metricCard("SMA-50", String(format: "$%.2f", snap.sma50))
metricCard("SMA-200", String(format: "$%.2f", snap.sma200))
if let support = snap.nearestSupport {
metricCard("Support", String(format: "$%.2f", support))
}
if let resistance = signals.nearestResistance {
if let resistance = snap.nearestResistance {
metricCard("Resistance", String(format: "$%.2f", resistance))
}
}
if let earnings = snap.earningsDate {
HStack(spacing: 6) {
Image(systemName: "calendar.badge.exclamationmark").foregroundStyle(Constants.Color.warning)
Text("Earnings: \(earningsDateFmt.string(from: earnings))")
.font(.caption).foregroundStyle(Constants.Color.warning)
}
}
}
}
private func rationaleSection(_ rec: Recommendation) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("Rationale")
.font(.headline)
Text("Rationale").font(.headline)
Text(rec.rationale)
.font(.subheadline)
.foregroundStyle(.secondary)
@@ -118,32 +117,27 @@ struct RecommendationDetailView: View {
private func earningsWarningBanner(_ rec: Recommendation) -> some View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Constants.Color.warning)
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Constants.Color.warning)
VStack(alignment: .leading, spacing: 2) {
Text("Earnings Warning")
.font(.subheadline.weight(.semibold))
Text("Earnings Warning").font(.subheadline.weight(.semibold))
if let earningsDate = rec.earningsDate {
Text("Earnings on \(earningsDate) fall within this expiry window.")
.font(.caption)
.foregroundStyle(.secondary)
.font(.caption).foregroundStyle(.secondary)
}
}
}
.padding()
.background(Constants.Color.warning.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Constants.Color.warning.opacity(0.3), lineWidth: 1))
.overlay(RoundedRectangle(cornerRadius: 10)
.stroke(Constants.Color.warning.opacity(0.3), lineWidth: 1))
}
// Helper views
// MARK: - Helpers
private func metricCard(_ label: String, _ value: String, highlight: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
Text(label).font(.caption).foregroundStyle(.secondary)
Text(value).font(.subheadline.weight(.semibold))
.foregroundStyle(highlight ? Constants.Color.strong : .primary)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -151,11 +145,16 @@ struct RecommendationDetailView: View {
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8))
}
private func loadDetail() async {
isLoading = true
vm.selectedStrategy = initialRec?.strategy ?? "covered_call"
vm.selectedHorizon = initialRec?.timeHorizon ?? "weekly"
detail = await vm.getDetail(ticker: ticker)
isLoading = false
private func loadSignals() async {
isLoadingSignals = true
if let history = try? await YahooFinanceClient.shared.priceHistory(ticker: ticker) {
let earningsDate = try? await YahooFinanceClient.shared.nextEarningsDate(ticker: ticker)
signals = SignalEngine.compute(
ticker: ticker,
history: history,
earningsDate: earningsDate
).snapshot
}
isLoadingSignals = false
}
}

View File

@@ -33,8 +33,6 @@ struct RecommendationsView: View {
// Content
if vm.isLoading {
LoadingView(message: "Getting recommendations...")
} else if let error = vm.error {
ErrorView(message: error) { await vm.load() }
} else if vm.filtered.isEmpty {
EmptyStateView(
icon: "chart.xyaxis.line",
@@ -66,7 +64,7 @@ struct RecommendationsView: View {
}
}
}
.task { await vm.load() }
.onAppear { vm.load() }
}
private func horizonLabel(_ h: String) -> String {