Files
InvestmentTrackerApp/PortfolioJournal/Utilities/GoalProjection.swift
T
alexandrev-tibco 6887d05b01 1.6.0 #6+#7: proyección/ETA de objetivos + insights inteligentes
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
2026-07-25 12:22:12 +02:00

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
)
}
}