Files
InvestmentTrackerApp/PortfolioJournal/ViewModels/ChartsViewModel.swift
T
2026-02-11 23:37:11 +01:00

977 lines
37 KiB
Swift

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"
var id: String { rawValue }
var icon: String {
switch self {
case .evolution: return "chart.line.uptrend.xyaxis"
case .allocation: return "chart.pie.fill"
case .performance: return "chart.bar.fill"
case .contributions: return "tray.and.arrow.down.fill"
case .rollingReturn: return "arrow.triangle.2.circlepath"
case .riskReturn: return "dot.square"
case .cashflow: return "chart.bar.xaxis"
case .drawdown: return "arrow.down.right.circle"
case .volatility: return "waveform.path.ecg"
case .prediction: return "wand.and.stars"
}
}
var isPremium: Bool {
switch self {
case .evolution:
return false
case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow,
.drawdown, .volatility, .prediction:
return true
}
}
var description: String {
switch self {
case .evolution:
return "Track your portfolio value over time"
case .allocation:
return "See how your investments are distributed"
case .performance:
return "Compare returns across categories"
case .contributions:
return "Review monthly inflows over time"
case .rollingReturn:
return "See rolling 12-month performance"
case .riskReturn:
return "Compare volatility vs return"
case .cashflow:
return "Compare growth vs contributions"
case .drawdown:
return "Analyze declines from peak values"
case .volatility:
return "Understand investment risk levels"
case .prediction:
return "View 12-month forecasts"
}
}
}
func availableChartTypes(calmModeEnabled: Bool) -> [ChartType] {
let types: [ChartType] = calmModeEnabled
? [.evolution, .allocation, .performance, .contributions]
: ChartType.allCases
if !types.contains(selectedChartType) {
selectedChartType = .evolution
}
return types
}
// MARK: - Published Properties
@Published var selectedChartType: ChartType = .evolution
@Published var selectedCategory: Category?
@Published var selectedSource: InvestmentSource?
@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 isLoading = false
@Published var showingPaywall = false
@Published private var predictionMonthsAhead = 12
// MARK: - Time Range
enum TimeRange: String, CaseIterable, Identifiable {
case month = "1M"
case quarter = "3M"
case halfYear = "6M"
case year = "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<AnyCancellable>()
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 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)
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
.sink { [weak self] combined, selectedSource, selectedBreakdown, showAll in
guard let self else { return }
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.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.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)
}
// 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, .performance:
return [.all, .yearToDate, .year, .quarter]
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 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
)
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)
case .performance:
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
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)
}
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:
return filtered
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 }
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
}
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..<end]
if let last = bucket.last {
sampled.append(last)
}
index += bucketSize
}
return sampled
}
private func downsampleDates(_ dates: [Date], maxPoints: Int) -> [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..<end]
if let last = bucket.last {
sampled.append(last)
}
index += bucketSize
}
return sampled
}
// 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
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
return DateComponents(year: components.year, month: components.month)
}
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:
allocationData = sources.compactMap { source in
guard source.latestValue > 0 else { return nil }
return (
category: source.name,
value: source.latestValue,
color: source.category?.colorHex ?? "#6B7280"
)
}.sorted { $0.value > $1.value }
}
}
private func completedMonthKeys(
sources: [InvestmentSource],
snapshots: [Snapshot],
after cutoff: Date
) -> Set<DateComponents> {
let sourceIds = Set(sources.compactMap { $0.id })
guard !sourceIds.isEmpty else { return [] }
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
return DateComponents(year: components.year, month: components.month)
}
var completed: Set<DateComponents> = []
completed.reserveCapacity(groupedByMonth.count)
for (key, monthSnapshots) in groupedByMonth {
guard let monthDate = Calendar.current.date(from: key) else { continue }
guard monthDate > cutoff else { continue }
guard MonthlyCheckInStore.completionDate(for: monthDate) != nil else { continue }
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] {
guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else {
return snapshots
}
let completedMonthsAfter = completedMonthKeys(
sources: sources,
snapshots: snapshots,
after: lastCompleted
)
return snapshots.filter { snapshot in
let monthDate = snapshot.date.startOfMonth
if let completionDate = MonthlyCheckInStore.completionDate(for: monthDate),
snapshot.date > completionDate {
return false
}
if monthDate <= lastCompleted {
return true
}
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
let key = DateComponents(year: components.year, month: components.month)
return completedMonthsAfter.contains(key)
}
}
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:
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: source.category?.colorHex ?? "#6B7280"
)
}.sorted { $0.cagr > $1.cagr }
}
}
private func calculateContributionsData(from snapshots: [Snapshot]) {
let grouped = Dictionary(grouping: snapshots) { $0.date.startOfMonth }
contributionsData = grouped.map { date, items in
let total = items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
return (date: date, amount: total)
}
.sorted { $0.date < $1.date }
}
private func calculateRollingReturnData(from snapshots: [Snapshot]) {
let monthlyTotals = monthlyTotals(from: snapshots)
guard monthlyTotals.count >= 13 else {
rollingReturnData = []
return
}
var returns: [(date: Date, value: Double)] = []
for index in 12..<monthlyTotals.count {
let current = monthlyTotals[index]
let base = monthlyTotals[index - 12]
guard base.totalValue > 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) { $0.date.startOfMonth }
.mapValues { items in
items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
}
var data: [(date: Date, contributions: Decimal, netPerformance: Decimal)] = []
for index in 0..<monthlyTotals.count {
let current = monthlyTotals[index]
let previousTotal = index > 0 ? monthlyTotals[index - 1].totalValue : 0
let contributions = contributionsByMonth[current.date] ?? 0
let netPerformance = current.totalValue - previousTotal - contributions
data.append((date: current.date, contributions: contributions, netPerformance: netPerformance))
}
cashflowData = data
}
private func calculateDrawdownData(from snapshots: [Snapshot]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
guard !sortedSnapshots.isEmpty else {
drawdownData = []
return
}
var peak = sortedSnapshots.first!.decimalValue
var data: [(date: Date, drawdown: Double)] = []
for snapshot in sortedSnapshots {
let value = snapshot.decimalValue
if value > peak {
peak = value
}
let drawdown = peak > 0
? NSDecimalNumber(decimal: (peak - value) / peak).doubleValue * 100
: 0
data.append((date: snapshot.date, drawdown: -drawdown))
}
drawdownData = data
}
private func calculateVolatilityData(from snapshots: [Snapshot]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
guard sortedSnapshots.count >= 3 else {
volatilityData = []
return
}
var data: [(date: Date, volatility: Double)] = []
let windowSize = 3
for i in windowSize..<sortedSnapshots.count {
let window = Array(sortedSnapshots[(i - windowSize)..<i])
let values = window.map { NSDecimalNumber(decimal: $0.decimalValue).doubleValue }
let mean = values.reduce(0, +) / Double(values.count)
let variance = values.map { pow($0 - mean, 2) }.reduce(0, +) / Double(values.count)
let stdDev = sqrt(variance)
let volatility = mean > 0 ? (stdDev / mean) * 100 : 0
data.append((date: sortedSnapshots[i].date, volatility: volatility))
}
volatilityData = data
}
private func calculatePredictionData(from snapshots: [Snapshot]) {
guard freemiumValidator.canViewPredictions() else {
predictionData = []
return
}
let result = predictionEngine.predict(snapshots: snapshots, monthsAhead: predictionMonthsAhead)
predictionData = result.predictions
}
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let months = Array(Set(sortedSnapshots.map { $0.date.startOfMonth })).sorted()
guard !months.isEmpty else { return [] }
var snapshotsBySource: [UUID: [Snapshot]] = [:]
for snapshot in sortedSnapshots {
guard let sourceId = snapshot.source?.id else { continue }
snapshotsBySource[sourceId, default: []].append(snapshot)
}
var indices: [UUID: Int] = [:]
var totals: [(date: Date, totalValue: Decimal)] = []
for (index, month) in months.enumerated() {
let nextMonth = index + 1 < months.count ? months[index + 1] : Date.distantFuture
var total: Decimal = 0
for (sourceId, sourceSnapshots) in snapshotsBySource {
var currentIndex = indices[sourceId] ?? 0
var latest: Snapshot?
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextMonth {
latest = sourceSnapshots[currentIndex]
currentIndex += 1
}
indices[sourceId] = currentIndex
total += latest?.decimalValue ?? 0
}
totals.append((date: month, totalValue: total))
}
return totals
}
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
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
return DateComponents(year: components.year, month: components.month)
}
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..<monthlyTotals.count {
let previous = monthlyTotals[index - 1]
let current = monthlyTotals[index]
guard previous.totalValue > 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
}
}