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>
240 lines
9.5 KiB
Swift
240 lines
9.5 KiB
Swift
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)
|
||
}
|
||
}
|