import Foundation class CalculationService { static let shared = CalculationService() // MARK: - Performance: Caching private var cachedMonthlyReturns: [ObjectIdentifier: [InvestmentMetrics.MonthlyReturn]] = [:] private var cacheVersion: Int = 0 private init() {} /// Call this when underlying data changes to invalidate caches func invalidateCache() { cachedMonthlyReturns.removeAll() cacheVersion += 1 } // MARK: - Shared DateFormatter (avoid repeated allocations) private static let monthYearFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM" return formatter }() // MARK: - Portfolio Summary func calculatePortfolioSummary( from sources: [InvestmentSource], snapshots: [Snapshot] ) -> PortfolioSummary { let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue } let totalContributions = sources.reduce(Decimal.zero) { $0 + $1.totalContributions } // Calculate period changes let now = Date() let dayAgo = Calendar.current.date(byAdding: .day, value: -1, to: now) ?? now let weekAgo = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: now) ?? now let monthAgo = Calendar.current.date(byAdding: .month, value: -1, to: now) ?? now let yearAgo = Calendar.current.date(byAdding: .year, value: -1, to: now) ?? now let dayChange = calculatePeriodChange(sources: sources, from: dayAgo) let weekChange = calculatePeriodChange(sources: sources, from: weekAgo) let monthChange = calculatePeriodChange(sources: sources, from: monthAgo) let yearChange = calculatePeriodChange(sources: sources, from: yearAgo) let baselineTotal = sources.reduce(Decimal.zero) { partial, source in if let firstSnapshot = source.sortedSnapshotsByDateAscending.first { return partial + firstSnapshot.decimalValue } return partial + source.latestValue } let allTimeReturn = totalValue - baselineTotal let allTimeReturnPercentage = baselineTotal > 0 ? NSDecimalNumber(decimal: allTimeReturn / baselineTotal).doubleValue * 100 : 0 let lastUpdated = snapshots.map { $0.date }.max() return PortfolioSummary( totalValue: totalValue, totalContributions: totalContributions, dayChange: dayChange.absolute, dayChangePercentage: dayChange.percentage, weekChange: weekChange.absolute, weekChangePercentage: weekChange.percentage, monthChange: monthChange.absolute, monthChangePercentage: monthChange.percentage, yearChange: yearChange.absolute, yearChangePercentage: yearChange.percentage, allTimeReturn: allTimeReturn, allTimeReturnPercentage: allTimeReturnPercentage, sourceCount: sources.count, lastUpdated: lastUpdated ) } private func calculatePeriodChange( sources: [InvestmentSource], from startDate: Date ) -> (absolute: Decimal, percentage: Double) { var previousTotal: Decimal = 0 var currentTotal: Decimal = 0 for source in sources { currentTotal += source.latestValue // Find snapshot closest to start date let snapshots = source.sortedSnapshotsByDateAscending let previousSnapshot = snapshots.last { $0.date <= startDate } ?? snapshots.first previousTotal += previousSnapshot?.decimalValue ?? 0 } let absolute = currentTotal - previousTotal let percentage = previousTotal > 0 ? NSDecimalNumber(decimal: absolute / previousTotal).doubleValue * 100 : 0 return (absolute, percentage) } // MARK: - Investment Metrics func calculateMetrics(for snapshots: [Snapshot]) -> InvestmentMetrics { guard !snapshots.isEmpty else { return .empty } let sortedSnapshots = snapshots.sorted { $0.date < $1.date } let values = sortedSnapshots.map { $0.decimalValue } guard let firstValue = values.first, let lastValue = values.last, firstValue != 0 else { return .empty } let totalValue = lastValue let totalContributions = sortedSnapshots.reduce(Decimal.zero) { $0 + $1.decimalContribution } let absoluteReturn = lastValue - firstValue let percentageReturn = (absoluteReturn / firstValue) * 100 // Calculate monthly returns for advanced metrics let monthlyReturns = calculateMonthlyReturns(from: sortedSnapshots) let cagr = calculateCAGR( startValue: firstValue, endValue: lastValue, startDate: sortedSnapshots.first?.date ?? Date(), endDate: sortedSnapshots.last?.date ?? Date() ) let twr = calculateTWR(snapshots: sortedSnapshots) let volatility = calculateVolatility(monthlyReturns: monthlyReturns) let maxDrawdown = calculateMaxDrawdown(values: values) let sharpeRatio = calculateSharpeRatio( averageReturn: monthlyReturns.map { $0.returnPercentage }.average(), volatility: volatility ) let winRate = calculateWinRate(monthlyReturns: monthlyReturns) let averageMonthlyReturn = monthlyReturns.map { $0.returnPercentage }.average() return InvestmentMetrics( totalValue: totalValue, totalContributions: totalContributions, absoluteReturn: absoluteReturn, percentageReturn: percentageReturn, cagr: cagr, twr: twr, volatility: volatility, maxDrawdown: maxDrawdown, sharpeRatio: sharpeRatio, bestMonth: monthlyReturns.max(by: { $0.returnPercentage < $1.returnPercentage }), worstMonth: monthlyReturns.min(by: { $0.returnPercentage < $1.returnPercentage }), winRate: winRate, averageMonthlyReturn: averageMonthlyReturn, startDate: sortedSnapshots.first?.date, endDate: sortedSnapshots.last?.date, totalMonths: monthlyReturns.count ) } // MARK: - Monthly Summary func calculateMonthlySummary( sources: [InvestmentSource], snapshots: [Snapshot], range: DateRange = .thisMonth ) -> MonthlySummary { let startDate = range.start let endDate = range.end var startingValue: Decimal = 0 var endingValue: Decimal = 0 var contributions: Decimal = 0 let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id } for source in sources { let sourceSnapshots = snapshotsBySource[source.id] ?? [] let sorted = sourceSnapshots.sorted { $0.date < $1.date } let startSnapshot = sorted.last { $0.date <= startDate } let endSnapshot = sorted.last { $0.date <= endDate } startingValue += startSnapshot?.decimalValue ?? 0 endingValue += endSnapshot?.decimalValue ?? 0 let rangeContributions = sorted .filter { $0.date >= startDate && $0.date <= endDate } .reduce(Decimal.zero) { $0 + $1.decimalContribution } contributions += rangeContributions } let netPerformance = endingValue - startingValue - contributions return MonthlySummary( periodLabel: "This Month", startDate: startDate, endDate: endDate, startingValue: startingValue, endingValue: endingValue, contributions: contributions, netPerformance: netPerformance ) } // MARK: - CAGR (Compound Annual Growth Rate) func calculateCAGR( startValue: Decimal, endValue: Decimal, startDate: Date, endDate: Date ) -> Double { guard startValue > 0 else { return 0 } let years = Calendar.current.dateComponents( [.day], from: startDate, to: endDate ).day.map { Double($0) / 365.25 } ?? 0 guard years > 0 else { return 0 } let ratio = NSDecimalNumber(decimal: endValue / startValue).doubleValue let cagr = pow(ratio, 1 / years) - 1 return cagr * 100 } // MARK: - TWR (Time-Weighted Return) func calculateTWR(snapshots: [Snapshot]) -> Double { guard snapshots.count >= 2 else { return 0 } var twr: Double = 1.0 for i in 1.. 0 else { continue } // Adjust for contributions let adjustedPreviousValue = previousValue + contribution let periodReturn = NSDecimalNumber( decimal: currentValue / adjustedPreviousValue ).doubleValue twr *= periodReturn } return (twr - 1) * 100 } // MARK: - Volatility (Annualized Standard Deviation) func calculateVolatility(monthlyReturns: [InvestmentMetrics.MonthlyReturn]) -> Double { let returns = monthlyReturns.map { $0.returnPercentage } guard returns.count >= 2 else { return 0 } let mean = returns.average() let squaredDifferences = returns.map { pow($0 - mean, 2) } let variance = squaredDifferences.reduce(0, +) / Double(returns.count - 1) let stdDev = sqrt(variance) // Annualize (multiply by sqrt(12) for monthly data) return stdDev * sqrt(12) } // MARK: - Max Drawdown func calculateMaxDrawdown(values: [Decimal]) -> Double { guard !values.isEmpty else { return 0 } var maxDrawdown: Double = 0 var peak = values[0] for value in values { if value > peak { peak = value } guard peak > 0 else { continue } let drawdown = NSDecimalNumber( decimal: (peak - value) / peak ).doubleValue * 100 maxDrawdown = max(maxDrawdown, drawdown) } return maxDrawdown } // MARK: - Sharpe Ratio func calculateSharpeRatio( averageReturn: Double, volatility: Double, riskFreeRate: Double = 2.0 // Assume 2% annual risk-free rate ) -> Double { guard volatility > 0 else { return 0 } // Convert to monthly risk-free rate let monthlyRiskFree = riskFreeRate / 12 return (averageReturn - monthlyRiskFree) / volatility * sqrt(12) } // MARK: - Win Rate func calculateWinRate(monthlyReturns: [InvestmentMetrics.MonthlyReturn]) -> Double { guard !monthlyReturns.isEmpty else { return 0 } let positiveMonths = monthlyReturns.filter { $0.returnPercentage > 0 }.count return Double(positiveMonths) / Double(monthlyReturns.count) * 100 } // MARK: - Monthly Returns func calculateMonthlyReturns(from snapshots: [Snapshot]) -> [InvestmentMetrics.MonthlyReturn] { guard snapshots.count >= 2 else { return [] } // Performance: Use shared formatter instead of creating new one each call let formatter = Self.monthYearFormatter // Group snapshots by month - pre-allocate capacity var monthlySnapshots: [String: [Snapshot]] = [:] monthlySnapshots.reserveCapacity(min(snapshots.count, 60)) // Reasonable max months for snapshot in snapshots { let key = formatter.string(from: snapshot.date) monthlySnapshots[key, default: []].append(snapshot) } // Sort months let sortedMonths = monthlySnapshots.keys.sorted() guard sortedMonths.count >= 2 else { return [] } // Pre-allocate result array var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = [] monthlyReturns.reserveCapacity(sortedMonths.count - 1) for i in 1.. 0 else { continue } let returnPercentage = NSDecimalNumber( decimal: (currentValue - previousValue) / previousValue ).doubleValue * 100 if let date = formatter.date(from: currentMonth) { monthlyReturns.append(InvestmentMetrics.MonthlyReturn( date: date, returnPercentage: returnPercentage )) } } return monthlyReturns } // MARK: - Category Metrics func calculateCategoryMetrics( for categories: [Category], sources: [InvestmentSource], totalPortfolioValue: Decimal ) -> [CategoryMetrics] { let rawMetrics = categories.map { category in let categorySources = sources.filter { $0.category?.id == category.id } let allSnapshots = categorySources.flatMap { $0.snapshotsArray } let metrics = calculateCategoryMetrics(from: allSnapshots) let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue } let percentage = totalPortfolioValue > 0 ? NSDecimalNumber(decimal: categoryValue / totalPortfolioValue).doubleValue * 100 : 0 return CategoryMetrics( id: category.id, categoryName: category.name, colorHex: category.colorHex, icon: category.icon, totalValue: categoryValue, percentageOfPortfolio: percentage, metrics: metrics ) } let filtered = rawMetrics.filter { metric in metric.totalValue > 0 && sources.contains { $0.category?.id == metric.id } } var deduped: [String: CategoryMetrics] = [:] for metric in filtered { let key = metric.categoryName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if let existing = deduped[key] { if metric.totalValue > existing.totalValue { deduped[key] = metric } } else { deduped[key] = metric } } return Array(deduped.values) } private struct SeriesPoint { let date: Date let value: Decimal let contribution: Decimal } private func calculateCategoryMetrics(from snapshots: [Snapshot]) -> InvestmentMetrics { let series = buildCategorySeries(from: snapshots) guard !series.isEmpty else { return .empty } let sortedSeries = series.sorted { $0.date < $1.date } let values = sortedSeries.map { $0.value } guard let firstValue = values.first, let lastValue = values.last, firstValue != 0 else { return .empty } let totalValue = lastValue let totalContributions = sortedSeries.reduce(Decimal.zero) { $0 + $1.contribution } let absoluteReturn = lastValue - firstValue let percentageReturn = (absoluteReturn / firstValue) * 100 let monthlyReturns = calculateMonthlyReturns(from: sortedSeries) let cagr = calculateCAGR( startValue: firstValue, endValue: lastValue, startDate: sortedSeries.first?.date ?? Date(), endDate: sortedSeries.last?.date ?? Date() ) let twr = calculateTWR(series: sortedSeries) let volatility = calculateVolatility(monthlyReturns: monthlyReturns) let maxDrawdown = calculateMaxDrawdown(values: values) let sharpeRatio = calculateSharpeRatio( averageReturn: monthlyReturns.map { $0.returnPercentage }.average(), volatility: volatility ) let winRate = calculateWinRate(monthlyReturns: monthlyReturns) let averageMonthlyReturn = monthlyReturns.map { $0.returnPercentage }.average() return InvestmentMetrics( totalValue: totalValue, totalContributions: totalContributions, absoluteReturn: absoluteReturn, percentageReturn: percentageReturn, cagr: cagr, twr: twr, volatility: volatility, maxDrawdown: maxDrawdown, sharpeRatio: sharpeRatio, bestMonth: monthlyReturns.max(by: { $0.returnPercentage < $1.returnPercentage }), worstMonth: monthlyReturns.min(by: { $0.returnPercentage < $1.returnPercentage }), winRate: winRate, averageMonthlyReturn: averageMonthlyReturn, startDate: sortedSeries.first?.date, endDate: sortedSeries.last?.date, totalMonths: monthlyReturns.count ) } private func buildCategorySeries(from snapshots: [Snapshot]) -> [SeriesPoint] { 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 series: [SeriesPoint] = [] series.reserveCapacity(groupedByMonth.count) for (key, monthSnapshots) in groupedByMonth { var latestBySource: [UUID: Snapshot] = [:] var contributions: Decimal = 0 for snapshot in monthSnapshots { contributions += snapshot.decimalContribution 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(SeriesPoint(date: date, value: total, contribution: contributions)) } return series.sorted { $0.date < $1.date } } private func calculateMonthlyReturns(from series: [SeriesPoint]) -> [InvestmentMetrics.MonthlyReturn] { guard series.count >= 2 else { return [] } // Performance: Use shared formatter let formatter = Self.monthYearFormatter var monthlySeries: [String: [SeriesPoint]] = [:] monthlySeries.reserveCapacity(min(series.count, 60)) for point in series { let key = formatter.string(from: point.date) monthlySeries[key, default: []].append(point) } let sortedMonths = monthlySeries.keys.sorted() guard sortedMonths.count >= 2 else { return [] } var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = [] monthlyReturns.reserveCapacity(sortedMonths.count - 1) for i in 1.. 0 else { continue } let returnPercentage = NSDecimalNumber( decimal: (currentValue - previousValue) / previousValue ).doubleValue * 100 if let date = formatter.date(from: currentMonth) { monthlyReturns.append(InvestmentMetrics.MonthlyReturn( date: date, returnPercentage: returnPercentage )) } } return monthlyReturns } private func calculateTWR(series: [SeriesPoint]) -> Double { guard series.count >= 2 else { return 0 } var twr: Double = 1.0 for i in 1.. 0 else { continue } let periodReturn = (currentValue - contribution) / previousValue twr *= NSDecimalNumber(decimal: periodReturn).doubleValue } return (twr - 1) * 100 } } // MARK: - Array Extension extension Array where Element == Double { func average() -> Double { guard !isEmpty else { return 0 } return reduce(0, +) / Double(count) } }