6887d05b01
Goal ETA (#6): GoalProjection engine (CAGR mensual robusto de la evolución + aportaciones, proyecta mes a mes → fecha estimada, estado ahead/on-track/behind/ unreachable, sparkline). GoalsView muestra 'Projected: <mes>', chip de ritmo con color, fallback 'Add contributions to reach this'. GoalsViewModel.projection(for:) cacheado. Insights (#7): InsightsEngine rule-based on-device (sin red/AI). Reglas: allocation vs target, riesgo de concentración, mejor/peor categoría, diversificación, racha, YTD, ganancias, forecast, proximidad a milestone. Priorizados por tono, top 4. PortfolioInsight gana detail + tone. Surface en el Dashboard. Localización: 19 claves nuevas en los 7 idiomas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
168 lines
6.5 KiB
Swift
168 lines
6.5 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|