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:
@@ -731,80 +731,45 @@ class DashboardViewModel: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
/// Prioritized, on-device insights. Gathers local inputs and delegates the
|
||||
/// rules to `InsightsEngine` (no network / no AI).
|
||||
var insights: [PortfolioInsight] {
|
||||
var result: [PortfolioInsight] = []
|
||||
guard portfolioSummary.totalValue > 0 else { return result }
|
||||
let total = portfolioSummary.totalValue
|
||||
guard total > 0 else { return [] }
|
||||
|
||||
// 1. Milestone approaching (within 10% of next round milestone)
|
||||
let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
|
||||
100000, 250000, 500000, 1_000_000,
|
||||
2_500_000, 5_000_000, 10_000_000]
|
||||
if let next = milestones.first(where: { $0 > total }) {
|
||||
let pct = NSDecimalNumber(decimal: total / next).doubleValue
|
||||
if pct >= 0.90 {
|
||||
let gap = next - total
|
||||
let gapStr = CurrencyFormatter.format(gap, style: .currency, maximumFractionDigits: 0)
|
||||
let msStr = CurrencyFormatter.format(next, style: .currency, maximumFractionDigits: 0)
|
||||
result.append(PortfolioInsight(
|
||||
id: "milestone",
|
||||
systemImage: "flag.checkered",
|
||||
title: String(localized: "insight_milestone_title"),
|
||||
value: String(format: String(localized: "insight_milestone_value"), gapStr, msStr),
|
||||
accentColor: .orange
|
||||
))
|
||||
}
|
||||
let categorySlices: [InsightsEngine.CategorySlice] = categoryMetrics.map { cat in
|
||||
InsightsEngine.CategorySlice(
|
||||
name: cat.categoryName,
|
||||
percentageOfPortfolio: cat.percentageOfPortfolio,
|
||||
targetPercentage: AllocationTargetStore.target(for: cat.id),
|
||||
returnPercentage: NSDecimalNumber(decimal: cat.metrics.percentageReturn).doubleValue
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Year-to-date performance
|
||||
let ytdPct = portfolioSummary.yearChangePercentage
|
||||
if abs(ytdPct) >= 0.1 {
|
||||
let icon = ytdPct >= 0 ? "arrow.up.right.circle.fill" : "arrow.down.right.circle.fill"
|
||||
result.append(PortfolioInsight(
|
||||
id: "ytd",
|
||||
systemImage: icon,
|
||||
title: String(localized: "insight_ytd_title"),
|
||||
value: String(format: "%+.1f%%", ytdPct),
|
||||
accentColor: ytdPct >= 0 ? .positiveGreen : .negativeRed
|
||||
))
|
||||
let sources = filteredSources()
|
||||
let totalDouble = NSDecimalNumber(decimal: total).doubleValue
|
||||
let sourceSlices: [InsightsEngine.SourceSlice] = sources.compactMap { source in
|
||||
let value = NSDecimalNumber(decimal: source.latestValue).doubleValue
|
||||
guard value > 0, totalDouble > 0 else { return nil }
|
||||
return InsightsEngine.SourceSlice(
|
||||
name: source.name,
|
||||
percentageOfPortfolio: value / totalDouble * 100
|
||||
)
|
||||
}
|
||||
|
||||
// 3. All-time market gains
|
||||
let gains = portfolioSummary.allTimeReturn
|
||||
if gains > 0 {
|
||||
let gainStr = CurrencyFormatter.format(gains, style: .currency, maximumFractionDigits: 0)
|
||||
result.append(PortfolioInsight(
|
||||
id: "market_gains",
|
||||
systemImage: "chart.line.uptrend.xyaxis",
|
||||
title: String(localized: "insight_market_gains_title"),
|
||||
value: String(format: String(localized: "insight_market_gains_value"), gainStr),
|
||||
accentColor: .appPrimary
|
||||
))
|
||||
}
|
||||
let input = InsightsEngine.Input(
|
||||
totalValue: total,
|
||||
categories: categorySlices,
|
||||
sources: sourceSlices,
|
||||
fundedCategoryCount: categoryMetrics.filter { $0.totalValue > 0 }.count,
|
||||
fundedSourceCount: sourceSlices.count,
|
||||
yearChangePercentage: portfolioSummary.yearChangePercentage,
|
||||
allTimeReturn: portfolioSummary.allTimeReturn,
|
||||
updateStreak: updateStreak,
|
||||
forecast: portfolioForecast
|
||||
)
|
||||
|
||||
// 4. Tracking streak (>= 3 months)
|
||||
if updateStreak >= 3 {
|
||||
result.append(PortfolioInsight(
|
||||
id: "streak",
|
||||
systemImage: "flame.fill",
|
||||
title: String(localized: "insight_streak_title"),
|
||||
value: String(format: String(localized: "insight_streak_value"), updateStreak),
|
||||
accentColor: .orange
|
||||
))
|
||||
}
|
||||
|
||||
// 5. Forecast
|
||||
if let forecast = portfolioForecast, forecast.forecastValue > total {
|
||||
result.append(PortfolioInsight(
|
||||
id: "forecast",
|
||||
systemImage: "wand.and.stars",
|
||||
title: String(localized: "insight_forecast_title"),
|
||||
value: "\(forecast.formattedForecastValue) · \(forecast.formattedForecastDate)",
|
||||
accentColor: .purple
|
||||
))
|
||||
}
|
||||
|
||||
return result
|
||||
return InsightsEngine.makeInsights(input)
|
||||
}
|
||||
|
||||
var topCategories: [CategoryMetrics] {
|
||||
|
||||
Reference in New Issue
Block a user