initial version
This commit is contained in:
@@ -0,0 +1,705 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user