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:
@@ -166,70 +166,62 @@ class GoalsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func estimateCompletionDate(for goal: Goal) -> Date? {
|
||||
// Performance: Use cached completion date if available
|
||||
if let cached = cachedCompletionDates[goal.id] {
|
||||
projection(for: goal).projectedDate
|
||||
}
|
||||
|
||||
/// Full forward projection for a goal, blending recent growth trend with the
|
||||
/// monthly contributions of the relevant sources. Cached per goal.
|
||||
func projection(for goal: Goal) -> GoalProjection.Result {
|
||||
let sources = relevantSources(for: goal)
|
||||
let currentValue = totalValue(for: goal)
|
||||
|
||||
guard !sources.isEmpty else {
|
||||
let cached = GoalProjection.Result(
|
||||
projectedDate: nil, monthsToReach: nil,
|
||||
status: goal.targetDate == nil ? .noTargetDate : .unreachable,
|
||||
monthlyGrowthRate: 0, sparkline: []
|
||||
)
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return cached
|
||||
}
|
||||
|
||||
let sources: [InvestmentSource]
|
||||
let evolutionData = evolutionData(for: goal, sources: sources)
|
||||
let rate = GoalProjection.monthlyGrowthRate(from: evolutionData)
|
||||
let contribution = sources.reduce(Decimal.zero) {
|
||||
$0 + ($1.monthlyContribution?.decimalValue ?? .zero)
|
||||
}
|
||||
|
||||
let result = GoalProjection.project(
|
||||
currentValue: currentValue,
|
||||
targetAmount: goal.targetDecimal,
|
||||
monthlyGrowthRate: rate,
|
||||
monthlyContribution: contribution,
|
||||
targetDate: goal.targetDate
|
||||
)
|
||||
cachedCompletionDates[goal.id] = result.projectedDate
|
||||
return result
|
||||
}
|
||||
|
||||
private func relevantSources(for goal: Goal) -> [InvestmentSource] {
|
||||
if let accountId = goal.account?.safeId {
|
||||
sources = sourceRepository.sources.filter { $0.account?.id == accountId }
|
||||
} else {
|
||||
sources = sourceRepository.sources
|
||||
return sourceRepository.sources.filter { $0.account?.id == accountId }
|
||||
}
|
||||
return sourceRepository.sources
|
||||
}
|
||||
|
||||
private func evolutionData(
|
||||
for goal: Goal,
|
||||
sources: [InvestmentSource]
|
||||
) -> [(date: Date, value: Decimal)] {
|
||||
let cacheKey = goal.account?.safeId ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
|
||||
if let cached = cachedEvolutionData[cacheKey] {
|
||||
return cached
|
||||
}
|
||||
let sourceIds = sources.compactMap { $0.id }
|
||||
guard !sourceIds.isEmpty else {
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Performance: Use cached evolution data if available
|
||||
let cacheKey = goal.account?.safeId ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
|
||||
let evolutionData: [(date: Date, value: Decimal)]
|
||||
if let cached = cachedEvolutionData[cacheKey] {
|
||||
evolutionData = cached
|
||||
} else {
|
||||
let snapshots = snapshotRepository.fetchSnapshots(
|
||||
for: sourceIds,
|
||||
months: maxHistoryMonths
|
||||
)
|
||||
evolutionData = calculateEvolutionData(from: snapshots)
|
||||
cachedEvolutionData[cacheKey] = evolutionData
|
||||
}
|
||||
|
||||
guard evolutionData.count >= 3,
|
||||
let first = evolutionData.suffix(6).first,
|
||||
let last = evolutionData.suffix(6).last else {
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
let monthsBetween = max(1, first.date.monthsBetween(last.date))
|
||||
let delta = last.value - first.value
|
||||
guard delta > 0 else {
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
let monthlyGain = delta / Decimal(monthsBetween)
|
||||
guard monthlyGain > 0 else {
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
let currentValue = totalValue(for: goal)
|
||||
let remaining = goal.targetDecimal - currentValue
|
||||
guard remaining > 0 else {
|
||||
let result = Date()
|
||||
cachedCompletionDates[goal.id] = result
|
||||
return result
|
||||
}
|
||||
|
||||
let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue
|
||||
let monthsRounded = Int(ceil(months))
|
||||
let result = Calendar.current.date(byAdding: .month, value: monthsRounded, to: last.date)
|
||||
cachedCompletionDates[goal.id] = result
|
||||
return result
|
||||
let snapshots = snapshotRepository.fetchSnapshots(for: sourceIds, months: maxHistoryMonths)
|
||||
let data = calculateEvolutionData(from: snapshots)
|
||||
cachedEvolutionData[cacheKey] = data
|
||||
return data
|
||||
}
|
||||
|
||||
private func calculateEvolutionData(from snapshots: [Snapshot]) -> [(date: Date, value: Decimal)] {
|
||||
|
||||
Reference in New Issue
Block a user