diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/DataStore.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/DataStore.swift index 34bd3c7..ec89523 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/DataStore.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/DataStore.swift @@ -44,6 +44,7 @@ final class DataStore: ObservableObject { costBasis: costBasis, createdAt: Date() )) } + portfolio.sort { $0.ticker < $1.ticker } savePortfolio() } @@ -52,6 +53,18 @@ final class DataStore: ObservableObject { savePortfolio() } + func updatePortfolioPosition(id: UUID, shares: Int, costBasis: Double?) { + guard let idx = portfolio.firstIndex(where: { $0.id == id }) else { return } + portfolio[idx] = PortfolioPosition( + id: id, + ticker: portfolio[idx].ticker, + shares: shares, + costBasis: costBasis, + createdAt: portfolio[idx].createdAt + ) + savePortfolio() + } + var tickers: [String] { portfolio.map(\.ticker) } // MARK: - Option positions diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/RecommendationEngine.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/RecommendationEngine.swift index 4b0f6e7..fc987b0 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/RecommendationEngine.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Services/RecommendationEngine.swift @@ -54,7 +54,8 @@ enum RecommendationEngine { contracts: contracts, strategy: strategy, currentPrice: currentPrice, - signal: signal + signal: signal, + daysToExpiry: daysToExp ) else { return nil } // T in years @@ -135,14 +136,18 @@ enum RecommendationEngine { // MARK: Strike selection - /// Picks the contract with delta closest to 0.25 (target range 0.15–0.35). + /// Picks the contract with delta closest to 0.25 (target range 0.10–0.40). + /// `daysToExpiry` is passed in so delta is calculated at the real T, not a fixed 30 days. static func selectBestContract( contracts: [OptionContract], strategy: String, currentPrice: Double, - signal: SignalResult + signal: SignalResult, + daysToExpiry: Int = 30 ) -> OptionContract? { let isCall = strategy == "covered_call" + // Use at least 1 day so T > 0 + let T = Double(max(daysToExpiry, 1)) / 365.0 // Only OTM contracts with reasonable premium let candidates = contracts.filter { c in @@ -153,7 +158,6 @@ enum RecommendationEngine { // 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, @@ -161,8 +165,9 @@ enum RecommendationEngine { sigma: c.impliedVolatility, isCall: isCall )) - // Hard filter: keep delta 0.10–0.40 - guard delta >= 0.10, delta <= 0.40 else { return nil } + // Hard filter: keep delta 0.10–0.40 — relax slightly for very short expirations + let minDelta = daysToExpiry <= 3 ? 0.05 : 0.10 + guard delta >= minDelta, delta <= 0.45 else { return nil } // Penalty for deviation from target 0.25 let targetDelta = 0.25 @@ -182,7 +187,13 @@ enum RecommendationEngine { return (c, score) } - return scored.max(by: { $0.1 < $1.1 })?.0 + if let best = scored.max(by: { $0.1 < $1.1 })?.0 { return best } + + // Fallback: delta filter found nothing (e.g., illiquid chain). + // Return the closest-to-ATM OTM contract that has a positive bid. + return candidates + .filter { isCall ? $0.strike > currentPrice : $0.strike < currentPrice } + .min(by: { abs($0.strike - currentPrice) < abs($1.strike - currentPrice) }) } // MARK: Expiration helpers diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PortfolioViewModel.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PortfolioViewModel.swift index 0bc9751..7b83f34 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PortfolioViewModel.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PortfolioViewModel.swift @@ -25,6 +25,10 @@ final class PortfolioViewModel: ObservableObject { DataStore.shared.upsertPortfolioPosition(ticker: ticker, shares: shares, costBasis: costBasis) } + func update(id: UUID, shares: Int, costBasis: Double?) { + DataStore.shared.updatePortfolioPosition(id: id, shares: shares, costBasis: costBasis) + } + func delete(id: UUID) { DataStore.shared.removePortfolioPosition(id: id) } diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Portfolio/PortfolioView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Portfolio/PortfolioView.swift index b20636c..a28d1e0 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Portfolio/PortfolioView.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Portfolio/PortfolioView.swift @@ -2,7 +2,8 @@ import SwiftUI struct PortfolioView: View { @StateObject private var vm = PortfolioViewModel() - @State private var showAddSheet = false + @State private var showAddSheet = false + @State private var positionToEdit: PortfolioPosition? = nil var body: some View { NavigationStack { @@ -16,7 +17,12 @@ struct PortfolioView: View { } else { List { ForEach(vm.positions) { position in - PortfolioRowView(position: position) + Button { + positionToEdit = position + } label: { + PortfolioRowView(position: position) + } + .buttonStyle(.plain) } .onDelete { indexSet in for i in indexSet { @@ -40,6 +46,9 @@ struct PortfolioView: View { .sheet(isPresented: $showAddSheet) { AddPositionSheet(vm: vm) } + .sheet(item: $positionToEdit) { position in + EditPositionSheet(vm: vm, position: position) + } } } } @@ -68,6 +77,10 @@ struct PortfolioRowView: View { .font(.caption) .foregroundStyle(.secondary) } + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.leading, 4) } .padding(.vertical, 4) } @@ -86,9 +99,9 @@ struct AddPositionSheet: View { enum Field { case ticker, shares, cost } - var sharesInt: Int? { Int(sharesText) } + var sharesInt: Int? { Int(sharesText) } var costDouble: Double? { costBasisText.isEmpty ? nil : Double(costBasisText) } - var isValid: Bool { !ticker.isEmpty && (sharesInt ?? 0) > 0 } + var isValid: Bool { !ticker.isEmpty && (sharesInt ?? 0) > 0 } var body: some View { NavigationStack { @@ -112,11 +125,9 @@ struct AddPositionSheet: View { if let shares = sharesInt, shares > 0 { Section("") { HStack { - Image(systemName: "info.circle") - .foregroundStyle(.secondary) + Image(systemName: "info.circle").foregroundStyle(.secondary) Text("You can sell up to \(shares / 100) covered call contracts.") - .font(.caption) - .foregroundStyle(.secondary) + .font(.caption).foregroundStyle(.secondary) } } } @@ -124,9 +135,7 @@ struct AddPositionSheet: View { .navigationTitle("Add Stock") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Add") { vm.add(ticker: ticker, shares: sharesInt!, costBasis: costDouble) @@ -139,3 +148,110 @@ struct AddPositionSheet: View { .onAppear { focusedField = .ticker } } } + +// MARK: - Edit Sheet + +struct EditPositionSheet: View { + @ObservedObject var vm: PortfolioViewModel + let position: PortfolioPosition + @Environment(\.dismiss) var dismiss + + @State private var sharesText: String + @State private var costBasisText: String + @State private var showDeleteConfirm = false + @FocusState private var focusedField: Field? + + enum Field { case shares, cost } + + init(vm: PortfolioViewModel, position: PortfolioPosition) { + self.vm = vm + self.position = position + _sharesText = State(initialValue: "\(position.shares)") + _costBasisText = State(initialValue: position.costBasis.map { String(format: "%.2f", $0) } ?? "") + } + + var sharesInt: Int? { Int(sharesText) } + var costDouble: Double? { costBasisText.isEmpty ? nil : Double(costBasisText) } + var isValid: Bool { (sharesInt ?? 0) > 0 } + + var body: some View { + NavigationStack { + Form { + Section("Stock") { + HStack { + Text("Ticker") + Spacer() + Text(position.ticker) + .foregroundStyle(.secondary) + } + } + + Section("Position") { + HStack { + Text("Shares") + Spacer() + TextField("0", text: $sharesText) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .focused($focusedField, equals: .shares) + } + HStack { + Text("Avg Cost Basis") + Spacer() + TextField("Optional", text: $costBasisText) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .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) + } + } + } + + Section { + Button(role: .destructive) { + showDeleteConfirm = true + } label: { + HStack { + Spacer() + Label("Remove \(position.ticker)", systemImage: "trash") + Spacer() + } + } + } + } + .navigationTitle("Edit \(position.ticker)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + vm.update(id: position.id, shares: sharesInt!, costBasis: costDouble) + dismiss() + } + .disabled(!isValid) + } + } + .confirmationDialog( + "Remove \(position.ticker)?", + isPresented: $showDeleteConfirm, + titleVisibility: .visible + ) { + Button("Remove", role: .destructive) { + vm.delete(id: position.id) + dismiss() + } + } message: { + Text("This will also stop monitoring for recommendations on \(position.ticker).") + } + } + .onAppear { focusedField = .shares } + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationsView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationsView.swift index f790aea..e7375c7 100644 --- a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationsView.swift +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationsView.swift @@ -3,7 +3,7 @@ import SwiftUI struct RecommendationsView: View { @StateObject private var vm = RecommendationsViewModel() - private let horizons = ["weekly", "monthly", "0dte", "1dte"] + private let horizons = ["weekly", "monthly", "0dte", "1dte"] private let strategies = ["covered_call", "cash_secured_put"] var body: some View { @@ -31,13 +31,26 @@ struct RecommendationsView: View { Divider() // ─── Content ────────────────────────────────────────────── - if vm.isLoading { - LoadingView(message: "Getting recommendations...") + if vm.isRefreshing { + // Show a full-screen spinner while fetching + VStack(spacing: 16) { + Spacer() + ProgressView() + .scaleEffect(1.4) + Text("Fetching market data…") + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + } } else if vm.filtered.isEmpty { EmptyStateView( icon: "chart.xyaxis.line", - title: "No recommendations", - subtitle: "Add stocks to your portfolio or try a different horizon." + title: vm.recommendations.isEmpty + ? "No data yet — tap ↻ to fetch" + : "No \(horizonLabel(vm.selectedHorizon)) recommendations", + subtitle: vm.recommendations.isEmpty + ? "Add stocks in the Portfolio tab first, then refresh." + : "Try a different horizon or strategy." ) } else { List(vm.filtered) { rec in @@ -49,7 +62,7 @@ struct RecommendationsView: View { .refreshable { await vm.refresh() } } } - .navigationTitle("Recommendations") + .navigationTitle("Setups") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { if vm.isRefreshing { @@ -64,21 +77,27 @@ struct RecommendationsView: View { } } } - .onAppear { vm.load() } + .task { + vm.load() + // Auto-fetch on first open when we have no data yet + if vm.recommendations.isEmpty && !DataStore.shared.tickers.isEmpty { + await vm.refresh() + } + } } private func horizonLabel(_ h: String) -> String { switch h { - case "0dte": return "0DTE" - case "1dte": return "1DTE" - case "weekly": return "Weekly" + case "0dte": return "0DTE" + case "1dte": return "1DTE" + case "weekly": return "Weekly" case "monthly": return "Monthly" - default: return h.capitalized + default: return h.capitalized } } } -// ─── List Row ───────────────────────────────────────────────────────────────── +// MARK: - List Row struct RecommendationListRow: View { let rec: Recommendation @@ -97,7 +116,7 @@ struct RecommendationListRow: View { } HStack(spacing: 14) { dataPoint("Strike", String(format: "$%.0f", rec.recommendedStrike)) - dataPoint("Exp", rec.recommendedExpiration) + dataPoint("Exp", rec.recommendedExpiration) dataPoint("Credit", String(format: "$%.2f", rec.estimatedPremium)) } }