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,241 @@
|
||||
import SwiftUI
|
||||
|
||||
/// On-device, privacy-friendly rule-based insights engine.
|
||||
///
|
||||
/// Produces a prioritized list of `PortfolioInsight`s purely from local data —
|
||||
/// no network, no AI calls. The `DashboardViewModel` gathers the inputs from
|
||||
/// Core Data and passes them in; this keeps the rules pure and testable.
|
||||
enum InsightsEngine {
|
||||
|
||||
/// One category's allocation snapshot for the allocation-vs-target rule.
|
||||
struct CategorySlice {
|
||||
let name: String
|
||||
let percentageOfPortfolio: Double
|
||||
/// User's target allocation percentage, if set.
|
||||
let targetPercentage: Double?
|
||||
/// Trailing return percentage over the analysed window, if computable.
|
||||
let returnPercentage: Double?
|
||||
}
|
||||
|
||||
/// One source's weight for the concentration-risk rule.
|
||||
struct SourceSlice {
|
||||
let name: String
|
||||
let percentageOfPortfolio: Double
|
||||
}
|
||||
|
||||
struct Input {
|
||||
let totalValue: Decimal
|
||||
let categories: [CategorySlice]
|
||||
let sources: [SourceSlice]
|
||||
/// Number of categories that actually hold value.
|
||||
let fundedCategoryCount: Int
|
||||
/// Number of sources that actually hold value.
|
||||
let fundedSourceCount: Int
|
||||
let yearChangePercentage: Double
|
||||
let allTimeReturn: Decimal
|
||||
let updateStreak: Int
|
||||
let forecast: PortfolioForecast?
|
||||
}
|
||||
|
||||
// Thresholds
|
||||
private static let concentrationThreshold = 0.40 // one source > 40% of portfolio
|
||||
private static let allocationDriftThreshold = 5.0 // > 5 percentage points off target
|
||||
private static let milestoneNearThreshold = 0.90 // within 10% of next milestone
|
||||
|
||||
static func makeInsights(_ input: Input, limit: Int = 4) -> [PortfolioInsight] {
|
||||
guard input.totalValue > 0 else { return [] }
|
||||
var result: [PortfolioInsight] = []
|
||||
|
||||
result.append(contentsOf: milestoneInsight(input))
|
||||
result.append(contentsOf: allocationDriftInsight(input))
|
||||
result.append(contentsOf: concentrationInsight(input))
|
||||
result.append(contentsOf: performerInsights(input))
|
||||
result.append(contentsOf: diversificationInsight(input))
|
||||
result.append(contentsOf: streakInsight(input))
|
||||
result.append(contentsOf: ytdInsight(input))
|
||||
result.append(contentsOf: marketGainsInsight(input))
|
||||
result.append(contentsOf: forecastInsight(input))
|
||||
|
||||
// Prioritize: attention first, then positive, then neutral; stable within tone.
|
||||
let sorted = result.enumerated().sorted { lhs, rhs in
|
||||
if lhs.element.tone.priority != rhs.element.tone.priority {
|
||||
return lhs.element.tone.priority > rhs.element.tone.priority
|
||||
}
|
||||
return lhs.offset < rhs.offset
|
||||
}.map(\.element)
|
||||
|
||||
return Array(sorted.prefix(limit))
|
||||
}
|
||||
|
||||
// MARK: - Rules
|
||||
|
||||
private static func milestoneInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
|
||||
100000, 250000, 500000, 1_000_000,
|
||||
2_500_000, 5_000_000, 10_000_000]
|
||||
guard let next = milestones.first(where: { $0 > input.totalValue }) else { return [] }
|
||||
let pct = NSDecimalNumber(decimal: input.totalValue / next).doubleValue
|
||||
guard pct >= milestoneNearThreshold else { return [] }
|
||||
let gap = next - input.totalValue
|
||||
let gapStr = CurrencyFormatter.format(gap, style: .currency, maximumFractionDigits: 0)
|
||||
let msStr = CurrencyFormatter.format(next, style: .currency, maximumFractionDigits: 0)
|
||||
return [PortfolioInsight(
|
||||
id: "milestone",
|
||||
systemImage: "flag.checkered",
|
||||
title: String(localized: "insight_milestone_title"),
|
||||
value: String(format: String(localized: "insight_milestone_value"), gapStr, msStr),
|
||||
accentColor: .orange,
|
||||
tone: .positive
|
||||
)]
|
||||
}
|
||||
|
||||
private static func allocationDriftInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
// Pick the category most off its target (over or under).
|
||||
let drifts = input.categories.compactMap { cat -> (CategorySlice, Double)? in
|
||||
guard let target = cat.targetPercentage, target > 0 else { return nil }
|
||||
return (cat, cat.percentageOfPortfolio - target)
|
||||
}
|
||||
guard let (cat, drift) = drifts.max(by: { abs($0.1) < abs($1.1) }),
|
||||
abs(drift) >= allocationDriftThreshold,
|
||||
let target = cat.targetPercentage else { return [] }
|
||||
|
||||
let over = drift > 0
|
||||
let value = String(
|
||||
format: String(localized: over ? "insight_allocation_over_value" : "insight_allocation_under_value"),
|
||||
cat.name,
|
||||
cat.percentageOfPortfolio,
|
||||
target
|
||||
)
|
||||
return [PortfolioInsight(
|
||||
id: "allocation_\(cat.name)",
|
||||
systemImage: over ? "arrow.up.forward.circle.fill" : "arrow.down.forward.circle.fill",
|
||||
title: String(localized: "insight_allocation_title"),
|
||||
value: value,
|
||||
accentColor: .appWarning,
|
||||
detail: String(localized: "insight_allocation_detail"),
|
||||
tone: .attention
|
||||
)]
|
||||
}
|
||||
|
||||
private static func concentrationInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
guard let top = input.sources.max(by: { $0.percentageOfPortfolio < $1.percentageOfPortfolio }),
|
||||
top.percentageOfPortfolio >= concentrationThreshold * 100,
|
||||
input.fundedSourceCount >= 2 else { return [] }
|
||||
return [PortfolioInsight(
|
||||
id: "concentration",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
title: String(localized: "insight_concentration_title"),
|
||||
value: String(format: String(localized: "insight_concentration_value"), top.name, top.percentageOfPortfolio),
|
||||
accentColor: .appWarning,
|
||||
detail: String(localized: "insight_concentration_detail"),
|
||||
tone: .attention
|
||||
)]
|
||||
}
|
||||
|
||||
private static func performerInsights(_ input: Input) -> [PortfolioInsight] {
|
||||
let ranked = input.categories.compactMap { cat -> (CategorySlice, Double)? in
|
||||
guard let ret = cat.returnPercentage else { return nil }
|
||||
return (cat, ret)
|
||||
}
|
||||
guard ranked.count >= 2 else { return [] }
|
||||
var out: [PortfolioInsight] = []
|
||||
|
||||
if let best = ranked.max(by: { $0.1 < $1.1 }), best.1 > 0 {
|
||||
out.append(PortfolioInsight(
|
||||
id: "best_performer",
|
||||
systemImage: "trophy.fill",
|
||||
title: String(localized: "insight_best_performer_title"),
|
||||
value: String(format: String(localized: "insight_performer_value"), best.0.name, best.1),
|
||||
accentColor: .positiveGreen,
|
||||
tone: .positive
|
||||
))
|
||||
}
|
||||
if let worst = ranked.min(by: { $0.1 < $1.1 }), worst.1 < 0 {
|
||||
out.append(PortfolioInsight(
|
||||
id: "worst_performer",
|
||||
systemImage: "chart.line.downtrend.xyaxis",
|
||||
title: String(localized: "insight_worst_performer_title"),
|
||||
value: String(format: String(localized: "insight_performer_value"), worst.0.name, worst.1),
|
||||
accentColor: .negativeRed,
|
||||
tone: .attention
|
||||
))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private static func diversificationInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
// Encourage diversification only when the portfolio is thin.
|
||||
if input.fundedSourceCount == 1 {
|
||||
return [PortfolioInsight(
|
||||
id: "diversification",
|
||||
systemImage: "square.grid.2x2",
|
||||
title: String(localized: "insight_diversification_title"),
|
||||
value: String(localized: "insight_diversification_single_value"),
|
||||
accentColor: .appPrimary,
|
||||
tone: .neutral
|
||||
)]
|
||||
}
|
||||
if input.fundedCategoryCount >= 4 {
|
||||
return [PortfolioInsight(
|
||||
id: "diversification",
|
||||
systemImage: "square.grid.2x2.fill",
|
||||
title: String(localized: "insight_diversification_title"),
|
||||
value: String(format: String(localized: "insight_diversification_good_value"), input.fundedCategoryCount),
|
||||
accentColor: .positiveGreen,
|
||||
tone: .positive
|
||||
)]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private static func streakInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
guard input.updateStreak >= 3 else { return [] }
|
||||
return [PortfolioInsight(
|
||||
id: "streak",
|
||||
systemImage: "flame.fill",
|
||||
title: String(localized: "insight_streak_title"),
|
||||
value: String(format: String(localized: "insight_streak_value"), input.updateStreak),
|
||||
accentColor: .orange,
|
||||
tone: .positive
|
||||
)]
|
||||
}
|
||||
|
||||
private static func ytdInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
let ytd = input.yearChangePercentage
|
||||
guard abs(ytd) >= 0.1 else { return [] }
|
||||
let icon = ytd >= 0 ? "arrow.up.right.circle.fill" : "arrow.down.right.circle.fill"
|
||||
return [PortfolioInsight(
|
||||
id: "ytd",
|
||||
systemImage: icon,
|
||||
title: String(localized: "insight_ytd_title"),
|
||||
value: String(format: "%+.1f%%", ytd),
|
||||
accentColor: ytd >= 0 ? .positiveGreen : .negativeRed,
|
||||
tone: ytd >= 0 ? .positive : .neutral
|
||||
)]
|
||||
}
|
||||
|
||||
private static func marketGainsInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
guard input.allTimeReturn > 0 else { return [] }
|
||||
let gainStr = CurrencyFormatter.format(input.allTimeReturn, style: .currency, maximumFractionDigits: 0)
|
||||
return [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,
|
||||
tone: .positive
|
||||
)]
|
||||
}
|
||||
|
||||
private static func forecastInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
guard let forecast = input.forecast, forecast.forecastValue > input.totalValue else { return [] }
|
||||
return [PortfolioInsight(
|
||||
id: "forecast",
|
||||
systemImage: "wand.and.stars",
|
||||
title: String(localized: "insight_forecast_title"),
|
||||
value: "\(forecast.formattedForecastValue) · \(forecast.formattedForecastDate)",
|
||||
accentColor: .purple,
|
||||
tone: .neutral
|
||||
)]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user