Primera build enviada
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
|
||||
@MainActor
|
||||
class ChartsViewModel: ObservableObject {
|
||||
@@ -149,7 +150,6 @@ class ChartsViewModel: ObservableObject {
|
||||
private var lastAccountId: UUID?
|
||||
private var lastShowAllAccounts: Bool = true
|
||||
private var cachedSnapshots: [Snapshot]?
|
||||
private var cachedSnapshotsBySource: [UUID: [Snapshot]]?
|
||||
private var isUpdateInProgress = false
|
||||
|
||||
// MARK: - Initialization
|
||||
@@ -186,20 +186,20 @@ class ChartsViewModel: ObservableObject {
|
||||
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.lastAccountId != self.selectedAccount?.id ||
|
||||
self.lastAccountId != safeSelectedAccountId ||
|
||||
self.lastShowAllAccounts != showAll
|
||||
|
||||
if hasChanges {
|
||||
self.lastChartType = chartType
|
||||
self.lastTimeRange = timeRange
|
||||
self.lastCategoryId = category?.id
|
||||
self.lastAccountId = self.selectedAccount?.id
|
||||
self.lastAccountId = safeSelectedAccountId
|
||||
self.lastShowAllAccounts = showAll
|
||||
self.cachedSnapshots = nil // Invalidate cache on meaningful changes
|
||||
self.cachedSnapshotsBySource = nil
|
||||
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
|
||||
}
|
||||
}
|
||||
@@ -266,48 +266,40 @@ class ChartsViewModel: ObservableObject {
|
||||
cachedSnapshots = snapshots
|
||||
}
|
||||
|
||||
// Performance: Cache snapshotsBySource
|
||||
let snapshotsBySource: [UUID: [Snapshot]]
|
||||
if let cached = cachedSnapshotsBySource {
|
||||
snapshotsBySource = cached
|
||||
} else {
|
||||
var grouped: [UUID: [Snapshot]] = [:]
|
||||
grouped.reserveCapacity(sources.count)
|
||||
for snapshot in snapshots {
|
||||
guard let id = snapshot.source?.id else { continue }
|
||||
grouped[id, default: []].append(snapshot)
|
||||
}
|
||||
snapshotsBySource = grouped
|
||||
cachedSnapshotsBySource = grouped
|
||||
}
|
||||
let completedSnapshots = filterSnapshotsForCharts(
|
||||
sources: sources,
|
||||
snapshots: snapshots
|
||||
)
|
||||
|
||||
// Performance: Only calculate data for the selected chart type
|
||||
switch chartType {
|
||||
case .evolution:
|
||||
calculateEvolutionData(from: snapshots)
|
||||
calculateEvolutionData(from: completedSnapshots)
|
||||
let categoriesForChart = categoriesForStackedChart(
|
||||
sources: sources,
|
||||
selectedCategory: selectedCategory
|
||||
)
|
||||
calculateCategoryEvolutionData(from: snapshots, categories: categoriesForChart)
|
||||
calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart)
|
||||
case .allocation:
|
||||
calculateAllocationData(for: sources)
|
||||
case .performance:
|
||||
calculatePerformanceData(for: sources, snapshotsBySource: snapshotsBySource)
|
||||
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
|
||||
calculatePerformanceData(for: sources, snapshotsBySource: completedSnapshotsBySource)
|
||||
case .contributions:
|
||||
calculateContributionsData(from: snapshots)
|
||||
calculateContributionsData(from: completedSnapshots)
|
||||
case .rollingReturn:
|
||||
calculateRollingReturnData(from: snapshots)
|
||||
calculateRollingReturnData(from: completedSnapshots)
|
||||
case .riskReturn:
|
||||
calculateRiskReturnData(for: sources, snapshotsBySource: snapshotsBySource)
|
||||
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
|
||||
calculateRiskReturnData(for: sources, snapshotsBySource: completedSnapshotsBySource)
|
||||
case .cashflow:
|
||||
calculateCashflowData(from: snapshots)
|
||||
calculateCashflowData(from: completedSnapshots)
|
||||
case .drawdown:
|
||||
calculateDrawdownData(from: snapshots)
|
||||
calculateDrawdownData(from: completedSnapshots)
|
||||
case .volatility:
|
||||
calculateVolatilityData(from: snapshots)
|
||||
calculateVolatilityData(from: completedSnapshots)
|
||||
case .prediction:
|
||||
calculatePredictionData(from: snapshots)
|
||||
calculatePredictionData(from: completedSnapshots)
|
||||
}
|
||||
|
||||
if let selected = selectedCategory,
|
||||
@@ -336,7 +328,17 @@ class ChartsViewModel: ObservableObject {
|
||||
if showAllAccounts || selectedAccount == nil {
|
||||
return true
|
||||
}
|
||||
return source.account?.id == selectedAccount?.id
|
||||
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(
|
||||
@@ -406,17 +408,34 @@ class ChartsViewModel: ObservableObject {
|
||||
|
||||
private func calculateEvolutionData(from snapshots: [Snapshot]) {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
|
||||
var dateValues: [Date: Decimal] = [:]
|
||||
|
||||
for snapshot in sortedSnapshots {
|
||||
let day = Calendar.current.startOfDay(for: snapshot.date)
|
||||
dateValues[day, default: 0] += snapshot.decimalValue
|
||||
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)
|
||||
}
|
||||
|
||||
let series = dateValues
|
||||
.map { (date: $0.key, value: $0.value) }
|
||||
.sorted { $0.date < $1.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)
|
||||
}
|
||||
|
||||
@@ -477,6 +496,69 @@ class ChartsViewModel: ObservableObject {
|
||||
}.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 []
|
||||
}
|
||||
|
||||
let completedMonthsAfter = completedMonthKeys(
|
||||
sources: sources,
|
||||
snapshots: snapshots,
|
||||
after: lastCompleted
|
||||
)
|
||||
|
||||
return snapshots.filter { snapshot in
|
||||
let monthDate = snapshot.date.startOfMonth
|
||||
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]]
|
||||
@@ -492,11 +574,20 @@ class ChartsViewModel: ObservableObject {
|
||||
}.flatMap { $0 }
|
||||
guard snapshots.count >= 2 else { return nil }
|
||||
|
||||
let metrics = calculationService.calculateMetrics(for: snapshots)
|
||||
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: metrics.cagr,
|
||||
cagr: cagr,
|
||||
color: category.colorHex
|
||||
)
|
||||
}.sorted { $0.cagr > $1.cagr }
|
||||
@@ -543,12 +634,23 @@ class ChartsViewModel: ObservableObject {
|
||||
let id = source.id
|
||||
return snapshotsBySource[id]
|
||||
}.flatMap { $0 }
|
||||
guard snapshots.count >= 3 else { return nil }
|
||||
let metrics = calculationService.calculateMetrics(for: snapshots)
|
||||
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: metrics.cagr,
|
||||
volatility: metrics.volatility,
|
||||
cagr: cagr,
|
||||
volatility: volatility,
|
||||
color: category.colorHex
|
||||
)
|
||||
}.sorted { $0.cagr > $1.cagr }
|
||||
@@ -670,6 +772,61 @@ class ChartsViewModel: ObservableObject {
|
||||
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(),
|
||||
|
||||
Reference in New Issue
Block a user