f1aeafccf6
Feedback TestFlight build 78: arrastrar las asas del minimapa disparaba el swipe entre charts (el brush vivía dentro de chartContent, bajo el simultaneousGesture del swipe). El brush sale del chart y pasa a la barra de periodo, fuera del área con gesto. Y de paso, el cambio de diseño pedido: la ventana elegida ya no es zoom visual por-chart sino ChartsViewModel.customRange — filtra los DATOS de todas las gráficas (evolution, contributions, rolling, drawdown, volatility, cashflow, comparison, YoY, prediction). Sparkline del brush desde fullHistorySeries (agregación mensual extraída de calculateEvolutionData como monthlySeries). Presets limpian el rango custom; selección completa = sin filtro. El pinch por-chart se mantiene como zoom visual. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
1839 lines
79 KiB
Swift
1839 lines
79 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"
|
||
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"
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
/// Global custom data window (range brush): when set, it REPLACES the
|
||
/// preset time range as the data filter for EVERY chart. Month-granular.
|
||
@Published var customRange: ClosedRange<Date>?
|
||
/// Full-history monthly totals for the current source/account universe —
|
||
/// the sparkline behind the range brush. Rebuilt with the snapshot cache.
|
||
@Published var fullHistorySeries: [(date: Date, value: Decimal)] = []
|
||
@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] = []
|
||
/// Months of data hidden by the free-tier 12-month history limit (0 for premium).
|
||
/// Drives the "unlock X more months" teaser in the charts screen.
|
||
@Published var hiddenHistoryMonths: Int = 0
|
||
/// Portfolio-level metrics for the active time range — feeds the iPad KPI header.
|
||
@Published var portfolioMetrics: InvestmentMetrics = .empty
|
||
@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
|
||
private var pendingUpdateRequested = 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.
|
||
// last* bookkeeping happens INSIDE updateChartData (after its
|
||
// re-entrancy guard) — recording it here marked dropped updates
|
||
// as done, so retrying the same selection was a permanent no-op.
|
||
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.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 custom = self.customRange {
|
||
filtered = snapshots.filter { custom.contains($0.date) }
|
||
} else 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)
|
||
|
||
// Global custom range (brush): refilter the selected chart's data.
|
||
// Calls updateChartData directly (like performancePeriodMonths) because
|
||
// the main pipeline's skip-check doesn't track customRange.
|
||
$customRange
|
||
.dropFirst()
|
||
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
|
||
.sink { [weak self] _ in
|
||
guard let self else { return }
|
||
self.updateChartData(chartType: self.selectedChartType, category: self.selectedCategory, timeRange: self.selectedTimeRange)
|
||
}
|
||
.store(in: &cancellables)
|
||
|
||
// Picking a preset range clears the custom brush window.
|
||
$selectedTimeRange
|
||
.removeDuplicates()
|
||
.dropFirst()
|
||
.sink { [weak self] _ in
|
||
guard let self, self.customRange != nil else { return }
|
||
self.customRange = nil
|
||
}
|
||
.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")
|
||
}
|
||
|
||
/// Selection is NEVER blocked — premium charts select normally and the chart
|
||
/// area shows an unlock teaser instead (see ChartsContainerView). Gating the
|
||
/// tap behind a paywall sheet made premium tiles look dead whenever the sheet
|
||
/// failed to present (and silently, on devices without the entitlement).
|
||
func selectChart(_ chartType: ChartType) {
|
||
selectedChartType = chartType
|
||
let allowedRanges = availableTimeRanges(for: chartType)
|
||
if !allowedRanges.contains(selectedTimeRange) {
|
||
selectedTimeRange = allowedRanges.first ?? .year
|
||
}
|
||
// Compute synchronously on tap instead of waiting for the debounced
|
||
// observer: makes selection deterministic and lets re-tapping a tile
|
||
// act as a retry if a previous update was dropped.
|
||
updateChartData(chartType: chartType, category: selectedCategory, timeRange: selectedTimeRange)
|
||
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. Coalesce instead of dropping — a
|
||
// silently discarded update left the chart stuck with stale/empty data.
|
||
guard !isUpdateInProgress else {
|
||
pendingUpdateRequested = true
|
||
return
|
||
}
|
||
isUpdateInProgress = true
|
||
isLoading = true
|
||
|
||
defer {
|
||
isLoading = false
|
||
isUpdateInProgress = false
|
||
if pendingUpdateRequested {
|
||
pendingUpdateRequested = false
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self else { return }
|
||
self.updateChartData(
|
||
chartType: self.selectedChartType,
|
||
category: self.selectedCategory,
|
||
timeRange: self.selectedTimeRange
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Record what we're about to compute (the observer compares against this
|
||
// to skip redundant work). Raw snapshots only depend on the data selection
|
||
// (sources/account/category) — chart type, range or breakdown reuse them.
|
||
let safeAccountId = safeSelectedAccountId
|
||
let dataSelectionChanged = lastCategoryId != category?.id ||
|
||
lastSourceId != selectedSource?.id ||
|
||
lastSourceIds != selectedSourceIds ||
|
||
lastAccountId != safeAccountId ||
|
||
lastShowAllAccounts != showAllAccounts
|
||
lastChartType = chartType
|
||
lastTimeRange = timeRange
|
||
lastCategoryId = category?.id
|
||
lastSourceId = selectedSource?.id
|
||
lastSourceIds = selectedSourceIds
|
||
lastAccountId = safeAccountId
|
||
lastShowAllAccounts = showAllAccounts
|
||
lastBreakdown = selectedBreakdown
|
||
if dataSelectionChanged {
|
||
cachedSnapshots = nil
|
||
}
|
||
|
||
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 sourceIds = sources.compactMap { $0.id }
|
||
|
||
// Performance: cache the FULL history once and slice by time range in memory.
|
||
// The cache is shared across chart types and ranges, so it must never be
|
||
// fetched with a range-dependent months limit (that truncated Year vs Year
|
||
// and "All" after visiting a chart with a shorter range).
|
||
var snapshots: [Snapshot]
|
||
if let cached = cachedSnapshots {
|
||
snapshots = cached
|
||
} else {
|
||
snapshots = snapshotRepository.fetchSnapshots(
|
||
for: sourceIds,
|
||
months: maxHistoryMonths
|
||
)
|
||
hiddenHistoryMonths = freemiumValidator.hiddenHistoryMonths(in: snapshots)
|
||
snapshots = freemiumValidator.filterSnapshots(snapshots)
|
||
cachedSnapshots = snapshots
|
||
fullHistorySeries = monthlySeries(from: snapshots)
|
||
}
|
||
|
||
// The brush's custom window replaces the preset range as data filter.
|
||
if let custom = customRange {
|
||
snapshots = snapshots.filter { custom.contains($0.date) }
|
||
} else if let cutoffDate = timeRange.startDate() {
|
||
snapshots = snapshots.filter { $0.date >= cutoffDate }
|
||
}
|
||
|
||
// Portfolio KPIs for the visible range (iPad header)
|
||
portfolioMetrics = computePortfolioKPIs(from: snapshots)
|
||
|
||
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)
|
||
// Also needed by the Evolution "vs Contributions" overlay (premium).
|
||
calculateContributionsData(from: completedSnapshots)
|
||
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 custom = customRange {
|
||
filteredForComparison = allSnapshots.filter { custom.contains($0.date) }
|
||
} else 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 1–20 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
|
||
|
||
/// Sets the custom window from two month-granular dates (inclusive months).
|
||
func setCustomRange(fromMonth: Date, toMonth: Date) {
|
||
let cal = Calendar.current
|
||
let start = cal.date(from: cal.dateComponents([.year, .month], from: fromMonth)) ?? fromMonth
|
||
let endMonthStart = cal.date(from: cal.dateComponents([.year, .month], from: toMonth)) ?? toMonth
|
||
let end = cal.date(byAdding: DateComponents(month: 1, second: -1), to: endMonthStart) ?? toMonth
|
||
customRange = start...max(start, end)
|
||
}
|
||
|
||
private func calculateEvolutionData(from snapshots: [Snapshot]) {
|
||
evolutionData = downsampleSeries(monthlySeries(from: snapshots), maxPoints: maxChartPoints)
|
||
}
|
||
|
||
/// Pure monthly aggregation (grid + gap fill + per-month totals) — shared by
|
||
/// the evolution chart and the range-brush sparkline.
|
||
private func monthlySeries(from snapshots: [Snapshot]) -> [(date: Date, value: Decimal)] {
|
||
// Ordered month grid + each month's index.
|
||
let sortedMonthKeys = Set(snapshots.map { chartMonth(for: $0.date) }).sorted {
|
||
(Calendar.current.date(from: $0) ?? .distantPast) < (Calendar.current.date(from: $1) ?? .distantPast)
|
||
}
|
||
let monthCount = sortedMonthKeys.count
|
||
guard monthCount > 0 else { return [] }
|
||
var indexByKey: [DateComponents: Int] = [:]
|
||
for (i, key) in sortedMonthKeys.enumerated() { indexByKey[key] = i }
|
||
|
||
// Per source: latest value at each month index.
|
||
var knownBySource: [UUID: [Int: (value: Decimal, date: Date)]] = [:]
|
||
for snapshot in snapshots {
|
||
guard let sourceId = snapshot.source?.id,
|
||
let idx = indexByKey[chartMonth(for: snapshot.date)] else { continue }
|
||
let existing = knownBySource[sourceId]?[idx]
|
||
if existing == nil || snapshot.date > existing!.date {
|
||
knownBySource[sourceId, default: [:]][idx] = (snapshot.decimalValue, snapshot.date)
|
||
}
|
||
}
|
||
|
||
// Fill each source's gaps (interpolated when enabled, else stepped
|
||
// carry-forward), then sum per month. Display only — see ChartGapFill.
|
||
let interpolate = ChartGapFill.isEnabled
|
||
var monthTotals = [Decimal](repeating: 0, count: monthCount)
|
||
for (_, known) in knownBySource {
|
||
let dense = ChartGapFill.denseValues(
|
||
known: known.mapValues { $0.value },
|
||
monthCount: monthCount,
|
||
interpolate: interpolate
|
||
)
|
||
for i in 0..<monthCount where dense[i] != nil {
|
||
monthTotals[i] += dense[i]!
|
||
}
|
||
}
|
||
|
||
var series: [(date: Date, value: Decimal)] = []
|
||
series.reserveCapacity(monthCount)
|
||
for i in 0..<monthCount {
|
||
let date = Calendar.current.date(from: sortedMonthKeys[i]) ?? Date()
|
||
series.append((date: date, value: monthTotals[i]))
|
||
}
|
||
|
||
return series
|
||
}
|
||
|
||
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)])] = []
|
||
|
||
// Forward-filled across months: a source keeps its last known value in months
|
||
// where it wasn't updated, so category allocations don't distort when a single
|
||
// source is missing that month.
|
||
var sourceLatest: [UUID: Snapshot] = [:]
|
||
|
||
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)] = [:]
|
||
|
||
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))
|
||
}
|
||
// The 12-month lookback needs full history, but the OUTPUT must respect
|
||
// the selected time range — otherwise the period filter does nothing.
|
||
if let cutoff = selectedTimeRange.startDate() {
|
||
returns = returns.filter { $0.date >= cutoff }
|
||
}
|
||
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
|
||
}
|
||
|
||
guard let firstTotal = totals.first else {
|
||
drawdownData = []
|
||
return
|
||
}
|
||
var peak = firstTotal.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 reset sliders when the source set actually changed; otherwise refresh
|
||
// current percentages but keep the user's simulated allocations.
|
||
if simulatorSources.map({ $0.id }) != simulatorNew.map({ $0.id }) {
|
||
simulatorSources = simulatorNew
|
||
} else {
|
||
simulatorSources = zip(simulatorSources, simulatorNew).map { old, new in
|
||
var updated = new
|
||
updated.simulatedPct = old.simulatedPct
|
||
return updated
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
let sortedKeys = grouped.keys.sorted {
|
||
(Calendar.current.date(from: $0) ?? .distantPast) < (Calendar.current.date(from: $1) ?? .distantPast)
|
||
}
|
||
var monthlyTotalsArr: [(date: Date, value: Double)] = []
|
||
// Forward-fill per source across the period so an un-updated source keeps
|
||
// its last value instead of dropping the month's total.
|
||
var latestBySource: [UUID: Snapshot] = [:]
|
||
for key in sortedKeys {
|
||
for snap in grouped[key] ?? [] {
|
||
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))
|
||
}
|
||
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
|
||
}
|
||
|
||
/// Portfolio-level KPIs computed over the AGGREGATED monthly totals.
|
||
/// Feeding raw multi-source snapshots into CalculationService.calculateMetrics
|
||
/// produced nonsense (first/last belong to different sources) — the aggregate
|
||
/// series is the correct basis for portfolio return, CAGR, volatility and drawdown.
|
||
private func computePortfolioKPIs(from snapshots: [Snapshot]) -> InvestmentMetrics {
|
||
let totals = monthlyTotals(from: snapshots)
|
||
guard totals.count >= 2,
|
||
let first = totals.first, let last = totals.last,
|
||
first.totalValue > 0 else {
|
||
let value = totals.last?.totalValue ?? 0
|
||
return InvestmentMetrics(
|
||
totalValue: value, totalContributions: 0,
|
||
absoluteReturn: 0, percentageReturn: 0,
|
||
cagr: 0, twr: 0, volatility: 0, maxDrawdown: 0, sharpeRatio: 0,
|
||
bestMonth: nil, worstMonth: nil, winRate: 0, averageMonthlyReturn: 0,
|
||
startDate: totals.first?.date, endDate: totals.last?.date,
|
||
totalMonths: totals.count
|
||
)
|
||
}
|
||
|
||
let absolute = last.totalValue - first.totalValue
|
||
let percentage = (absolute / first.totalValue) * 100
|
||
let cagr = calculationService.calculateCAGR(
|
||
startValue: first.totalValue, endValue: last.totalValue,
|
||
startDate: first.date, endDate: last.date
|
||
)
|
||
|
||
// Monthly returns over the aggregate series
|
||
var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = []
|
||
for i in 1..<totals.count where totals[i - 1].totalValue > 0 {
|
||
let r = NSDecimalNumber(
|
||
decimal: (totals[i].totalValue - totals[i - 1].totalValue) / totals[i - 1].totalValue
|
||
).doubleValue * 100
|
||
monthlyReturns.append(.init(date: totals[i].date, returnPercentage: r))
|
||
}
|
||
let volatility = calculationService.calculateVolatility(monthlyReturns: monthlyReturns)
|
||
let maxDrawdown = calculationService.calculateMaxDrawdown(values: totals.map { $0.totalValue })
|
||
let avgMonthly = monthlyReturns.isEmpty
|
||
? 0 : monthlyReturns.map { $0.returnPercentage }.reduce(0, +) / Double(monthlyReturns.count)
|
||
let sharpe = calculationService.calculateSharpeRatio(
|
||
averageReturn: avgMonthly * 12, volatility: volatility, riskFreeRate: 2.0
|
||
)
|
||
|
||
return InvestmentMetrics(
|
||
totalValue: last.totalValue,
|
||
totalContributions: 0,
|
||
absoluteReturn: absolute,
|
||
percentageReturn: percentage,
|
||
cagr: cagr, twr: 0,
|
||
volatility: volatility, maxDrawdown: maxDrawdown, sharpeRatio: sharpe,
|
||
bestMonth: monthlyReturns.max(by: { $0.returnPercentage < $1.returnPercentage }),
|
||
worstMonth: monthlyReturns.min(by: { $0.returnPercentage < $1.returnPercentage }),
|
||
winRate: calculationService.calculateWinRate(monthlyReturns: monthlyReturns),
|
||
averageMonthlyReturn: avgMonthly,
|
||
startDate: first.date, endDate: last.date,
|
||
totalMonths: totals.count
|
||
)
|
||
}
|
||
|
||
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 groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in
|
||
chartMonth(for: snapshot.date)
|
||
}
|
||
|
||
let sortedMonthKeys = groupedByMonth.keys.sorted {
|
||
(Calendar.current.date(from: $0) ?? .distantPast) < (Calendar.current.date(from: $1) ?? .distantPast)
|
||
}
|
||
|
||
var totals: [(date: Date, totalValue: Decimal)] = []
|
||
totals.reserveCapacity(sortedMonthKeys.count)
|
||
|
||
// Forward-fill per source so each month's total reflects every source's last
|
||
// known value (matches monthlyTotals and the portfolio total).
|
||
var latestBySource: [UUID: Snapshot] = [:]
|
||
|
||
for key in sortedMonthKeys {
|
||
for snapshot in groupedByMonth[key] ?? [] {
|
||
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
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|