54dfd8a8e3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1560 lines
63 KiB
Swift
1560 lines
63 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"
|
||
}
|
||
}
|
||
}
|
||
|
||
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] = []
|
||
|
||
struct YearSeries: Identifiable {
|
||
let id: Int // year
|
||
let year: Int
|
||
let values: [Decimal] // one value per month (Jan=0, Dec=11), 0 if no data
|
||
}
|
||
|
||
// MARK: - Comparison chart data models
|
||
|
||
enum ComparisonDisplayMode: String, CaseIterable, Identifiable {
|
||
case indexed = "Base 100"
|
||
case returnPct = "Return %"
|
||
case absolute = "Value"
|
||
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 = .indexed
|
||
@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
|
||
|
||
// 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)
|
||
|
||
// 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, .performance:
|
||
return [.all, .yearToDate, .year, .quarter]
|
||
case .yearOverYear, .simulator, .periodComparison:
|
||
return [] // No time range filter for these charts
|
||
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:
|
||
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
|
||
calculatePerformanceData(
|
||
for: sources,
|
||
snapshotsBySource: completedSnapshotsBySource,
|
||
breakdown: selectedBreakdown
|
||
)
|
||
case .contributions:
|
||
calculateContributionsData(from: completedSnapshots)
|
||
case .rollingReturn:
|
||
calculateRollingReturnData(from: completedSnapshots)
|
||
case .riskReturn:
|
||
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
|
||
calculateRiskReturnData(for: sources, snapshotsBySource: completedSnapshotsBySource)
|
||
case .cashflow:
|
||
calculateCashflowData(from: completedSnapshots)
|
||
case .drawdown:
|
||
calculateDrawdownData(from: completedSnapshots)
|
||
case .volatility:
|
||
calculateVolatilityData(from: completedSnapshots)
|
||
case .prediction:
|
||
calculatePredictionData(from: completedSnapshots)
|
||
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)
|
||
private static let sourceColorHexes: [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.sourceColorHexes[index % Self.sourceColorHexes.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
|
||
|
||
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 snapshots: [Snapshot]) {
|
||
let monthlyTotals = monthlyTotals(from: snapshots)
|
||
guard monthlyTotals.count >= 13 else {
|
||
rollingReturnData = []
|
||
return
|
||
}
|
||
|
||
var returns: [(date: Date, value: Double)] = []
|
||
for index in 12..<monthlyTotals.count {
|
||
let current = monthlyTotals[index]
|
||
let base = monthlyTotals[index - 12]
|
||
guard base.totalValue > 0 else { continue }
|
||
let change = current.totalValue - base.totalValue
|
||
let percent = NSDecimalNumber(decimal: change / base.totalValue).doubleValue * 100
|
||
returns.append((date: current.date, value: percent))
|
||
}
|
||
rollingReturnData = returns
|
||
}
|
||
|
||
private func calculateRiskReturnData(
|
||
for sources: [InvestmentSource],
|
||
snapshotsBySource: [UUID: [Snapshot]]
|
||
) {
|
||
let categories = categoryRepository.categories
|
||
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||
|
||
riskReturnData = categories.compactMap { category in
|
||
let categorySources = sourcesByCategory[category.id] ?? []
|
||
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
|
||
let id = source.id
|
||
return snapshotsBySource[id]
|
||
}.flatMap { $0 }
|
||
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
|
||
guard monthlyTotals.count >= 3,
|
||
let first = monthlyTotals.first,
|
||
let last = monthlyTotals.last,
|
||
first.totalValue > 0 else { return nil }
|
||
let cagr = calculationService.calculateCAGR(
|
||
startValue: first.totalValue,
|
||
endValue: last.totalValue,
|
||
startDate: first.date,
|
||
endDate: last.date
|
||
)
|
||
let monthlyReturns = monthlyReturnSeries(from: monthlyTotals)
|
||
let volatility = calculationService.calculateVolatility(monthlyReturns: monthlyReturns)
|
||
return (
|
||
category: category.name,
|
||
cagr: cagr,
|
||
volatility: volatility,
|
||
color: category.colorHex
|
||
)
|
||
}.sorted { $0.cagr > $1.cagr }
|
||
}
|
||
|
||
private func calculateCashflowData(from snapshots: [Snapshot]) {
|
||
let monthlyTotals = monthlyTotals(from: snapshots)
|
||
let contributionsByMonth = Dictionary(grouping: snapshots) { 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 sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||
guard !sortedSnapshots.isEmpty else {
|
||
drawdownData = []
|
||
return
|
||
}
|
||
|
||
var peak = sortedSnapshots.first!.decimalValue
|
||
var data: [(date: Date, drawdown: Double)] = []
|
||
|
||
for snapshot in sortedSnapshots {
|
||
let value = snapshot.decimalValue
|
||
if value > peak {
|
||
peak = value
|
||
}
|
||
|
||
let drawdown = peak > 0
|
||
? NSDecimalNumber(decimal: (peak - value) / peak).doubleValue * 100
|
||
: 0
|
||
|
||
data.append((date: snapshot.date, drawdown: -drawdown))
|
||
}
|
||
|
||
drawdownData = data
|
||
}
|
||
|
||
private func calculateVolatilityData(from snapshots: [Snapshot]) {
|
||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||
guard sortedSnapshots.count >= 3 else {
|
||
volatilityData = []
|
||
return
|
||
}
|
||
|
||
var data: [(date: Date, volatility: Double)] = []
|
||
let windowSize = 3
|
||
|
||
for i in windowSize..<sortedSnapshots.count {
|
||
let window = Array(sortedSnapshots[(i - windowSize)..<i])
|
||
let values = window.map { NSDecimalNumber(decimal: $0.decimalValue).doubleValue }
|
||
|
||
let mean = values.reduce(0, +) / Double(values.count)
|
||
let variance = values.map { pow($0 - mean, 2) }.reduce(0, +) / Double(values.count)
|
||
let stdDev = sqrt(variance)
|
||
let volatility = mean > 0 ? (stdDev / mean) * 100 : 0
|
||
|
||
data.append((date: sortedSnapshots[i].date, volatility: volatility))
|
||
}
|
||
|
||
volatilityData = data
|
||
}
|
||
|
||
private func calculatePredictionData(from snapshots: [Snapshot]) {
|
||
guard freemiumValidator.canViewPredictions() else {
|
||
predictionData = []
|
||
return
|
||
}
|
||
|
||
let result = predictionEngine.predict(snapshots: snapshots, monthsAhead: predictionMonthsAhead)
|
||
predictionData = result.predictions
|
||
}
|
||
|
||
private func calculateYearOverYearData(from snapshots: [Snapshot]) {
|
||
guard !snapshots.isEmpty else {
|
||
yearOverYearData = []
|
||
return
|
||
}
|
||
|
||
// Build all monthly totals (no time range filter for YoY)
|
||
let allSourceIds = sourceRepository.sources.filter { shouldIncludeSource($0) }.compactMap { $0.id }
|
||
let allSnapshots: [Snapshot]
|
||
if let cached = cachedSnapshots {
|
||
allSnapshots = cached
|
||
} else {
|
||
allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
|
||
}
|
||
|
||
let calendar = Calendar.current
|
||
// group by (year, month), take latest per source
|
||
var byYearMonth: [Int: [Int: Decimal]] = [:]
|
||
var latestBySource: [UUID: Snapshot] = [:]
|
||
|
||
let sorted = allSnapshots.sorted { $0.date < $1.date }
|
||
for snap in sorted {
|
||
guard let sourceId = snap.source?.id else { continue }
|
||
latestBySource[sourceId] = snap
|
||
let year = calendar.component(.year, from: snap.date)
|
||
let month = calendar.component(.month, from: snap.date) // 1-based
|
||
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
|
||
byYearMonth[year, default: [:]][month] = total
|
||
}
|
||
|
||
let years = byYearMonth.keys.sorted()
|
||
guard !years.isEmpty else {
|
||
yearOverYearData = []
|
||
return
|
||
}
|
||
|
||
// Forward-fill within each year: carry previous month if no data
|
||
yearOverYearData = years.map { year in
|
||
var values = [Decimal](repeating: .zero, count: 12)
|
||
var last: Decimal = .zero
|
||
for monthIdx in 0..<12 {
|
||
let month = monthIdx + 1
|
||
if let v = byYearMonth[year]?[month] {
|
||
last = v
|
||
}
|
||
values[monthIdx] = last
|
||
}
|
||
return YearSeries(id: year, year: year, values: values)
|
||
}
|
||
}
|
||
|
||
// MARK: - Comparison Chart Calculation
|
||
|
||
func calculateComparisonData(sources: [InvestmentSource], allSnapshots: [Snapshot]) {
|
||
let colorHexes = Self.sourceColorHexes
|
||
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
|
||
}
|
||
|
||
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.sourceColorHexes
|
||
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? {
|
||
let periodSnapshots = allSnapshots.filter { $0.date >= start && $0.date <= end }
|
||
guard !periodSnapshots.isEmpty else { return nil }
|
||
|
||
// Group by month, compute portfolio total
|
||
let grouped = Dictionary(grouping: periodSnapshots) { snap -> DateComponents in
|
||
chartMonth(for: 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
|
||
}
|
||
}
|