Files
InvestmentTrackerApp/PortfolioJournal/Services/InsightsEngine.swift
T
alexandrev-tibco 6887d05b01 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
2026-07-25 12:22:12 +02:00

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
)]
}
}