6887d05b01
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
242 lines
10 KiB
Swift
242 lines
10 KiB
Swift
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
|
|
)]
|
|
}
|
|
}
|