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