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
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user