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" case yearOverYear = "Year vs Year" case comparison = "Compare" case simulator = "What If" case periodComparison = "Period vs Period" 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" case .yearOverYear: return "calendar" case .comparison: return "chart.xyaxis.line" case .simulator: return "slider.horizontal.3" case .periodComparison: return "calendar.badge.clock" } } var isPremium: Bool { switch self { case .evolution, .comparison, .periodComparison: return false case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow, .drawdown, .volatility, .prediction, .yearOverYear, .simulator: 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" case .yearOverYear: return "Compare portfolio values across years" case .comparison: return "Compare sources side by side" case .simulator: return "Simulate portfolio reallocation" case .periodComparison: return "Compare two time periods side by side" } } } func availableChartTypes(calmModeEnabled: Bool) -> [ChartType] { let types: [ChartType] = calmModeEnabled ? [.evolution, .allocation, .performance, .contributions, .comparison, .periodComparison] : ChartType.allCases if !types.contains(selectedChartType) { selectedChartType = .evolution } return types } // MARK: - Published Properties @Published var selectedChartType: ChartType = .evolution @Published var selectedCategory: Category? @Published var selectedSource: InvestmentSource? @Published var selectedSourceIds: Set = [] @Published var selectedTimeRange: TimeRange = .year @Published var selectedAccount: Account? @Published var showAllAccounts = true @Published var selectedBreakdown: BreakdownMode = .category @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 allocationEvolutionData: [(date: Date, category: String, percentage: Double, color: String)] = [] @Published var yearOverYearData: [YearSeries] = [] /// Months of data hidden by the free-tier 12-month history limit (0 for premium). /// Drives the "unlock X more months" teaser in the charts screen. @Published var hiddenHistoryMonths: Int = 0 @Published var yoySelectedYears: Set = [] struct YearSeries: Identifiable { let id: Int // year let year: Int let values: [Double] // cumulative % return within year (first available month = 0%), NaN = no data var forecastEndValue: Double? = nil // estimated cumulative % at year-end (only for the current, still-incomplete year) } // MARK: - Comparison chart data models enum ComparisonDisplayMode: String, CaseIterable, Identifiable { case indexed = "Base 100" case returnPct = "Return %" case absolute = "Value" case monthlyReturn = "Month %" var id: String { rawValue } } struct ComparisonSeries: Identifiable { let id: UUID let name: String let colorHex: String let points: [(date: Date, value: Double)] } @Published var comparisonData: [ComparisonSeries] = [] @Published var comparisonSelectedSourceIds: Set = [] @Published var comparisonDisplayMode: ComparisonDisplayMode = .returnPct @Published var comparisonAvailableSources: [InvestmentSource] = [] // MARK: - Simulator chart data models struct SimulatorSource: Identifiable { let id: UUID let name: String let currentPct: Double var simulatedPct: Double let colorHex: String } @Published var simulatorSources: [SimulatorSource] = [] @Published var simulatorActualData: [(date: Date, value: Double)] = [] @Published var simulatorData: [(date: Date, value: Double)] = [] // MARK: - Period comparison data models struct PeriodSeries: Identifiable { let id: String let label: String let colorHex: String // e.g. "#0000FF" for blue, "#FF8000" for orange let points: [(monthOffset: Int, returnPct: Double)] } @Published var periodAStart: Date = Calendar.current.date(byAdding: .month, value: -6, to: Date()) ?? Date() @Published var periodAEnd: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date() @Published var periodBStart: Date = Calendar.current.date(byAdding: .month, value: -18, to: Date()) ?? Date() @Published var periodBEnd: Date = Calendar.current.date(byAdding: .month, value: -13, to: Date()) ?? Date() @Published var periodComparisonData: [PeriodSeries] = [] @Published var isLoading = false @Published var showingPaywall = false @Published private var predictionMonthsAhead = 12 @Published var performancePeriodMonths: Int = 12 // MARK: - Time Range enum TimeRange: String, CaseIterable, Identifiable { case month = "1M" case quarter = "3M" case halfYear = "6M" case year = "12M" case yearToDate = "YTD" 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 .yearToDate: return nil case .all: return nil } } func startDate(referenceDate: Date = Date()) -> Date? { switch self { case .month, .quarter, .halfYear, .year: guard let months else { return nil } return referenceDate.adding(months: -months).startOfDay case .yearToDate: return referenceDate.startOfYear case .all: return nil } } } enum BreakdownMode: String, CaseIterable, Identifiable { case category = "By Category" case source = "By Source" var id: String { rawValue } } static func supportsAllocationTargets(for breakdown: BreakdownMode) -> Bool { breakdown == .category } // 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 lastSourceId: UUID? private var lastSourceIds: Set = [] private var lastAccountId: UUID? private var lastShowAllAccounts: Bool = true private var lastBreakdown: BreakdownMode = .category 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($selectedSource, $selectedBreakdown, $showAllAccounts) .combineLatest($selectedSourceIds) .debounce(for: .milliseconds(150), scheduler: DispatchQueue.main) .sink { [weak self] outer, sourceIds in guard let self else { return } let (combined, selectedSource, selectedBreakdown, showAll) = outer 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.lastSourceId != selectedSource?.id || self.lastSourceIds != sourceIds || self.lastAccountId != safeSelectedAccountId || self.lastShowAllAccounts != showAll || self.lastBreakdown != selectedBreakdown if hasChanges { self.lastChartType = chartType self.lastTimeRange = timeRange self.lastCategoryId = category?.id self.lastSourceId = selectedSource?.id self.lastSourceIds = sourceIds self.lastAccountId = safeSelectedAccountId self.lastShowAllAccounts = showAll self.lastBreakdown = selectedBreakdown self.cachedSnapshots = nil // Invalidate cache on meaningful changes self.updateChartData(chartType: chartType, category: category, timeRange: timeRange) } } .store(in: &cancellables) // Comparison chart: react to source selection and display mode changes Publishers.CombineLatest($comparisonSelectedSourceIds, $comparisonDisplayMode) .debounce(for: .milliseconds(150), scheduler: DispatchQueue.main) .sink { [weak self] _ in guard let self, self.selectedChartType == .comparison else { return } self.isLoading = true let allSources = self.sourceRepository.sources.filter { self.shouldIncludeSource($0) } let allSourceIds = allSources.compactMap { $0.id } var snapshots: [Snapshot] if let cached = self.cachedSnapshots { snapshots = cached } else { snapshots = self.snapshotRepository.fetchSnapshots(for: allSourceIds, months: self.maxHistoryMonths) snapshots = self.freemiumValidator.filterSnapshots(snapshots) } var filtered = snapshots if let cutoff = self.selectedTimeRange.startDate() { filtered = snapshots.filter { $0.date >= cutoff } } self.calculateComparisonData(sources: allSources, allSnapshots: filtered) self.isLoading = false } .store(in: &cancellables) // Simulator chart: react to slider changes $simulatorSources .debounce(for: .milliseconds(200), scheduler: DispatchQueue.main) .sink { [weak self] sources in guard let self, self.selectedChartType == .simulator, !sources.isEmpty else { return } self.recalculateSimulatedLine(sources: sources) } .store(in: &cancellables) // Performance: react to custom period slider $performancePeriodMonths .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) .sink { [weak self] _ in guard let self, self.selectedChartType == .performance else { return } self.updateChartData(chartType: .performance, category: self.selectedCategory, timeRange: self.selectedTimeRange) } .store(in: &cancellables) // Period comparison: react to date picker changes Publishers.CombineLatest4($periodAStart, $periodAEnd, $periodBStart, $periodBEnd) .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) .sink { [weak self] _ in guard let self, self.selectedChartType == .periodComparison else { return } let allSources = self.sourceRepository.sources.filter { self.shouldIncludeSource($0) } let allSourceIds = allSources.compactMap { $0.id } let snapshots = self.snapshotRepository.fetchSnapshots(for: allSourceIds, months: self.maxHistoryMonths) self.calculatePeriodComparisonData(allSnapshots: snapshots) } .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 let allowedRanges = availableTimeRanges(for: chartType) if !allowedRanges.contains(selectedTimeRange) { selectedTimeRange = allowedRanges.first ?? .year } FirebaseService.shared.logChartViewed( chartType: chartType.rawValue, isPremium: chartType.isPremium ) } func availableTimeRanges(for chartType: ChartType) -> [TimeRange] { switch chartType { case .evolution: return [.all, .yearToDate, .year, .quarter] case .performance, .yearOverYear, .simulator, .periodComparison: return [] // Performance uses custom slider; others have no time range case .comparison: return [.month, .quarter, .halfYear, .year, .yearToDate, .all] default: return [.month, .quarter, .halfYear, .year, .all] } } 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 !selectedSourceIds.isEmpty { sources = sourceRepository.sources.filter { source in selectedSourceIds.contains(source.id) && shouldIncludeSource(source) } } else if let selectedSource { sources = sourceRepository.sources.filter { $0.id == selectedSource.id && shouldIncludeSource($0) } } else 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 ) hiddenHistoryMonths = freemiumValidator.hiddenHistoryMonths(in: snapshots) snapshots = freemiumValidator.filterSnapshots(snapshots) cachedSnapshots = snapshots } if let cutoffDate = timeRange.startDate() { snapshots = snapshots.filter { $0.date >= cutoffDate } } 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, breakdown: selectedBreakdown) calculateAllocationEvolutionData(from: completedSnapshots) case .performance: // Fetch with performancePeriodMonths (independent of selectedTimeRange) let perfSourceIds = sources.compactMap { $0.id } let perfAllSnapshots = freemiumValidator.filterSnapshots( snapshotRepository.fetchSnapshots(for: perfSourceIds, months: performancePeriodMonths + 1) ) let perfCutoff = Calendar.current.date(byAdding: .month, value: -performancePeriodMonths, to: Date()) ?? Date() let perfFiltered = filterSnapshotsForCharts( sources: sources, snapshots: perfAllSnapshots.filter { $0.date >= perfCutoff } ) let completedSnapshotsBySource = groupSnapshotsBySource(perfFiltered) calculatePerformanceData( for: sources, snapshotsBySource: completedSnapshotsBySource, breakdown: selectedBreakdown ) 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) case .yearOverYear: calculateYearOverYearData(from: completedSnapshots) case .comparison: // Use ALL sources, not filtered by selectedSource/category let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) } comparisonAvailableSources = allSources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } let allSourceIds = allSources.compactMap { $0.id } var allSnapshots: [Snapshot] if let cached = cachedSnapshots { allSnapshots = cached } else { allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths) allSnapshots = freemiumValidator.filterSnapshots(allSnapshots) } var filteredForComparison = allSnapshots if let cutoff = timeRange.startDate() { filteredForComparison = allSnapshots.filter { $0.date >= cutoff } } calculateComparisonData(sources: allSources, allSnapshots: filteredForComparison) case .simulator: let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) } let allSourceIds = allSources.compactMap { $0.id } let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths) calculateSimulatorData(sources: allSources, allSnapshots: allSnapshots) case .periodComparison: let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) } let allSourceIds = allSources.compactMap { $0.id } let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths) calculatePeriodComparisonData(allSnapshots: allSnapshots) } if let selected = selectedCategory, !availableCategories(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) { selectedCategory = nil } if let selected = selectedSource, !availableSources(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) { selectedSource = 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, .allocation, .performance: return filtered case .comparison, .simulator, .periodComparison: return [] default: return [] } } func availableSources( for chartType: ChartType, sources: [InvestmentSource]? = nil ) -> [InvestmentSource] { let relevantSources = sources ?? sourceRepository.sources.filter { shouldIncludeSource($0) } switch chartType { case .evolution, .allocation, .performance: return relevantSources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } case .comparison, .simulator, .periodComparison: return [] 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 } /// Source-specific color palette (distinct from category colors, same order as Color.sourceColors) static let sourceColorHexesPublic: [String] = [ "#6366F1", "#F97316", "#06B6D4", "#EF4444", "#84CC16", "#EC4899", "#14B8A6", "#F59E0B", "#8B5CF6", "#3B82F6", "#A855F7", "#10B981" ] /// Assigns a unique color to each source based on its sorted position among all sources. private func sourceColorMap(for sources: [InvestmentSource]) -> [UUID: String] { let sorted = sources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } var map: [UUID: String] = [:] for (index, source) in sorted.enumerated() { map[source.id] = Self.sourceColorHexesPublic[index % Self.sourceColorHexesPublic.count] } return map } 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 { let effective = MonthlyCheckInStore.effectiveMonth(for: snapshotDate, relativeTo: snapshotDate) return Calendar.current.dateComponents([.year, .month], from: effective) } private func chartMonthStart(for snapshotDate: Date) -> Date { MonthlyCheckInStore.effectiveMonth(for: snapshotDate, relativeTo: snapshotDate) } // MARK: - Chart Calculations private func calculateEvolutionData(from snapshots: [Snapshot]) { let sortedSnapshots = snapshots.sorted { $0.date < $1.date } let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in chartMonth(for: snapshot.date) } 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], breakdown: BreakdownMode) { switch breakdown { case .category: 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 } case .source: let colorMap = sourceColorMap(for: sources) allocationData = sources.compactMap { source in guard source.latestValue > 0 else { return nil } return ( category: source.name, value: source.latestValue, color: colorMap[source.id] ?? "#6B7280" ) }.sorted { $0.value > $1.value } } } private func calculateAllocationEvolutionData(from snapshots: [Snapshot]) { let sortedSnapshots = snapshots.sorted { $0.date < $1.date } let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in chartMonth(for: snapshot.date) } let sortedMonths = groupedByMonth.keys.sorted { let d1 = ($0.year ?? 0) * 100 + ($0.month ?? 0) let d2 = ($1.year ?? 0) * 100 + ($1.month ?? 0) return d1 < d2 } // First pass: compute totals per category across all months for stable ordering var globalCategoryTotals: [String: (total: Decimal, color: String)] = [:] var monthlyData: [(date: Date, categories: [String: (value: Decimal, color: String)])] = [] for monthKey in sortedMonths { guard let monthSnapshots = groupedByMonth[monthKey], let monthDate = Calendar.current.date(from: monthKey) else { continue } var categoryTotals: [String: (value: Decimal, color: String)] = [:] var sourceLatest: [UUID: Snapshot] = [:] for snapshot in monthSnapshots { guard let sourceId = snapshot.source?.id else { continue } if let existing = sourceLatest[sourceId] { if snapshot.date > existing.date { sourceLatest[sourceId] = snapshot } } else { sourceLatest[sourceId] = snapshot } } for (_, snapshot) in sourceLatest { let categoryName = snapshot.source?.category?.name ?? "Other" let colorHex = snapshot.source?.category?.colorHex ?? "#6B7280" let existing = categoryTotals[categoryName] ?? (value: 0, color: colorHex) categoryTotals[categoryName] = (value: existing.value + snapshot.decimalValue, color: colorHex) let globalExisting = globalCategoryTotals[categoryName] ?? (total: 0, color: colorHex) globalCategoryTotals[categoryName] = (total: globalExisting.total + snapshot.decimalValue, color: colorHex) } let total = categoryTotals.values.reduce(Decimal.zero) { $0 + $1.value } guard total > 0 else { continue } monthlyData.append((date: monthDate, categories: categoryTotals)) } // Stable category order based on overall totals (largest first) let stableCategoryOrder = globalCategoryTotals .sorted { $0.value.total > $1.value.total } .map { $0.key } // Second pass: emit data points in stable order var result: [(date: Date, category: String, percentage: Double, color: String)] = [] for month in monthlyData { let total = month.categories.values.reduce(Decimal.zero) { $0 + $1.value } guard total > 0 else { continue } for category in stableCategoryOrder { guard let info = month.categories[category] else { continue } let percentage = NSDecimalNumber(decimal: info.value / total * 100).doubleValue result.append((date: month.date, category: category, percentage: percentage, color: info.color)) } } allocationEvolutionData = result } private func completedMonthKeys( sources: [InvestmentSource], snapshots: [Snapshot] ) -> Set { let sourceIds = Set(sources.compactMap { $0.id }) guard !sourceIds.isEmpty else { return [] } let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in chartMonth(for: snapshot.date) } var completed: Set = [] completed.reserveCapacity(groupedByMonth.count) for (key, monthSnapshots) in groupedByMonth { // A month is complete when all active sources have snapshot data for it. // MonthlyCheckInStore (UserDefaults) is NOT synced via iCloud — don't use it as a filter. 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] { let completedKeys = completedMonthKeys(sources: sources, snapshots: snapshots) return snapshots.filter { completedKeys.contains(chartMonth(for: $0.date)) } } 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]], breakdown: BreakdownMode ) { switch breakdown { case .category: 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 } case .source: let colorMap = sourceColorMap(for: sources) performanceData = sources.compactMap { source in guard let snapshots = snapshotsBySource[source.id], 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: source.name, cagr: cagr, color: colorMap[source.id] ?? "#6B7280" ) }.sorted { $0.cagr > $1.cagr } } } private func calculateContributionsData(from snapshots: [Snapshot]) { let grouped = Dictionary(grouping: snapshots) { self.chartMonthStart(for: $0.date) } 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 _: [Snapshot]) { // Needs full history (not time-filtered) to have 13+ months let allSourceIds = sourceRepository.sources.filter { shouldIncludeSource($0) }.compactMap { $0.id } let allSnapshots = freemiumValidator.filterSnapshots( snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths) ) let totals = monthlyTotals(from: allSnapshots) guard totals.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) { self.chartMonthStart(for: $0.date) } .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 totals = monthlyTotals(from: snapshots) guard !totals.isEmpty else { drawdownData = [] return } var peak = totals.first!.totalValue var data: [(date: Date, drawdown: Double)] = [] for point in totals { let value = point.totalValue if value > peak { peak = value } let drawdown = peak > 0 ? NSDecimalNumber(decimal: (peak - value) / peak).doubleValue * 100 : 0 data.append((date: point.date, drawdown: -drawdown)) } drawdownData = data } private func calculateVolatilityData(from snapshots: [Snapshot]) { let totals = monthlyTotals(from: snapshots) guard totals.count >= 4 else { volatilityData = [] return } // Compute monthly return series from portfolio totals var monthlyReturns: [Double] = [] var returnDates: [Date] = [] for i in 1.. 0 else { continue } let ret = NSDecimalNumber(decimal: (curr - prev) / prev).doubleValue * 100 monthlyReturns.append(ret) returnDates.append(totals[i].date) } let windowSize = 3 guard monthlyReturns.count >= windowSize else { volatilityData = [] return } // Rolling std dev of monthly returns (annualised) var data: [(date: Date, volatility: Double)] = [] for i in (windowSize - 1).. YearSeries in var values = [Double](repeating: Double.nan, count: 12) let yearData = byYearMonth[year] ?? [:] let sortedMonths = yearData.keys.sorted() guard let baselineMonth = sortedMonths.first, let baselineDecimal = yearData[baselineMonth] else { return YearSeries(id: year, year: year, values: values) } let baseline = NSDecimalNumber(decimal: baselineDecimal).doubleValue guard baseline > 0 else { return YearSeries(id: year, year: year, values: values) } for monthIdx in 0..<12 { let month = monthIdx + 1 if let v = yearData[month] { let vDouble = NSDecimalNumber(decimal: v).doubleValue values[monthIdx] = ((vDouble - baseline) / baseline) * 100.0 } } // #146: forecast the year-end value for the current, still-incomplete year so the // "end diff" compares full-year vs full-year (estimated) instead of ~N months vs 12. var forecastEnd: Double? = nil if year == nowYear, nowMonth < 12, let lastDataMonth = sortedMonths.last, lastDataMonth < 12 { let monthsAhead = 12 - lastDataMonth let absSeries: [(date: Date, value: Decimal)] = sortedMonths.compactMap { m in guard let v = yearData[m], let d = calendar.date(from: DateComponents(year: year, month: m, day: 1)) else { return nil } return (date: d, value: v) } if absSeries.count >= 3 { let result = predictionEngine.predict(series: absSeries, monthsAhead: monthsAhead) if let predDec = result.predictions.last?.predictedValue { let predDouble = NSDecimalNumber(decimal: predDec).doubleValue forecastEnd = ((predDouble - baseline) / baseline) * 100.0 } } // Fallback (fewer than 3 points): linear extrapolation of the cumulative % to December. if forecastEnd == nil, let firstIdx = (0..<12).first(where: { !values[$0].isNaN }), let lastIdx = (0..<12).last(where: { !values[$0].isNaN }), lastIdx > firstIdx { let monthlyRate = values[lastIdx] / Double(lastIdx - firstIdx) forecastEnd = monthlyRate * Double(11 - firstIdx) } } return YearSeries(id: year, year: year, values: values, forecastEndValue: forecastEnd) } // Default-select the last 2 available years let availableYears = Set(years) if yoySelectedYears.isEmpty || !yoySelectedYears.isSubset(of: availableYears) { yoySelectedYears = Set(years.suffix(2)) } } // MARK: - Comparison Chart Calculation func calculateComparisonData(sources: [InvestmentSource], allSnapshots: [Snapshot]) { let colorHexes = Self.sourceColorHexesPublic let sortedSources = sources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } var result: [ComparisonSeries] = [] for (index, source) in sortedSources.enumerated() { guard let sourceId = source.id as UUID?, comparisonSelectedSourceIds.contains(sourceId) else { continue } let sourceSnapshots = allSnapshots .filter { $0.source?.id == sourceId } .sorted { $0.date < $1.date } // Group by month, take latest per month let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in chartMonth(for: snap.date) } var monthlyValues: [(date: Date, value: Double)] = [] for (key, snaps) in grouped { guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue } let date = Calendar.current.date(from: key) ?? Date() monthlyValues.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue)) } monthlyValues.sort { $0.date < $1.date } guard !monthlyValues.isEmpty else { continue } let firstValue = monthlyValues[0].value let points: [(date: Date, value: Double)] switch comparisonDisplayMode { case .indexed: points = monthlyValues.map { item in let v = firstValue > 0 ? (item.value / firstValue) * 100.0 : 0 return (date: item.date, value: v) } case .returnPct: points = monthlyValues.map { item in let v = firstValue > 0 ? ((item.value - firstValue) / firstValue) * 100.0 : 0 return (date: item.date, value: v) } case .absolute: points = monthlyValues case .monthlyReturn: points = monthlyValues.enumerated().map { idx, item in guard idx > 0 else { return (date: item.date, value: 0.0) } let prev = monthlyValues[idx - 1].value let pct = prev > 0 ? ((item.value - prev) / prev) * 100.0 : 0.0 return (date: item.date, value: pct) } } let colorHex = colorHexes[index % colorHexes.count] result.append(ComparisonSeries( id: sourceId, name: source.name, colorHex: colorHex, points: points )) } comparisonData = result } // MARK: - Simulator Chart Calculation func calculateSimulatorData(sources: [InvestmentSource], allSnapshots: [Snapshot]) { guard !sources.isEmpty else { simulatorSources = [] simulatorActualData = [] simulatorData = [] return } let colorHexes = Self.sourceColorHexesPublic let sortedSources = sources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } // Build monthly totals per source var sourceMonthlyValues: [UUID: [(date: Date, value: Double)]] = [:] for source in sortedSources { guard let sourceId = source.id as UUID? else { continue } let sourceSnapshots = allSnapshots .filter { $0.source?.id == sourceId } .sorted { $0.date < $1.date } let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in chartMonth(for: snap.date) } var monthly: [(date: Date, value: Double)] = [] for (key, snaps) in grouped { guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue } let date = Calendar.current.date(from: key) ?? Date() monthly.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue)) } monthly.sort { $0.date < $1.date } sourceMonthlyValues[sourceId] = monthly } // Gather all unique months across sources var allMonthsSet = Set() for monthly in sourceMonthlyValues.values { monthly.forEach { allMonthsSet.insert($0.date) } } let allMonths = allMonthsSet.sorted() guard !allMonths.isEmpty else { simulatorActualData = [] simulatorData = [] return } // Build actual total portfolio per month (forward-fill per source) var lastValueBySource: [UUID: Double] = [:] var actualTotals: [(date: Date, value: Double)] = [] for month in allMonths { for source in sortedSources { guard let sourceId = source.id as UUID? else { continue } if let monthly = sourceMonthlyValues[sourceId], let entry = monthly.first(where: { $0.date == month }) { lastValueBySource[sourceId] = entry.value } } let total = lastValueBySource.values.reduce(0, +) actualTotals.append((date: month, value: total)) } // Normalize actual to Base 100 let firstTotal = actualTotals.first?.value ?? 1 simulatorActualData = actualTotals.map { item in (date: item.date, value: firstTotal > 0 ? (item.value / firstTotal) * 100.0 : 0) } // Compute current weights let latestTotalValue = actualTotals.last?.value ?? 0 var simulatorNew: [SimulatorSource] = [] for (index, source) in sortedSources.enumerated() { guard let sourceId = source.id as UUID? else { continue } let latestSourceValue = sourceMonthlyValues[sourceId]?.last?.value ?? 0 let currentPct = latestTotalValue > 0 ? (latestSourceValue / latestTotalValue) * 100.0 : 0 // Preserve existing simulatedPct if source already exists in simulatorSources let existingPct = simulatorSources.first(where: { $0.id == sourceId })?.simulatedPct ?? currentPct simulatorNew.append(SimulatorSource( id: sourceId, name: source.name, currentPct: currentPct, simulatedPct: existingPct, colorHex: colorHexes[index % colorHexes.count] )) } // Only update if sources changed (avoid resetting sliders on normal refresh) if simulatorSources.map({ $0.id }) != simulatorNew.map({ $0.id }) { simulatorSources = simulatorNew } else { // Update currentPct but preserve simulatedPct simulatorSources = simulatorNew } recalculateSimulatedLine(sources: simulatorSources, sourceMonthlyValues: sourceMonthlyValues, allMonths: allMonths) } func recalculateSimulatedLine(sources: [SimulatorSource]) { // Re-fetch source monthly values for recalculation from current allSources let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) } let allSourceIds = allSources.compactMap { $0.id } let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths) var sourceMonthlyValues: [UUID: [(date: Date, value: Double)]] = [:] for source in allSources { guard let sourceId = source.id as UUID? else { continue } let sourceSnapshots = allSnapshots .filter { $0.source?.id == sourceId } .sorted { $0.date < $1.date } let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in chartMonth(for: snap.date) } var monthly: [(date: Date, value: Double)] = [] for (key, snaps) in grouped { guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue } let date = Calendar.current.date(from: key) ?? Date() monthly.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue)) } monthly.sort { $0.date < $1.date } sourceMonthlyValues[sourceId] = monthly } var allMonthsSet = Set() for monthly in sourceMonthlyValues.values { monthly.forEach { allMonthsSet.insert($0.date) } } let allMonths = allMonthsSet.sorted() recalculateSimulatedLine(sources: sources, sourceMonthlyValues: sourceMonthlyValues, allMonths: allMonths) } private func recalculateSimulatedLine( sources: [SimulatorSource], sourceMonthlyValues: [UUID: [(date: Date, value: Double)]], allMonths: [Date] ) { guard !allMonths.isEmpty, !sources.isEmpty else { simulatorData = [] return } // Normalize weights let totalSimulated = sources.reduce(0) { $0 + $1.simulatedPct } guard totalSimulated > 0 else { simulatorData = [] return } let normalizedWeights: [UUID: Double] = Dictionary( uniqueKeysWithValues: sources.map { ($0.id, $0.simulatedPct / totalSimulated) } ) // Build per-source monthly return series var sourceReturns: [UUID: [Date: Double]] = [:] for source in sources { guard let monthly = sourceMonthlyValues[source.id], monthly.count >= 2 else { continue } var returns: [Date: Double] = [:] for i in 1.. 0 { returns[monthly[i].date] = (curr - prev) / prev } } sourceReturns[source.id] = returns } // Simulate portfolio var simulated = 100.0 var simulatedSeries: [(date: Date, value: Double)] = [] if let firstMonth = allMonths.first { simulatedSeries.append((date: firstMonth, value: simulated)) } for i in 1.. PeriodSeries? { // Incluir el mes de `end` completo: el date picker normaliza `end` al día 1 del mes, // por lo que usar `<= end` excluía cualquier snapshot tomado dentro de ese mes (off-by-one). // Cota superior exclusiva al inicio del mes siguiente → incluye todo el último mes (simétrico A/B). let rangeStart = start.startOfMonth let rangeEnd = end.startOfMonth.adding(months: 1) let periodSnapshots = allSnapshots.filter { $0.date >= rangeStart && $0.date < rangeEnd } guard !periodSnapshots.isEmpty else { return nil } // Group by month, compute portfolio total. // Usar el mes calendario crudo del snapshot (mismo criterio que el filtro de rango). // chartMonth(for:) aplica la lógica de "grace period" del check-in mensual, que para // snapshots históricos con día <= 20 los reasigna al mes anterior y hace desaparecer // el bucket del último mes del periodo (off-by-one que ocultaba el último mes de B). let grouped = Dictionary(grouping: periodSnapshots) { snap -> DateComponents in Calendar.current.dateComponents([.year, .month], from: snap.date) } var monthlyTotalsArr: [(date: Date, value: Double)] = [] for (key, snaps) in grouped { var latestBySource: [UUID: Snapshot] = [:] for snap in snaps { guard let sourceId = snap.source?.id else { continue } if let existing = latestBySource[sourceId] { if snap.date > existing.date { latestBySource[sourceId] = snap } } else { latestBySource[sourceId] = snap } } let total = latestBySource.values.reduce(0.0) { $0 + NSDecimalNumber(decimal: $1.decimalValue).doubleValue } let date = Calendar.current.date(from: key) ?? Date() monthlyTotalsArr.append((date: date, value: total)) } monthlyTotalsArr.sort { $0.date < $1.date } guard let firstValue = monthlyTotalsArr.first?.value, firstValue > 0 else { return nil } let points = monthlyTotalsArr.enumerated().map { index, item in (monthOffset: index, returnPct: ((item.value - firstValue) / firstValue) * 100.0) } return PeriodSeries(id: id, label: label, colorHex: colorHex, points: points) } let dateFormatter = DateFormatter() dateFormatter.dateFormat = "MMM yyyy" let labelA = "\(dateFormatter.string(from: periodAStart))–\(dateFormatter.string(from: periodAEnd))" let labelB = "\(dateFormatter.string(from: periodBStart))–\(dateFormatter.string(from: periodBEnd))" var result: [PeriodSeries] = [] if let seriesA = seriesForPeriod(start: periodAStart, end: periodAEnd, label: labelA, colorHex: "#3478F6", id: "A") { result.append(seriesA) } if let seriesB = seriesForPeriod(start: periodBStart, end: periodBEnd, label: labelB, colorHex: "#FF9500", id: "B") { result.append(seriesB) } periodComparisonData = result } private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] { let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in chartMonthStart(for: snapshot.date) } let months = groupedByMonth.keys.sorted() guard !months.isEmpty else { return [] } var latestBySource: [UUID: Snapshot] = [:] var totals: [(date: Date, totalValue: Decimal)] = [] for month in months { if let monthSnapshots = groupedByMonth[month] { for snapshot in monthSnapshots.sorted(by: { $0.date < $1.date }) { guard let sourceId = snapshot.source?.id else { continue } latestBySource[sourceId] = snapshot } } let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue } 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 chartMonth(for: snapshot.date) } 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 } }