initial version

This commit is contained in:
alexandrev-tibco
2026-01-15 09:24:06 +01:00
parent bab350dd22
commit 7988257399
139 changed files with 13149 additions and 3233 deletions
@@ -0,0 +1,85 @@
import Foundation
import Combine
import CoreData
@MainActor
class AccountStore: ObservableObject {
@Published private(set) var accounts: [Account] = []
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
private let accountRepository: AccountRepository
private let iapService: IAPService
private var cancellables = Set<AnyCancellable>()
init(
accountRepository: AccountRepository? = nil,
iapService: IAPService
) {
self.accountRepository = accountRepository ?? AccountRepository()
self.iapService = iapService
self.accountRepository.fetchAccounts()
let defaultAccount = self.accountRepository.createDefaultAccountIfNeeded()
accounts = self.accountRepository.accounts
selectedAccount = defaultAccount
loadSelection()
setupObservers()
}
private func setupObservers() {
accountRepository.$accounts
.receive(on: DispatchQueue.main)
.sink { [weak self] accounts in
self?.accounts = accounts
self?.syncSelectedAccount()
}
.store(in: &cancellables)
}
private func loadSelection() {
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
showAllAccounts = settings.showAllAccounts
if let selectedId = settings.selectedAccountId,
let account = accountRepository.fetchAccount(by: selectedId) {
selectedAccount = account
} else {
selectedAccount = accounts.first
}
}
private func syncSelectedAccount() {
if showAllAccounts { return }
if let selected = selectedAccount,
accounts.contains(where: { $0.id == selected.id }) {
return
}
selectedAccount = accounts.first
}
func selectAllAccounts() {
showAllAccounts = true
persistSelection()
}
func selectAccount(_ account: Account) {
showAllAccounts = false
selectedAccount = account
persistSelection()
}
func persistSelection() {
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.showAllAccounts = showAllAccounts
settings.selectedAccountId = showAllAccounts ? nil : selectedAccount?.id
CoreDataStack.shared.save()
}
func canAddAccount() -> Bool {
iapService.isPremium || accounts.count < 1
}
}
@@ -0,0 +1,176 @@
import Foundation
import SwiftUI
import Combine
import GoogleMobileAds
import AppTrackingTransparency
import AdSupport
@MainActor
class AdMobService: ObservableObject {
// MARK: - Published Properties
@Published var isConsentObtained = false
@Published var canShowAds = false
@Published var isLoading = false
// MARK: - Ad Unit IDs
// Test Ad Unit IDs - Replace with production IDs before release
#if DEBUG
static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716"
#else
static let bannerAdUnitID = "ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY" // Replace with your Ad Unit ID
#endif
// MARK: - Initialization
init() {
checkConsentStatus()
}
// MARK: - Consent Management
func checkConsentStatus() {
// Check if we already have consent
let consentStatus = UserDefaults.standard.bool(forKey: "adConsentObtained")
isConsentObtained = consentStatus
canShowAds = consentStatus
}
func requestConsent() async {
if #available(iOS 14.5, *) {
let status = await ATTrackingManager.requestTrackingAuthorization()
switch status {
case .authorized:
isConsentObtained = true
canShowAds = true
case .denied, .restricted:
// Can still show non-personalized ads
isConsentObtained = true
canShowAds = true
case .notDetermined:
// Will be asked again later
break
@unknown default:
break
}
} else {
// iOS 14.4 and earlier - consent assumed
isConsentObtained = true
canShowAds = true
}
UserDefaults.standard.set(isConsentObtained, forKey: "adConsentObtained")
}
// MARK: - GDPR Consent (UMP SDK)
func requestGDPRConsent() async {
// Implement UMP SDK consent flow if targeting EU users
// This is a simplified version - full implementation requires UMP SDK
let isEUUser = isUserInEU()
if isEUUser {
// Show GDPR consent dialog
// For now, assume consent if user continues
isConsentObtained = true
canShowAds = true
} else {
isConsentObtained = true
canShowAds = true
}
UserDefaults.standard.set(isConsentObtained, forKey: "adConsentObtained")
}
private func isUserInEU() -> Bool {
let euCountries = [
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
"PL", "PT", "RO", "SK", "SI", "ES", "SE", "GB", "IS", "LI",
"NO", "CH"
]
let countryCode = Locale.current.region?.identifier ?? ""
return euCountries.contains(countryCode)
}
// MARK: - Analytics
func logAdImpression() {
FirebaseService.shared.logAdImpression(adType: "banner")
}
func logAdClick() {
FirebaseService.shared.logAdClick(adType: "banner")
}
}
// MARK: - Banner Ad Coordinator
class BannerAdCoordinator: NSObject, BannerViewDelegate {
weak var adMobService: AdMobService?
func bannerViewDidReceiveAd(_ bannerView: BannerView) {
print("Banner ad received")
adMobService?.logAdImpression()
}
func bannerView(_ bannerView: BannerView, didFailToReceiveAdWithError error: Error) {
print("Banner ad failed to load: \(error.localizedDescription)")
}
func bannerViewDidRecordImpression(_ bannerView: BannerView) {
// Impression recorded
}
func bannerViewDidRecordClick(_ bannerView: BannerView) {
adMobService?.logAdClick()
}
func bannerViewWillPresentScreen(_ bannerView: BannerView) {
// Ad will present full screen
}
func bannerViewWillDismissScreen(_ bannerView: BannerView) {
// Ad will dismiss
}
func bannerViewDidDismissScreen(_ bannerView: BannerView) {
// Ad dismissed
}
}
// MARK: - UIKit Banner View Wrapper
struct BannerAdView: UIViewRepresentable {
@EnvironmentObject var adMobService: AdMobService
func makeUIView(context: Context) -> BannerView {
let bannerView = BannerView(adSize: AdSizeBanner)
bannerView.adUnitID = AdMobService.bannerAdUnitID
// Get root view controller
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
bannerView.rootViewController = rootViewController
}
bannerView.delegate = context.coordinator
bannerView.load(Request())
return bannerView
}
func updateUIView(_ uiView: BannerView, context: Context) {
// Banner updates automatically
}
func makeCoordinator() -> BannerAdCoordinator {
let coordinator = BannerAdCoordinator()
coordinator.adMobService = adMobService
return coordinator
}
}
@@ -0,0 +1,587 @@
import Foundation
class CalculationService {
static let shared = CalculationService()
// MARK: - Performance: Caching
private var cachedMonthlyReturns: [ObjectIdentifier: [InvestmentMetrics.MonthlyReturn]] = [:]
private var cacheVersion: Int = 0
private init() {}
/// Call this when underlying data changes to invalidate caches
func invalidateCache() {
cachedMonthlyReturns.removeAll()
cacheVersion += 1
}
// MARK: - Shared DateFormatter (avoid repeated allocations)
private static let monthYearFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM"
return formatter
}()
// MARK: - Portfolio Summary
func calculatePortfolioSummary(
from sources: [InvestmentSource],
snapshots: [Snapshot]
) -> PortfolioSummary {
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
let totalContributions = sources.reduce(Decimal.zero) { $0 + $1.totalContributions }
// Calculate period changes
let now = Date()
let dayAgo = Calendar.current.date(byAdding: .day, value: -1, to: now) ?? now
let weekAgo = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: now) ?? now
let monthAgo = Calendar.current.date(byAdding: .month, value: -1, to: now) ?? now
let yearAgo = Calendar.current.date(byAdding: .year, value: -1, to: now) ?? now
let dayChange = calculatePeriodChange(sources: sources, from: dayAgo)
let weekChange = calculatePeriodChange(sources: sources, from: weekAgo)
let monthChange = calculatePeriodChange(sources: sources, from: monthAgo)
let yearChange = calculatePeriodChange(sources: sources, from: yearAgo)
let allTimeReturn = totalValue - totalContributions
let allTimeReturnPercentage = totalContributions > 0
? NSDecimalNumber(decimal: allTimeReturn / totalContributions).doubleValue * 100
: 0
let lastUpdated = snapshots.map { $0.date }.max()
return PortfolioSummary(
totalValue: totalValue,
totalContributions: totalContributions,
dayChange: dayChange.absolute,
dayChangePercentage: dayChange.percentage,
weekChange: weekChange.absolute,
weekChangePercentage: weekChange.percentage,
monthChange: monthChange.absolute,
monthChangePercentage: monthChange.percentage,
yearChange: yearChange.absolute,
yearChangePercentage: yearChange.percentage,
allTimeReturn: allTimeReturn,
allTimeReturnPercentage: allTimeReturnPercentage,
sourceCount: sources.count,
lastUpdated: lastUpdated
)
}
private func calculatePeriodChange(
sources: [InvestmentSource],
from startDate: Date
) -> (absolute: Decimal, percentage: Double) {
var previousTotal: Decimal = 0
var currentTotal: Decimal = 0
for source in sources {
currentTotal += source.latestValue
// Find snapshot closest to start date
let snapshots = source.sortedSnapshotsByDateAscending
let previousSnapshot = snapshots.last { $0.date <= startDate } ?? snapshots.first
previousTotal += previousSnapshot?.decimalValue ?? 0
}
let absolute = currentTotal - previousTotal
let percentage = previousTotal > 0
? NSDecimalNumber(decimal: absolute / previousTotal).doubleValue * 100
: 0
return (absolute, percentage)
}
// MARK: - Investment Metrics
func calculateMetrics(for snapshots: [Snapshot]) -> InvestmentMetrics {
guard !snapshots.isEmpty else { return .empty }
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let values = sortedSnapshots.map { $0.decimalValue }
guard let firstValue = values.first,
let lastValue = values.last,
firstValue != 0 else {
return .empty
}
let totalValue = lastValue
let totalContributions = sortedSnapshots.reduce(Decimal.zero) { $0 + $1.decimalContribution }
let absoluteReturn = lastValue - firstValue
let percentageReturn = (absoluteReturn / firstValue) * 100
// Calculate monthly returns for advanced metrics
let monthlyReturns = calculateMonthlyReturns(from: sortedSnapshots)
let cagr = calculateCAGR(
startValue: firstValue,
endValue: lastValue,
startDate: sortedSnapshots.first?.date ?? Date(),
endDate: sortedSnapshots.last?.date ?? Date()
)
let twr = calculateTWR(snapshots: sortedSnapshots)
let volatility = calculateVolatility(monthlyReturns: monthlyReturns)
let maxDrawdown = calculateMaxDrawdown(values: values)
let sharpeRatio = calculateSharpeRatio(
averageReturn: monthlyReturns.map { $0.returnPercentage }.average(),
volatility: volatility
)
let winRate = calculateWinRate(monthlyReturns: monthlyReturns)
let averageMonthlyReturn = monthlyReturns.map { $0.returnPercentage }.average()
return InvestmentMetrics(
totalValue: totalValue,
totalContributions: totalContributions,
absoluteReturn: absoluteReturn,
percentageReturn: percentageReturn,
cagr: cagr,
twr: twr,
volatility: volatility,
maxDrawdown: maxDrawdown,
sharpeRatio: sharpeRatio,
bestMonth: monthlyReturns.max(by: { $0.returnPercentage < $1.returnPercentage }),
worstMonth: monthlyReturns.min(by: { $0.returnPercentage < $1.returnPercentage }),
winRate: winRate,
averageMonthlyReturn: averageMonthlyReturn,
startDate: sortedSnapshots.first?.date,
endDate: sortedSnapshots.last?.date,
totalMonths: monthlyReturns.count
)
}
// MARK: - Monthly Summary
func calculateMonthlySummary(
sources: [InvestmentSource],
snapshots: [Snapshot],
range: DateRange = .thisMonth
) -> MonthlySummary {
let startDate = range.start
let endDate = range.end
var startingValue: Decimal = 0
var endingValue: Decimal = 0
var contributions: Decimal = 0
let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id }
for source in sources {
let sourceSnapshots = snapshotsBySource[source.id] ?? []
let sorted = sourceSnapshots.sorted { $0.date < $1.date }
let startSnapshot = sorted.last { $0.date <= startDate }
let endSnapshot = sorted.last { $0.date <= endDate }
startingValue += startSnapshot?.decimalValue ?? 0
endingValue += endSnapshot?.decimalValue ?? 0
let rangeContributions = sorted
.filter { $0.date >= startDate && $0.date <= endDate }
.reduce(Decimal.zero) { $0 + $1.decimalContribution }
contributions += rangeContributions
}
let netPerformance = endingValue - startingValue - contributions
return MonthlySummary(
periodLabel: "This Month",
startDate: startDate,
endDate: endDate,
startingValue: startingValue,
endingValue: endingValue,
contributions: contributions,
netPerformance: netPerformance
)
}
// MARK: - CAGR (Compound Annual Growth Rate)
func calculateCAGR(
startValue: Decimal,
endValue: Decimal,
startDate: Date,
endDate: Date
) -> Double {
guard startValue > 0 else { return 0 }
let years = Calendar.current.dateComponents(
[.day],
from: startDate,
to: endDate
).day.map { Double($0) / 365.25 } ?? 0
guard years > 0 else { return 0 }
let ratio = NSDecimalNumber(decimal: endValue / startValue).doubleValue
let cagr = pow(ratio, 1 / years) - 1
return cagr * 100
}
// MARK: - TWR (Time-Weighted Return)
func calculateTWR(snapshots: [Snapshot]) -> Double {
guard snapshots.count >= 2 else { return 0 }
var twr: Double = 1.0
for i in 1..<snapshots.count {
let previousValue = snapshots[i-1].decimalValue
let currentValue = snapshots[i].decimalValue
let contribution = snapshots[i].decimalContribution
guard previousValue > 0 else { continue }
// Adjust for contributions
let adjustedPreviousValue = previousValue + contribution
let periodReturn = NSDecimalNumber(
decimal: currentValue / adjustedPreviousValue
).doubleValue
twr *= periodReturn
}
return (twr - 1) * 100
}
// MARK: - Volatility (Annualized Standard Deviation)
func calculateVolatility(monthlyReturns: [InvestmentMetrics.MonthlyReturn]) -> Double {
let returns = monthlyReturns.map { $0.returnPercentage }
guard returns.count >= 2 else { return 0 }
let mean = returns.average()
let squaredDifferences = returns.map { pow($0 - mean, 2) }
let variance = squaredDifferences.reduce(0, +) / Double(returns.count - 1)
let stdDev = sqrt(variance)
// Annualize (multiply by sqrt(12) for monthly data)
return stdDev * sqrt(12)
}
// MARK: - Max Drawdown
func calculateMaxDrawdown(values: [Decimal]) -> Double {
guard !values.isEmpty else { return 0 }
var maxDrawdown: Double = 0
var peak = values[0]
for value in values {
if value > peak {
peak = value
}
guard peak > 0 else { continue }
let drawdown = NSDecimalNumber(
decimal: (peak - value) / peak
).doubleValue * 100
maxDrawdown = max(maxDrawdown, drawdown)
}
return maxDrawdown
}
// MARK: - Sharpe Ratio
func calculateSharpeRatio(
averageReturn: Double,
volatility: Double,
riskFreeRate: Double = 2.0 // Assume 2% annual risk-free rate
) -> Double {
guard volatility > 0 else { return 0 }
// Convert to monthly risk-free rate
let monthlyRiskFree = riskFreeRate / 12
return (averageReturn - monthlyRiskFree) / volatility * sqrt(12)
}
// MARK: - Win Rate
func calculateWinRate(monthlyReturns: [InvestmentMetrics.MonthlyReturn]) -> Double {
guard !monthlyReturns.isEmpty else { return 0 }
let positiveMonths = monthlyReturns.filter { $0.returnPercentage > 0 }.count
return Double(positiveMonths) / Double(monthlyReturns.count) * 100
}
// MARK: - Monthly Returns
func calculateMonthlyReturns(from snapshots: [Snapshot]) -> [InvestmentMetrics.MonthlyReturn] {
guard snapshots.count >= 2 else { return [] }
// Performance: Use shared formatter instead of creating new one each call
let formatter = Self.monthYearFormatter
// Group snapshots by month - pre-allocate capacity
var monthlySnapshots: [String: [Snapshot]] = [:]
monthlySnapshots.reserveCapacity(min(snapshots.count, 60)) // Reasonable max months
for snapshot in snapshots {
let key = formatter.string(from: snapshot.date)
monthlySnapshots[key, default: []].append(snapshot)
}
// Sort months
let sortedMonths = monthlySnapshots.keys.sorted()
guard sortedMonths.count >= 2 else { return [] }
// Pre-allocate result array
var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = []
monthlyReturns.reserveCapacity(sortedMonths.count - 1)
for i in 1..<sortedMonths.count {
let previousMonth = sortedMonths[i-1]
let currentMonth = sortedMonths[i]
guard let previousSnapshots = monthlySnapshots[previousMonth],
let currentSnapshots = monthlySnapshots[currentMonth],
let previousValue = previousSnapshots.last?.decimalValue,
let currentValue = currentSnapshots.last?.decimalValue,
previousValue > 0 else {
continue
}
let returnPercentage = NSDecimalNumber(
decimal: (currentValue - previousValue) / previousValue
).doubleValue * 100
if let date = formatter.date(from: currentMonth) {
monthlyReturns.append(InvestmentMetrics.MonthlyReturn(
date: date,
returnPercentage: returnPercentage
))
}
}
return monthlyReturns
}
// MARK: - Category Metrics
func calculateCategoryMetrics(
for categories: [Category],
sources: [InvestmentSource],
totalPortfolioValue: Decimal
) -> [CategoryMetrics] {
categories.map { category in
let categorySources = sources.filter { $0.category?.id == category.id }
let allSnapshots = categorySources.flatMap { $0.snapshotsArray }
let metrics = calculateCategoryMetrics(from: allSnapshots)
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
let percentage = totalPortfolioValue > 0
? NSDecimalNumber(decimal: categoryValue / totalPortfolioValue).doubleValue * 100
: 0
return CategoryMetrics(
id: category.id,
categoryName: category.name,
colorHex: category.colorHex,
icon: category.icon,
totalValue: categoryValue,
percentageOfPortfolio: percentage,
metrics: metrics
)
}
}
private struct SeriesPoint {
let date: Date
let value: Decimal
let contribution: Decimal
}
private func calculateCategoryMetrics(from snapshots: [Snapshot]) -> InvestmentMetrics {
let series = buildCategorySeries(from: snapshots)
guard !series.isEmpty else { return .empty }
let sortedSeries = series.sorted { $0.date < $1.date }
let values = sortedSeries.map { $0.value }
guard let firstValue = values.first,
let lastValue = values.last,
firstValue != 0 else {
return .empty
}
let totalValue = lastValue
let totalContributions = sortedSeries.reduce(Decimal.zero) { $0 + $1.contribution }
let absoluteReturn = lastValue - firstValue
let percentageReturn = (absoluteReturn / firstValue) * 100
let monthlyReturns = calculateMonthlyReturns(from: sortedSeries)
let cagr = calculateCAGR(
startValue: firstValue,
endValue: lastValue,
startDate: sortedSeries.first?.date ?? Date(),
endDate: sortedSeries.last?.date ?? Date()
)
let twr = calculateTWR(series: sortedSeries)
let volatility = calculateVolatility(monthlyReturns: monthlyReturns)
let maxDrawdown = calculateMaxDrawdown(values: values)
let sharpeRatio = calculateSharpeRatio(
averageReturn: monthlyReturns.map { $0.returnPercentage }.average(),
volatility: volatility
)
let winRate = calculateWinRate(monthlyReturns: monthlyReturns)
let averageMonthlyReturn = monthlyReturns.map { $0.returnPercentage }.average()
return InvestmentMetrics(
totalValue: totalValue,
totalContributions: totalContributions,
absoluteReturn: absoluteReturn,
percentageReturn: percentageReturn,
cagr: cagr,
twr: twr,
volatility: volatility,
maxDrawdown: maxDrawdown,
sharpeRatio: sharpeRatio,
bestMonth: monthlyReturns.max(by: { $0.returnPercentage < $1.returnPercentage }),
worstMonth: monthlyReturns.min(by: { $0.returnPercentage < $1.returnPercentage }),
winRate: winRate,
averageMonthlyReturn: averageMonthlyReturn,
startDate: sortedSeries.first?.date,
endDate: sortedSeries.last?.date,
totalMonths: monthlyReturns.count
)
}
private func buildCategorySeries(from snapshots: [Snapshot]) -> [SeriesPoint] {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let uniqueDates = Array(Set(sortedSnapshots.map { Calendar.current.startOfDay(for: $0.date) }))
.sorted()
guard !uniqueDates.isEmpty else { return [] }
var snapshotsBySource: [UUID: [(date: Date, value: Decimal)]] = [:]
var contributionsByDate: [Date: Decimal] = [:]
for snapshot in sortedSnapshots {
guard let sourceId = snapshot.source?.id else { continue }
snapshotsBySource[sourceId, default: []].append(
(date: snapshot.date, value: snapshot.decimalValue)
)
let day = Calendar.current.startOfDay(for: snapshot.date)
contributionsByDate[day, default: 0] += snapshot.decimalContribution
}
var indices: [UUID: Int] = [:]
var series: [SeriesPoint] = []
for (index, date) in uniqueDates.enumerated() {
let nextDate = index + 1 < uniqueDates.count
? uniqueDates[index + 1]
: Date.distantFuture
var total: Decimal = 0
for (sourceId, sourceSnapshots) in snapshotsBySource {
var currentIndex = indices[sourceId] ?? 0
var latest: (date: Date, value: Decimal)?
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
latest = sourceSnapshots[currentIndex]
currentIndex += 1
}
indices[sourceId] = currentIndex
if let latest {
total += latest.value
}
}
series.append(
SeriesPoint(
date: date,
value: total,
contribution: contributionsByDate[date] ?? 0
)
)
}
return series
}
private func calculateMonthlyReturns(from series: [SeriesPoint]) -> [InvestmentMetrics.MonthlyReturn] {
guard series.count >= 2 else { return [] }
// Performance: Use shared formatter
let formatter = Self.monthYearFormatter
var monthlySeries: [String: [SeriesPoint]] = [:]
monthlySeries.reserveCapacity(min(series.count, 60))
for point in series {
let key = formatter.string(from: point.date)
monthlySeries[key, default: []].append(point)
}
let sortedMonths = monthlySeries.keys.sorted()
guard sortedMonths.count >= 2 else { return [] }
var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = []
monthlyReturns.reserveCapacity(sortedMonths.count - 1)
for i in 1..<sortedMonths.count {
let previousMonth = sortedMonths[i - 1]
let currentMonth = sortedMonths[i]
guard let previousPoints = monthlySeries[previousMonth],
let currentPoints = monthlySeries[currentMonth],
let previousValue = previousPoints.last?.value,
let currentValue = currentPoints.last?.value,
previousValue > 0 else {
continue
}
let returnPercentage = NSDecimalNumber(
decimal: (currentValue - previousValue) / previousValue
).doubleValue * 100
if let date = formatter.date(from: currentMonth) {
monthlyReturns.append(InvestmentMetrics.MonthlyReturn(
date: date,
returnPercentage: returnPercentage
))
}
}
return monthlyReturns
}
private func calculateTWR(series: [SeriesPoint]) -> Double {
guard series.count >= 2 else { return 0 }
var twr: Double = 1.0
for i in 1..<series.count {
let previousValue = series[i - 1].value
let currentValue = series[i].value
let contribution = series[i].contribution
guard previousValue > 0 else { continue }
let periodReturn = (currentValue - contribution) / previousValue
twr *= NSDecimalNumber(decimal: periodReturn).doubleValue
}
return (twr - 1) * 100
}
}
// MARK: - Array Extension
extension Array where Element == Double {
func average() -> Double {
guard !isEmpty else { return 0 }
return reduce(0, +) / Double(count)
}
}
@@ -0,0 +1,258 @@
import Foundation
import UIKit
class ExportService {
static let shared = ExportService()
private init() {}
// MARK: - Export Formats
enum ExportFormat: String, CaseIterable, Identifiable {
case csv = "CSV"
case json = "JSON"
var id: String { rawValue }
var fileExtension: String {
switch self {
case .csv: return "csv"
case .json: return "json"
}
}
var mimeType: String {
switch self {
case .csv: return "text/csv"
case .json: return "application/json"
}
}
}
// MARK: - Export Data
func exportToCSV(
sources: [InvestmentSource],
categories: [Category]
) -> String {
let currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
var csv = "Account,Category,Source,Date,Value (\(currencyCode)),Contribution (\(currencyCode)),Notes\n"
for source in sources.sorted(by: { $0.name < $1.name }) {
let accountName = source.account?.name ?? "Default"
let categoryName = source.category?.name ?? "Uncategorized"
for snapshot in source.snapshotsArray {
let date = formatDate(snapshot.date)
let value = formatDecimal(snapshot.decimalValue)
let contribution = snapshot.contribution != nil
? formatDecimal(snapshot.decimalContribution)
: ""
let notes = escapeCSV(snapshot.notes ?? "")
csv += "\(escapeCSV(accountName)),\(escapeCSV(categoryName)),\(escapeCSV(source.name)),\(date),\(value),\(contribution),\(notes)\n"
}
}
return csv
}
func exportToJSON(
sources: [InvestmentSource],
categories: [Category]
) -> String {
var exportData: [String: Any] = [:]
exportData["exportDate"] = ISO8601DateFormatter().string(from: Date())
exportData["version"] = 2
exportData["currency"] = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
let accounts = Dictionary(grouping: sources) { $0.account?.id.uuidString ?? "default" }
var accountsArray: [[String: Any]] = []
for (_, accountSources) in accounts {
let account = accountSources.first?.account
var accountDict: [String: Any] = [
"name": account?.name ?? "Default",
"currency": account?.currency ?? exportData["currency"] as? String ?? "EUR",
"inputMode": account?.inputMode ?? InputMode.simple.rawValue,
"notificationFrequency": account?.notificationFrequency ?? NotificationFrequency.monthly.rawValue,
"customFrequencyMonths": account?.customFrequencyMonths ?? 1
]
// Export categories for this account
let categoriesById = Dictionary(uniqueKeysWithValues: categories.map { ($0.id, $0) })
let sourcesByCategory = Dictionary(grouping: accountSources) { $0.category?.id ?? UUID() }
var categoriesArray: [[String: Any]] = []
for (categoryId, categorySources) in sourcesByCategory {
let category = categoriesById[categoryId]
var categoryDict: [String: Any] = [
"name": category?.name ?? "Uncategorized",
"color": category?.colorHex ?? "#3B82F6",
"icon": category?.icon ?? "chart.pie.fill"
]
var sourcesArray: [[String: Any]] = []
for source in categorySources {
var sourceDict: [String: Any] = [
"name": source.name,
"isActive": source.isActive,
"notificationFrequency": source.notificationFrequency
]
var snapshotsArray: [[String: Any]] = []
for snapshot in source.snapshotsArray {
var snapshotDict: [String: Any] = [
"date": ISO8601DateFormatter().string(from: snapshot.date),
"value": NSDecimalNumber(decimal: snapshot.decimalValue).doubleValue
]
if snapshot.contribution != nil {
snapshotDict["contribution"] = NSDecimalNumber(
decimal: snapshot.decimalContribution
).doubleValue
}
if let notes = snapshot.notes, !notes.isEmpty {
snapshotDict["notes"] = notes
}
snapshotsArray.append(snapshotDict)
}
sourceDict["snapshots"] = snapshotsArray
sourcesArray.append(sourceDict)
}
categoryDict["sources"] = sourcesArray
categoriesArray.append(categoryDict)
}
accountDict["categories"] = categoriesArray
accountsArray.append(accountDict)
}
exportData["accounts"] = accountsArray
// Add summary
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
exportData["summary"] = [
"totalSources": sources.count,
"totalCategories": categories.count,
"totalValue": NSDecimalNumber(decimal: totalValue).doubleValue,
"totalSnapshots": sources.reduce(0) { $0 + $1.snapshotCount }
]
// Convert to JSON
do {
let jsonData = try JSONSerialization.data(
withJSONObject: exportData,
options: [.prettyPrinted, .sortedKeys]
)
return String(data: jsonData, encoding: .utf8) ?? "{}"
} catch {
print("JSON export error: \(error)")
return "{}"
}
}
// MARK: - Share
func share(
format: ExportFormat,
sources: [InvestmentSource],
categories: [Category],
from viewController: UIViewController
) {
let content: String
let fileName: String
switch format {
case .csv:
content = exportToCSV(sources: sources, categories: categories)
fileName = "investment_tracker_export.csv"
case .json:
content = exportToJSON(sources: sources, categories: categories)
fileName = "investment_tracker_export.json"
}
// Create temporary file
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(fileName)
do {
try content.write(to: tempURL, atomically: true, encoding: .utf8)
let activityVC = UIActivityViewController(
activityItems: [tempURL],
applicationActivities: nil
)
// iPad support
if let popover = activityVC.popoverPresentationController {
popover.sourceView = viewController.view
popover.sourceRect = CGRect(
x: viewController.view.bounds.midX,
y: viewController.view.bounds.midY,
width: 0,
height: 0
)
}
viewController.present(activityVC, animated: true) {
FirebaseService.shared.logExportAttempt(
format: format.rawValue,
success: true
)
}
} catch {
print("Export error: \(error)")
FirebaseService.shared.logExportAttempt(
format: format.rawValue,
success: false
)
}
}
// MARK: - Helpers
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
private func formatDecimal(_ decimal: Decimal) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = 2
formatter.decimalSeparator = "."
formatter.groupingSeparator = ""
return formatter.string(from: decimal as NSDecimalNumber) ?? "0.00"
}
private func escapeCSV(_ value: String) -> String {
var escaped = value
if escaped.contains("\"") || escaped.contains(",") || escaped.contains("\n") {
escaped = escaped.replacingOccurrences(of: "\"", with: "\"\"")
escaped = "\"\(escaped)\""
}
return escaped
}
}
// MARK: - Import Service (Future)
extension ExportService {
func importFromCSV(_ content: String) -> (sources: [ImportedSource], errors: [String]) {
// Future implementation for importing data
return ([], ["Import not yet implemented"])
}
struct ImportedSource {
let name: String
let categoryName: String
let snapshots: [(date: Date, value: Decimal, contribution: Decimal?, notes: String?)]
}
}
@@ -0,0 +1,201 @@
import Foundation
import FirebaseAnalytics
import FirebaseCore
class FirebaseService {
static let shared = FirebaseService()
private var isConfigured: Bool {
FirebaseApp.app() != nil
}
private init() {}
// MARK: - User Properties
func setUserTier(_ tier: UserTier) {
guard isConfigured else { return }
Analytics.setUserProperty(tier.rawValue, forName: "user_tier")
}
enum UserTier: String {
case free = "free"
case premium = "premium"
}
// MARK: - Screen Tracking
func logScreenView(screenName: String, screenClass: String? = nil) {
guard isConfigured else { return }
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
AnalyticsParameterScreenName: screenName,
AnalyticsParameterScreenClass: screenClass ?? screenName
])
}
// MARK: - Investment Events
func logSourceAdded(categoryName: String, sourceCount: Int) {
guard isConfigured else { return }
Analytics.logEvent("source_added", parameters: [
"category_name": categoryName,
"total_sources": sourceCount
])
}
func logSourceDeleted(categoryName: String) {
guard isConfigured else { return }
Analytics.logEvent("source_deleted", parameters: [
"category_name": categoryName
])
}
func logSnapshotAdded(sourceName: String, value: Decimal) {
guard isConfigured else { return }
Analytics.logEvent("snapshot_added", parameters: [
"source_name": sourceName,
"value": NSDecimalNumber(decimal: value).doubleValue
])
}
func logCategoryCreated(name: String) {
guard isConfigured else { return }
Analytics.logEvent("category_created", parameters: [
"category_name": name
])
}
// MARK: - Purchase Events
func logPaywallShown(trigger: String) {
guard isConfigured else { return }
Analytics.logEvent("paywall_shown", parameters: [
"trigger": trigger
])
}
func logPurchaseAttempt(productId: String) {
guard isConfigured else { return }
Analytics.logEvent("purchase_attempt", parameters: [
"product_id": productId
])
}
func logPurchaseSuccess(productId: String, price: Decimal, isFamilyShared: Bool) {
guard isConfigured else { return }
Analytics.logEvent(AnalyticsEventPurchase, parameters: [
AnalyticsParameterItemID: productId,
AnalyticsParameterPrice: NSDecimalNumber(decimal: price).doubleValue,
AnalyticsParameterCurrency: "EUR",
"is_family_shared": isFamilyShared
])
}
func logPurchaseFailure(productId: String, error: String) {
guard isConfigured else { return }
Analytics.logEvent("purchase_failed", parameters: [
"product_id": productId,
"error": error
])
}
func logRestorePurchases(success: Bool) {
guard isConfigured else { return }
Analytics.logEvent("restore_purchases", parameters: [
"success": success
])
}
// MARK: - Feature Usage Events
func logChartViewed(chartType: String, isPremium: Bool) {
guard isConfigured else { return }
Analytics.logEvent("chart_viewed", parameters: [
"chart_type": chartType,
"is_premium_chart": isPremium
])
}
func logPredictionViewed(algorithm: String) {
guard isConfigured else { return }
Analytics.logEvent("prediction_viewed", parameters: [
"algorithm": algorithm
])
}
func logExportAttempt(format: String, success: Bool) {
guard isConfigured else { return }
Analytics.logEvent("export_attempt", parameters: [
"format": format,
"success": success
])
}
func logNotificationScheduled(frequency: String) {
guard isConfigured else { return }
Analytics.logEvent("notification_scheduled", parameters: [
"frequency": frequency
])
}
// MARK: - Ad Events
func logAdImpression(adType: String) {
guard isConfigured else { return }
Analytics.logEvent("ad_impression", parameters: [
"ad_type": adType
])
}
func logAdClick(adType: String) {
guard isConfigured else { return }
Analytics.logEvent("ad_click", parameters: [
"ad_type": adType
])
}
// MARK: - Engagement Events
func logOnboardingCompleted(stepCount: Int) {
guard isConfigured else { return }
Analytics.logEvent("onboarding_completed", parameters: [
"steps_completed": stepCount
])
}
func logWidgetUsed(widgetType: String) {
guard isConfigured else { return }
Analytics.logEvent("widget_used", parameters: [
"widget_type": widgetType
])
}
func logAppOpened(fromWidget: Bool) {
guard isConfigured else { return }
Analytics.logEvent("app_opened", parameters: [
"from_widget": fromWidget
])
}
// MARK: - Portfolio Events
func logPortfolioMilestone(totalValue: Decimal, milestone: String) {
guard isConfigured else { return }
Analytics.logEvent("portfolio_milestone", parameters: [
"total_value": NSDecimalNumber(decimal: totalValue).doubleValue,
"milestone": milestone
])
}
// MARK: - Error Events
func logError(type: String, message: String, context: String? = nil) {
guard isConfigured else { return }
Analytics.logEvent("app_error", parameters: [
"error_type": type,
"error_message": message,
"context": context ?? ""
])
}
}
@@ -0,0 +1,44 @@
import Foundation
import SwiftUI
import UIKit
class GoalShareService {
static let shared = GoalShareService()
private init() {}
@MainActor
func shareGoal(
name: String,
progress: Double,
currentValue: Decimal,
targetValue: Decimal
) {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let viewController = windowScene.windows.first?.rootViewController else {
return
}
let card = GoalShareCardView(
name: name,
progress: progress,
currentValue: currentValue,
targetValue: targetValue
)
if #available(iOS 16.0, *) {
let renderer = ImageRenderer(content: card)
let scale = viewController.view.window?.windowScene?.screen.scale
?? viewController.traitCollection.displayScale
renderer.scale = scale
if let image = renderer.uiImage {
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
viewController.present(activityVC, animated: true)
}
} else {
let text = "I am \(Int(progress * 100))% towards \(name) on Portfolio Journal!"
let activityVC = UIActivityViewController(activityItems: [text], applicationActivities: nil)
viewController.present(activityVC, animated: true)
}
}
}
+256
View File
@@ -0,0 +1,256 @@
import Foundation
import StoreKit
import Combine
@MainActor
class IAPService: ObservableObject {
// MARK: - Published Properties
@Published private(set) var isPremium = false
@Published private(set) var products: [Product] = []
@Published private(set) var purchaseState: PurchaseState = .idle
@Published private(set) var isFamilyShared = false
#if DEBUG
@Published var debugOverrideEnabled = false
#endif
// MARK: - Constants
static let premiumProductID = "com.portfoliojournal.premium"
static let premiumPrice = "€4.69"
// MARK: - Private Properties
private var updateListenerTask: Task<Void, Error>?
private var cancellables = Set<AnyCancellable>()
private let sharedDefaults = UserDefaults(suiteName: AppConstants.appGroupIdentifier)
// MARK: - Purchase State
enum PurchaseState: Equatable {
case idle
case purchasing
case purchased
case failed(String)
case restored
}
// MARK: - Initialization
init() {
#if DEBUG
debugOverrideEnabled = UserDefaults.standard.bool(forKey: "debugPremiumOverride")
#endif
updateListenerTask = listenForTransactions()
Task {
await loadProducts()
await updatePremiumStatus()
}
}
deinit {
updateListenerTask?.cancel()
}
// MARK: - Load Products
func loadProducts() async {
do {
products = try await Product.products(for: [Self.premiumProductID])
print("Loaded \(products.count) products")
} catch {
print("Failed to load products: \(error)")
}
}
// MARK: - Purchase
func purchase() async throws {
guard let product = products.first else {
throw IAPError.productNotFound
}
purchaseState = .purchasing
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
// Check if family shared
isFamilyShared = transaction.ownershipType == .familyShared
await transaction.finish()
await updatePremiumStatus()
purchaseState = .purchased
// Track analytics
FirebaseService.shared.logPurchaseSuccess(
productId: product.id,
price: product.price,
isFamilyShared: isFamilyShared
)
case .userCancelled:
purchaseState = .idle
case .pending:
purchaseState = .idle
@unknown default:
purchaseState = .idle
}
} catch {
purchaseState = .failed(error.localizedDescription)
FirebaseService.shared.logPurchaseFailure(
productId: product.id,
error: error.localizedDescription
)
throw error
}
}
// MARK: - Restore Purchases
func restorePurchases() async {
purchaseState = .purchasing
do {
try await AppStore.sync()
await updatePremiumStatus()
if isPremium {
purchaseState = .restored
} else {
purchaseState = .failed("No purchases to restore")
}
} catch {
purchaseState = .failed(error.localizedDescription)
}
}
// MARK: - Update Premium Status
func updatePremiumStatus() async {
var isEntitled = false
var familyShared = false
#if DEBUG
if debugOverrideEnabled {
isPremium = true
isFamilyShared = false
return
}
#endif
for await result in StoreKit.Transaction.currentEntitlements {
if case .verified(let transaction) = result {
if transaction.productID == Self.premiumProductID {
isEntitled = true
familyShared = transaction.ownershipType == .familyShared
break
}
}
}
isPremium = isEntitled
isFamilyShared = familyShared
sharedDefaults?.set(isEntitled, forKey: "premiumUnlocked")
// Update Core Data
let context = CoreDataStack.shared.viewContext
PremiumStatus.updateStatus(
isPremium: isEntitled,
productIdentifier: Self.premiumProductID,
transactionId: nil,
isFamilyShared: familyShared,
in: context
)
}
#if DEBUG
func setDebugPremiumOverride(_ enabled: Bool) {
debugOverrideEnabled = enabled
UserDefaults.standard.set(enabled, forKey: "debugPremiumOverride")
if enabled {
isPremium = true
isFamilyShared = false
sharedDefaults?.set(true, forKey: "premiumUnlocked")
} else {
Task { await updatePremiumStatus() }
}
}
#endif
// MARK: - Transaction Listener
private func listenForTransactions() -> Task<Void, Error> {
return Task.detached { [weak self] in
for await result in StoreKit.Transaction.updates {
if case .verified(let transaction) = result {
await transaction.finish()
await self?.updatePremiumStatus()
}
}
}
}
// MARK: - Verification
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error):
throw IAPError.verificationFailed(error.localizedDescription)
case .verified(let safe):
return safe
}
}
// MARK: - Product Info
var premiumProduct: Product? {
products.first { $0.id == Self.premiumProductID }
}
var formattedPrice: String {
premiumProduct?.displayPrice ?? Self.premiumPrice
}
}
// MARK: - IAP Error
enum IAPError: LocalizedError {
case productNotFound
case verificationFailed(String)
case purchaseFailed(String)
var errorDescription: String? {
switch self {
case .productNotFound:
return "Product not found. Please try again later."
case .verificationFailed(let message):
return "Verification failed: \(message)"
case .purchaseFailed(let message):
return "Purchase failed: \(message)"
}
}
}
// MARK: - Premium Features
extension IAPService {
static let premiumFeatures: [(icon: String, title: String, description: String)] = [
("person.2", "Multiple Accounts", "Separate portfolios for business or family"),
("infinity", "Unlimited Sources", "Track as many investments as you want"),
("clock.arrow.circlepath", "Full History", "Access your complete investment history"),
("chart.bar.xaxis", "Advanced Charts", "5 types of detailed analytics charts"),
("wand.and.stars", "Predictions", "AI-powered 12-month forecasts"),
("square.and.arrow.up", "Export Data", "Export to CSV and JSON formats"),
("xmark.circle", "No Ads", "Ad-free experience forever"),
("person.2", "Family Sharing", "Share with up to 5 family members")
]
}
@@ -0,0 +1,678 @@
import Foundation
import CoreData
import Combine
class ImportService {
static let shared = ImportService()
private init() {}
struct ImportResult {
let accountsCreated: Int
let sourcesCreated: Int
let snapshotsCreated: Int
let errors: [String]
}
struct ImportProgress {
let completed: Int
let total: Int
let message: String
var fraction: Double {
guard total > 0 else { return 0 }
return min(max(Double(completed) / Double(total), 0), 1)
}
}
enum ImportFormat {
case csv
case json
}
struct ImportedAccount {
let name: String
let currency: String?
let inputMode: InputMode
let notificationFrequency: NotificationFrequency
let customFrequencyMonths: Int
let categories: [ImportedCategory]
}
struct ImportedCategory {
let name: String
let colorHex: String?
let icon: String?
let sources: [ImportedSource]
}
struct ImportedSource {
let name: String
let snapshots: [ImportedSnapshot]
}
struct ImportedSnapshot {
let date: Date
let value: Decimal
let contribution: Decimal?
let notes: String?
}
func importData(
content: String,
format: ImportFormat,
allowMultipleAccounts: Bool,
defaultAccountName: String? = nil
) -> ImportResult {
switch format {
case .csv:
let parsed = parseCSV(
content,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
case .json:
let parsed = parseJSON(content, allowMultipleAccounts: allowMultipleAccounts)
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
}
}
func importDataAsync(
content: String,
format: ImportFormat,
allowMultipleAccounts: Bool,
defaultAccountName: String? = nil,
progress: @escaping (ImportProgress) -> Void
) async -> ImportResult {
await withCheckedContinuation { continuation in
CoreDataStack.shared.performBackgroundTask { context in
let parsed: [ImportedAccount]
switch format {
case .csv:
parsed = self.parseCSV(
content,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
case .json:
parsed = self.parseJSON(content, allowMultipleAccounts: allowMultipleAccounts)
}
let totalSnapshots = parsed.reduce(0) { total, account in
total + account.categories.reduce(0) { subtotal, category in
subtotal + category.sources.reduce(0) { sourceTotal, source in
sourceTotal + source.snapshots.count
}
}
}
DispatchQueue.main.async {
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
}
let result = self.applyImport(parsed, context: context) { completed in
DispatchQueue.main.async {
progress(ImportProgress(
completed: completed,
total: totalSnapshots,
message: "Imported \(completed) of \(totalSnapshots) snapshots"
))
}
}
continuation.resume(returning: result)
}
}
}
static func sampleCSV() -> String {
return """
Account,Category,Source,Date,Value,Contribution,Notes
Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
Personal,Crypto,BTC,2024-01-01,3200,,Cold storage
Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
"""
}
static func sampleJSON() -> String {
return """
{
"version": 2,
"currency": "EUR",
"accounts": [{
"name": "Personal",
"inputMode": "simple",
"notificationFrequency": "monthly",
"categories": [{
"name": "Stocks",
"color": "#3B82F6",
"icon": "chart.line.uptrend.xyaxis",
"sources": [{
"name": "Index Fund",
"snapshots": [{
"date": "2024-01-01T00:00:00Z",
"value": 15000,
"contribution": 12000
}]
}]
}]
}]
}
"""
}
// MARK: - Parsing
private func parseCSV(
_ content: String,
allowMultipleAccounts: Bool,
defaultAccountName: String?
) -> [ImportedAccount] {
let rows = parseCSVRows(content)
guard rows.count > 1 else { return [] }
let headers = rows[0].map { $0.lowercased().trimmingCharacters(in: .whitespaces) }
let indexOfAccount = headers.firstIndex(of: "account")
let indexOfCategory = headers.firstIndex(of: "category")
let indexOfSource = headers.firstIndex(of: "source")
let indexOfDate = headers.firstIndex(of: "date")
let indexOfValue = headers.firstIndex(where: { $0.hasPrefix("value") })
let indexOfContribution = headers.firstIndex(where: { $0.hasPrefix("contribution") })
let indexOfNotes = headers.firstIndex(of: "notes")
var grouped: [String: [String: [String: [ImportedSnapshot]]]] = [:]
for row in rows.dropFirst() {
let providedAccount = indexOfAccount.flatMap { row.safeValue(at: $0) }
let fallbackAccount = defaultAccountName ?? "Personal"
let normalizedAccount = (providedAccount ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
let rawAccountName = normalizedAccount.isEmpty ? fallbackAccount : normalizedAccount
let accountName = allowMultipleAccounts ? rawAccountName : "Personal"
guard let categoryName = indexOfCategory.flatMap({ row.safeValue(at: $0) }), !categoryName.isEmpty,
let sourceName = indexOfSource.flatMap({ row.safeValue(at: $0) }), !sourceName.isEmpty,
let dateString = indexOfDate.flatMap({ row.safeValue(at: $0) }),
let valueString = indexOfValue.flatMap({ row.safeValue(at: $0) }) else {
continue
}
guard let date = parseDate(dateString),
let value = parseDecimal(valueString) else { continue }
let contribution = indexOfContribution
.flatMap { row.safeValue(at: $0) }
.flatMap(parseDecimal)
let notes = indexOfNotes
.flatMap { row.safeValue(at: $0) }
.flatMap { $0.isEmpty ? nil : $0 }
let snapshot = ImportedSnapshot(
date: date,
value: value,
contribution: contribution,
notes: notes
)
grouped[accountName, default: [:]][categoryName, default: [:]][sourceName, default: []].append(snapshot)
}
return grouped.map { accountName, categories in
let importedCategories = categories.map { categoryName, sources in
let importedSources = sources.map { sourceName, snapshots in
ImportedSource(name: sourceName, snapshots: snapshots)
}
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
}
return ImportedAccount(
name: accountName,
currency: nil,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1,
categories: importedCategories
)
}
}
private func parseJSON(_ content: String, allowMultipleAccounts: Bool) -> [ImportedAccount] {
guard let data = content.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return []
}
if let accountsArray = json["accounts"] as? [[String: Any]] {
return accountsArray.compactMap { accountDict in
let rawName = accountDict["name"] as? String ?? "Personal"
let name = allowMultipleAccounts ? rawName : "Personal"
let currency = accountDict["currency"] as? String
let inputMode = InputMode(rawValue: accountDict["inputMode"] as? String ?? "") ?? .simple
let notificationFrequency = NotificationFrequency(
rawValue: accountDict["notificationFrequency"] as? String ?? ""
) ?? .monthly
let customFrequencyMonths = accountDict["customFrequencyMonths"] as? Int ?? 1
let categoriesArray = accountDict["categories"] as? [[String: Any]] ?? []
let categories = categoriesArray.map { categoryDict in
let categoryName = categoryDict["name"] as? String ?? "Uncategorized"
let colorHex = categoryDict["color"] as? String
let icon = categoryDict["icon"] as? String
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
let sources = sourcesArray.map { sourceDict in
let sourceName = sourceDict["name"] as? String ?? "Source"
let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? []
let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in
guard let dateString = snapshotDict["date"] as? String,
let date = ISO8601DateFormatter().date(from: dateString),
let value = snapshotDict["value"] as? Double else {
return nil
}
let contribution = (snapshotDict["contribution"] as? Double).map { Decimal($0) }
let notes = snapshotDict["notes"] as? String
return ImportedSnapshot(
date: date,
value: Decimal(value),
contribution: contribution,
notes: notes
)
}
return ImportedSource(name: sourceName, snapshots: snapshots)
}
return ImportedCategory(
name: categoryName,
colorHex: colorHex,
icon: icon,
sources: sources
)
}
return ImportedAccount(
name: name,
currency: currency,
inputMode: inputMode,
notificationFrequency: notificationFrequency,
customFrequencyMonths: customFrequencyMonths,
categories: categories
)
}
}
// Legacy JSON: categories only
if let categoriesArray = json["categories"] as? [[String: Any]] {
let categories = categoriesArray.map { categoryDict in
let categoryName = categoryDict["name"] as? String ?? "Uncategorized"
let colorHex = categoryDict["color"] as? String
let icon = categoryDict["icon"] as? String
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
let sources = sourcesArray.map { sourceDict in
let sourceName = sourceDict["name"] as? String ?? "Source"
let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? []
let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in
guard let dateString = snapshotDict["date"] as? String,
let date = ISO8601DateFormatter().date(from: dateString),
let value = snapshotDict["value"] as? Double else {
return nil
}
let contribution = (snapshotDict["contribution"] as? Double).map { Decimal($0) }
let notes = snapshotDict["notes"] as? String
return ImportedSnapshot(
date: date,
value: Decimal(value),
contribution: contribution,
notes: notes
)
}
return ImportedSource(name: sourceName, snapshots: snapshots)
}
return ImportedCategory(
name: categoryName,
colorHex: colorHex,
icon: icon,
sources: sources
)
}
return [
ImportedAccount(
name: "Personal",
currency: json["currency"] as? String,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1,
categories: categories
)
]
}
return []
}
// MARK: - Apply Import
private func applyImport(
_ accounts: [ImportedAccount],
context: NSManagedObjectContext,
snapshotProgress: ((Int) -> Void)? = nil
) -> ImportResult {
let accountRepository = AccountRepository(context: context)
let categoryRepository = CategoryRepository(context: context)
let sourceRepository = InvestmentSourceRepository(context: context)
let snapshotRepository = SnapshotRepository(context: context)
var accountsCreated = 0
var sourcesCreated = 0
var snapshotsCreated = 0
var errors: [String] = []
var categoryLookup = buildCategoryLookup(from: categoryRepository.categories)
let otherCategory = resolveExistingCategory(named: "Other", lookup: categoryLookup) ??
categoryRepository.createCategory(
name: "Other",
colorHex: "#64748B",
icon: "ellipsis.circle.fill"
)
categoryLookup[normalizedCategoryName(otherCategory.name)] = otherCategory
var completionDatesByMonth: [String: Date] = [:]
for importedAccount in accounts {
let existingAccount = accountRepository.accounts.first(where: { $0.name == importedAccount.name })
let account = existingAccount ?? accountRepository.createAccount(
name: importedAccount.name,
currency: importedAccount.currency,
inputMode: importedAccount.inputMode,
notificationFrequency: importedAccount.notificationFrequency,
customFrequencyMonths: importedAccount.customFrequencyMonths
)
if existingAccount == nil {
accountsCreated += 1
}
for importedCategory in importedAccount.categories {
let existingCategory = resolveExistingCategory(
named: importedCategory.name,
lookup: categoryLookup
)
let shouldUseOther = existingCategory == nil &&
importedCategory.colorHex == nil &&
importedCategory.icon == nil
let resolvedName = canonicalCategoryName(for: importedCategory.name) ?? importedCategory.name
let category = existingCategory ?? (shouldUseOther
? otherCategory
: categoryRepository.createCategory(
name: resolvedName,
colorHex: importedCategory.colorHex ?? "#3B82F6",
icon: importedCategory.icon ?? "chart.pie.fill"
))
categoryLookup[normalizedCategoryName(category.name)] = category
for importedSource in importedCategory.sources {
let existingSource = sourceRepository.sources.first(where: {
$0.name == importedSource.name && $0.account?.id == account.id
})
let source = existingSource ?? sourceRepository.createSource(
name: importedSource.name,
category: category,
notificationFrequency: importedAccount.notificationFrequency,
customFrequencyMonths: importedAccount.customFrequencyMonths
)
source.account = account
if existingSource == nil {
sourcesCreated += 1
}
for snapshot in importedSource.snapshots {
snapshotRepository.createSnapshot(
for: source,
date: snapshot.date,
value: snapshot.value,
contribution: snapshot.contribution,
notes: snapshot.notes
)
snapshotsCreated += 1
snapshotProgress?(snapshotsCreated)
let monthKey = MonthlyCheckInStore.monthKey(for: snapshot.date)
if let existingDate = completionDatesByMonth[monthKey] {
if snapshot.date > existingDate {
completionDatesByMonth[monthKey] = snapshot.date
}
} else {
completionDatesByMonth[monthKey] = snapshot.date
}
}
}
}
}
if context.hasChanges {
do {
try context.save()
} catch {
errors.append("Failed to save imported data.")
}
}
if !completionDatesByMonth.isEmpty {
for (monthKey, completionDate) in completionDatesByMonth {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM"
if let monthDate = formatter.date(from: monthKey) {
MonthlyCheckInStore.setCompletionDate(completionDate, for: monthDate)
}
}
}
return ImportResult(
accountsCreated: accountsCreated,
sourcesCreated: sourcesCreated,
snapshotsCreated: snapshotsCreated,
errors: errors
)
}
private func resolveExistingCategory(
named rawName: String,
lookup: [String: Category]
) -> Category? {
if let canonical = canonicalCategoryName(for: rawName) {
let canonicalKey = normalizedCategoryName(canonical)
if let match = lookup[canonicalKey] {
return match
}
}
return lookup[normalizedCategoryName(rawName)]
}
private func buildCategoryLookup(from categories: [Category]) -> [String: Category] {
var lookup: [String: Category] = [:]
for category in categories {
lookup[normalizedCategoryName(category.name)] = category
}
return lookup
}
private func canonicalCategoryName(for rawName: String) -> String? {
let normalized = normalizedCategoryName(rawName)
for mapping in categoryAliasMappings {
if mapping.aliases.contains(where: { normalizedCategoryName($0) == normalized }) {
return mapping.canonical
}
}
return nil
}
private func normalizedCategoryName(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
return normalized.replacingOccurrences(
of: "\\s+",
with: " ",
options: .regularExpression
)
}
private var categoryAliasMappings: [(canonical: String, aliases: [String])] {
[
(
canonical: "Stocks",
aliases: ["Stocks", "category_stocks", String(localized: "category_stocks"), "Acciones"]
),
(
canonical: "Bonds",
aliases: ["Bonds", "category_bonds", String(localized: "category_bonds"), "Bonos"]
),
(
canonical: "Real Estate",
aliases: ["Real Estate", "category_real_estate", String(localized: "category_real_estate"), "Inmobiliario"]
),
(
canonical: "Crypto",
aliases: ["Crypto", "category_crypto", String(localized: "category_crypto"), "Cripto"]
),
(
canonical: "Cash",
aliases: ["Cash", "category_cash", String(localized: "category_cash"), "Efectivo"]
),
(
canonical: "ETFs",
aliases: ["ETFs", "category_etfs", String(localized: "category_etfs"), "ETF"]
),
(
canonical: "Retirement",
aliases: ["Retirement", "category_retirement", String(localized: "category_retirement"), "Jubilación"]
),
(
canonical: "Other",
aliases: [
"Other",
"category_other",
String(localized: "category_other"),
"Uncategorized",
"uncategorized",
String(localized: "uncategorized"),
"Otros",
"Sin categoría"
]
)
]
}
// MARK: - CSV Helpers
private func parseCSVRows(_ content: String) -> [[String]] {
var rows: [[String]] = []
var currentRow: [String] = []
var currentField = ""
var insideQuotes = false
for char in content {
if char == "\"" {
insideQuotes.toggle()
continue
}
if char == "," && !insideQuotes {
currentRow.append(currentField)
currentField = ""
continue
}
if char == "\n" && !insideQuotes {
currentRow.append(currentField)
rows.append(currentRow.map { $0.trimmingCharacters(in: .whitespaces) })
currentRow = []
currentField = ""
continue
}
currentField.append(char)
}
if !currentField.isEmpty || !currentRow.isEmpty {
currentRow.append(currentField)
rows.append(currentRow.map { $0.trimmingCharacters(in: .whitespaces) })
}
return rows
}
private func parseDate(_ value: String) -> Date? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if let iso = ISO8601DateFormatter().date(from: trimmed) {
return iso
}
let formats = [
"yyyy-MM-dd",
"yyyy/MM/dd",
"dd/MM/yyyy",
"MM/dd/yyyy",
"dd-MM-yyyy",
"MM-dd-yyyy",
"yyyy-MM-dd HH:mm",
"yyyy-MM-dd HH:mm:ss",
"yyyy/MM/dd HH:mm",
"yyyy/MM/dd HH:mm:ss",
"dd/MM/yyyy HH:mm",
"dd/MM/yyyy HH:mm:ss",
"MM/dd/yyyy HH:mm",
"MM/dd/yyyy HH:mm:ss",
"dd-MM-yyyy HH:mm",
"dd-MM-yyyy HH:mm:ss",
"MM-dd-yyyy HH:mm",
"MM-dd-yyyy HH:mm:ss",
"dd/MM/yyyy h:mm a",
"dd/MM/yyyy h:mm:ss a",
"MM/dd/yyyy h:mm a",
"MM/dd/yyyy h:mm:ss a",
"dd-MM-yyyy h:mm a",
"dd-MM-yyyy h:mm:ss a",
"MM-dd-yyyy h:mm a",
"MM-dd-yyyy h:mm:ss a"
]
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = .current
for format in formats {
formatter.dateFormat = format
if let date = formatter.date(from: trimmed) {
return date
}
}
return nil
}
private func parseDecimal(_ value: String) -> Decimal? {
let cleaned = value
.replacingOccurrences(of: ",", with: ".")
.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
return Decimal(string: cleaned)
}
}
private extension Array where Element == String {
func safeValue(at index: Int) -> String? {
guard index >= 0, index < count else { return nil }
return self[index]
}
}
@@ -0,0 +1,189 @@
import Foundation
import Combine
import UserNotifications
import UIKit
class NotificationService: ObservableObject {
static let shared = NotificationService()
@Published var isAuthorized = false
@Published var pendingCount = 0
private let center = UNUserNotificationCenter.current()
private init() {
checkAuthorizationStatus()
}
// MARK: - Authorization
func checkAuthorizationStatus() {
center.getNotificationSettings { [weak self] settings in
DispatchQueue.main.async {
self?.isAuthorized = settings.authorizationStatus == .authorized
}
}
}
func requestAuthorization() async -> Bool {
do {
let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])
await MainActor.run {
self.isAuthorized = granted
}
return granted
} catch {
print("Notification authorization error: \(error)")
return false
}
}
// MARK: - Schedule Notifications
func scheduleReminder(for source: InvestmentSource) {
guard let nextDate = source.nextReminderDate else { return }
guard source.frequency != .never else { return }
// Remove existing notification for this source
cancelReminder(for: source)
// Get notification time from settings
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
let notificationTime = settings.defaultNotificationTime ?? defaultNotificationTime()
// Combine date and time
let calendar = Calendar.current
var components = calendar.dateComponents([.year, .month, .day], from: nextDate)
let timeComponents = calendar.dateComponents([.hour, .minute], from: notificationTime)
components.hour = timeComponents.hour
components.minute = timeComponents.minute
guard let triggerDate = calendar.date(from: components) else { return }
// Create notification content
let content = UNMutableNotificationContent()
content.title = "Investment Update Reminder"
content.body = "Time to update \(source.name). Tap to add a new snapshot."
content.sound = .default
content.badge = NSNumber(value: pendingCount + 1)
content.userInfo = [
"sourceId": source.id.uuidString,
"sourceName": source.name
]
// Create trigger
let triggerComponents = calendar.dateComponents(
[.year, .month, .day, .hour, .minute],
from: triggerDate
)
let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: false)
// Create request
let request = UNNotificationRequest(
identifier: notificationIdentifier(for: source),
content: content,
trigger: trigger
)
// Schedule
center.add(request) { error in
if let error = error {
print("Failed to schedule notification: \(error)")
} else {
print("Scheduled reminder for \(source.name) on \(triggerDate)")
FirebaseService.shared.logNotificationScheduled(frequency: source.notificationFrequency)
}
}
}
func scheduleAllReminders(for sources: [InvestmentSource]) {
for source in sources {
scheduleReminder(for: source)
}
}
// MARK: - Cancel Notifications
func cancelReminder(for source: InvestmentSource) {
center.removePendingNotificationRequests(
withIdentifiers: [notificationIdentifier(for: source)]
)
}
func cancelAllReminders() {
center.removeAllPendingNotificationRequests()
}
// MARK: - Badge Management
func updateBadgeCount() {
let repository = InvestmentSourceRepository()
let needsUpdate = repository.fetchSourcesNeedingUpdate()
pendingCount = needsUpdate.count
center.setBadgeCount(pendingCount) { _ in }
}
func clearBadge() {
pendingCount = 0
center.setBadgeCount(0) { _ in }
}
// MARK: - Pending Notifications
func getPendingNotifications() async -> [UNNotificationRequest] {
await center.pendingNotificationRequests()
}
// MARK: - Helpers
private func notificationIdentifier(for source: InvestmentSource) -> String {
"investment_reminder_\(source.id.uuidString)"
}
private func defaultNotificationTime() -> Date {
var components = DateComponents()
components.hour = 9
components.minute = 0
return Calendar.current.date(from: components) ?? Date()
}
}
// MARK: - Deep Link Handler
extension NotificationService {
func handleNotificationResponse(_ response: UNNotificationResponse) {
let userInfo = response.notification.request.content.userInfo
guard let sourceIdString = userInfo["sourceId"] as? String,
let sourceId = UUID(uuidString: sourceIdString) else {
return
}
// Post notification for deep linking
NotificationCenter.default.post(
name: .openSourceDetail,
object: nil,
userInfo: ["sourceId": sourceId]
)
}
}
// MARK: - Notification Names
extension Notification.Name {
static let openSourceDetail = Notification.Name("openSourceDetail")
}
// MARK: - Background Refresh
extension NotificationService {
func performBackgroundRefresh() {
updateBadgeCount()
// Reschedule any missed notifications
let repository = InvestmentSourceRepository()
let sources = repository.fetchActiveSources()
scheduleAllReminders(for: sources)
}
}
@@ -0,0 +1,465 @@
import Foundation
class PredictionEngine {
static let shared = PredictionEngine()
private let context = CoreDataStack.shared.viewContext
// MARK: - Performance: Cached Calendar reference
private static let calendar = Calendar.current
private init() {}
// MARK: - Main Prediction Interface
func predict(
snapshots: [Snapshot],
monthsAhead: Int = 12,
algorithm: PredictionAlgorithm? = nil
) -> PredictionResult {
guard snapshots.count >= 3 else {
return PredictionResult(
predictions: [],
algorithm: .linear,
accuracy: 0,
volatility: 0
)
}
// Sort snapshots by date
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
// Calculate volatility for algorithm selection
let volatility = calculateVolatility(snapshots: sortedSnapshots)
// Select algorithm if not specified
let selectedAlgorithm = algorithm ?? selectBestAlgorithm(volatility: volatility)
// Generate predictions
let predictions: [Prediction]
let accuracy: Double
switch selectedAlgorithm {
case .linear:
predictions = predictLinear(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
accuracy = calculateLinearAccuracy(snapshots: sortedSnapshots)
case .exponentialSmoothing:
predictions = predictExponentialSmoothing(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
accuracy = calculateESAccuracy(snapshots: sortedSnapshots)
case .movingAverage:
predictions = predictMovingAverage(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
accuracy = calculateMAAccuracy(snapshots: sortedSnapshots)
case .holtTrend:
predictions = predictHoltTrend(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
accuracy = calculateHoltAccuracy(snapshots: sortedSnapshots)
}
return PredictionResult(
predictions: predictions,
algorithm: selectedAlgorithm,
accuracy: accuracy,
volatility: volatility
)
}
// MARK: - Algorithm Selection
private func selectBestAlgorithm(volatility: Double) -> PredictionAlgorithm {
switch volatility {
case 0..<8:
return .holtTrend
case 8..<20:
return .exponentialSmoothing
default:
return .movingAverage
}
}
// MARK: - Linear Regression
func predictLinear(snapshots: [Snapshot], monthsAhead: Int = 12) -> [Prediction] {
guard snapshots.count >= 3 else { return [] }
guard let firstDate = snapshots.first?.date else { return [] }
let dataPoints: [(x: Double, y: Double)] = snapshots.map { snapshot in
let daysSinceStart = snapshot.date.timeIntervalSince(firstDate) / 86400
return (x: daysSinceStart, y: snapshot.decimalValue.doubleValue)
}
let (slope, intercept) = calculateLinearRegression(dataPoints: dataPoints)
let residualStdDev = calculateResidualStdDev(dataPoints: dataPoints, slope: slope, intercept: intercept)
var predictions: [Prediction] = []
let lastDate = snapshots.last!.date
for month in 1...monthsAhead {
guard let futureDate = Self.calendar.date(
byAdding: .month,
value: month,
to: lastDate
) else { continue }
let daysFromStart = futureDate.timeIntervalSince(firstDate) / 86400
let predictedValue = max(0, slope * daysFromStart + intercept)
// Widen confidence interval for further predictions
let confidenceMultiplier = 1.0 + (Double(month) * 0.02)
let intervalWidth = residualStdDev * 1.96 * confidenceMultiplier
predictions.append(Prediction(
date: futureDate,
predictedValue: Decimal(predictedValue),
algorithm: .linear,
confidenceInterval: Prediction.ConfidenceInterval(
lower: Decimal(max(0, predictedValue - intervalWidth)),
upper: Decimal(predictedValue + intervalWidth)
)
))
}
return predictions
}
private func calculateLinearRegression(
dataPoints: [(x: Double, y: Double)]
) -> (slope: Double, intercept: Double) {
let n = Double(dataPoints.count)
let sumX = dataPoints.reduce(0) { $0 + $1.x }
let sumY = dataPoints.reduce(0) { $0 + $1.y }
let sumXY = dataPoints.reduce(0) { $0 + ($1.x * $1.y) }
let sumX2 = dataPoints.reduce(0) { $0 + ($1.x * $1.x) }
let denominator = n * sumX2 - sumX * sumX
guard denominator != 0 else { return (0, sumY / n) }
let slope = (n * sumXY - sumX * sumY) / denominator
let intercept = (sumY - slope * sumX) / n
return (slope, intercept)
}
private func calculateResidualStdDev(
dataPoints: [(x: Double, y: Double)],
slope: Double,
intercept: Double
) -> Double {
guard dataPoints.count > 2 else { return 0 }
let residuals = dataPoints.map { point in
let predicted = slope * point.x + intercept
return pow(point.y - predicted, 2)
}
let meanSquaredError = residuals.reduce(0, +) / Double(dataPoints.count - 2)
return sqrt(meanSquaredError)
}
private func calculateLinearAccuracy(snapshots: [Snapshot]) -> Double {
guard snapshots.count >= 5 else { return 0.5 }
// Use last 20% of data for validation
let splitIndex = Int(Double(snapshots.count) * 0.8)
let trainingData = Array(snapshots.prefix(splitIndex))
let validationData = Array(snapshots.suffix(from: splitIndex))
guard let firstDate = trainingData.first?.date else { return 0.5 }
let trainPoints = trainingData.map { snapshot in
(x: snapshot.date.timeIntervalSince(firstDate) / 86400, y: snapshot.decimalValue.doubleValue)
}
let (slope, intercept) = calculateLinearRegression(dataPoints: trainPoints)
// Calculate R-squared on validation data
let validationValues = validationData.map { $0.decimalValue.doubleValue }
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
var ssRes: Double = 0
var ssTot: Double = 0
for snapshot in validationData {
let x = snapshot.date.timeIntervalSince(firstDate) / 86400
let actual = snapshot.decimalValue.doubleValue
let predicted = slope * x + intercept
ssRes += pow(actual - predicted, 2)
ssTot += pow(actual - meanValidation, 2)
}
guard ssTot != 0 else { return 0.5 }
let rSquared = max(0, 1 - (ssRes / ssTot))
return min(1.0, rSquared)
}
// MARK: - Exponential Smoothing
func predictExponentialSmoothing(
snapshots: [Snapshot],
monthsAhead: Int = 12,
alpha: Double = 0.3
) -> [Prediction] {
guard snapshots.count >= 3 else { return [] }
let values = snapshots.map { $0.decimalValue.doubleValue }
// Calculate smoothed values
var smoothed = values[0]
for i in 1..<values.count {
smoothed = alpha * values[i] + (1 - alpha) * smoothed
}
// Calculate trend
var trend: Double = 0
if values.count >= 2 {
let recentChange = values.suffix(3).reduce(0) { $0 + $1 } / 3.0 -
values.prefix(3).reduce(0) { $0 + $1 } / 3.0
trend = recentChange / Double(values.count)
}
// Calculate standard deviation for confidence interval
let stdDev = calculateStdDev(values: values)
var predictions: [Prediction] = []
let lastDate = snapshots.last!.date
for month in 1...monthsAhead {
guard let futureDate = Self.calendar.date(
byAdding: .month,
value: month,
to: lastDate
) else { continue }
let predictedValue = max(0, smoothed + trend * Double(month))
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.05)
predictions.append(Prediction(
date: futureDate,
predictedValue: Decimal(predictedValue),
algorithm: .exponentialSmoothing,
confidenceInterval: Prediction.ConfidenceInterval(
lower: Decimal(max(0, predictedValue - intervalWidth)),
upper: Decimal(predictedValue + intervalWidth)
)
))
}
return predictions
}
private func calculateESAccuracy(snapshots: [Snapshot]) -> Double {
guard snapshots.count >= 5 else { return 0.5 }
let values = snapshots.map { $0.decimalValue.doubleValue }
let splitIndex = Int(Double(values.count) * 0.8)
var smoothed = values[0]
for i in 1..<splitIndex {
smoothed = 0.3 * values[i] + 0.7 * smoothed
}
let validationValues = Array(values.suffix(from: splitIndex))
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
var ssRes: Double = 0
var ssTot: Double = 0
for (i, actual) in validationValues.enumerated() {
let predicted = smoothed + (smoothed - values[splitIndex - 1]) * Double(i + 1) / Double(splitIndex)
ssRes += pow(actual - predicted, 2)
ssTot += pow(actual - meanValidation, 2)
}
guard ssTot != 0 else { return 0.5 }
return max(0, min(1.0, 1 - (ssRes / ssTot)))
}
// MARK: - Moving Average
func predictMovingAverage(
snapshots: [Snapshot],
monthsAhead: Int = 12,
windowSize: Int = 3
) -> [Prediction] {
guard snapshots.count >= windowSize else { return [] }
let values = snapshots.map { $0.decimalValue.doubleValue }
// Calculate moving average of last window
let recentValues = Array(values.suffix(windowSize))
let movingAverage = recentValues.reduce(0, +) / Double(windowSize)
// Calculate average monthly change
var changes: [Double] = []
for i in 1..<values.count {
changes.append(values[i] - values[i - 1])
}
let avgChange = changes.isEmpty ? 0 : changes.reduce(0, +) / Double(changes.count)
let stdDev = calculateStdDev(values: values)
var predictions: [Prediction] = []
let lastDate = snapshots.last!.date
for month in 1...monthsAhead {
guard let futureDate = Self.calendar.date(
byAdding: .month,
value: month,
to: lastDate
) else { continue }
let predictedValue = max(0, movingAverage + avgChange * Double(month))
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.03)
predictions.append(Prediction(
date: futureDate,
predictedValue: Decimal(predictedValue),
algorithm: .movingAverage,
confidenceInterval: Prediction.ConfidenceInterval(
lower: Decimal(max(0, predictedValue - intervalWidth)),
upper: Decimal(predictedValue + intervalWidth)
)
))
}
return predictions
}
private func calculateMAAccuracy(snapshots: [Snapshot]) -> Double {
guard snapshots.count >= 5 else { return 0.5 }
let values = snapshots.map { $0.decimalValue.doubleValue }
let windowSize = 3
let splitIndex = Int(Double(values.count) * 0.8)
guard splitIndex > windowSize else { return 0.5 }
let recentWindow = Array(values[(splitIndex - windowSize)..<splitIndex])
let movingAvg = recentWindow.reduce(0, +) / Double(windowSize)
let validationValues = Array(values.suffix(from: splitIndex))
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
var ssRes: Double = 0
var ssTot: Double = 0
for actual in validationValues {
ssRes += pow(actual - movingAvg, 2)
ssTot += pow(actual - meanValidation, 2)
}
guard ssTot != 0 else { return 0.5 }
return max(0, min(1.0, 1 - (ssRes / ssTot)))
}
// MARK: - Holt Trend (Double Exponential Smoothing)
func predictHoltTrend(
snapshots: [Snapshot],
monthsAhead: Int = 12,
alpha: Double = 0.4,
beta: Double = 0.3
) -> [Prediction] {
guard snapshots.count >= 3 else { return [] }
let values = snapshots.map { $0.decimalValue.doubleValue }
var level = values[0]
var trend = values[1] - values[0]
var fitted: [Double] = []
for value in values {
let lastLevel = level
level = alpha * value + (1 - alpha) * (level + trend)
trend = beta * (level - lastLevel) + (1 - beta) * trend
fitted.append(level + trend)
}
let residuals = zip(values, fitted).map { $0 - $1 }
let stdDev = calculateStdDev(values: residuals)
var predictions: [Prediction] = []
let lastDate = snapshots.last!.date
for month in 1...monthsAhead {
guard let futureDate = Self.calendar.date(
byAdding: .month,
value: month,
to: lastDate
) else { continue }
let predictedValue = max(0, level + Double(month) * trend)
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.04)
predictions.append(Prediction(
date: futureDate,
predictedValue: Decimal(predictedValue),
algorithm: .holtTrend,
confidenceInterval: Prediction.ConfidenceInterval(
lower: Decimal(max(0, predictedValue - intervalWidth)),
upper: Decimal(predictedValue + intervalWidth)
)
))
}
return predictions
}
private func calculateHoltAccuracy(snapshots: [Snapshot]) -> Double {
guard snapshots.count >= 5 else { return 0.5 }
let values = snapshots.map { $0.decimalValue.doubleValue }
let splitIndex = Int(Double(values.count) * 0.8)
guard splitIndex >= 2 else { return 0.5 }
var level = values[0]
var trend = values[1] - values[0]
for value in values.prefix(splitIndex) {
let lastLevel = level
level = 0.4 * value + 0.6 * (level + trend)
trend = 0.3 * (level - lastLevel) + 0.7 * trend
}
let validationValues = Array(values.suffix(from: splitIndex))
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
var ssRes: Double = 0
var ssTot: Double = 0
for (i, actual) in validationValues.enumerated() {
let predicted = level + Double(i + 1) * trend
ssRes += pow(actual - predicted, 2)
ssTot += pow(actual - meanValidation, 2)
}
guard ssTot != 0 else { return 0.5 }
return max(0, min(1.0, 1 - (ssRes / ssTot)))
}
// MARK: - Helpers
private func calculateVolatility(snapshots: [Snapshot]) -> Double {
let values = snapshots.map { $0.decimalValue.doubleValue }
guard values.count >= 2 else { return 0 }
var returns: [Double] = []
for i in 1..<values.count {
guard values[i - 1] != 0 else { continue }
let periodReturn = (values[i] - values[i - 1]) / values[i - 1] * 100
returns.append(periodReturn)
}
return calculateStdDev(values: returns)
}
private func calculateStdDev(values: [Double]) -> Double {
guard values.count >= 2 else { return 0 }
let mean = values.reduce(0, +) / Double(values.count)
let squaredDifferences = values.map { pow($0 - mean, 2) }
let variance = squaredDifferences.reduce(0, +) / Double(values.count - 1)
return sqrt(variance)
}
}
@@ -0,0 +1,101 @@
import Foundation
class SampleDataService {
static let shared = SampleDataService()
private init() {}
func seedSampleData() {
let context = CoreDataStack.shared.viewContext
let sourceRepository = InvestmentSourceRepository(context: context)
guard sourceRepository.sourceCount == 0 else { return }
let categoryRepository = CategoryRepository(context: context)
categoryRepository.createDefaultCategoriesIfNeeded()
let accountRepository = AccountRepository(context: context)
let account = accountRepository.createDefaultAccountIfNeeded()
let snapshotRepository = SnapshotRepository(context: context)
let goalRepository = GoalRepository(context: context)
let transactionRepository = TransactionRepository(context: context)
let categories = categoryRepository.categories
let stocksCategory = categories.first { $0.name == "Stocks" } ?? categories.first!
let cryptoCategory = categories.first { $0.name == "Crypto" } ?? categories.first!
let realEstateCategory = categories.first { $0.name == "Real Estate" } ?? categories.first!
let stocks = sourceRepository.createSource(
name: "Index Fund",
category: stocksCategory,
account: account
)
let crypto = sourceRepository.createSource(
name: "BTC",
category: cryptoCategory,
account: account
)
let realEstate = sourceRepository.createSource(
name: "Rental Property",
category: realEstateCategory,
account: account
)
seedSnapshots(for: stocks, baseValue: 12000, monthlyIncrease: 450, repository: snapshotRepository)
seedSnapshots(for: crypto, baseValue: 3000, monthlyIncrease: 250, repository: snapshotRepository)
seedSnapshots(for: realEstate, baseValue: 80000, monthlyIncrease: 600, repository: snapshotRepository)
seedMonthlyNotes()
transactionRepository.createTransaction(
source: stocks,
type: .buy,
date: Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date(),
shares: 10,
price: 400,
amount: nil,
notes: "Sample buy"
)
_ = goalRepository.createGoal(
name: "1M Goal",
targetAmount: 1_000_000,
targetDate: nil,
account: account
)
}
private func seedSnapshots(
for source: InvestmentSource,
baseValue: Decimal,
monthlyIncrease: Decimal,
repository: SnapshotRepository
) {
let calendar = Calendar.current
for monthOffset in (0..<6).reversed() {
let date = calendar.date(byAdding: .month, value: -monthOffset, to: Date()) ?? Date()
let value = baseValue + Decimal(monthOffset) * monthlyIncrease
repository.createSnapshot(
for: source,
date: date,
value: value,
contribution: monthOffset == 0 ? monthlyIncrease : nil,
notes: nil
)
}
}
private func seedMonthlyNotes() {
let calendar = Calendar.current
let notes = [
"Rebalanced slightly toward equities. Stayed calm despite noise.",
"Focused on contributions. No major changes.",
"Reviewed allocation drift and decided to hold positions."
]
for (index, note) in notes.enumerated() {
let date = calendar.date(byAdding: .month, value: -index, to: Date()) ?? Date()
MonthlyCheckInStore.setNote(note, for: date)
}
}
}
@@ -0,0 +1,112 @@
import Foundation
import UIKit
class ShareService {
static let shared = ShareService()
private init() {}
func shareTextFile(content: String, fileName: String) {
guard let viewController = ShareService.topViewController() else { return }
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(fileName)
do {
try content.write(to: tempURL, atomically: true, encoding: .utf8)
let activityVC = UIActivityViewController(
activityItems: [tempURL],
applicationActivities: nil
)
if let popover = activityVC.popoverPresentationController {
popover.sourceView = viewController.view
popover.sourceRect = CGRect(
x: viewController.view.bounds.midX,
y: viewController.view.bounds.midY,
width: 0,
height: 0
)
}
DispatchQueue.main.async {
viewController.present(activityVC, animated: true)
}
} catch {
print("Share file error: \(error)")
}
}
func shareCalendarEvent(
title: String,
notes: String,
startDate: Date,
durationMinutes: Int = 60
) {
let endDate = startDate.addingTimeInterval(TimeInterval(durationMinutes * 60))
let icsContent = calendarICS(
title: title,
notes: notes,
startDate: startDate,
endDate: endDate
)
shareTextFile(content: icsContent, fileName: "PortfolioJournal-CheckIn.ics")
}
private static func topViewController(
base: UIViewController? = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first(where: { $0.isKeyWindow })?.rootViewController
) -> UIViewController? {
if let nav = base as? UINavigationController {
return topViewController(base: nav.visibleViewController)
}
if let tab = base as? UITabBarController {
return topViewController(base: tab.selectedViewController)
}
if let presented = base?.presentedViewController {
return topViewController(base: presented)
}
return base
}
private func calendarICS(
title: String,
notes: String,
startDate: Date,
endDate: Date
) -> String {
let uid = UUID().uuidString
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
formatter.timeZone = TimeZone(secondsFromGMT: 0)
let stamp = formatter.string(from: Date())
let start = formatter.string(from: startDate)
let end = formatter.string(from: endDate)
return """
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//PortfolioJournal//MonthlyCheckIn//EN
BEGIN:VEVENT
UID:\(uid)
DTSTAMP:\(stamp)
DTSTART:\(start)
DTEND:\(end)
SUMMARY:\(escapeICS(title))
DESCRIPTION:\(escapeICS(notes))
END:VEVENT
END:VCALENDAR
"""
}
private func escapeICS(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: ";", with: "\\;")
.replacingOccurrences(of: ",", with: "\\,")
}
}