Initial implementation of Options Sidekick
Full-stack iOS options trading assistant: - Python FastAPI backend with SQLite, APScheduler (15-min position monitor), APNs push notifications, and yfinance market data integration - Signal engine: IV Rank (rolling HV proxy), SMA-50/200, swing-based support/resistance, earnings detection, signal strength scoring and noise-resistant SHA hash for change detection - Recommendation engine: covered call and cash-secured put strike/expiry selection across 0DTE, 1DTE, weekly, and monthly horizons - REST API: /devices, /portfolio, /recommendations, /positions, /signals, /alerts - iOS SwiftUI app (iOS 17+): dashboard, recommendations, trades, portfolio, and alerts tabs with push notification deep-linking - Unit + integration tests for signal engine and API layer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
59
ios/OptionsSidekick/OptionsSidekick/AppDelegate.swift
Normal file
59
ios/OptionsSidekick/OptionsSidekick/AppDelegate.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
NotificationHandler.shared.setup()
|
||||
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 application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
print("APNs registration failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// ─── 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
|
||||
)
|
||||
// Temporarily set the token so APIClient has it, but use requestNoAuth
|
||||
// since this is the registration call itself
|
||||
let old = LocalStore.shared.deviceToken
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
26
ios/OptionsSidekick/OptionsSidekick/Config/Constants.swift
Normal file
26
ios/OptionsSidekick/OptionsSidekick/Config/Constants.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
enum Color {
|
||||
static let strong = SwiftUI.Color.green
|
||||
static let moderate = SwiftUI.Color.yellow
|
||||
static let weak = SwiftUI.Color(red: 0.9, green: 0.4, blue: 0.2)
|
||||
static let accent = SwiftUI.Color.blue
|
||||
static let destructive = SwiftUI.Color.red
|
||||
static let warning = SwiftUI.Color.orange
|
||||
}
|
||||
|
||||
// ─── App ──────────────────────────────────────────────────────────────────
|
||||
static let appName = "Options Sidekick"
|
||||
static let notificationCategory = "POSITION_ALERT"
|
||||
static let notificationActionView = "VIEW_POSITION"
|
||||
}
|
||||
33
ios/OptionsSidekick/OptionsSidekick/Models/AppAlert.swift
Normal file
33
ios/OptionsSidekick/OptionsSidekick/Models/AppAlert.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
struct AppAlert: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let ticker: String
|
||||
let optionPositionId: Int?
|
||||
let alertType: String // "close_early" | "roll_out" | "roll_up_down" | "earnings_warning"
|
||||
let message: String
|
||||
let sentAt: Date
|
||||
var acknowledged: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, ticker, message, acknowledged
|
||||
case optionPositionId = "option_position_id"
|
||||
case alertType = "alert_type"
|
||||
case sentAt = "sent_at"
|
||||
}
|
||||
|
||||
var typeLabel: String {
|
||||
switch alertType {
|
||||
case "close_early": return "Close Early"
|
||||
case "roll_out": return "Roll Out"
|
||||
case "roll_up_down": return "Roll Strike"
|
||||
case "earnings_warning": return "Earnings Warning"
|
||||
case "new_rec": return "New Recommendation"
|
||||
default: return alertType.replacingOccurrences(of: "_", with: " ").capitalized
|
||||
}
|
||||
}
|
||||
|
||||
var isUrgent: Bool {
|
||||
alertType == "close_early" || alertType == "earnings_warning"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
struct OptionPosition: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let ticker: String
|
||||
let strategy: String // "covered_call" | "cash_secured_put"
|
||||
let strike: Double
|
||||
let expiration: String // ISO date string "YYYY-MM-DD"
|
||||
let premiumReceived: Double
|
||||
let contracts: Int
|
||||
let status: String // "open" | "closed" | "rolled"
|
||||
let 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 strategyLabel: String {
|
||||
strategy == "covered_call" ? "Covered Call" : "Cash-Secured Put"
|
||||
}
|
||||
|
||||
var expirationDate: Date? {
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateFormat = "yyyy-MM-dd"
|
||||
return fmt.date(from: expiration)
|
||||
}
|
||||
|
||||
var daysToExpiry: Int? {
|
||||
guard let exp = expirationDate else { return nil }
|
||||
let days = Calendar.current.dateComponents([.day], from: Date(), to: exp).day
|
||||
return days
|
||||
}
|
||||
|
||||
var totalCredit: Double {
|
||||
premiumReceived * Double(contracts) * 100
|
||||
}
|
||||
}
|
||||
|
||||
struct OptionPositionCreate: Codable {
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
struct PortfolioPosition: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
struct Recommendation: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let ticker: String
|
||||
let strategy: String
|
||||
let timeHorizon: String
|
||||
let currentPrice: Double
|
||||
let recommendedStrike: Double
|
||||
let recommendedExpiration: String // ISO date "YYYY-MM-DD"
|
||||
let estimatedPremium: Double
|
||||
let delta: Double
|
||||
let theta: Double
|
||||
let ivRank: Double
|
||||
let signalStrength: String // "strong" | "moderate" | "weak"
|
||||
let earningsWarning: Bool
|
||||
let earningsDate: String?
|
||||
let rationale: String
|
||||
let signalHash: String
|
||||
let createdAt: Date
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, ticker, strategy, rationale
|
||||
case timeHorizon = "time_horizon"
|
||||
case currentPrice = "current_price"
|
||||
case recommendedStrike = "recommended_strike"
|
||||
case recommendedExpiration = "recommended_expiration"
|
||||
case estimatedPremium = "estimated_premium"
|
||||
case delta, theta
|
||||
case ivRank = "iv_rank"
|
||||
case signalStrength = "signal_strength"
|
||||
case earningsWarning = "earnings_warning"
|
||||
case earningsDate = "earnings_date"
|
||||
case signalHash = "signal_hash"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
var strategyLabel: String {
|
||||
strategy == "covered_call" ? "Covered Call" : "Cash-Secured Put"
|
||||
}
|
||||
|
||||
var horizonLabel: String {
|
||||
switch timeHorizon {
|
||||
case "0dte": return "0DTE"
|
||||
case "1dte": return "1DTE"
|
||||
case "weekly": return "Weekly"
|
||||
case "monthly": return "Monthly"
|
||||
default: return timeHorizon.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
var annualizedPremiumPct: Double {
|
||||
guard currentPrice > 0 else { return 0 }
|
||||
let daysToExpiry = expirationDate.map { Calendar.current.dateComponents([.day], from: Date(), to: $0).day ?? 30 } ?? 30
|
||||
let dailyReturn = estimatedPremium / currentPrice
|
||||
return dailyReturn * (365.0 / max(1, Double(daysToExpiry))) * 100
|
||||
}
|
||||
|
||||
var expirationDate: Date? {
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateFormat = "yyyy-MM-dd"
|
||||
return fmt.date(from: recommendedExpiration)
|
||||
}
|
||||
}
|
||||
|
||||
struct SignalSnapshot: Codable {
|
||||
let ticker: String
|
||||
let currentPrice: Double
|
||||
let ivRank: Double
|
||||
let sma50: Double
|
||||
let sma200: Double
|
||||
let nearestSupport: Double?
|
||||
let nearestResistance: Double?
|
||||
let trend: String
|
||||
let earningsDate: String?
|
||||
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
|
||||
}
|
||||
144
ios/OptionsSidekick/OptionsSidekick/Networking/APIClient.swift
Normal file
144
ios/OptionsSidekick/OptionsSidekick/Networking/APIClient.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
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)
|
||||
// Try ISO8601 with fractional seconds first, then without
|
||||
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 }
|
||||
}
|
||||
// Try plain date (YYYY-MM-DD) for date-only fields decoded as 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: (some Encodable)? = Optional<EmptyBody>.none
|
||||
) 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: (some Encodable)? = Optional<EmptyBody>.none) 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: (some Encodable)? = Optional<EmptyBody>.none) 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: (some 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 encoder.encode(body)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
private struct EmptyBody: Encodable {}
|
||||
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
/// Handles incoming push notifications — both foreground and background tap.
|
||||
@MainActor
|
||||
final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
static let shared = NotificationHandler()
|
||||
|
||||
/// Published so views can react to a deep-link navigation request.
|
||||
@Published var navigateToPositionId: Int? = nil
|
||||
@Published var inAppAlertMessage: String? = nil
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
func setup() {
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
}
|
||||
|
||||
// ─── 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
|
||||
completionHandler([.banner, .sound, .badge])
|
||||
}
|
||||
|
||||
// ─── Background tap / action tap ──────────────────────────────────────────
|
||||
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
|
||||
}
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
final class NotificationPermissions {
|
||||
static let shared = NotificationPermissions()
|
||||
private init() {}
|
||||
|
||||
func requestIfNeeded() async {
|
||||
guard !LocalStore.shared.notificationPermissionRequested else { return }
|
||||
LocalStore.shared.notificationPermissionRequested = true
|
||||
|
||||
let center = UNUserNotificationCenter.current()
|
||||
|
||||
// Register a custom category with a "View" action for deep linking
|
||||
let viewAction = UNNotificationAction(
|
||||
identifier: Constants.notificationActionView,
|
||||
title: "View Position",
|
||||
options: [.foreground]
|
||||
)
|
||||
let category = UNNotificationCategory(
|
||||
identifier: Constants.notificationCategory,
|
||||
actions: [viewAction],
|
||||
intentIdentifiers: [],
|
||||
options: []
|
||||
)
|
||||
center.setNotificationCategories([category])
|
||||
|
||||
do {
|
||||
let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])
|
||||
if granted {
|
||||
await MainActor.run {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Notification permission error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
13
ios/OptionsSidekick/OptionsSidekick/OptionsSidekickApp.swift
Normal file
13
ios/OptionsSidekick/OptionsSidekick/OptionsSidekickApp.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct OptionsSidekickApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(NotificationHandler.shared)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
/// Lightweight UserDefaults wrapper for device-local 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 ──────────────────────────────────────────────
|
||||
|
||||
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") }
|
||||
}
|
||||
}
|
||||
47
ios/OptionsSidekick/OptionsSidekick/Resources/Info.plist
Normal file
47
ios/OptionsSidekick/OptionsSidekick/Resources/Info.plist
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Options Sidekick</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AlertsViewModel: ObservableObject {
|
||||
@Published var alerts: [AppAlert] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String? = nil
|
||||
|
||||
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: Optional<String>.none
|
||||
)
|
||||
LocalStore.shared.unreadAlertCount = unreadCount
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func acknowledge(_ alert: AppAlert) async {
|
||||
do {
|
||||
let updated: AppAlert = try await APIClient.shared.request(
|
||||
.acknowledgeAlert(alert.id),
|
||||
body: Optional<String>.none
|
||||
)
|
||||
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 {
|
||||
for alert in alerts where !alert.acknowledged {
|
||||
await acknowledge(alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class DashboardViewModel: ObservableObject {
|
||||
@Published var stockPositions: [PortfolioPosition] = []
|
||||
@Published var openOptionPositions: [OptionPosition] = []
|
||||
@Published var recommendations: [Recommendation] = []
|
||||
@Published var unreadAlerts: [AppAlert] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String? = nil
|
||||
|
||||
var urgentAlerts: [AppAlert] { unreadAlerts.filter { $0.isUrgent } }
|
||||
|
||||
/// Top-level recommendation per ticker (best signal strength)
|
||||
var topRecommendations: [Recommendation] {
|
||||
let order = ["strong": 0, "moderate": 1, "weak": 2]
|
||||
var best: [String: Recommendation] = [:]
|
||||
for rec in recommendations {
|
||||
let current = best[rec.ticker]
|
||||
if current == nil || (order[rec.signalStrength] ?? 2) < (order[current!.signalStrength] ?? 2) {
|
||||
best[rec.ticker] = rec
|
||||
}
|
||||
}
|
||||
return Array(best.values).sorted { $0.ticker < $1.ticker }
|
||||
}
|
||||
|
||||
func loadAll() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
async let stocks: [PortfolioPosition] = loadStocks()
|
||||
async let options: [OptionPosition] = loadOptions()
|
||||
async let recs: [Recommendation] = loadRecommendations()
|
||||
async let alerts: [AppAlert] = loadAlerts()
|
||||
|
||||
(stockPositions, openOptionPositions, recommendations, unreadAlerts) = await (stocks, options, recs, alerts)
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
await loadAll()
|
||||
}
|
||||
|
||||
// ─── Private loaders ──────────────────────────────────────────────────────
|
||||
|
||||
private func loadStocks() async -> [PortfolioPosition] {
|
||||
(try? await APIClient.shared.request(.getPortfolio, body: Optional<String>.none)) ?? []
|
||||
}
|
||||
|
||||
private func loadOptions() async -> [OptionPosition] {
|
||||
(try? await APIClient.shared.request(.getPositions(status: "open"), body: Optional<String>.none)) ?? []
|
||||
}
|
||||
|
||||
private func loadRecommendations() async -> [Recommendation] {
|
||||
(try? await APIClient.shared.request(.getRecommendations(timeHorizon: nil), body: Optional<String>.none)) ?? []
|
||||
}
|
||||
|
||||
private func loadAlerts() async -> [AppAlert] {
|
||||
(try? await APIClient.shared.request(.getAlerts(unreadOnly: true), body: Optional<String>.none)) ?? []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class PortfolioViewModel: ObservableObject {
|
||||
@Published var positions: [PortfolioPosition] = []
|
||||
@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: Optional<String>.none)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
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 add(ticker: String, shares: Int, costBasis: Double?) async {
|
||||
var 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 delete(ticker: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
try await APIClient.shared.requestVoid(.deleteTicker(ticker), body: Optional<String>.none)
|
||||
positions.removeAll { $0.ticker == ticker }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class PositionsViewModel: ObservableObject {
|
||||
@Published var positions: [OptionPosition] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String? = nil
|
||||
|
||||
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: Optional<String>.none
|
||||
)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func log(create: OptionPositionCreate) async -> Bool {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let new: OptionPosition = try await APIClient.shared.request(.logPosition, body: create)
|
||||
positions.insert(new, at: 0)
|
||||
isLoading = false
|
||||
return true
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
isLoading = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func 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 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class RecommendationsViewModel: ObservableObject {
|
||||
@Published var recommendations: [Recommendation] = []
|
||||
@Published var isLoading = false
|
||||
@Published var isRefreshing = false
|
||||
@Published var error: String? = nil
|
||||
@Published var selectedHorizon: String = "weekly"
|
||||
@Published var selectedStrategy: String = "covered_call"
|
||||
|
||||
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: Optional<String>.none
|
||||
)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isRefreshing = true
|
||||
error = nil
|
||||
do {
|
||||
recommendations = try await APIClient.shared.request(
|
||||
.refreshRecommendations,
|
||||
body: Optional<String>.none
|
||||
)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isRefreshing = false
|
||||
}
|
||||
|
||||
func getDetail(ticker: String) async -> RecommendationWithSignals? {
|
||||
do {
|
||||
return try await APIClient.shared.request(
|
||||
.getRecommendation(ticker: ticker, strategy: selectedStrategy, timeHorizon: selectedHorizon),
|
||||
body: Optional<String>.none
|
||||
)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AlertsView: View {
|
||||
@StateObject private var vm = AlertsViewModel()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if vm.isLoading && vm.alerts.isEmpty {
|
||||
LoadingView(message: "Loading alerts...")
|
||||
} else if vm.alerts.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "bell.slash",
|
||||
title: "No alerts",
|
||||
subtitle: "Alerts appear here when signal changes on your open positions."
|
||||
)
|
||||
} else {
|
||||
List(vm.alerts) { alert in
|
||||
AlertRowView(alert: alert) {
|
||||
Task { await vm.acknowledge(alert) }
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable { await vm.load() }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Alerts")
|
||||
.toolbar {
|
||||
if vm.unreadCount > 0 {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Mark All Read") {
|
||||
Task { await vm.acknowledgeAll() }
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await vm.load() }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Row ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
struct AlertRowView: View {
|
||||
let alert: AppAlert
|
||||
let onAcknowledge: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
// Unread indicator
|
||||
Circle()
|
||||
.fill(alert.acknowledged ? Color.clear : Constants.Color.accent)
|
||||
.frame(width: 8, height: 8)
|
||||
.padding(.top, 5)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Text(alert.ticker)
|
||||
.font(.headline)
|
||||
AlertTypeBadge(alertType: alert.alertType)
|
||||
}
|
||||
Text(alert.message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(RelativeDateTimeFormatter().localizedString(for: alert.sentAt, relativeTo: Date()))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !alert.acknowledged {
|
||||
Button {
|
||||
onAcknowledge()
|
||||
} label: {
|
||||
Image(systemName: "checkmark.circle")
|
||||
.foregroundStyle(Constants.Color.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.opacity(alert.acknowledged ? 0.6 : 1.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoadingView: View {
|
||||
var message: String = "Loading..."
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct ErrorView: View {
|
||||
let message: String
|
||||
let retry: () async -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(Constants.Color.destructive)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
Button("Retry") {
|
||||
Task { await retry() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyStateView: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.quaternary)
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SignalBadge: View {
|
||||
let strength: String // "strong" | "moderate" | "weak"
|
||||
|
||||
var color: Color {
|
||||
switch strength {
|
||||
case "strong": return Constants.Color.strong
|
||||
case "moderate": return Constants.Color.moderate
|
||||
default: return Constants.Color.weak
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(strength.capitalized)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(color.opacity(0.18))
|
||||
.foregroundStyle(color)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
struct IVRankBadge: View {
|
||||
let ivRank: Double
|
||||
|
||||
var color: Color {
|
||||
if ivRank >= 50 { return Constants.Color.strong }
|
||||
if ivRank >= 30 { return Constants.Color.moderate }
|
||||
return Constants.Color.weak
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 2) {
|
||||
Text("IV")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(String(format: "%.0f%%", ivRank))
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(color.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
struct AlertTypeBadge: View {
|
||||
let alertType: String
|
||||
|
||||
var label: String {
|
||||
switch alertType {
|
||||
case "close_early": return "Close"
|
||||
case "roll_out": return "Roll Out"
|
||||
case "roll_up_down": return "Roll"
|
||||
case "earnings_warning": return "Earnings"
|
||||
default: return alertType
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
alertType == "close_early" || alertType == "earnings_warning"
|
||||
? Constants.Color.destructive
|
||||
: Constants.Color.warning
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(label)
|
||||
.font(.caption2.weight(.bold))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(color.opacity(0.15))
|
||||
.foregroundStyle(color)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
52
ios/OptionsSidekick/OptionsSidekick/Views/ContentView.swift
Normal file
52
ios/OptionsSidekick/OptionsSidekick/Views/ContentView.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject private var notificationHandler: NotificationHandler
|
||||
@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")
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
RecommendationsView()
|
||||
.tabItem {
|
||||
Label("Setups", systemImage: "lightbulb.fill")
|
||||
}
|
||||
.tag(1)
|
||||
|
||||
OpenPositionsView()
|
||||
.tabItem {
|
||||
Label("Trades", systemImage: "doc.text.fill")
|
||||
}
|
||||
.tag(2)
|
||||
|
||||
PortfolioView()
|
||||
.tabItem {
|
||||
Label("Portfolio", systemImage: "briefcase.fill")
|
||||
}
|
||||
.tag(3)
|
||||
|
||||
AlertsView()
|
||||
.tabItem {
|
||||
Label("Alerts", systemImage: "bell.fill")
|
||||
}
|
||||
.badge(LocalStore.shared.unreadAlertCount > 0 ? "\(LocalStore.shared.unreadAlertCount)" : nil)
|
||||
.tag(4)
|
||||
}
|
||||
.task {
|
||||
await NotificationPermissions.shared.requestIfNeeded()
|
||||
}
|
||||
.onChange(of: notificationHandler.navigateToPositionId) { _, posId in
|
||||
if posId != nil {
|
||||
// Deep link: switch to Trades tab
|
||||
selectedTab = 2
|
||||
navigateToPositionId = posId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
@StateObject private var vm = DashboardViewModel()
|
||||
@EnvironmentObject private var notificationHandler: NotificationHandler
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if vm.isLoading && vm.stockPositions.isEmpty {
|
||||
LoadingView(message: "Loading your positions...")
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
// ─── Urgent alert banners ──────────────────────
|
||||
if !vm.urgentAlerts.isEmpty {
|
||||
urgentAlertSection
|
||||
}
|
||||
|
||||
// ─── Open options positions ────────────────────
|
||||
if !vm.openOptionPositions.isEmpty {
|
||||
openPositionsSection
|
||||
}
|
||||
|
||||
// ─── Recommendations ──────────────────────────
|
||||
if !vm.topRecommendations.isEmpty {
|
||||
recommendationsSection
|
||||
}
|
||||
|
||||
// ─── Empty state ──────────────────────────────
|
||||
if vm.stockPositions.isEmpty {
|
||||
emptyState
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.refreshable { await vm.refresh() }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Options Sidekick")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if vm.unreadAlerts.count > 0 {
|
||||
alertBell
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await vm.loadAll() }
|
||||
.onChange(of: notificationHandler.inAppAlertMessage) { _, msg in
|
||||
if msg != nil { Task { await vm.refresh() } }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sections ─────────────────────────────────────────────────────────────
|
||||
|
||||
private var urgentAlertSection: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(vm.urgentAlerts.prefix(3)) { alert in
|
||||
AlertBannerView(alert: alert)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var openPositionsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
sectionHeader("Open Positions", systemImage: "chart.line.uptrend.xyaxis")
|
||||
ForEach(vm.openOptionPositions) { position in
|
||||
NavigationLink(destination: PositionDetailView(position: position)) {
|
||||
OpenPositionRowView(position: position)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
private var recommendationsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
sectionHeader("Today's Setups", systemImage: "lightbulb")
|
||||
ForEach(vm.topRecommendations) { rec in
|
||||
NavigationLink(destination: RecommendationDetailView(ticker: rec.ticker)) {
|
||||
RecommendationCardView(recommendation: rec)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
EmptyStateView(
|
||||
icon: "tray",
|
||||
title: "No positions yet",
|
||||
subtitle: "Add stocks to your portfolio to get recommendations."
|
||||
)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
|
||||
private var alertBell: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: "bell.fill")
|
||||
.foregroundStyle(Constants.Color.warning)
|
||||
Circle()
|
||||
.fill(Constants.Color.destructive)
|
||||
.frame(width: 8, height: 8)
|
||||
.offset(x: 4, y: -4)
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionHeader(_ title: String, systemImage: String) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Alert Banner ──────────────────────────────────────────────────────────────
|
||||
|
||||
struct AlertBannerView: View {
|
||||
let alert: AppAlert
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: alert.isUrgent ? "exclamationmark.triangle.fill" : "bell.fill")
|
||||
.foregroundStyle(alert.isUrgent ? Constants.Color.destructive : Constants.Color.warning)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(alert.ticker)
|
||||
.font(.subheadline.weight(.bold))
|
||||
AlertTypeBadge(alertType: alert.alertType)
|
||||
}
|
||||
Text(alert.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(10)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(
|
||||
alert.isUrgent ? Constants.Color.destructive.opacity(0.4) : Constants.Color.warning.opacity(0.3),
|
||||
lineWidth: 1
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Recommendation Card ───────────────────────────────────────────────────────
|
||||
|
||||
struct RecommendationCardView: View {
|
||||
let recommendation: Recommendation
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Text(recommendation.ticker)
|
||||
.font(.headline)
|
||||
Text(recommendation.strategyLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
SignalBadge(strength: recommendation.signalStrength)
|
||||
if recommendation.earningsWarning {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Constants.Color.warning)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
label("Strike", value: String(format: "$%.0f", recommendation.recommendedStrike))
|
||||
label("Exp", value: recommendation.recommendedExpiration)
|
||||
label("Premium", value: String(format: "$%.2f", recommendation.estimatedPremium))
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
IVRankBadge(ivRank: recommendation.ivRank)
|
||||
Text(String(format: "Δ %.2f", recommendation.delta))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private func label(_ title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title).font(.caption2).foregroundStyle(.tertiary)
|
||||
Text(value).font(.caption.weight(.medium))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Open Position Row ─────────────────────────────────────────────────────────
|
||||
|
||||
struct OpenPositionRowView: View {
|
||||
let position: OptionPosition
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 6) {
|
||||
Text(position.ticker).font(.headline)
|
||||
Text(position.strategyLabel).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
Text(String(format: "$%.0f strike", position.strike)).font(.caption)
|
||||
if let dte = position.daysToExpiry {
|
||||
Text("\(dte)d").font(.caption).foregroundStyle(dte <= 3 ? Constants.Color.warning : .secondary)
|
||||
}
|
||||
Text(String(format: "×%d", position.contracts)).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(String(format: "$%.2f", position.premiumReceived))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("per contract").font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PortfolioView: View {
|
||||
@StateObject private var vm = PortfolioViewModel()
|
||||
@State private var showAddSheet = false
|
||||
|
||||
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 {
|
||||
EmptyStateView(
|
||||
icon: "briefcase",
|
||||
title: "No stocks yet",
|
||||
subtitle: "Add the tickers you hold shares in to get covered call and cash-secured put recommendations."
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(vm.positions) { position in
|
||||
PortfolioRowView(position: position)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
Task {
|
||||
for i in indexSet {
|
||||
await vm.delete(ticker: vm.positions[i].ticker)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
.navigationTitle("My Stocks")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showAddSheet = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
AddPositionSheet(vm: vm)
|
||||
}
|
||||
}
|
||||
.task { await vm.load() }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Row ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
struct PortfolioRowView: View {
|
||||
let position: PortfolioPosition
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(position.ticker)
|
||||
.font(.headline)
|
||||
if let cost = position.costBasis {
|
||||
Text(String(format: "Avg cost $%.2f", cost))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("\(position.shares) shares")
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text("\(position.shares / 100) contracts available")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Add Sheet ────────────────────────────────────────────────────────────────
|
||||
|
||||
struct AddPositionSheet: View {
|
||||
@ObservedObject var vm: PortfolioViewModel
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@State private var ticker = ""
|
||||
@State private var sharesText = ""
|
||||
@State private var costBasisText = ""
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
enum Field { case ticker, shares, cost }
|
||||
|
||||
var sharesInt: Int? { Int(sharesText) }
|
||||
var costDouble: Double? { costBasisText.isEmpty ? nil : Double(costBasisText) }
|
||||
var isValid: Bool { !ticker.isEmpty && (sharesInt ?? 0) > 0 }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Stock") {
|
||||
TextField("Ticker (e.g. AAPL)", text: $ticker)
|
||||
.textInputAutocapitalization(.characters)
|
||||
.focused($focusedField, equals: .ticker)
|
||||
}
|
||||
|
||||
Section("Position") {
|
||||
TextField("Shares owned", text: $sharesText)
|
||||
.keyboardType(.numberPad)
|
||||
.focused($focusedField, equals: .shares)
|
||||
|
||||
TextField("Avg cost basis (optional)", text: $costBasisText)
|
||||
.keyboardType(.decimalPad)
|
||||
.focused($focusedField, equals: .cost)
|
||||
}
|
||||
|
||||
if let shares = sharesInt, shares > 0 {
|
||||
Section("") {
|
||||
HStack {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("You can sell up to \(shares / 100) covered call contracts.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Stock")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Add") {
|
||||
Task {
|
||||
await vm.add(ticker: ticker, shares: sharesInt!, costBasis: costDouble)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { focusedField = .ticker }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LogTradeSheet: View {
|
||||
@ObservedObject var vm: PositionsViewModel
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@State private var ticker = ""
|
||||
@State private var strategy = "covered_call"
|
||||
@State private var strikeText = ""
|
||||
@State private var expiration = Date().addingTimeInterval(14 * 86400)
|
||||
@State private var premiumText = ""
|
||||
@State private var contractsText = "1"
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
enum Field { case ticker, strike, premium, contracts }
|
||||
|
||||
var strike: Double? { Double(strikeText) }
|
||||
var premium: Double? { Double(premiumText) }
|
||||
var contracts: Int { Int(contractsText) ?? 1 }
|
||||
var isValid: Bool { !ticker.isEmpty && (strike ?? 0) > 0 && (premium ?? 0) > 0 }
|
||||
|
||||
private let dateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Trade") {
|
||||
TextField("Ticker (e.g. AAPL)", text: $ticker)
|
||||
.textInputAutocapitalization(.characters)
|
||||
.focused($focusedField, equals: .ticker)
|
||||
|
||||
Picker("Strategy", selection: $strategy) {
|
||||
Text("Covered Call").tag("covered_call")
|
||||
Text("Cash-Secured Put").tag("cash_secured_put")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Details") {
|
||||
TextField("Strike price", text: $strikeText)
|
||||
.keyboardType(.decimalPad)
|
||||
.focused($focusedField, equals: .strike)
|
||||
|
||||
DatePicker("Expiration", selection: $expiration, in: Date()..., displayedComponents: .date)
|
||||
|
||||
TextField("Premium received (per share)", text: $premiumText)
|
||||
.keyboardType(.decimalPad)
|
||||
.focused($focusedField, equals: .premium)
|
||||
|
||||
TextField("Contracts", text: $contractsText)
|
||||
.keyboardType(.numberPad)
|
||||
.focused($focusedField, equals: .contracts)
|
||||
}
|
||||
|
||||
if let p = premium, let s = strike, contracts > 0 {
|
||||
Section("") {
|
||||
HStack {
|
||||
Text("Total credit received")
|
||||
Spacer()
|
||||
Text(String(format: "$%.2f", p * Double(contracts) * 100))
|
||||
.font(.headline)
|
||||
.foregroundStyle(Constants.Color.strong)
|
||||
}
|
||||
HStack {
|
||||
Text("Max profit per contract")
|
||||
Spacer()
|
||||
Text(String(format: "$%.2f", p * 100))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Log Trade")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Log") {
|
||||
guard isValid else { return }
|
||||
Task {
|
||||
let create = OptionPositionCreate(
|
||||
ticker: ticker.uppercased(),
|
||||
strategy: strategy,
|
||||
strike: strike!,
|
||||
expiration: dateFormatter.string(from: expiration),
|
||||
premiumReceived: premium!,
|
||||
contracts: contracts
|
||||
)
|
||||
let success = await vm.log(create: create)
|
||||
if success { dismiss() }
|
||||
}
|
||||
}
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { focusedField = .ticker }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OpenPositionsView: View {
|
||||
@StateObject private var vm = PositionsViewModel()
|
||||
@State private var showLogSheet = false
|
||||
|
||||
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 {
|
||||
EmptyStateView(
|
||||
icon: "doc.text",
|
||||
title: "No logged trades",
|
||||
subtitle: "Log your executed options trades here. The app will monitor them and alert you when signals change."
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
if !vm.openPositions.isEmpty {
|
||||
Section("Open") {
|
||||
ForEach(vm.openPositions) { position in
|
||||
NavigationLink(destination: PositionDetailView(position: position, vm: vm)) {
|
||||
LoggedPositionRow(position: position)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !vm.closedPositions.isEmpty {
|
||||
Section("Closed / Rolled") {
|
||||
ForEach(vm.closedPositions) { position in
|
||||
LoggedPositionRow(position: position)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable { await vm.load() }
|
||||
}
|
||||
}
|
||||
.navigationTitle("My Trades")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showLogSheet = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showLogSheet) {
|
||||
LogTradeSheet(vm: vm)
|
||||
}
|
||||
}
|
||||
.task { await vm.load() }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Row ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
struct LoggedPositionRow: View {
|
||||
let position: OptionPosition
|
||||
|
||||
var statusColor: Color {
|
||||
switch position.status {
|
||||
case "open": return Constants.Color.strong
|
||||
case "rolled": return Constants.Color.moderate
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 6) {
|
||||
Text(position.ticker).font(.headline)
|
||||
Text(position.strategyLabel).font(.caption).foregroundStyle(.secondary)
|
||||
Circle().fill(statusColor).frame(width: 6, height: 6)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
Text(String(format: "$%.0f", position.strike)).font(.caption)
|
||||
if let dte = position.daysToExpiry {
|
||||
Text("\(dte)d to expiry")
|
||||
.font(.caption)
|
||||
.foregroundStyle(dte <= 5 ? Constants.Color.warning : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(String(format: "$%.2f", position.premiumReceived))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(String(format: "×%d", position.contracts))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PositionDetailView: View {
|
||||
let position: OptionPosition
|
||||
@ObservedObject var vm: PositionsViewModel
|
||||
@State private var signals: SignalSnapshot? = nil
|
||||
@State private var isLoadingSignals = false
|
||||
@State private var showCloseConfirm = false
|
||||
@State private var showRollConfirm = false
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
init(position: OptionPosition, vm: PositionsViewModel = PositionsViewModel()) {
|
||||
self.position = position
|
||||
self.vm = vm
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
|
||||
// ─── Position summary ──────────────────────────────────────
|
||||
summarySection
|
||||
|
||||
Divider()
|
||||
|
||||
// ─── Current signals ───────────────────────────────────────
|
||||
if isLoadingSignals {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Loading signals...").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
} else if let signals {
|
||||
signalsSection(signals)
|
||||
Divider()
|
||||
}
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────
|
||||
if position.status == "open" {
|
||||
actionSection
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("\(position.ticker) \(position.strategyLabel)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await loadSignals() }
|
||||
.confirmationDialog("Close Position", isPresented: $showCloseConfirm) {
|
||||
Button("Bought Back", role: .destructive) {
|
||||
Task { await vm.close(position: position, reason: "bought_back"); dismiss() }
|
||||
}
|
||||
Button("Expired Worthless") {
|
||||
Task { await 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sections ──────────────────────────────────────────────────────────────
|
||||
|
||||
private var summarySection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
||||
metricCard("Strike", String(format: "$%.2f", position.strike))
|
||||
metricCard("Expiration", position.expiration)
|
||||
metricCard("Premium", String(format: "$%.2f", position.premiumReceived))
|
||||
metricCard("Contracts", "\(position.contracts)")
|
||||
metricCard("Total Credit", String(format: "$%.2f", position.totalCredit))
|
||||
if let dte = position.daysToExpiry {
|
||||
metricCard("Days Left", "\(dte)d", highlight: dte <= 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func signalsSection(_ snap: SignalSnapshot) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
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)
|
||||
if let support = snap.nearestSupport {
|
||||
metricCard("Support", String(format: "$%.2f", support))
|
||||
}
|
||||
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: \(earnings)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Constants.Color.warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var actionSection: some View {
|
||||
VStack(spacing: 10) {
|
||||
Button {
|
||||
showCloseConfirm = true
|
||||
} label: {
|
||||
Label("Close Position", systemImage: "xmark.circle")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Constants.Color.destructive)
|
||||
|
||||
Button {
|
||||
showRollConfirm = true
|
||||
} label: {
|
||||
Label("Roll Position", systemImage: "arrow.2.circlepath")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Constants.Color.accent)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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))
|
||||
.foregroundStyle(highlight ? Constants.Color.strong : .primary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func loadSignals() async {
|
||||
isLoadingSignals = true
|
||||
do {
|
||||
signals = try await APIClient.shared.request(
|
||||
.getSignals(position.ticker),
|
||||
body: Optional<String>.none
|
||||
)
|
||||
} catch {
|
||||
// Non-critical — just don't show signals
|
||||
}
|
||||
isLoadingSignals = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import SwiftUI
|
||||
|
||||
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
|
||||
|
||||
var rec: Recommendation? { detail?.recommendation ?? initialRec }
|
||||
var signals: SignalSnapshot? { detail?.signals }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if isLoading {
|
||||
LoadingView()
|
||||
} else if let rec {
|
||||
// ─── Header ───────────────────────────────────────────
|
||||
headerSection(rec)
|
||||
|
||||
Divider()
|
||||
|
||||
// ─── Trade Setup ──────────────────────────────────────
|
||||
tradeSetupSection(rec)
|
||||
|
||||
Divider()
|
||||
|
||||
// ─── Signal Detail ────────────────────────────────────
|
||||
if let signals {
|
||||
signalDetailSection(signals)
|
||||
Divider()
|
||||
}
|
||||
|
||||
// ─── Rationale ────────────────────────────────────────
|
||||
rationaleSection(rec)
|
||||
|
||||
if rec.earningsWarning {
|
||||
earningsWarningBanner(rec)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle(ticker)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await loadDetail() }
|
||||
}
|
||||
|
||||
// ─── 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)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
SignalBadge(strength: rec.signalStrength)
|
||||
Text(String(format: "$%.2f", rec.currentPrice))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func tradeSetupSection(_ rec: Recommendation) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Trade Setup")
|
||||
.font(.headline)
|
||||
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
||||
metricCard("Strike", String(format: "$%.2f", rec.recommendedStrike))
|
||||
metricCard("Expiration", rec.recommendedExpiration)
|
||||
metricCard("Est. Premium", String(format: "$%.2f", rec.estimatedPremium))
|
||||
metricCard("Ann. Return", String(format: "%.1f%%", rec.annualizedPremiumPct))
|
||||
metricCard("Delta", String(format: "%.3f", rec.delta))
|
||||
metricCard("Theta", String(format: "%.3f", rec.theta))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func signalDetailSection(_ signals: SignalSnapshot) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Market Signals")
|
||||
.font(.headline)
|
||||
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
||||
metricCard("IV Rank", String(format: "%.0f%%", signals.ivRank), highlight: signals.ivRank >= 50)
|
||||
metricCard("Trend", signals.trend.capitalized)
|
||||
metricCard("SMA-50", String(format: "$%.2f", signals.sma50))
|
||||
metricCard("SMA-200", String(format: "$%.2f", signals.sma200))
|
||||
if let support = signals.nearestSupport {
|
||||
metricCard("Support", String(format: "$%.2f", support))
|
||||
}
|
||||
if let resistance = signals.nearestResistance {
|
||||
metricCard("Resistance", String(format: "$%.2f", resistance))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func rationaleSection(_ rec: Recommendation) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Rationale")
|
||||
.font(.headline)
|
||||
Text(rec.rationale)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func earningsWarningBanner(_ rec: Recommendation) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Constants.Color.warning)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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))
|
||||
}
|
||||
|
||||
// ─── Helper views ──────────────────────────────────────────────────────────
|
||||
|
||||
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))
|
||||
.foregroundStyle(highlight ? Constants.Color.strong : .primary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RecommendationsView: View {
|
||||
@StateObject private var vm = RecommendationsViewModel()
|
||||
|
||||
private let horizons = ["weekly", "monthly", "0dte", "1dte"]
|
||||
private let strategies = ["covered_call", "cash_secured_put"]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// ─── Pickers ──────────────────────────────────────────────
|
||||
VStack(spacing: 8) {
|
||||
Picker("Horizon", selection: $vm.selectedHorizon) {
|
||||
ForEach(horizons, id: \.self) { h in
|
||||
Text(horizonLabel(h)).tag(h)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Picker("Strategy", selection: $vm.selectedStrategy) {
|
||||
Text("Covered Call").tag("covered_call")
|
||||
Text("Cash-Secured Put").tag("cash_secured_put")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(.bar)
|
||||
|
||||
Divider()
|
||||
|
||||
// ─── 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",
|
||||
title: "No recommendations",
|
||||
subtitle: "Add stocks to your portfolio or try a different horizon."
|
||||
)
|
||||
} else {
|
||||
List(vm.filtered) { rec in
|
||||
NavigationLink(destination: RecommendationDetailView(ticker: rec.ticker, initialRec: rec)) {
|
||||
RecommendationListRow(rec: rec)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable { await vm.refresh() }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Recommendations")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if vm.isRefreshing {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button {
|
||||
Task { await vm.refresh() }
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await vm.load() }
|
||||
}
|
||||
|
||||
private func horizonLabel(_ h: String) -> String {
|
||||
switch h {
|
||||
case "0dte": return "0DTE"
|
||||
case "1dte": return "1DTE"
|
||||
case "weekly": return "Weekly"
|
||||
case "monthly": return "Monthly"
|
||||
default: return h.capitalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── List Row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
struct RecommendationListRow: View {
|
||||
let rec: Recommendation
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Text(rec.ticker).font(.headline)
|
||||
SignalBadge(strength: rec.signalStrength)
|
||||
if rec.earningsWarning {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Constants.Color.warning)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 14) {
|
||||
dataPoint("Strike", String(format: "$%.0f", rec.recommendedStrike))
|
||||
dataPoint("Exp", rec.recommendedExpiration)
|
||||
dataPoint("Credit", String(format: "$%.2f", rec.estimatedPremium))
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
IVRankBadge(ivRank: rec.ivRank)
|
||||
Text(String(format: "%.0f%% ann.", rec.annualizedPremiumPct))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func dataPoint(_ label: String, _ value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(label).font(.caption2).foregroundStyle(.tertiary)
|
||||
Text(value).font(.caption.weight(.medium))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user