588 lines
20 KiB
Swift
588 lines
20 KiB
Swift
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 allTimeReturn = totalValue - totalContributions
|
|
let allTimeReturnPercentage = totalContributions > 0
|
|
? NSDecimalNumber(decimal: allTimeReturn / totalContributions).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..<snapshots.count {
|
|
let previousValue = snapshots[i-1].decimalValue
|
|
let currentValue = snapshots[i].decimalValue
|
|
let contribution = snapshots[i].decimalContribution
|
|
|
|
guard previousValue > 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..<sortedMonths.count {
|
|
let previousMonth = sortedMonths[i-1]
|
|
let currentMonth = sortedMonths[i]
|
|
|
|
guard let previousSnapshots = monthlySnapshots[previousMonth],
|
|
let currentSnapshots = monthlySnapshots[currentMonth],
|
|
let previousValue = previousSnapshots.last?.decimalValue,
|
|
let currentValue = currentSnapshots.last?.decimalValue,
|
|
previousValue > 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] {
|
|
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
|
|
)
|
|
}
|
|
}
|
|
|
|
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 uniqueDates = Array(Set(sortedSnapshots.map { Calendar.current.startOfDay(for: $0.date) }))
|
|
.sorted()
|
|
guard !uniqueDates.isEmpty else { return [] }
|
|
|
|
var snapshotsBySource: [UUID: [(date: Date, value: Decimal)]] = [:]
|
|
var contributionsByDate: [Date: Decimal] = [:]
|
|
|
|
for snapshot in sortedSnapshots {
|
|
guard let sourceId = snapshot.source?.id else { continue }
|
|
snapshotsBySource[sourceId, default: []].append(
|
|
(date: snapshot.date, value: snapshot.decimalValue)
|
|
)
|
|
let day = Calendar.current.startOfDay(for: snapshot.date)
|
|
contributionsByDate[day, default: 0] += snapshot.decimalContribution
|
|
}
|
|
|
|
var indices: [UUID: Int] = [:]
|
|
var series: [SeriesPoint] = []
|
|
|
|
for (index, date) in uniqueDates.enumerated() {
|
|
let nextDate = index + 1 < uniqueDates.count
|
|
? uniqueDates[index + 1]
|
|
: Date.distantFuture
|
|
var total: Decimal = 0
|
|
|
|
for (sourceId, sourceSnapshots) in snapshotsBySource {
|
|
var currentIndex = indices[sourceId] ?? 0
|
|
var latest: (date: Date, value: Decimal)?
|
|
|
|
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
|
|
latest = sourceSnapshots[currentIndex]
|
|
currentIndex += 1
|
|
}
|
|
|
|
indices[sourceId] = currentIndex
|
|
|
|
if let latest {
|
|
total += latest.value
|
|
}
|
|
}
|
|
|
|
series.append(
|
|
SeriesPoint(
|
|
date: date,
|
|
value: total,
|
|
contribution: contributionsByDate[date] ?? 0
|
|
)
|
|
)
|
|
}
|
|
|
|
return series
|
|
}
|
|
|
|
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..<sortedMonths.count {
|
|
let previousMonth = sortedMonths[i - 1]
|
|
let currentMonth = sortedMonths[i]
|
|
|
|
guard let previousPoints = monthlySeries[previousMonth],
|
|
let currentPoints = monthlySeries[currentMonth],
|
|
let previousValue = previousPoints.last?.value,
|
|
let currentValue = currentPoints.last?.value,
|
|
previousValue > 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..<series.count {
|
|
let previousValue = series[i - 1].value
|
|
let currentValue = series[i].value
|
|
let contribution = series[i].contribution
|
|
|
|
guard previousValue > 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)
|
|
}
|
|
}
|