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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct PortfolioView: View {
|
||||
@StateObject private var vm = PortfolioViewModel()
|
||||
@State private var showAddSheet = false
|
||||
@State private var positionToEdit: PortfolioPosition? = nil
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -16,8 +17,13 @@ struct PortfolioView: View {
|
||||
} else {
|
||||
List {
|
||||
ForEach(vm.positions) { position in
|
||||
Button {
|
||||
positionToEdit = position
|
||||
} label: {
|
||||
PortfolioRowView(position: position)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for i in indexSet {
|
||||
vm.delete(id: vm.positions[i].id)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +77,13 @@ 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 {
|
||||
@@ -78,7 +97,7 @@ struct RecommendationsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── List Row ─────────────────────────────────────────────────────────────────
|
||||
// MARK: - List Row
|
||||
|
||||
struct RecommendationListRow: View {
|
||||
let rec: Recommendation
|
||||
|
||||
Reference in New Issue
Block a user