Files
Options-SideKick/ios/OptionsSidekick/OptionsSidekick/Views/Dashboard/DashboardView.swift
olsch01 b7d4e900cc Initial implementation of Options Sidekick
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>
2026-04-09 14:38:25 -04:00

240 lines
9.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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