Files
InvestmentTrackerApp/PortfolioJournal/ViewModels/ChartsViewModel.swift
T
alexandrev-tibco 7a18dd8360 1.4.1 (build 42): contribución por snapshot, fixes Period vs Period y navegación iPad
- 151: contribución editable por snapshot en cualquier modo (quitado gate detailed)
  con diálogo de propagación al editar: solo este / adelante / atrás / todos
  (SnapshotRepository.propagateContribution, SnapshotFormViewModel.contributionChanged)
- 148: Period vs Period mostraba mal el último mes del period B — la agrupación usaba
  chartMonth(for:) que aplicaba el grace-period del check-in a snapshots históricos;
  ahora agrupa por mes calendario crudo
- 152: iPad Sources no saltaba entre fuentes — añadido .id() al SourceDetailView para
  recrear el StateObject al cambiar de selección
- 146/147: Year vs Year con forecast del año en curso (asterisco de estimado) y KPIs
  arriba / detalle debajo
- 145: card de contribución mensual en SourceDetailView
- Nuevas claves snapshot_contribution_propagate_* en los 7 idiomas

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-06-30 17:01:50 +02:00

1652 lines
69 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
case yearOverYear = "Year vs Year"
case comparison = "Compare"
case simulator = "What If"
case periodComparison = "Period vs Period"
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"
case .yearOverYear: return "calendar"
case .comparison: return "chart.xyaxis.line"
case .simulator: return "slider.horizontal.3"
case .periodComparison: return "calendar.badge.clock"
}
}
var isPremium: Bool {
switch self {
case .evolution, .comparison, .periodComparison:
return false
case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow,
.drawdown, .volatility, .prediction, .yearOverYear, .simulator:
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"
case .yearOverYear:
return "Compare portfolio values across years"
case .comparison:
return "Compare sources side by side"
case .simulator:
return "Simulate portfolio reallocation"
case .periodComparison:
return "Compare two time periods side by side"
}
}
}
func availableChartTypes(calmModeEnabled: Bool) -> [ChartType] {
let types: [ChartType] = calmModeEnabled
? [.evolution, .allocation, .performance, .contributions, .comparison, .periodComparison]
: 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 selectedSourceIds: Set<UUID> = []
@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 allocationEvolutionData: [(date: Date, category: String, percentage: Double, color: String)] = []
@Published var yearOverYearData: [YearSeries] = []
@Published var yoySelectedYears: Set<Int> = []
struct YearSeries: Identifiable {
let id: Int // year
let year: Int
let values: [Double] // cumulative % return within year (first available month = 0%), NaN = no data
var forecastEndValue: Double? = nil // estimated cumulative % at year-end (only for the current, still-incomplete year)
}
// MARK: - Comparison chart data models
enum ComparisonDisplayMode: String, CaseIterable, Identifiable {
case indexed = "Base 100"
case returnPct = "Return %"
case absolute = "Value"
case monthlyReturn = "Month %"
var id: String { rawValue }
}
struct ComparisonSeries: Identifiable {
let id: UUID
let name: String
let colorHex: String
let points: [(date: Date, value: Double)]
}
@Published var comparisonData: [ComparisonSeries] = []
@Published var comparisonSelectedSourceIds: Set<UUID> = []
@Published var comparisonDisplayMode: ComparisonDisplayMode = .returnPct
@Published var comparisonAvailableSources: [InvestmentSource] = []
// MARK: - Simulator chart data models
struct SimulatorSource: Identifiable {
let id: UUID
let name: String
let currentPct: Double
var simulatedPct: Double
let colorHex: String
}
@Published var simulatorSources: [SimulatorSource] = []
@Published var simulatorActualData: [(date: Date, value: Double)] = []
@Published var simulatorData: [(date: Date, value: Double)] = []
// MARK: - Period comparison data models
struct PeriodSeries: Identifiable {
let id: String
let label: String
let colorHex: String // e.g. "#0000FF" for blue, "#FF8000" for orange
let points: [(monthOffset: Int, returnPct: Double)]
}
@Published var periodAStart: Date = Calendar.current.date(byAdding: .month, value: -6, to: Date()) ?? Date()
@Published var periodAEnd: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
@Published var periodBStart: Date = Calendar.current.date(byAdding: .month, value: -18, to: Date()) ?? Date()
@Published var periodBEnd: Date = Calendar.current.date(byAdding: .month, value: -13, to: Date()) ?? Date()
@Published var periodComparisonData: [PeriodSeries] = []
@Published var isLoading = false
@Published var showingPaywall = false
@Published private var predictionMonthsAhead = 12
@Published var performancePeriodMonths: Int = 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 lastSourceIds: Set<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)
.combineLatest($selectedSourceIds)
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
.sink { [weak self] outer, sourceIds in
guard let self else { return }
let (combined, selectedSource, selectedBreakdown, showAll) = outer
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.lastSourceIds != sourceIds ||
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.lastSourceIds = sourceIds
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)
// Comparison chart: react to source selection and display mode changes
Publishers.CombineLatest($comparisonSelectedSourceIds, $comparisonDisplayMode)
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self, self.selectedChartType == .comparison else { return }
self.isLoading = true
let allSources = self.sourceRepository.sources.filter { self.shouldIncludeSource($0) }
let allSourceIds = allSources.compactMap { $0.id }
var snapshots: [Snapshot]
if let cached = self.cachedSnapshots {
snapshots = cached
} else {
snapshots = self.snapshotRepository.fetchSnapshots(for: allSourceIds, months: self.maxHistoryMonths)
snapshots = self.freemiumValidator.filterSnapshots(snapshots)
}
var filtered = snapshots
if let cutoff = self.selectedTimeRange.startDate() {
filtered = snapshots.filter { $0.date >= cutoff }
}
self.calculateComparisonData(sources: allSources, allSnapshots: filtered)
self.isLoading = false
}
.store(in: &cancellables)
// Simulator chart: react to slider changes
$simulatorSources
.debounce(for: .milliseconds(200), scheduler: DispatchQueue.main)
.sink { [weak self] sources in
guard let self, self.selectedChartType == .simulator, !sources.isEmpty else { return }
self.recalculateSimulatedLine(sources: sources)
}
.store(in: &cancellables)
// Performance: react to custom period slider
$performancePeriodMonths
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self, self.selectedChartType == .performance else { return }
self.updateChartData(chartType: .performance, category: self.selectedCategory, timeRange: self.selectedTimeRange)
}
.store(in: &cancellables)
// Period comparison: react to date picker changes
Publishers.CombineLatest4($periodAStart, $periodAEnd, $periodBStart, $periodBEnd)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self, self.selectedChartType == .periodComparison else { return }
let allSources = self.sourceRepository.sources.filter { self.shouldIncludeSource($0) }
let allSourceIds = allSources.compactMap { $0.id }
let snapshots = self.snapshotRepository.fetchSnapshots(for: allSourceIds, months: self.maxHistoryMonths)
self.calculatePeriodComparisonData(allSnapshots: snapshots)
}
.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:
return [.all, .yearToDate, .year, .quarter]
case .performance, .yearOverYear, .simulator, .periodComparison:
return [] // Performance uses custom slider; others have no time range
case .comparison:
return [.month, .quarter, .halfYear, .year, .yearToDate, .all]
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 !selectedSourceIds.isEmpty {
sources = sourceRepository.sources.filter { source in
selectedSourceIds.contains(source.id) && shouldIncludeSource(source)
}
} else 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)
calculateAllocationEvolutionData(from: completedSnapshots)
case .performance:
// Fetch with performancePeriodMonths (independent of selectedTimeRange)
let perfSourceIds = sources.compactMap { $0.id }
let perfAllSnapshots = freemiumValidator.filterSnapshots(
snapshotRepository.fetchSnapshots(for: perfSourceIds, months: performancePeriodMonths + 1)
)
let perfCutoff = Calendar.current.date(byAdding: .month, value: -performancePeriodMonths, to: Date()) ?? Date()
let perfFiltered = filterSnapshotsForCharts(
sources: sources,
snapshots: perfAllSnapshots.filter { $0.date >= perfCutoff }
)
let completedSnapshotsBySource = groupSnapshotsBySource(perfFiltered)
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)
case .yearOverYear:
calculateYearOverYearData(from: completedSnapshots)
case .comparison:
// Use ALL sources, not filtered by selectedSource/category
let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
comparisonAvailableSources = allSources.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
let allSourceIds = allSources.compactMap { $0.id }
var allSnapshots: [Snapshot]
if let cached = cachedSnapshots {
allSnapshots = cached
} else {
allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
allSnapshots = freemiumValidator.filterSnapshots(allSnapshots)
}
var filteredForComparison = allSnapshots
if let cutoff = timeRange.startDate() {
filteredForComparison = allSnapshots.filter { $0.date >= cutoff }
}
calculateComparisonData(sources: allSources, allSnapshots: filteredForComparison)
case .simulator:
let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
let allSourceIds = allSources.compactMap { $0.id }
let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
calculateSimulatorData(sources: allSources, allSnapshots: allSnapshots)
case .periodComparison:
let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
let allSourceIds = allSources.compactMap { $0.id }
let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
calculatePeriodComparisonData(allSnapshots: allSnapshots)
}
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, .allocation, .performance:
return filtered
case .comparison, .simulator, .periodComparison:
return []
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 }
case .comparison, .simulator, .periodComparison:
return []
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
}
/// Source-specific color palette (distinct from category colors, same order as Color.sourceColors)
static let sourceColorHexesPublic: [String] = [
"#6366F1", "#F97316", "#06B6D4", "#EF4444", "#84CC16", "#EC4899",
"#14B8A6", "#F59E0B", "#8B5CF6", "#3B82F6", "#A855F7", "#10B981"
]
/// Assigns a unique color to each source based on its sorted position among all sources.
private func sourceColorMap(for sources: [InvestmentSource]) -> [UUID: String] {
let sorted = sources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
var map: [UUID: String] = [:]
for (index, source) in sorted.enumerated() {
map[source.id] = Self.sourceColorHexesPublic[index % Self.sourceColorHexesPublic.count]
}
return map
}
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: - Effective Month Mapping
/// Maps a snapshot date to its effective check-in month.
/// Snapshots on days 120 are attributed to the previous month's check-in.
private func chartMonth(for snapshotDate: Date) -> DateComponents {
let effective = MonthlyCheckInStore.effectiveMonth(for: snapshotDate, relativeTo: snapshotDate)
return Calendar.current.dateComponents([.year, .month], from: effective)
}
private func chartMonthStart(for snapshotDate: Date) -> Date {
MonthlyCheckInStore.effectiveMonth(for: snapshotDate, relativeTo: snapshotDate)
}
// 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
chartMonth(for: snapshot.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)
}
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:
let colorMap = sourceColorMap(for: sources)
allocationData = sources.compactMap { source in
guard source.latestValue > 0 else { return nil }
return (
category: source.name,
value: source.latestValue,
color: colorMap[source.id] ?? "#6B7280"
)
}.sorted { $0.value > $1.value }
}
}
private func calculateAllocationEvolutionData(from snapshots: [Snapshot]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
chartMonth(for: snapshot.date)
}
let sortedMonths = groupedByMonth.keys.sorted {
let d1 = ($0.year ?? 0) * 100 + ($0.month ?? 0)
let d2 = ($1.year ?? 0) * 100 + ($1.month ?? 0)
return d1 < d2
}
// First pass: compute totals per category across all months for stable ordering
var globalCategoryTotals: [String: (total: Decimal, color: String)] = [:]
var monthlyData: [(date: Date, categories: [String: (value: Decimal, color: String)])] = []
for monthKey in sortedMonths {
guard let monthSnapshots = groupedByMonth[monthKey],
let monthDate = Calendar.current.date(from: monthKey) else { continue }
var categoryTotals: [String: (value: Decimal, color: String)] = [:]
var sourceLatest: [UUID: Snapshot] = [:]
for snapshot in monthSnapshots {
guard let sourceId = snapshot.source?.id else { continue }
if let existing = sourceLatest[sourceId] {
if snapshot.date > existing.date {
sourceLatest[sourceId] = snapshot
}
} else {
sourceLatest[sourceId] = snapshot
}
}
for (_, snapshot) in sourceLatest {
let categoryName = snapshot.source?.category?.name ?? "Other"
let colorHex = snapshot.source?.category?.colorHex ?? "#6B7280"
let existing = categoryTotals[categoryName] ?? (value: 0, color: colorHex)
categoryTotals[categoryName] = (value: existing.value + snapshot.decimalValue, color: colorHex)
let globalExisting = globalCategoryTotals[categoryName] ?? (total: 0, color: colorHex)
globalCategoryTotals[categoryName] = (total: globalExisting.total + snapshot.decimalValue, color: colorHex)
}
let total = categoryTotals.values.reduce(Decimal.zero) { $0 + $1.value }
guard total > 0 else { continue }
monthlyData.append((date: monthDate, categories: categoryTotals))
}
// Stable category order based on overall totals (largest first)
let stableCategoryOrder = globalCategoryTotals
.sorted { $0.value.total > $1.value.total }
.map { $0.key }
// Second pass: emit data points in stable order
var result: [(date: Date, category: String, percentage: Double, color: String)] = []
for month in monthlyData {
let total = month.categories.values.reduce(Decimal.zero) { $0 + $1.value }
guard total > 0 else { continue }
for category in stableCategoryOrder {
guard let info = month.categories[category] else { continue }
let percentage = NSDecimalNumber(decimal: info.value / total * 100).doubleValue
result.append((date: month.date, category: category, percentage: percentage, color: info.color))
}
}
allocationEvolutionData = result
}
private func completedMonthKeys(
sources: [InvestmentSource],
snapshots: [Snapshot]
) -> Set<DateComponents> {
let sourceIds = Set(sources.compactMap { $0.id })
guard !sourceIds.isEmpty else { return [] }
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in
chartMonth(for: snapshot.date)
}
var completed: Set<DateComponents> = []
completed.reserveCapacity(groupedByMonth.count)
for (key, monthSnapshots) in groupedByMonth {
// A month is complete when all active sources have snapshot data for it.
// MonthlyCheckInStore (UserDefaults) is NOT synced via iCloud don't use it as a filter.
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] {
let completedKeys = completedMonthKeys(sources: sources, snapshots: snapshots)
return snapshots.filter { completedKeys.contains(chartMonth(for: $0.date)) }
}
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:
let colorMap = sourceColorMap(for: sources)
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: colorMap[source.id] ?? "#6B7280"
)
}.sorted { $0.cagr > $1.cagr }
}
}
private func calculateContributionsData(from snapshots: [Snapshot]) {
let grouped = Dictionary(grouping: snapshots) { self.chartMonthStart(for: $0.date) }
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 _: [Snapshot]) {
// Needs full history (not time-filtered) to have 13+ months
let allSourceIds = sourceRepository.sources.filter { shouldIncludeSource($0) }.compactMap { $0.id }
let allSnapshots = freemiumValidator.filterSnapshots(
snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
)
let totals = monthlyTotals(from: allSnapshots)
guard totals.count >= 13 else {
rollingReturnData = []
return
}
var returns: [(date: Date, value: Double)] = []
for index in 12..<totals.count {
let current = totals[index]
let base = totals[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) { self.chartMonthStart(for: $0.date) }
.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 totals = monthlyTotals(from: snapshots)
guard !totals.isEmpty else {
drawdownData = []
return
}
var peak = totals.first!.totalValue
var data: [(date: Date, drawdown: Double)] = []
for point in totals {
let value = point.totalValue
if value > peak { peak = value }
let drawdown = peak > 0
? NSDecimalNumber(decimal: (peak - value) / peak).doubleValue * 100
: 0
data.append((date: point.date, drawdown: -drawdown))
}
drawdownData = data
}
private func calculateVolatilityData(from snapshots: [Snapshot]) {
let totals = monthlyTotals(from: snapshots)
guard totals.count >= 4 else {
volatilityData = []
return
}
// Compute monthly return series from portfolio totals
var monthlyReturns: [Double] = []
var returnDates: [Date] = []
for i in 1..<totals.count {
let prev = totals[i - 1].totalValue
let curr = totals[i].totalValue
guard prev > 0 else { continue }
let ret = NSDecimalNumber(decimal: (curr - prev) / prev).doubleValue * 100
monthlyReturns.append(ret)
returnDates.append(totals[i].date)
}
let windowSize = 3
guard monthlyReturns.count >= windowSize else {
volatilityData = []
return
}
// Rolling std dev of monthly returns (annualised)
var data: [(date: Date, volatility: Double)] = []
for i in (windowSize - 1)..<monthlyReturns.count {
let window = Array(monthlyReturns[(i - windowSize + 1)...i])
let mean = window.reduce(0, +) / Double(window.count)
let variance = window.map { pow($0 - mean, 2) }.reduce(0, +) / Double(max(1, window.count - 1))
let stdDev = sqrt(max(0, variance))
data.append((date: returnDates[i], volatility: stdDev))
}
volatilityData = data
}
private func calculatePredictionData(from snapshots: [Snapshot]) {
guard freemiumValidator.canViewPredictions() else {
predictionData = []
return
}
let totals = monthlyTotals(from: snapshots)
let series = totals.map { (date: $0.date, value: $0.totalValue) }
let result = predictionEngine.predict(series: series, monthsAhead: predictionMonthsAhead)
predictionData = result.predictions
}
private func calculateYearOverYearData(from _: [Snapshot]) {
// Always use full history (not time-range filtered)
let allSourceIds = sourceRepository.sources.filter { shouldIncludeSource($0) }.compactMap { $0.id }
let allSnapshots = freemiumValidator.filterSnapshots(
snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
)
let totals = monthlyTotals(from: allSnapshots)
guard !totals.isEmpty else {
yearOverYearData = []
return
}
let calendar = Calendar.current
// Group monthly totals by (year, month)
var byYearMonth: [Int: [Int: Decimal]] = [:]
for point in totals {
let year = calendar.component(.year, from: point.date)
let month = calendar.component(.month, from: point.date)
byYearMonth[year, default: [:]][month] = point.totalValue
}
let years = byYearMonth.keys.sorted()
guard !years.isEmpty else {
yearOverYearData = []
return
}
let nowYear = calendar.component(.year, from: Date())
let nowMonth = calendar.component(.month, from: Date())
// For each year, compute cumulative % return from the first available month (baseline = 0%)
yearOverYearData = years.map { year -> YearSeries in
var values = [Double](repeating: Double.nan, count: 12)
let yearData = byYearMonth[year] ?? [:]
let sortedMonths = yearData.keys.sorted()
guard let baselineMonth = sortedMonths.first,
let baselineDecimal = yearData[baselineMonth] else {
return YearSeries(id: year, year: year, values: values)
}
let baseline = NSDecimalNumber(decimal: baselineDecimal).doubleValue
guard baseline > 0 else { return YearSeries(id: year, year: year, values: values) }
for monthIdx in 0..<12 {
let month = monthIdx + 1
if let v = yearData[month] {
let vDouble = NSDecimalNumber(decimal: v).doubleValue
values[monthIdx] = ((vDouble - baseline) / baseline) * 100.0
}
}
// #146: forecast the year-end value for the current, still-incomplete year so the
// "end diff" compares full-year vs full-year (estimated) instead of ~N months vs 12.
var forecastEnd: Double? = nil
if year == nowYear, nowMonth < 12, let lastDataMonth = sortedMonths.last, lastDataMonth < 12 {
let monthsAhead = 12 - lastDataMonth
let absSeries: [(date: Date, value: Decimal)] = sortedMonths.compactMap { m in
guard let v = yearData[m],
let d = calendar.date(from: DateComponents(year: year, month: m, day: 1)) else { return nil }
return (date: d, value: v)
}
if absSeries.count >= 3 {
let result = predictionEngine.predict(series: absSeries, monthsAhead: monthsAhead)
if let predDec = result.predictions.last?.predictedValue {
let predDouble = NSDecimalNumber(decimal: predDec).doubleValue
forecastEnd = ((predDouble - baseline) / baseline) * 100.0
}
}
// Fallback (fewer than 3 points): linear extrapolation of the cumulative % to December.
if forecastEnd == nil,
let firstIdx = (0..<12).first(where: { !values[$0].isNaN }),
let lastIdx = (0..<12).last(where: { !values[$0].isNaN }),
lastIdx > firstIdx {
let monthlyRate = values[lastIdx] / Double(lastIdx - firstIdx)
forecastEnd = monthlyRate * Double(11 - firstIdx)
}
}
return YearSeries(id: year, year: year, values: values, forecastEndValue: forecastEnd)
}
// Default-select the last 2 available years
let availableYears = Set(years)
if yoySelectedYears.isEmpty || !yoySelectedYears.isSubset(of: availableYears) {
yoySelectedYears = Set(years.suffix(2))
}
}
// MARK: - Comparison Chart Calculation
func calculateComparisonData(sources: [InvestmentSource], allSnapshots: [Snapshot]) {
let colorHexes = Self.sourceColorHexesPublic
let sortedSources = sources.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
var result: [ComparisonSeries] = []
for (index, source) in sortedSources.enumerated() {
guard let sourceId = source.id as UUID?,
comparisonSelectedSourceIds.contains(sourceId) else { continue }
let sourceSnapshots = allSnapshots
.filter { $0.source?.id == sourceId }
.sorted { $0.date < $1.date }
// Group by month, take latest per month
let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in
chartMonth(for: snap.date)
}
var monthlyValues: [(date: Date, value: Double)] = []
for (key, snaps) in grouped {
guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue }
let date = Calendar.current.date(from: key) ?? Date()
monthlyValues.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue))
}
monthlyValues.sort { $0.date < $1.date }
guard !monthlyValues.isEmpty else { continue }
let firstValue = monthlyValues[0].value
let points: [(date: Date, value: Double)]
switch comparisonDisplayMode {
case .indexed:
points = monthlyValues.map { item in
let v = firstValue > 0 ? (item.value / firstValue) * 100.0 : 0
return (date: item.date, value: v)
}
case .returnPct:
points = monthlyValues.map { item in
let v = firstValue > 0 ? ((item.value - firstValue) / firstValue) * 100.0 : 0
return (date: item.date, value: v)
}
case .absolute:
points = monthlyValues
case .monthlyReturn:
points = monthlyValues.enumerated().map { idx, item in
guard idx > 0 else { return (date: item.date, value: 0.0) }
let prev = monthlyValues[idx - 1].value
let pct = prev > 0 ? ((item.value - prev) / prev) * 100.0 : 0.0
return (date: item.date, value: pct)
}
}
let colorHex = colorHexes[index % colorHexes.count]
result.append(ComparisonSeries(
id: sourceId,
name: source.name,
colorHex: colorHex,
points: points
))
}
comparisonData = result
}
// MARK: - Simulator Chart Calculation
func calculateSimulatorData(sources: [InvestmentSource], allSnapshots: [Snapshot]) {
guard !sources.isEmpty else {
simulatorSources = []
simulatorActualData = []
simulatorData = []
return
}
let colorHexes = Self.sourceColorHexesPublic
let sortedSources = sources.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
// Build monthly totals per source
var sourceMonthlyValues: [UUID: [(date: Date, value: Double)]] = [:]
for source in sortedSources {
guard let sourceId = source.id as UUID? else { continue }
let sourceSnapshots = allSnapshots
.filter { $0.source?.id == sourceId }
.sorted { $0.date < $1.date }
let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in
chartMonth(for: snap.date)
}
var monthly: [(date: Date, value: Double)] = []
for (key, snaps) in grouped {
guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue }
let date = Calendar.current.date(from: key) ?? Date()
monthly.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue))
}
monthly.sort { $0.date < $1.date }
sourceMonthlyValues[sourceId] = monthly
}
// Gather all unique months across sources
var allMonthsSet = Set<Date>()
for monthly in sourceMonthlyValues.values {
monthly.forEach { allMonthsSet.insert($0.date) }
}
let allMonths = allMonthsSet.sorted()
guard !allMonths.isEmpty else {
simulatorActualData = []
simulatorData = []
return
}
// Build actual total portfolio per month (forward-fill per source)
var lastValueBySource: [UUID: Double] = [:]
var actualTotals: [(date: Date, value: Double)] = []
for month in allMonths {
for source in sortedSources {
guard let sourceId = source.id as UUID? else { continue }
if let monthly = sourceMonthlyValues[sourceId],
let entry = monthly.first(where: { $0.date == month }) {
lastValueBySource[sourceId] = entry.value
}
}
let total = lastValueBySource.values.reduce(0, +)
actualTotals.append((date: month, value: total))
}
// Normalize actual to Base 100
let firstTotal = actualTotals.first?.value ?? 1
simulatorActualData = actualTotals.map { item in
(date: item.date, value: firstTotal > 0 ? (item.value / firstTotal) * 100.0 : 0)
}
// Compute current weights
let latestTotalValue = actualTotals.last?.value ?? 0
var simulatorNew: [SimulatorSource] = []
for (index, source) in sortedSources.enumerated() {
guard let sourceId = source.id as UUID? else { continue }
let latestSourceValue = sourceMonthlyValues[sourceId]?.last?.value ?? 0
let currentPct = latestTotalValue > 0 ? (latestSourceValue / latestTotalValue) * 100.0 : 0
// Preserve existing simulatedPct if source already exists in simulatorSources
let existingPct = simulatorSources.first(where: { $0.id == sourceId })?.simulatedPct ?? currentPct
simulatorNew.append(SimulatorSource(
id: sourceId,
name: source.name,
currentPct: currentPct,
simulatedPct: existingPct,
colorHex: colorHexes[index % colorHexes.count]
))
}
// Only update if sources changed (avoid resetting sliders on normal refresh)
if simulatorSources.map({ $0.id }) != simulatorNew.map({ $0.id }) {
simulatorSources = simulatorNew
} else {
// Update currentPct but preserve simulatedPct
simulatorSources = simulatorNew
}
recalculateSimulatedLine(sources: simulatorSources, sourceMonthlyValues: sourceMonthlyValues, allMonths: allMonths)
}
func recalculateSimulatedLine(sources: [SimulatorSource]) {
// Re-fetch source monthly values for recalculation from current allSources
let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
let allSourceIds = allSources.compactMap { $0.id }
let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
var sourceMonthlyValues: [UUID: [(date: Date, value: Double)]] = [:]
for source in allSources {
guard let sourceId = source.id as UUID? else { continue }
let sourceSnapshots = allSnapshots
.filter { $0.source?.id == sourceId }
.sorted { $0.date < $1.date }
let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in
chartMonth(for: snap.date)
}
var monthly: [(date: Date, value: Double)] = []
for (key, snaps) in grouped {
guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue }
let date = Calendar.current.date(from: key) ?? Date()
monthly.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue))
}
monthly.sort { $0.date < $1.date }
sourceMonthlyValues[sourceId] = monthly
}
var allMonthsSet = Set<Date>()
for monthly in sourceMonthlyValues.values {
monthly.forEach { allMonthsSet.insert($0.date) }
}
let allMonths = allMonthsSet.sorted()
recalculateSimulatedLine(sources: sources, sourceMonthlyValues: sourceMonthlyValues, allMonths: allMonths)
}
private func recalculateSimulatedLine(
sources: [SimulatorSource],
sourceMonthlyValues: [UUID: [(date: Date, value: Double)]],
allMonths: [Date]
) {
guard !allMonths.isEmpty, !sources.isEmpty else {
simulatorData = []
return
}
// Normalize weights
let totalSimulated = sources.reduce(0) { $0 + $1.simulatedPct }
guard totalSimulated > 0 else {
simulatorData = []
return
}
let normalizedWeights: [UUID: Double] = Dictionary(
uniqueKeysWithValues: sources.map { ($0.id, $0.simulatedPct / totalSimulated) }
)
// Build per-source monthly return series
var sourceReturns: [UUID: [Date: Double]] = [:]
for source in sources {
guard let monthly = sourceMonthlyValues[source.id], monthly.count >= 2 else { continue }
var returns: [Date: Double] = [:]
for i in 1..<monthly.count {
let prev = monthly[i - 1].value
let curr = monthly[i].value
if prev > 0 {
returns[monthly[i].date] = (curr - prev) / prev
}
}
sourceReturns[source.id] = returns
}
// Simulate portfolio
var simulated = 100.0
var simulatedSeries: [(date: Date, value: Double)] = []
if let firstMonth = allMonths.first {
simulatedSeries.append((date: firstMonth, value: simulated))
}
for i in 1..<allMonths.count {
let month = allMonths[i]
var portfolioReturn = 0.0
for source in sources {
let weight = normalizedWeights[source.id] ?? 0
let ret = sourceReturns[source.id]?[month] ?? 0
portfolioReturn += weight * ret
}
simulated *= (1 + portfolioReturn)
simulatedSeries.append((date: month, value: simulated))
}
simulatorData = simulatedSeries
}
// MARK: - Period Comparison Chart Calculation
func calculatePeriodComparisonData(allSnapshots: [Snapshot]) {
func seriesForPeriod(start: Date, end: Date, label: String, colorHex: String, id: String) -> PeriodSeries? {
// Incluir el mes de `end` completo: el date picker normaliza `end` al día 1 del mes,
// por lo que usar `<= end` excluía cualquier snapshot tomado dentro de ese mes (off-by-one).
// Cota superior exclusiva al inicio del mes siguiente incluye todo el último mes (simétrico A/B).
let rangeStart = start.startOfMonth
let rangeEnd = end.startOfMonth.adding(months: 1)
let periodSnapshots = allSnapshots.filter { $0.date >= rangeStart && $0.date < rangeEnd }
guard !periodSnapshots.isEmpty else { return nil }
// Group by month, compute portfolio total.
// Usar el mes calendario crudo del snapshot (mismo criterio que el filtro de rango).
// chartMonth(for:) aplica la lógica de "grace period" del check-in mensual, que para
// snapshots históricos con día <= 20 los reasigna al mes anterior y hace desaparecer
// el bucket del último mes del periodo (off-by-one que ocultaba el último mes de B).
let grouped = Dictionary(grouping: periodSnapshots) { snap -> DateComponents in
Calendar.current.dateComponents([.year, .month], from: snap.date)
}
var monthlyTotalsArr: [(date: Date, value: Double)] = []
for (key, snaps) in grouped {
var latestBySource: [UUID: Snapshot] = [:]
for snap in snaps {
guard let sourceId = snap.source?.id else { continue }
if let existing = latestBySource[sourceId] {
if snap.date > existing.date { latestBySource[sourceId] = snap }
} else {
latestBySource[sourceId] = snap
}
}
let total = latestBySource.values.reduce(0.0) { $0 + NSDecimalNumber(decimal: $1.decimalValue).doubleValue }
let date = Calendar.current.date(from: key) ?? Date()
monthlyTotalsArr.append((date: date, value: total))
}
monthlyTotalsArr.sort { $0.date < $1.date }
guard let firstValue = monthlyTotalsArr.first?.value, firstValue > 0 else { return nil }
let points = monthlyTotalsArr.enumerated().map { index, item in
(monthOffset: index, returnPct: ((item.value - firstValue) / firstValue) * 100.0)
}
return PeriodSeries(id: id, label: label, colorHex: colorHex, points: points)
}
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MMM yyyy"
let labelA = "\(dateFormatter.string(from: periodAStart))\(dateFormatter.string(from: periodAEnd))"
let labelB = "\(dateFormatter.string(from: periodBStart))\(dateFormatter.string(from: periodBEnd))"
var result: [PeriodSeries] = []
if let seriesA = seriesForPeriod(start: periodAStart, end: periodAEnd,
label: labelA, colorHex: "#3478F6", id: "A") {
result.append(seriesA)
}
if let seriesB = seriesForPeriod(start: periodBStart, end: periodBEnd,
label: labelB, colorHex: "#FF9500", id: "B") {
result.append(seriesB)
}
periodComparisonData = result
}
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
chartMonthStart(for: snapshot.date)
}
let months = groupedByMonth.keys.sorted()
guard !months.isEmpty else { return [] }
var latestBySource: [UUID: Snapshot] = [:]
var totals: [(date: Date, totalValue: Decimal)] = []
for month in months {
if let monthSnapshots = groupedByMonth[month] {
for snapshot in monthSnapshots.sorted(by: { $0.date < $1.date }) {
guard let sourceId = snapshot.source?.id else { continue }
latestBySource[sourceId] = snapshot
}
}
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
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
chartMonth(for: snapshot.date)
}
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
}
}