import Foundation import Combine @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 cachedSnapshotsBySource: [UUID: [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 hasChanges = self.lastChartType != chartType || self.lastTimeRange != timeRange || self.lastCategoryId != category?.id || self.lastAccountId != self.selectedAccount?.id || self.lastShowAllAccounts != showAll if hasChanges { self.lastChartType = chartType self.lastTimeRange = timeRange self.lastCategoryId = category?.id self.lastAccountId = self.selectedAccount?.id self.lastShowAllAccounts = showAll self.cachedSnapshots = nil // Invalidate cache on meaningful changes self.cachedSnapshotsBySource = nil 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 } // Performance: Cache snapshotsBySource let snapshotsBySource: [UUID: [Snapshot]] if let cached = cachedSnapshotsBySource { snapshotsBySource = cached } else { var grouped: [UUID: [Snapshot]] = [:] grouped.reserveCapacity(sources.count) for snapshot in snapshots { guard let id = snapshot.source?.id else { continue } grouped[id, default: []].append(snapshot) } snapshotsBySource = grouped cachedSnapshotsBySource = grouped } // Performance: Only calculate data for the selected chart type switch chartType { case .evolution: calculateEvolutionData(from: snapshots) let categoriesForChart = categoriesForStackedChart( sources: sources, selectedCategory: selectedCategory ) calculateCategoryEvolutionData(from: snapshots, categories: categoriesForChart) case .allocation: calculateAllocationData(for: sources) case .performance: calculatePerformanceData(for: sources, snapshotsBySource: snapshotsBySource) case .contributions: calculateContributionsData(from: snapshots) case .rollingReturn: calculateRollingReturnData(from: snapshots) case .riskReturn: calculateRiskReturnData(for: sources, snapshotsBySource: snapshotsBySource) case .cashflow: calculateCashflowData(from: snapshots) case .drawdown: calculateDrawdownData(from: snapshots) case .volatility: calculateVolatilityData(from: snapshots) case .prediction: calculatePredictionData(from: snapshots) } 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 } return source.account?.id == selectedAccount?.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.. 0 else { return nil } return ( category: category.name, value: categoryValue, color: category.colorHex ) }.sorted { $0.value > $1.value } } 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 metrics = calculationService.calculateMetrics(for: snapshots) return ( category: category.name, cagr: metrics.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 } guard snapshots.count >= 3 else { return nil } let metrics = calculationService.calculateMetrics(for: snapshots) return ( category: category.name, cagr: metrics.cagr, volatility: metrics.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 } 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 } }