InvestmentTrackerApp/InvestmentTracker/Services/CalculationService.swift

335 lines
11 KiB
Swift

import Foundation
class CalculationService {
static let shared = CalculationService()
private init() {}
// 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: - 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 [] }
var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = []
let calendar = Calendar.current
// Group snapshots by month
var monthlySnapshots: [String: [Snapshot]] = [:]
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM"
for snapshot in snapshots {
let key = formatter.string(from: snapshot.date)
monthlySnapshots[key, default: []].append(snapshot)
}
// Sort months
let sortedMonths = monthlySnapshots.keys.sorted()
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],
totalPortfolioValue: Decimal
) -> [CategoryMetrics] {
categories.map { category in
let allSnapshots = category.sourcesArray.flatMap { $0.snapshotsArray }
let metrics = calculateMetrics(for: allSnapshots)
let percentage = totalPortfolioValue > 0
? NSDecimalNumber(decimal: category.totalValue / totalPortfolioValue).doubleValue * 100
: 0
return CategoryMetrics(
id: category.id,
categoryName: category.name,
colorHex: category.colorHex,
icon: category.icon,
totalValue: category.totalValue,
percentageOfPortfolio: percentage,
metrics: metrics
)
}
}
}
// MARK: - Array Extension
extension Array where Element == Double {
func average() -> Double {
guard !isEmpty else { return 0 }
return reduce(0, +) / Double(count)
}
}