import Foundation import Combine import CoreData @MainActor class ChartsViewModel: ObservableObject { // MARK: - Chart Types enum ChartType: String, CaseIterable, Identifiable { case evolution = "Evolution" case allocation = "Allocation" case performance = "Performance" case contributions = "Contributions" case rollingReturn = "Rolling 12M" case riskReturn = "Risk vs Return" case cashflow = "Net vs Contributions" case drawdown = "Drawdown" case volatility = "Volatility" case prediction = "Prediction" var id: String { rawValue } var icon: String { switch self { case .evolution: return "chart.line.uptrend.xyaxis" case .allocation: return "chart.pie.fill" case .performance: return "chart.bar.fill" case .contributions: return "tray.and.arrow.down.fill" case .rollingReturn: return "arrow.triangle.2.circlepath" case .riskReturn: return "dot.square" case .cashflow: return "chart.bar.xaxis" case .drawdown: return "arrow.down.right.circle" case .volatility: return "waveform.path.ecg" case .prediction: return "wand.and.stars" } } var isPremium: Bool { switch self { case .evolution: return false case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow, .drawdown, .volatility, .prediction: return true } } var description: String { switch self { case .evolution: return "Track your portfolio value over time" case .allocation: return "See how your investments are distributed" case .performance: return "Compare returns across categories" case .contributions: return "Review monthly inflows over time" case .rollingReturn: return "See rolling 12-month performance" case .riskReturn: return "Compare volatility vs return" case .cashflow: return "Compare growth vs contributions" case .drawdown: return "Analyze declines from peak values" case .volatility: return "Understand investment risk levels" case .prediction: return "View 12-month forecasts" } } } func availableChartTypes(calmModeEnabled: Bool) -> [ChartType] { let types: [ChartType] = calmModeEnabled ? [.evolution, .allocation, .performance, .contributions] : ChartType.allCases if !types.contains(selectedChartType) { selectedChartType = .evolution } return types } // MARK: - Published Properties @Published var selectedChartType: ChartType = .evolution @Published var selectedCategory: Category? @Published var selectedTimeRange: TimeRange = .year @Published var selectedAccount: Account? @Published var showAllAccounts = true @Published var evolutionData: [(date: Date, value: Decimal)] = [] @Published var categoryEvolutionData: [CategoryEvolutionPoint] = [] @Published var allocationData: [(category: String, value: Decimal, color: String)] = [] @Published var performanceData: [(category: String, cagr: Double, color: String)] = [] @Published var contributionsData: [(date: Date, amount: Decimal)] = [] @Published var rollingReturnData: [(date: Date, value: Double)] = [] @Published var riskReturnData: [(category: String, cagr: Double, volatility: Double, color: String)] = [] @Published var cashflowData: [(date: Date, contributions: Decimal, netPerformance: Decimal)] = [] @Published var drawdownData: [(date: Date, drawdown: Double)] = [] @Published var volatilityData: [(date: Date, volatility: Double)] = [] @Published var predictionData: [Prediction] = [] @Published var isLoading = false @Published var showingPaywall = false @Published private var predictionMonthsAhead = 12 // MARK: - Time Range enum TimeRange: String, CaseIterable, Identifiable { case month = "1M" case quarter = "3M" case halfYear = "6M" case year = "1Y" case all = "All" var id: String { rawValue } var months: Int? { switch self { case .month: return 1 case .quarter: return 3 case .halfYear: return 6 case .year: return 12 case .all: return nil } } } // MARK: - Dependencies private let sourceRepository: InvestmentSourceRepository private let categoryRepository: CategoryRepository private let snapshotRepository: SnapshotRepository private let calculationService: CalculationService private let predictionEngine: PredictionEngine private let freemiumValidator: FreemiumValidator private let maxHistoryMonths = 60 private let maxStackedCategories = 6 private let maxChartPoints = 500 private var cancellables = Set() private var allCategories: [Category] { categoryRepository.categories } // MARK: - Performance: Caching and State private var lastChartType: ChartType? private var lastTimeRange: TimeRange? private var lastCategoryId: UUID? private var lastAccountId: UUID? private var lastShowAllAccounts: Bool = true private var cachedSnapshots: [Snapshot]? private var isUpdateInProgress = false // MARK: - Initialization init( sourceRepository: InvestmentSourceRepository? = nil, categoryRepository: CategoryRepository? = nil, snapshotRepository: SnapshotRepository? = nil, calculationService: CalculationService? = nil, predictionEngine: PredictionEngine? = nil, iapService: IAPService ) { self.sourceRepository = sourceRepository ?? InvestmentSourceRepository() self.categoryRepository = categoryRepository ?? CategoryRepository() self.snapshotRepository = snapshotRepository ?? SnapshotRepository() self.calculationService = calculationService ?? .shared self.predictionEngine = predictionEngine ?? .shared self.freemiumValidator = FreemiumValidator(iapService: iapService) setupObservers() loadData() } // MARK: - Setup private func setupObservers() { // Performance: Combine all selection changes into a single debounced stream // This prevents multiple rapid updates when switching between views Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount) .combineLatest($showAllAccounts) .debounce(for: .milliseconds(150), scheduler: DispatchQueue.main) .sink { [weak self] combined, showAll in guard let self else { return } let (chartType, category, timeRange, _) = combined // Performance: Skip update if nothing meaningful changed let safeSelectedAccountId = self.safeSelectedAccountId let hasChanges = self.lastChartType != chartType || self.lastTimeRange != timeRange || self.lastCategoryId != category?.id || self.lastAccountId != safeSelectedAccountId || self.lastShowAllAccounts != showAll if hasChanges { self.lastChartType = chartType self.lastTimeRange = timeRange self.lastCategoryId = category?.id self.lastAccountId = safeSelectedAccountId self.lastShowAllAccounts = showAll self.cachedSnapshots = nil // Invalidate cache on meaningful changes self.updateChartData(chartType: chartType, category: category, timeRange: timeRange) } } .store(in: &cancellables) } // MARK: - Data Loading func loadData() { updateChartData( chartType: selectedChartType, category: selectedCategory, timeRange: selectedTimeRange ) FirebaseService.shared.logScreenView(screenName: "Charts") } func selectChart(_ chartType: ChartType) { if chartType.isPremium && !freemiumValidator.isPremium { showingPaywall = true FirebaseService.shared.logPaywallShown(trigger: "advanced_charts") return } selectedChartType = chartType FirebaseService.shared.logChartViewed( chartType: chartType.rawValue, isPremium: chartType.isPremium ) } private func updateChartData(chartType: ChartType, category: Category?, timeRange: TimeRange) { // Performance: Prevent re-entrancy guard !isUpdateInProgress else { return } isUpdateInProgress = true isLoading = true defer { isLoading = false isUpdateInProgress = false } let sources: [InvestmentSource] if let category = category { sources = sourceRepository.fetchSources(for: category).filter { shouldIncludeSource($0) } } else { sources = sourceRepository.sources.filter { shouldIncludeSource($0) } } let monthsLimit = timeRange.months ?? maxHistoryMonths let sourceIds = sources.compactMap { $0.id } // Performance: Reuse cached snapshots when possible var snapshots: [Snapshot] if let cached = cachedSnapshots { snapshots = cached } else { snapshots = snapshotRepository.fetchSnapshots( for: sourceIds, months: monthsLimit ) snapshots = freemiumValidator.filterSnapshots(snapshots) cachedSnapshots = snapshots } let completedSnapshots = filterSnapshotsForCharts( sources: sources, snapshots: snapshots ) // Performance: Only calculate data for the selected chart type switch chartType { case .evolution: calculateEvolutionData(from: completedSnapshots) let categoriesForChart = categoriesForStackedChart( sources: sources, selectedCategory: selectedCategory ) calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart) case .allocation: calculateAllocationData(for: sources) case .performance: let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots) calculatePerformanceData(for: sources, snapshotsBySource: completedSnapshotsBySource) case .contributions: calculateContributionsData(from: completedSnapshots) case .rollingReturn: calculateRollingReturnData(from: completedSnapshots) case .riskReturn: let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots) calculateRiskReturnData(for: sources, snapshotsBySource: completedSnapshotsBySource) case .cashflow: calculateCashflowData(from: completedSnapshots) case .drawdown: calculateDrawdownData(from: completedSnapshots) case .volatility: calculateVolatilityData(from: completedSnapshots) case .prediction: calculatePredictionData(from: completedSnapshots) } if let selected = selectedCategory, !availableCategories(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) { selectedCategory = nil } } func availableCategories( for chartType: ChartType, sources: [InvestmentSource]? = nil ) -> [Category] { let relevantSources = sources ?? sourceRepository.sources.filter { shouldIncludeSource($0) } let categoriesWithData = Set(relevantSources.compactMap { $0.category?.id }) let filtered = allCategories.filter { categoriesWithData.contains($0.id) } switch chartType { case .evolution, .prediction: return filtered default: return [] } } private func shouldIncludeSource(_ source: InvestmentSource) -> Bool { if showAllAccounts || selectedAccount == nil { return true } guard let selectedId = safeSelectedAccountId else { return true } return source.account?.id == selectedId } private var safeSelectedAccountId: UUID? { guard !showAllAccounts, let selected = selectedAccount, !selected.isDeleted else { return nil } return selected.id } private func categoriesForStackedChart( sources: [InvestmentSource], selectedCategory: Category? ) -> [Category] { var totals: [UUID: Decimal] = [:] for source in sources { guard let categoryId = source.category?.id else { continue } totals[categoryId, default: 0] += source.latestValue } var topCategoryIds = Set( totals.sorted { $0.value > $1.value } .prefix(maxStackedCategories) .map { $0.key } ) if let selectedCategory { topCategoryIds.insert(selectedCategory.id) } return categoryRepository.categories.filter { topCategoryIds.contains($0.id) } } private func downsampleSeries( _ data: [(date: Date, value: Decimal)], maxPoints: Int ) -> [(date: Date, value: Decimal)] { guard data.count > maxPoints, maxPoints > 0 else { return data } let bucketSize = max(1, Int(ceil(Double(data.count) / Double(maxPoints)))) var sampled: [(date: Date, value: Decimal)] = [] sampled.reserveCapacity(maxPoints) var index = 0 while index < data.count { let end = min(index + bucketSize, data.count) let bucket = data[index.. [Date] { guard dates.count > maxPoints, maxPoints > 0 else { return dates } let bucketSize = max(1, Int(ceil(Double(dates.count) / Double(maxPoints)))) var sampled: [Date] = [] sampled.reserveCapacity(maxPoints) var index = 0 while index < dates.count { let end = min(index + bucketSize, dates.count) let bucket = dates[index.. DateComponents in let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date) return DateComponents(year: components.year, month: components.month) } var series: [(date: Date, value: Decimal)] = [] series.reserveCapacity(groupedByMonth.count) for (key, monthSnapshots) in groupedByMonth { var latestBySource: [UUID: Snapshot] = [:] for snapshot in monthSnapshots { guard let sourceId = snapshot.source?.id else { continue } if let existing = latestBySource[sourceId] { if snapshot.date > existing.date { latestBySource[sourceId] = snapshot } } else { latestBySource[sourceId] = snapshot } } let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue } let date = Calendar.current.date(from: key) ?? Date() series.append((date: date, value: total)) } series.sort { $0.date < $1.date } evolutionData = downsampleSeries(series, maxPoints: maxChartPoints) } private func calculateCategoryEvolutionData(from snapshots: [Snapshot], categories: [Category]) { let sortedSnapshots = snapshots.sorted { $0.date < $1.date } let categoriesWithData = Set(sortedSnapshots.compactMap { $0.source?.category?.id }) let filteredCategories = categories.filter { categoriesWithData.contains($0.id) } let snapshotsByDay = Dictionary(grouping: sortedSnapshots) { Calendar.current.startOfDay(for: $0.date) } let uniqueDates = downsampleDates(snapshotsByDay.keys.sorted(), maxPoints: maxChartPoints) var latestBySource: [UUID: Snapshot] = [:] var points: [CategoryEvolutionPoint] = [] for date in uniqueDates { if let daySnapshots = snapshotsByDay[date] { for snapshot in daySnapshots { guard let sourceId = snapshot.source?.id else { continue } latestBySource[sourceId] = snapshot } } var valuesByCategory: [UUID: Decimal] = [:] for snapshot in latestBySource.values { guard let category = snapshot.source?.category else { continue } valuesByCategory[category.id, default: 0] += snapshot.decimalValue } for category in filteredCategories { let value = valuesByCategory[category.id] ?? 0 points.append(CategoryEvolutionPoint( date: date, categoryName: category.name, colorHex: category.colorHex, value: value )) } } categoryEvolutionData = points } private func calculateAllocationData(for sources: [InvestmentSource]) { let categories = categoryRepository.categories let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() } allocationData = categories.compactMap { category in let categorySources = valuesByCategory[category.id] ?? [] let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue } guard categoryValue > 0 else { return nil } return ( category: category.name, value: categoryValue, color: category.colorHex ) }.sorted { $0.value > $1.value } } private func completedMonthKeys( sources: [InvestmentSource], snapshots: [Snapshot], after cutoff: Date ) -> Set { let sourceIds = Set(sources.compactMap { $0.id }) guard !sourceIds.isEmpty else { return [] } let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date) return DateComponents(year: components.year, month: components.month) } var completed: Set = [] completed.reserveCapacity(groupedByMonth.count) for (key, monthSnapshots) in groupedByMonth { guard let monthDate = Calendar.current.date(from: key) else { continue } guard monthDate > cutoff else { continue } guard MonthlyCheckInStore.completionDate(for: monthDate) != nil else { continue } let monthSourceIds = Set(monthSnapshots.compactMap { $0.source?.id }) if sourceIds.isSubset(of: monthSourceIds) { completed.insert(key) } } return completed } private func filterSnapshotsForCharts( sources: [InvestmentSource], snapshots: [Snapshot] ) -> [Snapshot] { guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else { return snapshots } let completedMonthsAfter = completedMonthKeys( sources: sources, snapshots: snapshots, after: lastCompleted ) return snapshots.filter { snapshot in let monthDate = snapshot.date.startOfMonth if let completionDate = MonthlyCheckInStore.completionDate(for: monthDate), snapshot.date > completionDate { return false } if monthDate <= lastCompleted { return true } let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date) let key = DateComponents(year: components.year, month: components.month) return completedMonthsAfter.contains(key) } } private func groupSnapshotsBySource(_ snapshots: [Snapshot]) -> [UUID: [Snapshot]] { var grouped: [UUID: [Snapshot]] = [:] for snapshot in snapshots { guard let id = snapshot.source?.id else { continue } grouped[id, default: []].append(snapshot) } return grouped } private func calculatePerformanceData( for sources: [InvestmentSource], snapshotsBySource: [UUID: [Snapshot]] ) { let categories = categoryRepository.categories let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() } performanceData = categories.compactMap { category in let categorySources = sourcesByCategory[category.id] ?? [] let snapshots = categorySources.compactMap { source -> [Snapshot]? in let id = source.id return snapshotsBySource[id] }.flatMap { $0 } guard snapshots.count >= 2 else { return nil } let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots) guard let first = monthlyTotals.first, let last = monthlyTotals.last, first.totalValue > 0 else { return nil } let cagr = calculationService.calculateCAGR( startValue: first.totalValue, endValue: last.totalValue, startDate: first.date, endDate: last.date ) return ( category: category.name, cagr: cagr, color: category.colorHex ) }.sorted { $0.cagr > $1.cagr } } private func calculateContributionsData(from snapshots: [Snapshot]) { let grouped = Dictionary(grouping: snapshots) { $0.date.startOfMonth } contributionsData = grouped.map { date, items in let total = items.reduce(Decimal.zero) { $0 + $1.decimalContribution } return (date: date, amount: total) } .sorted { $0.date < $1.date } } private func calculateRollingReturnData(from snapshots: [Snapshot]) { let monthlyTotals = monthlyTotals(from: snapshots) guard monthlyTotals.count >= 13 else { rollingReturnData = [] return } var returns: [(date: Date, value: Double)] = [] for index in 12.. 0 else { continue } let change = current.totalValue - base.totalValue let percent = NSDecimalNumber(decimal: change / base.totalValue).doubleValue * 100 returns.append((date: current.date, value: percent)) } rollingReturnData = returns } private func calculateRiskReturnData( for sources: [InvestmentSource], snapshotsBySource: [UUID: [Snapshot]] ) { let categories = categoryRepository.categories let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() } riskReturnData = categories.compactMap { category in let categorySources = sourcesByCategory[category.id] ?? [] let snapshots = categorySources.compactMap { source -> [Snapshot]? in let id = source.id return snapshotsBySource[id] }.flatMap { $0 } let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots) guard monthlyTotals.count >= 3, let first = monthlyTotals.first, let last = monthlyTotals.last, first.totalValue > 0 else { return nil } let cagr = calculationService.calculateCAGR( startValue: first.totalValue, endValue: last.totalValue, startDate: first.date, endDate: last.date ) let monthlyReturns = monthlyReturnSeries(from: monthlyTotals) let volatility = calculationService.calculateVolatility(monthlyReturns: monthlyReturns) return ( category: category.name, cagr: cagr, volatility: volatility, color: category.colorHex ) }.sorted { $0.cagr > $1.cagr } } private func calculateCashflowData(from snapshots: [Snapshot]) { let monthlyTotals = monthlyTotals(from: snapshots) let contributionsByMonth = Dictionary(grouping: snapshots) { $0.date.startOfMonth } .mapValues { items in items.reduce(Decimal.zero) { $0 + $1.decimalContribution } } var data: [(date: Date, contributions: Decimal, netPerformance: Decimal)] = [] for index in 0.. 0 ? monthlyTotals[index - 1].totalValue : 0 let contributions = contributionsByMonth[current.date] ?? 0 let netPerformance = current.totalValue - previousTotal - contributions data.append((date: current.date, contributions: contributions, netPerformance: netPerformance)) } cashflowData = data } private func calculateDrawdownData(from snapshots: [Snapshot]) { let sortedSnapshots = snapshots.sorted { $0.date < $1.date } guard !sortedSnapshots.isEmpty else { drawdownData = [] return } var peak = sortedSnapshots.first!.decimalValue var data: [(date: Date, drawdown: Double)] = [] for snapshot in sortedSnapshots { let value = snapshot.decimalValue if value > peak { peak = value } let drawdown = peak > 0 ? NSDecimalNumber(decimal: (peak - value) / peak).doubleValue * 100 : 0 data.append((date: snapshot.date, drawdown: -drawdown)) } drawdownData = data } private func calculateVolatilityData(from snapshots: [Snapshot]) { let sortedSnapshots = snapshots.sorted { $0.date < $1.date } guard sortedSnapshots.count >= 3 else { volatilityData = [] return } var data: [(date: Date, volatility: Double)] = [] let windowSize = 3 for i in windowSize.. 0 ? (stdDev / mean) * 100 : 0 data.append((date: sortedSnapshots[i].date, volatility: volatility)) } volatilityData = data } private func calculatePredictionData(from snapshots: [Snapshot]) { guard freemiumValidator.canViewPredictions() else { predictionData = [] return } let result = predictionEngine.predict(snapshots: snapshots, monthsAhead: predictionMonthsAhead) predictionData = result.predictions } private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] { let sortedSnapshots = snapshots.sorted { $0.date < $1.date } let months = Array(Set(sortedSnapshots.map { $0.date.startOfMonth })).sorted() guard !months.isEmpty else { return [] } var snapshotsBySource: [UUID: [Snapshot]] = [:] for snapshot in sortedSnapshots { guard let sourceId = snapshot.source?.id else { continue } snapshotsBySource[sourceId, default: []].append(snapshot) } var indices: [UUID: Int] = [:] var totals: [(date: Date, totalValue: Decimal)] = [] for (index, month) in months.enumerated() { let nextMonth = index + 1 < months.count ? months[index + 1] : Date.distantFuture var total: Decimal = 0 for (sourceId, sourceSnapshots) in snapshotsBySource { var currentIndex = indices[sourceId] ?? 0 var latest: Snapshot? while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextMonth { latest = sourceSnapshots[currentIndex] currentIndex += 1 } indices[sourceId] = currentIndex total += latest?.decimalValue ?? 0 } totals.append((date: month, totalValue: total)) } return totals } private func monthlyTotalsByMonthYear(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] { let sortedSnapshots = snapshots.sorted { $0.date < $1.date } let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date) return DateComponents(year: components.year, month: components.month) } var totals: [(date: Date, totalValue: Decimal)] = [] totals.reserveCapacity(groupedByMonth.count) for (key, monthSnapshots) in groupedByMonth { var latestBySource: [UUID: Snapshot] = [:] for snapshot in monthSnapshots { guard let sourceId = snapshot.source?.id else { continue } if let existing = latestBySource[sourceId] { if snapshot.date > existing.date { latestBySource[sourceId] = snapshot } } else { latestBySource[sourceId] = snapshot } } let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue } let date = Calendar.current.date(from: key) ?? Date() totals.append((date: date, totalValue: total)) } return totals.sorted { $0.date < $1.date } } private func monthlyReturnSeries( from monthlyTotals: [(date: Date, totalValue: Decimal)] ) -> [InvestmentMetrics.MonthlyReturn] { guard monthlyTotals.count >= 2 else { return [] } var returns: [InvestmentMetrics.MonthlyReturn] = [] returns.reserveCapacity(monthlyTotals.count - 1) for index in 1.. 0 else { continue } let returnPercentage = NSDecimalNumber( decimal: (current.totalValue - previous.totalValue) / previous.totalValue ).doubleValue * 100 returns.append(InvestmentMetrics.MonthlyReturn( date: current.date, returnPercentage: returnPercentage )) } return returns } func updatePredictionTargetDate(_ goals: [Goal]) { let futureGoalDates = goals.compactMap { $0.targetDate }.filter { $0 > Date() } guard let latestGoalDate = futureGoalDates.max(), let lastSnapshotDate = evolutionData.last?.date else { predictionMonthsAhead = 12 return } let months = max(1, lastSnapshotDate.startOfMonth.monthsBetween(latestGoalDate.startOfMonth)) predictionMonthsAhead = max(12, months) if selectedChartType == .prediction { updateChartData(chartType: selectedChartType, category: selectedCategory, timeRange: selectedTimeRange) } } // MARK: - Computed Properties var categories: [Category] { categoryRepository.categories } var availableChartTypes: [ChartType] { ChartType.allCases } var isPremium: Bool { freemiumValidator.isPremium } var hasData: Bool { !sourceRepository.sources.filter { shouldIncludeSource($0) }.isEmpty } }