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()
|
costBasis: costBasis, createdAt: Date()
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
portfolio.sort { $0.ticker < $1.ticker }
|
||||||
savePortfolio()
|
savePortfolio()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +53,18 @@ final class DataStore: ObservableObject {
|
|||||||
savePortfolio()
|
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) }
|
var tickers: [String] { portfolio.map(\.ticker) }
|
||||||
|
|
||||||
// MARK: - Option positions
|
// MARK: - Option positions
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ enum RecommendationEngine {
|
|||||||
contracts: contracts,
|
contracts: contracts,
|
||||||
strategy: strategy,
|
strategy: strategy,
|
||||||
currentPrice: currentPrice,
|
currentPrice: currentPrice,
|
||||||
signal: signal
|
signal: signal,
|
||||||
|
daysToExpiry: daysToExp
|
||||||
) else { return nil }
|
) else { return nil }
|
||||||
|
|
||||||
// T in years
|
// T in years
|
||||||
@@ -135,14 +136,18 @@ enum RecommendationEngine {
|
|||||||
|
|
||||||
// MARK: Strike selection
|
// 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(
|
static func selectBestContract(
|
||||||
contracts: [OptionContract],
|
contracts: [OptionContract],
|
||||||
strategy: String,
|
strategy: String,
|
||||||
currentPrice: Double,
|
currentPrice: Double,
|
||||||
signal: SignalResult
|
signal: SignalResult,
|
||||||
|
daysToExpiry: Int = 30
|
||||||
) -> OptionContract? {
|
) -> OptionContract? {
|
||||||
let isCall = strategy == "covered_call"
|
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
|
// Only OTM contracts with reasonable premium
|
||||||
let candidates = contracts.filter { c in
|
let candidates = contracts.filter { c in
|
||||||
@@ -153,7 +158,6 @@ enum RecommendationEngine {
|
|||||||
|
|
||||||
// Score each contract: prefer delta near 0.25, above support / below resistance
|
// Score each contract: prefer delta near 0.25, above support / below resistance
|
||||||
let scored: [(OptionContract, Double)] = candidates.compactMap { c in
|
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(
|
let delta = abs(SignalEngine.bsDelta(
|
||||||
S: currentPrice,
|
S: currentPrice,
|
||||||
K: c.strike,
|
K: c.strike,
|
||||||
@@ -161,8 +165,9 @@ enum RecommendationEngine {
|
|||||||
sigma: c.impliedVolatility,
|
sigma: c.impliedVolatility,
|
||||||
isCall: isCall
|
isCall: isCall
|
||||||
))
|
))
|
||||||
// Hard filter: keep delta 0.10–0.40
|
// Hard filter: keep delta 0.10–0.40 — relax slightly for very short expirations
|
||||||
guard delta >= 0.10, delta <= 0.40 else { return nil }
|
let minDelta = daysToExpiry <= 3 ? 0.05 : 0.10
|
||||||
|
guard delta >= minDelta, delta <= 0.45 else { return nil }
|
||||||
|
|
||||||
// Penalty for deviation from target 0.25
|
// Penalty for deviation from target 0.25
|
||||||
let targetDelta = 0.25
|
let targetDelta = 0.25
|
||||||
@@ -182,7 +187,13 @@ enum RecommendationEngine {
|
|||||||
return (c, score)
|
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
|
// MARK: Expiration helpers
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ final class PortfolioViewModel: ObservableObject {
|
|||||||
DataStore.shared.upsertPortfolioPosition(ticker: ticker, shares: shares, costBasis: costBasis)
|
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) {
|
func delete(id: UUID) {
|
||||||
DataStore.shared.removePortfolioPosition(id: id)
|
DataStore.shared.removePortfolioPosition(id: id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
struct PortfolioView: View {
|
struct PortfolioView: View {
|
||||||
@StateObject private var vm = PortfolioViewModel()
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -16,8 +17,13 @@ struct PortfolioView: View {
|
|||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(vm.positions) { position in
|
ForEach(vm.positions) { position in
|
||||||
|
Button {
|
||||||
|
positionToEdit = position
|
||||||
|
} label: {
|
||||||
PortfolioRowView(position: position)
|
PortfolioRowView(position: position)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
.onDelete { indexSet in
|
.onDelete { indexSet in
|
||||||
for i in indexSet {
|
for i in indexSet {
|
||||||
vm.delete(id: vm.positions[i].id)
|
vm.delete(id: vm.positions[i].id)
|
||||||
@@ -40,6 +46,9 @@ struct PortfolioView: View {
|
|||||||
.sheet(isPresented: $showAddSheet) {
|
.sheet(isPresented: $showAddSheet) {
|
||||||
AddPositionSheet(vm: vm)
|
AddPositionSheet(vm: vm)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $positionToEdit) { position in
|
||||||
|
EditPositionSheet(vm: vm, position: position)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +77,10 @@ struct PortfolioRowView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.padding(.leading, 4)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
@@ -112,11 +125,9 @@ struct AddPositionSheet: View {
|
|||||||
if let shares = sharesInt, shares > 0 {
|
if let shares = sharesInt, shares > 0 {
|
||||||
Section("") {
|
Section("") {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle").foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text("You can sell up to \(shares / 100) covered call contracts.")
|
Text("You can sell up to \(shares / 100) covered call contracts.")
|
||||||
.font(.caption)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,9 +135,7 @@ struct AddPositionSheet: View {
|
|||||||
.navigationTitle("Add Stock")
|
.navigationTitle("Add Stock")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
|
||||||
Button("Cancel") { dismiss() }
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("Add") {
|
Button("Add") {
|
||||||
vm.add(ticker: ticker, shares: sharesInt!, costBasis: costDouble)
|
vm.add(ticker: ticker, shares: sharesInt!, costBasis: costDouble)
|
||||||
@@ -139,3 +148,110 @@ struct AddPositionSheet: View {
|
|||||||
.onAppear { focusedField = .ticker }
|
.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()
|
Divider()
|
||||||
|
|
||||||
// ─── Content ──────────────────────────────────────────────
|
// ─── Content ──────────────────────────────────────────────
|
||||||
if vm.isLoading {
|
if vm.isRefreshing {
|
||||||
LoadingView(message: "Getting recommendations...")
|
// 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 {
|
} else if vm.filtered.isEmpty {
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: "chart.xyaxis.line",
|
icon: "chart.xyaxis.line",
|
||||||
title: "No recommendations",
|
title: vm.recommendations.isEmpty
|
||||||
subtitle: "Add stocks to your portfolio or try a different horizon."
|
? "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 {
|
} else {
|
||||||
List(vm.filtered) { rec in
|
List(vm.filtered) { rec in
|
||||||
@@ -49,7 +62,7 @@ struct RecommendationsView: View {
|
|||||||
.refreshable { await vm.refresh() }
|
.refreshable { await vm.refresh() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Recommendations")
|
.navigationTitle("Setups")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
if vm.isRefreshing {
|
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 {
|
private func horizonLabel(_ h: String) -> String {
|
||||||
@@ -78,7 +97,7 @@ struct RecommendationsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── List Row ─────────────────────────────────────────────────────────────────
|
// MARK: - List Row
|
||||||
|
|
||||||
struct RecommendationListRow: View {
|
struct RecommendationListRow: View {
|
||||||
let rec: Recommendation
|
let rec: Recommendation
|
||||||
|
|||||||
Reference in New Issue
Block a user