Portfolio edit/sort + fix Setups tab showing no recommendations

Portfolio:
- Alphabetize list on every add/edit (sort by ticker after upsert)
- Add DataStore.updatePortfolioPosition() and PortfolioViewModel.update()
- Add EditPositionSheet: tap any row to edit shares, cost basis, or delete
  with a confirmation dialog; ticker is read-only in edit mode
- Swipe-to-delete still works as before

Setups tab (three bugs fixed):
1. Auto-refresh on first open — .task now triggers vm.refresh() when
   DataStore has tickers but no cached recommendations, so the tab
   populates immediately without requiring a manual ↻ tap
2. Loading state — replaced the never-set vm.isLoading check with
   vm.isRefreshing so the spinner actually shows during fetch
3. Strike selection used a hardcoded T=30 days for all time horizons,
   causing weekly/1DTE options to pick strikes too far OTM (near-zero
   delta at actual expiry → zero bid → filtered out → no results).
   selectBestContract() now receives real daysToExpiry, relaxes the
   delta floor for very short expirations (≤3 days: 0.05 min vs 0.10),
   and adds a closest-to-ATM fallback when the delta filter finds nothing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 08:43:43 -04:00
parent d153296ac1
commit 86ad024252
5 changed files with 194 additions and 31 deletions

View File

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

View File

@@ -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.150.35).
/// Picks the contract with delta closest to 0.25 (target range 0.100.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.100.40
guard delta >= 0.10, delta <= 0.40 else { return nil }
// Hard filter: keep delta 0.100.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

View File

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

View File

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

View File

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