import Foundation /// Pure, dependency-free projection helper for goals. /// /// Given the current portfolio value relevant to a goal (its account's sources, /// or the whole portfolio when the goal has no account), a monthly growth rate /// derived from evolution history, and the total monthly contributions of the /// relevant sources, it projects value forward month by month and estimates the /// month the goal amount is reached — plus an on-track status vs the target date. /// /// Kept free of Core Data / SwiftUI so it is trivially unit-testable and reusable. enum GoalProjection { /// On-track status of a goal relative to its target date. enum Status: Equatable { /// Reached before the target date. case aheadOfSchedule /// Reached at (or within a small window of) the target date. case onTrack /// Reached after the target date. case behind /// Never reached at the current trajectory (no growth, no contributions). case unreachable /// No target date set — only a projected date is meaningful. case noTargetDate } struct Result { /// Projected month the goal amount is reached, or nil if unreachable. let projectedDate: Date? /// Number of months from `asOf` until the goal is reached, or nil if unreachable. let monthsToReach: Int? /// On-track status vs the target date. let status: Status /// Monthly growth rate used (fraction, e.g. 0.008 == +0.8%/mo). Zero if flat/negative. let monthlyGrowthRate: Double /// A short projection curve (value per month) for a sparkline, starting at `currentValue`. /// Empty when the goal is already reached or unreachable. let sparkline: [Decimal] var isReachable: Bool { projectedDate != nil } } /// Hard cap on how many months we project before declaring a goal unreachable /// at the current pace (50 years). private static let maxProjectionMonths = 600 /// Derives a robust monthly growth rate (as a fraction) from evolution history. /// /// Uses the trailing window (default 6 points) and computes a monthly CAGR from /// first to last value. Contributions inflate raw value growth, so this rate is a /// blended "how fast the pot grows" figure rather than pure market return — which /// is the honest thing to extrapolate for an ETA. Returns 0 for flat/negative /// trends or insufficient data. static func monthlyGrowthRate( from evolution: [(date: Date, value: Decimal)], window: Int = 6 ) -> Double { guard evolution.count >= 3 else { return 0 } let recent = Array(evolution.suffix(max(2, window))) guard let first = recent.first, let last = recent.last else { return 0 } let firstValue = NSDecimalNumber(decimal: first.value).doubleValue let lastValue = NSDecimalNumber(decimal: last.value).doubleValue guard firstValue > 0, lastValue > firstValue else { return 0 } let months = max(1, first.date.monthsBetween(last.date)) // Monthly CAGR: (last/first)^(1/months) - 1 let ratio = lastValue / firstValue let rate = pow(ratio, 1.0 / Double(months)) - 1.0 guard rate.isFinite, rate > 0 else { return 0 } // Clamp to a sane band to avoid absurd extrapolations from noisy data. return min(rate, 0.5) } /// Projects when `targetAmount` is reached. /// /// - Parameters: /// - currentValue: current portfolio value relevant to the goal. /// - targetAmount: the goal amount. /// - monthlyGrowthRate: fraction per month (from `monthlyGrowthRate(from:)`). /// - monthlyContribution: total monthly contributions of relevant sources. /// - targetDate: optional deadline to compare against. /// - asOf: base date to project from (defaults to now). static func project( currentValue: Decimal, targetAmount: Decimal, monthlyGrowthRate: Double, monthlyContribution: Decimal, targetDate: Date?, asOf: Date = Date() ) -> Result { // Already reached. if currentValue >= targetAmount, targetAmount > 0 { return Result( projectedDate: asOf, monthsToReach: 0, status: targetDate == nil ? .noTargetDate : .onTrack, monthlyGrowthRate: max(0, monthlyGrowthRate), sparkline: [] ) } let rate = max(0, monthlyGrowthRate) let contribution = NSDecimalNumber(decimal: max(0, monthlyContribution)).doubleValue var value = NSDecimalNumber(decimal: currentValue).doubleValue let target = NSDecimalNumber(decimal: targetAmount).doubleValue // No trajectory at all → unreachable. if rate <= 0 && contribution <= 0 { return Result( projectedDate: nil, monthsToReach: nil, status: .unreachable, monthlyGrowthRate: 0, sparkline: [] ) } var sparkline: [Decimal] = [Decimal(value)] var months = 0 while value < target && months < maxProjectionMonths { value = value * (1 + rate) + contribution months += 1 // Sample the sparkline sparsely (at most ~12 points). if months % max(1, months / 12) == 0 || value >= target { sparkline.append(Decimal(value)) } } guard value >= target else { return Result( projectedDate: nil, monthsToReach: nil, status: .unreachable, monthlyGrowthRate: rate, sparkline: [] ) } let projectedDate = Calendar.current.date(byAdding: .month, value: months, to: asOf) let status: Status if let targetDate { let projectedDay = (projectedDate ?? asOf).startOfDay let targetDay = targetDate.startOfDay let deltaDays = targetDay.daysBetween(projectedDay) if deltaDays <= -15 { status = .aheadOfSchedule } else if deltaDays >= 15 { status = .behind } else { status = .onTrack } } else { status = .noTargetDate } return Result( projectedDate: projectedDate, monthsToReach: months, status: status, monthlyGrowthRate: rate, sparkline: sparkline ) } }