706 lines
26 KiB
Swift
706 lines
26 KiB
Swift
import Foundation
|
|
import Combine
|
|
|
|
@MainActor
|
|
class ChartsViewModel: ObservableObject {
|
|
// MARK: - Chart Types
|
|
|
|
enum ChartType: String, CaseIterable, Identifiable {
|
|
case evolution = "Evolution"
|
|
case allocation = "Allocation"
|
|
case performance = "Performance"
|
|
case contributions = "Contributions"
|
|
case rollingReturn = "Rolling 12M"
|
|
case riskReturn = "Risk vs Return"
|
|
case cashflow = "Net vs Contributions"
|
|
case drawdown = "Drawdown"
|
|
case volatility = "Volatility"
|
|
case prediction = "Prediction"
|
|
|
|
var id: String { rawValue }
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .evolution: return "chart.line.uptrend.xyaxis"
|
|
case .allocation: return "chart.pie.fill"
|
|
case .performance: return "chart.bar.fill"
|
|
case .contributions: return "tray.and.arrow.down.fill"
|
|
case .rollingReturn: return "arrow.triangle.2.circlepath"
|
|
case .riskReturn: return "dot.square"
|
|
case .cashflow: return "chart.bar.xaxis"
|
|
case .drawdown: return "arrow.down.right.circle"
|
|
case .volatility: return "waveform.path.ecg"
|
|
case .prediction: return "wand.and.stars"
|
|
}
|
|
}
|
|
|
|
var isPremium: Bool {
|
|
switch self {
|
|
case .evolution:
|
|
return false
|
|
case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow,
|
|
.drawdown, .volatility, .prediction:
|
|
return true
|
|
}
|
|
}
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .evolution:
|
|
return "Track your portfolio value over time"
|
|
case .allocation:
|
|
return "See how your investments are distributed"
|
|
case .performance:
|
|
return "Compare returns across categories"
|
|
case .contributions:
|
|
return "Review monthly inflows over time"
|
|
case .rollingReturn:
|
|
return "See rolling 12-month performance"
|
|
case .riskReturn:
|
|
return "Compare volatility vs return"
|
|
case .cashflow:
|
|
return "Compare growth vs contributions"
|
|
case .drawdown:
|
|
return "Analyze declines from peak values"
|
|
case .volatility:
|
|
return "Understand investment risk levels"
|
|
case .prediction:
|
|
return "View 12-month forecasts"
|
|
}
|
|
}
|
|
}
|
|
|
|
func availableChartTypes(calmModeEnabled: Bool) -> [ChartType] {
|
|
let types: [ChartType] = calmModeEnabled
|
|
? [.evolution, .allocation, .performance, .contributions]
|
|
: ChartType.allCases
|
|
if !types.contains(selectedChartType) {
|
|
selectedChartType = .evolution
|
|
}
|
|
return types
|
|
}
|
|
|
|
// MARK: - Published Properties
|
|
|
|
@Published var selectedChartType: ChartType = .evolution
|
|
@Published var selectedCategory: Category?
|
|
@Published var selectedTimeRange: TimeRange = .year
|
|
@Published var selectedAccount: Account?
|
|
@Published var showAllAccounts = true
|
|
|
|
@Published var evolutionData: [(date: Date, value: Decimal)] = []
|
|
@Published var categoryEvolutionData: [CategoryEvolutionPoint] = []
|
|
@Published var allocationData: [(category: String, value: Decimal, color: String)] = []
|
|
@Published var performanceData: [(category: String, cagr: Double, color: String)] = []
|
|
@Published var contributionsData: [(date: Date, amount: Decimal)] = []
|
|
@Published var rollingReturnData: [(date: Date, value: Double)] = []
|
|
@Published var riskReturnData: [(category: String, cagr: Double, volatility: Double, color: String)] = []
|
|
@Published var cashflowData: [(date: Date, contributions: Decimal, netPerformance: Decimal)] = []
|
|
@Published var drawdownData: [(date: Date, drawdown: Double)] = []
|
|
@Published var volatilityData: [(date: Date, volatility: Double)] = []
|
|
@Published var predictionData: [Prediction] = []
|
|
|
|
@Published var isLoading = false
|
|
@Published var showingPaywall = false
|
|
@Published private var predictionMonthsAhead = 12
|
|
|
|
// MARK: - Time Range
|
|
|
|
enum TimeRange: String, CaseIterable, Identifiable {
|
|
case month = "1M"
|
|
case quarter = "3M"
|
|
case halfYear = "6M"
|
|
case year = "1Y"
|
|
case all = "All"
|
|
|
|
var id: String { rawValue }
|
|
|
|
var months: Int? {
|
|
switch self {
|
|
case .month: return 1
|
|
case .quarter: return 3
|
|
case .halfYear: return 6
|
|
case .year: return 12
|
|
case .all: return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Dependencies
|
|
|
|
private let sourceRepository: InvestmentSourceRepository
|
|
private let categoryRepository: CategoryRepository
|
|
private let snapshotRepository: SnapshotRepository
|
|
private let calculationService: CalculationService
|
|
private let predictionEngine: PredictionEngine
|
|
private let freemiumValidator: FreemiumValidator
|
|
private let maxHistoryMonths = 60
|
|
private let maxStackedCategories = 6
|
|
private let maxChartPoints = 500
|
|
private var cancellables = Set<AnyCancellable>()
|
|
private var allCategories: [Category] {
|
|
categoryRepository.categories
|
|
}
|
|
|
|
// MARK: - Performance: Caching and State
|
|
private var lastChartType: ChartType?
|
|
private var lastTimeRange: TimeRange?
|
|
private var lastCategoryId: UUID?
|
|
private var lastAccountId: UUID?
|
|
private var lastShowAllAccounts: Bool = true
|
|
private var cachedSnapshots: [Snapshot]?
|
|
private var cachedSnapshotsBySource: [UUID: [Snapshot]]?
|
|
private var isUpdateInProgress = false
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
sourceRepository: InvestmentSourceRepository? = nil,
|
|
categoryRepository: CategoryRepository? = nil,
|
|
snapshotRepository: SnapshotRepository? = nil,
|
|
calculationService: CalculationService? = nil,
|
|
predictionEngine: PredictionEngine? = nil,
|
|
iapService: IAPService
|
|
) {
|
|
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
|
self.categoryRepository = categoryRepository ?? CategoryRepository()
|
|
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
|
self.calculationService = calculationService ?? .shared
|
|
self.predictionEngine = predictionEngine ?? .shared
|
|
self.freemiumValidator = FreemiumValidator(iapService: iapService)
|
|
|
|
setupObservers()
|
|
loadData()
|
|
}
|
|
|
|
// MARK: - Setup
|
|
|
|
private func setupObservers() {
|
|
// Performance: Combine all selection changes into a single debounced stream
|
|
// This prevents multiple rapid updates when switching between views
|
|
Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount)
|
|
.combineLatest($showAllAccounts)
|
|
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
|
|
.sink { [weak self] combined, showAll in
|
|
guard let self else { return }
|
|
let (chartType, category, timeRange, _) = combined
|
|
|
|
// Performance: Skip update if nothing meaningful changed
|
|
let hasChanges = self.lastChartType != chartType ||
|
|
self.lastTimeRange != timeRange ||
|
|
self.lastCategoryId != category?.id ||
|
|
self.lastAccountId != self.selectedAccount?.id ||
|
|
self.lastShowAllAccounts != showAll
|
|
|
|
if hasChanges {
|
|
self.lastChartType = chartType
|
|
self.lastTimeRange = timeRange
|
|
self.lastCategoryId = category?.id
|
|
self.lastAccountId = self.selectedAccount?.id
|
|
self.lastShowAllAccounts = showAll
|
|
self.cachedSnapshots = nil // Invalidate cache on meaningful changes
|
|
self.cachedSnapshotsBySource = nil
|
|
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
// MARK: - Data Loading
|
|
|
|
func loadData() {
|
|
updateChartData(
|
|
chartType: selectedChartType,
|
|
category: selectedCategory,
|
|
timeRange: selectedTimeRange
|
|
)
|
|
|
|
FirebaseService.shared.logScreenView(screenName: "Charts")
|
|
}
|
|
|
|
func selectChart(_ chartType: ChartType) {
|
|
if chartType.isPremium && !freemiumValidator.isPremium {
|
|
showingPaywall = true
|
|
FirebaseService.shared.logPaywallShown(trigger: "advanced_charts")
|
|
return
|
|
}
|
|
|
|
selectedChartType = chartType
|
|
FirebaseService.shared.logChartViewed(
|
|
chartType: chartType.rawValue,
|
|
isPremium: chartType.isPremium
|
|
)
|
|
}
|
|
|
|
private func updateChartData(chartType: ChartType, category: Category?, timeRange: TimeRange) {
|
|
// Performance: Prevent re-entrancy
|
|
guard !isUpdateInProgress else { return }
|
|
isUpdateInProgress = true
|
|
isLoading = true
|
|
|
|
defer {
|
|
isLoading = false
|
|
isUpdateInProgress = false
|
|
}
|
|
|
|
let sources: [InvestmentSource]
|
|
if let category = category {
|
|
sources = sourceRepository.fetchSources(for: category).filter { shouldIncludeSource($0) }
|
|
} else {
|
|
sources = sourceRepository.sources.filter { shouldIncludeSource($0) }
|
|
}
|
|
|
|
let monthsLimit = timeRange.months ?? maxHistoryMonths
|
|
let sourceIds = sources.compactMap { $0.id }
|
|
|
|
// Performance: Reuse cached snapshots when possible
|
|
var snapshots: [Snapshot]
|
|
if let cached = cachedSnapshots {
|
|
snapshots = cached
|
|
} else {
|
|
snapshots = snapshotRepository.fetchSnapshots(
|
|
for: sourceIds,
|
|
months: monthsLimit
|
|
)
|
|
snapshots = freemiumValidator.filterSnapshots(snapshots)
|
|
cachedSnapshots = snapshots
|
|
}
|
|
|
|
// Performance: Cache snapshotsBySource
|
|
let snapshotsBySource: [UUID: [Snapshot]]
|
|
if let cached = cachedSnapshotsBySource {
|
|
snapshotsBySource = cached
|
|
} else {
|
|
var grouped: [UUID: [Snapshot]] = [:]
|
|
grouped.reserveCapacity(sources.count)
|
|
for snapshot in snapshots {
|
|
guard let id = snapshot.source?.id else { continue }
|
|
grouped[id, default: []].append(snapshot)
|
|
}
|
|
snapshotsBySource = grouped
|
|
cachedSnapshotsBySource = grouped
|
|
}
|
|
|
|
// Performance: Only calculate data for the selected chart type
|
|
switch chartType {
|
|
case .evolution:
|
|
calculateEvolutionData(from: snapshots)
|
|
let categoriesForChart = categoriesForStackedChart(
|
|
sources: sources,
|
|
selectedCategory: selectedCategory
|
|
)
|
|
calculateCategoryEvolutionData(from: snapshots, categories: categoriesForChart)
|
|
case .allocation:
|
|
calculateAllocationData(for: sources)
|
|
case .performance:
|
|
calculatePerformanceData(for: sources, snapshotsBySource: snapshotsBySource)
|
|
case .contributions:
|
|
calculateContributionsData(from: snapshots)
|
|
case .rollingReturn:
|
|
calculateRollingReturnData(from: snapshots)
|
|
case .riskReturn:
|
|
calculateRiskReturnData(for: sources, snapshotsBySource: snapshotsBySource)
|
|
case .cashflow:
|
|
calculateCashflowData(from: snapshots)
|
|
case .drawdown:
|
|
calculateDrawdownData(from: snapshots)
|
|
case .volatility:
|
|
calculateVolatilityData(from: snapshots)
|
|
case .prediction:
|
|
calculatePredictionData(from: snapshots)
|
|
}
|
|
|
|
if let selected = selectedCategory,
|
|
!availableCategories(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) {
|
|
selectedCategory = nil
|
|
}
|
|
}
|
|
|
|
func availableCategories(
|
|
for chartType: ChartType,
|
|
sources: [InvestmentSource]? = nil
|
|
) -> [Category] {
|
|
let relevantSources = sources ?? sourceRepository.sources.filter { shouldIncludeSource($0) }
|
|
let categoriesWithData = Set(relevantSources.compactMap { $0.category?.id })
|
|
let filtered = allCategories.filter { categoriesWithData.contains($0.id) }
|
|
|
|
switch chartType {
|
|
case .evolution, .prediction:
|
|
return filtered
|
|
default:
|
|
return []
|
|
}
|
|
}
|
|
|
|
private func shouldIncludeSource(_ source: InvestmentSource) -> Bool {
|
|
if showAllAccounts || selectedAccount == nil {
|
|
return true
|
|
}
|
|
return source.account?.id == selectedAccount?.id
|
|
}
|
|
|
|
private func categoriesForStackedChart(
|
|
sources: [InvestmentSource],
|
|
selectedCategory: Category?
|
|
) -> [Category] {
|
|
var totals: [UUID: Decimal] = [:]
|
|
|
|
for source in sources {
|
|
guard let categoryId = source.category?.id else { continue }
|
|
totals[categoryId, default: 0] += source.latestValue
|
|
}
|
|
|
|
var topCategoryIds = Set(
|
|
totals.sorted { $0.value > $1.value }
|
|
.prefix(maxStackedCategories)
|
|
.map { $0.key }
|
|
)
|
|
|
|
if let selectedCategory {
|
|
topCategoryIds.insert(selectedCategory.id)
|
|
}
|
|
|
|
return categoryRepository.categories.filter { topCategoryIds.contains($0.id) }
|
|
}
|
|
|
|
private func downsampleSeries(
|
|
_ data: [(date: Date, value: Decimal)],
|
|
maxPoints: Int
|
|
) -> [(date: Date, value: Decimal)] {
|
|
guard data.count > maxPoints, maxPoints > 0 else { return data }
|
|
let bucketSize = max(1, Int(ceil(Double(data.count) / Double(maxPoints))))
|
|
var sampled: [(date: Date, value: Decimal)] = []
|
|
sampled.reserveCapacity(maxPoints)
|
|
|
|
var index = 0
|
|
while index < data.count {
|
|
let end = min(index + bucketSize, data.count)
|
|
let bucket = data[index..<end]
|
|
if let last = bucket.last {
|
|
sampled.append(last)
|
|
}
|
|
index += bucketSize
|
|
}
|
|
return sampled
|
|
}
|
|
|
|
private func downsampleDates(_ dates: [Date], maxPoints: Int) -> [Date] {
|
|
guard dates.count > maxPoints, maxPoints > 0 else { return dates }
|
|
let bucketSize = max(1, Int(ceil(Double(dates.count) / Double(maxPoints))))
|
|
var sampled: [Date] = []
|
|
sampled.reserveCapacity(maxPoints)
|
|
|
|
var index = 0
|
|
while index < dates.count {
|
|
let end = min(index + bucketSize, dates.count)
|
|
let bucket = dates[index..<end]
|
|
if let last = bucket.last {
|
|
sampled.append(last)
|
|
}
|
|
index += bucketSize
|
|
}
|
|
return sampled
|
|
}
|
|
|
|
// MARK: - Chart Calculations
|
|
|
|
private func calculateEvolutionData(from snapshots: [Snapshot]) {
|
|
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
|
|
|
var dateValues: [Date: Decimal] = [:]
|
|
|
|
for snapshot in sortedSnapshots {
|
|
let day = Calendar.current.startOfDay(for: snapshot.date)
|
|
dateValues[day, default: 0] += snapshot.decimalValue
|
|
}
|
|
|
|
let series = dateValues
|
|
.map { (date: $0.key, value: $0.value) }
|
|
.sorted { $0.date < $1.date }
|
|
evolutionData = downsampleSeries(series, maxPoints: maxChartPoints)
|
|
}
|
|
|
|
private func calculateCategoryEvolutionData(from snapshots: [Snapshot], categories: [Category]) {
|
|
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
|
let categoriesWithData = Set(sortedSnapshots.compactMap { $0.source?.category?.id })
|
|
let filteredCategories = categories.filter { categoriesWithData.contains($0.id) }
|
|
let snapshotsByDay = Dictionary(grouping: sortedSnapshots) {
|
|
Calendar.current.startOfDay(for: $0.date)
|
|
}
|
|
let uniqueDates = downsampleDates(snapshotsByDay.keys.sorted(), maxPoints: maxChartPoints)
|
|
|
|
var latestBySource: [UUID: Snapshot] = [:]
|
|
var points: [CategoryEvolutionPoint] = []
|
|
|
|
for date in uniqueDates {
|
|
if let daySnapshots = snapshotsByDay[date] {
|
|
for snapshot in daySnapshots {
|
|
guard let sourceId = snapshot.source?.id else { continue }
|
|
latestBySource[sourceId] = snapshot
|
|
}
|
|
}
|
|
|
|
var valuesByCategory: [UUID: Decimal] = [:]
|
|
for snapshot in latestBySource.values {
|
|
guard let category = snapshot.source?.category else { continue }
|
|
valuesByCategory[category.id, default: 0] += snapshot.decimalValue
|
|
}
|
|
|
|
for category in filteredCategories {
|
|
let value = valuesByCategory[category.id] ?? 0
|
|
points.append(CategoryEvolutionPoint(
|
|
date: date,
|
|
categoryName: category.name,
|
|
colorHex: category.colorHex,
|
|
value: value
|
|
))
|
|
}
|
|
}
|
|
|
|
categoryEvolutionData = points
|
|
}
|
|
|
|
private func calculateAllocationData(for sources: [InvestmentSource]) {
|
|
let categories = categoryRepository.categories
|
|
let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
|
|
|
allocationData = categories.compactMap { category in
|
|
let categorySources = valuesByCategory[category.id] ?? []
|
|
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
|
guard categoryValue > 0 else { return nil }
|
|
|
|
return (
|
|
category: category.name,
|
|
value: categoryValue,
|
|
color: category.colorHex
|
|
)
|
|
}.sorted { $0.value > $1.value }
|
|
}
|
|
|
|
private func calculatePerformanceData(
|
|
for sources: [InvestmentSource],
|
|
snapshotsBySource: [UUID: [Snapshot]]
|
|
) {
|
|
let categories = categoryRepository.categories
|
|
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
|
|
|
performanceData = categories.compactMap { category in
|
|
let categorySources = sourcesByCategory[category.id] ?? []
|
|
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
|
|
let id = source.id
|
|
return snapshotsBySource[id]
|
|
}.flatMap { $0 }
|
|
guard snapshots.count >= 2 else { return nil }
|
|
|
|
let metrics = calculationService.calculateMetrics(for: snapshots)
|
|
|
|
return (
|
|
category: category.name,
|
|
cagr: metrics.cagr,
|
|
color: category.colorHex
|
|
)
|
|
}.sorted { $0.cagr > $1.cagr }
|
|
}
|
|
|
|
private func calculateContributionsData(from snapshots: [Snapshot]) {
|
|
let grouped = Dictionary(grouping: snapshots) { $0.date.startOfMonth }
|
|
contributionsData = grouped.map { date, items in
|
|
let total = items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
|
|
return (date: date, amount: total)
|
|
}
|
|
.sorted { $0.date < $1.date }
|
|
}
|
|
|
|
private func calculateRollingReturnData(from snapshots: [Snapshot]) {
|
|
let monthlyTotals = monthlyTotals(from: snapshots)
|
|
guard monthlyTotals.count >= 13 else {
|
|
rollingReturnData = []
|
|
return
|
|
}
|
|
|
|
var returns: [(date: Date, value: Double)] = []
|
|
for index in 12..<monthlyTotals.count {
|
|
let current = monthlyTotals[index]
|
|
let base = monthlyTotals[index - 12]
|
|
guard base.totalValue > 0 else { continue }
|
|
let change = current.totalValue - base.totalValue
|
|
let percent = NSDecimalNumber(decimal: change / base.totalValue).doubleValue * 100
|
|
returns.append((date: current.date, value: percent))
|
|
}
|
|
rollingReturnData = returns
|
|
}
|
|
|
|
private func calculateRiskReturnData(
|
|
for sources: [InvestmentSource],
|
|
snapshotsBySource: [UUID: [Snapshot]]
|
|
) {
|
|
let categories = categoryRepository.categories
|
|
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
|
|
|
riskReturnData = categories.compactMap { category in
|
|
let categorySources = sourcesByCategory[category.id] ?? []
|
|
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
|
|
let id = source.id
|
|
return snapshotsBySource[id]
|
|
}.flatMap { $0 }
|
|
guard snapshots.count >= 3 else { return nil }
|
|
let metrics = calculationService.calculateMetrics(for: snapshots)
|
|
return (
|
|
category: category.name,
|
|
cagr: metrics.cagr,
|
|
volatility: metrics.volatility,
|
|
color: category.colorHex
|
|
)
|
|
}.sorted { $0.cagr > $1.cagr }
|
|
}
|
|
|
|
private func calculateCashflowData(from snapshots: [Snapshot]) {
|
|
let monthlyTotals = monthlyTotals(from: snapshots)
|
|
let contributionsByMonth = Dictionary(grouping: snapshots) { $0.date.startOfMonth }
|
|
.mapValues { items in
|
|
items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
|
|
}
|
|
|
|
var data: [(date: Date, contributions: Decimal, netPerformance: Decimal)] = []
|
|
for index in 0..<monthlyTotals.count {
|
|
let current = monthlyTotals[index]
|
|
let previousTotal = index > 0 ? monthlyTotals[index - 1].totalValue : 0
|
|
let contributions = contributionsByMonth[current.date] ?? 0
|
|
let netPerformance = current.totalValue - previousTotal - contributions
|
|
data.append((date: current.date, contributions: contributions, netPerformance: netPerformance))
|
|
}
|
|
cashflowData = data
|
|
}
|
|
|
|
private func calculateDrawdownData(from snapshots: [Snapshot]) {
|
|
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
|
guard !sortedSnapshots.isEmpty else {
|
|
drawdownData = []
|
|
return
|
|
}
|
|
|
|
var peak = sortedSnapshots.first!.decimalValue
|
|
var data: [(date: Date, drawdown: Double)] = []
|
|
|
|
for snapshot in sortedSnapshots {
|
|
let value = snapshot.decimalValue
|
|
if value > peak {
|
|
peak = value
|
|
}
|
|
|
|
let drawdown = peak > 0
|
|
? NSDecimalNumber(decimal: (peak - value) / peak).doubleValue * 100
|
|
: 0
|
|
|
|
data.append((date: snapshot.date, drawdown: -drawdown))
|
|
}
|
|
|
|
drawdownData = data
|
|
}
|
|
|
|
private func calculateVolatilityData(from snapshots: [Snapshot]) {
|
|
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
|
guard sortedSnapshots.count >= 3 else {
|
|
volatilityData = []
|
|
return
|
|
}
|
|
|
|
var data: [(date: Date, volatility: Double)] = []
|
|
let windowSize = 3
|
|
|
|
for i in windowSize..<sortedSnapshots.count {
|
|
let window = Array(sortedSnapshots[(i - windowSize)..<i])
|
|
let values = window.map { NSDecimalNumber(decimal: $0.decimalValue).doubleValue }
|
|
|
|
let mean = values.reduce(0, +) / Double(values.count)
|
|
let variance = values.map { pow($0 - mean, 2) }.reduce(0, +) / Double(values.count)
|
|
let stdDev = sqrt(variance)
|
|
let volatility = mean > 0 ? (stdDev / mean) * 100 : 0
|
|
|
|
data.append((date: sortedSnapshots[i].date, volatility: volatility))
|
|
}
|
|
|
|
volatilityData = data
|
|
}
|
|
|
|
private func calculatePredictionData(from snapshots: [Snapshot]) {
|
|
guard freemiumValidator.canViewPredictions() else {
|
|
predictionData = []
|
|
return
|
|
}
|
|
|
|
let result = predictionEngine.predict(snapshots: snapshots, monthsAhead: predictionMonthsAhead)
|
|
predictionData = result.predictions
|
|
}
|
|
|
|
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
|
|
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
|
let months = Array(Set(sortedSnapshots.map { $0.date.startOfMonth })).sorted()
|
|
guard !months.isEmpty else { return [] }
|
|
|
|
var snapshotsBySource: [UUID: [Snapshot]] = [:]
|
|
for snapshot in sortedSnapshots {
|
|
guard let sourceId = snapshot.source?.id else { continue }
|
|
snapshotsBySource[sourceId, default: []].append(snapshot)
|
|
}
|
|
|
|
var indices: [UUID: Int] = [:]
|
|
var totals: [(date: Date, totalValue: Decimal)] = []
|
|
|
|
for (index, month) in months.enumerated() {
|
|
let nextMonth = index + 1 < months.count ? months[index + 1] : Date.distantFuture
|
|
var total: Decimal = 0
|
|
|
|
for (sourceId, sourceSnapshots) in snapshotsBySource {
|
|
var currentIndex = indices[sourceId] ?? 0
|
|
var latest: Snapshot?
|
|
|
|
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextMonth {
|
|
latest = sourceSnapshots[currentIndex]
|
|
currentIndex += 1
|
|
}
|
|
|
|
indices[sourceId] = currentIndex
|
|
total += latest?.decimalValue ?? 0
|
|
}
|
|
|
|
totals.append((date: month, totalValue: total))
|
|
}
|
|
|
|
return totals
|
|
}
|
|
|
|
func updatePredictionTargetDate(_ goals: [Goal]) {
|
|
let futureGoalDates = goals.compactMap { $0.targetDate }.filter { $0 > Date() }
|
|
guard let latestGoalDate = futureGoalDates.max(),
|
|
let lastSnapshotDate = evolutionData.last?.date else {
|
|
predictionMonthsAhead = 12
|
|
return
|
|
}
|
|
|
|
let months = max(1, lastSnapshotDate.startOfMonth.monthsBetween(latestGoalDate.startOfMonth))
|
|
predictionMonthsAhead = max(12, months)
|
|
if selectedChartType == .prediction {
|
|
updateChartData(chartType: selectedChartType, category: selectedCategory, timeRange: selectedTimeRange)
|
|
}
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
var categories: [Category] {
|
|
categoryRepository.categories
|
|
}
|
|
|
|
var availableChartTypes: [ChartType] {
|
|
ChartType.allCases
|
|
}
|
|
|
|
var isPremium: Bool {
|
|
freemiumValidator.isPremium
|
|
}
|
|
|
|
var hasData: Bool {
|
|
!sourceRepository.sources.filter { shouldIncludeSource($0) }.isEmpty
|
|
}
|
|
}
|