initial version
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
struct CategoryEvolutionPoint: Identifiable {
|
||||
let date: Date
|
||||
let categoryName: String
|
||||
let colorHex: String
|
||||
let value: Decimal
|
||||
|
||||
var id: String {
|
||||
"\(categoryName)-\(date.timeIntervalSince1970)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Account)
|
||||
public class Account: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Account> {
|
||||
return NSFetchRequest<Account>(entityName: "Account")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var currency: String?
|
||||
@NSManaged public var inputMode: String
|
||||
@NSManaged public var notificationFrequency: String
|
||||
@NSManaged public var customFrequencyMonths: Int16
|
||||
@NSManaged public var sortOrder: Int16
|
||||
@NSManaged public var sources: NSSet?
|
||||
@NSManaged public var goals: NSSet?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
name = "Account"
|
||||
inputMode = InputMode.simple.rawValue
|
||||
notificationFrequency = NotificationFrequency.monthly.rawValue
|
||||
customFrequencyMonths = 1
|
||||
sortOrder = 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Account {
|
||||
var sourcesArray: [InvestmentSource] {
|
||||
let set = sources as? Set<InvestmentSource> ?? []
|
||||
return set.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
var goalsArray: [Goal] {
|
||||
let set = goals as? Set<Goal> ?? []
|
||||
return set.sorted { $0.createdAt < $1.createdAt }
|
||||
}
|
||||
|
||||
var frequency: NotificationFrequency {
|
||||
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
|
||||
}
|
||||
|
||||
var currencyCode: String? {
|
||||
currency?.isEmpty == false ? currency : nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(AppSettings)
|
||||
public class AppSettings: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<AppSettings> {
|
||||
return NSFetchRequest<AppSettings>(entityName: "AppSettings")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var currency: String
|
||||
@NSManaged public var defaultNotificationTime: Date?
|
||||
@NSManaged public var enableAnalytics: Bool
|
||||
@NSManaged public var onboardingCompleted: Bool
|
||||
@NSManaged public var lastSyncDate: Date?
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var inputMode: String
|
||||
@NSManaged public var selectedAccountId: UUID?
|
||||
@NSManaged public var showAllAccounts: Bool
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
let localeCode = Locale.current.currency?.identifier
|
||||
currency = CurrencyPicker.commonCodes.contains(localeCode ?? "")
|
||||
? (localeCode ?? "EUR")
|
||||
: "EUR"
|
||||
enableAnalytics = true
|
||||
onboardingCompleted = false
|
||||
inputMode = InputMode.simple.rawValue
|
||||
showAllAccounts = true
|
||||
createdAt = Date()
|
||||
|
||||
// Default notification time: 9:00 AM
|
||||
var components = DateComponents()
|
||||
components.hour = 9
|
||||
components.minute = 0
|
||||
defaultNotificationTime = Calendar.current.date(from: components)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension AppSettings {
|
||||
var currencySymbol: String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = currency
|
||||
return formatter.currencySymbol ?? "€"
|
||||
}
|
||||
|
||||
var notificationTimeString: String {
|
||||
guard let time = defaultNotificationTime else { return "9:00 AM" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: time)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Static Helpers
|
||||
|
||||
extension AppSettings {
|
||||
static func getOrCreate(in context: NSManagedObjectContext) -> AppSettings {
|
||||
let request: NSFetchRequest<AppSettings> = AppSettings.fetchRequest()
|
||||
request.fetchLimit = 1
|
||||
|
||||
if let existing = try? context.fetch(request).first {
|
||||
return existing
|
||||
}
|
||||
|
||||
let new = AppSettings(context: context)
|
||||
try? context.save()
|
||||
return new
|
||||
}
|
||||
|
||||
static func markOnboardingComplete(in context: NSManagedObjectContext) {
|
||||
let settings = getOrCreate(in: context)
|
||||
settings.onboardingCompleted = true
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Asset)
|
||||
public class Asset: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Asset> {
|
||||
return NSFetchRequest<Asset>(entityName: "Asset")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var symbol: String?
|
||||
@NSManaged public var type: String
|
||||
@NSManaged public var currency: String?
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var sources: NSSet?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
name = ""
|
||||
type = AssetType.stock.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
enum AssetType: String, CaseIterable, Identifiable {
|
||||
case stock
|
||||
case etf
|
||||
case crypto
|
||||
case fund
|
||||
case cash
|
||||
case realEstate
|
||||
case other
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .stock: return "Stock"
|
||||
case .etf: return "ETF"
|
||||
case .crypto: return "Crypto"
|
||||
case .fund: return "Fund"
|
||||
case .cash: return "Cash"
|
||||
case .realEstate: return "Real Estate"
|
||||
case .other: return "Other"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import SwiftUI
|
||||
|
||||
@objc(Category)
|
||||
public class Category: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Category> {
|
||||
return NSFetchRequest<Category>(entityName: "Category")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var colorHex: String
|
||||
@NSManaged public var icon: String
|
||||
@NSManaged public var sortOrder: Int16
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var sources: NSSet?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
sortOrder = 0
|
||||
colorHex = "#3B82F6"
|
||||
icon = "chart.pie.fill"
|
||||
name = ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Category {
|
||||
var color: Color {
|
||||
Color(hex: colorHex) ?? .blue
|
||||
}
|
||||
|
||||
var sourcesArray: [InvestmentSource] {
|
||||
let set = sources as? Set<InvestmentSource> ?? []
|
||||
return set.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
var sourceCount: Int {
|
||||
sources?.count ?? 0
|
||||
}
|
||||
|
||||
var totalValue: Decimal {
|
||||
sourcesArray.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generated accessors for sources
|
||||
|
||||
extension Category {
|
||||
@objc(addSourcesObject:)
|
||||
@NSManaged public func addToSources(_ value: InvestmentSource)
|
||||
|
||||
@objc(removeSourcesObject:)
|
||||
@NSManaged public func removeFromSources(_ value: InvestmentSource)
|
||||
|
||||
@objc(addSources:)
|
||||
@NSManaged public func addToSources(_ values: NSSet)
|
||||
|
||||
@objc(removeSources:)
|
||||
@NSManaged public func removeFromSources(_ values: NSSet)
|
||||
}
|
||||
|
||||
// MARK: - Default Categories
|
||||
|
||||
extension Category {
|
||||
static let defaultCategories: [(name: String, colorHex: String, icon: String)] = [
|
||||
("Stocks", "#10B981", "chart.line.uptrend.xyaxis"),
|
||||
("Bonds", "#3B82F6", "building.columns.fill"),
|
||||
("Real Estate", "#F59E0B", "house.fill"),
|
||||
("Crypto", "#8B5CF6", "bitcoinsign.circle.fill"),
|
||||
("Cash", "#6B7280", "banknote.fill"),
|
||||
("ETFs", "#EC4899", "chart.bar.fill"),
|
||||
("Retirement", "#14B8A6", "person.fill"),
|
||||
("Other", "#64748B", "ellipsis.circle.fill")
|
||||
]
|
||||
|
||||
static func createDefaultCategories(in context: NSManagedObjectContext) {
|
||||
for (index, categoryData) in defaultCategories.enumerated() {
|
||||
let category = Category(context: context)
|
||||
category.name = categoryData.name
|
||||
category.colorHex = categoryData.colorHex
|
||||
category.icon = categoryData.icon
|
||||
category.sortOrder = Int16(index)
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Goal)
|
||||
public class Goal: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Goal> {
|
||||
return NSFetchRequest<Goal>(entityName: "Goal")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var targetAmount: NSDecimalNumber?
|
||||
@NSManaged public var targetDate: Date?
|
||||
@NSManaged public var isActive: Bool
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var account: Account?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
name = ""
|
||||
isActive = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Goal {
|
||||
var targetDecimal: Decimal {
|
||||
targetAmount?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(InvestmentSource)
|
||||
public class InvestmentSource: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<InvestmentSource> {
|
||||
return NSFetchRequest<InvestmentSource>(entityName: "InvestmentSource")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var notificationFrequency: String
|
||||
@NSManaged public var customFrequencyMonths: Int16
|
||||
@NSManaged public var isActive: Bool
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var category: Category?
|
||||
@NSManaged public var account: Account?
|
||||
@NSManaged public var snapshots: NSSet?
|
||||
@NSManaged public var transactions: NSSet?
|
||||
@NSManaged public var asset: Asset?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
isActive = true
|
||||
notificationFrequency = NotificationFrequency.monthly.rawValue
|
||||
customFrequencyMonths = 1
|
||||
name = ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Frequency
|
||||
|
||||
enum NotificationFrequency: String, CaseIterable, Identifiable {
|
||||
case monthly = "monthly"
|
||||
case quarterly = "quarterly"
|
||||
case semiannual = "semiannual"
|
||||
case annual = "annual"
|
||||
case custom = "custom"
|
||||
case never = "never"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .monthly: return "Monthly"
|
||||
case .quarterly: return "Quarterly"
|
||||
case .semiannual: return "Semi-Annual"
|
||||
case .annual: return "Annual"
|
||||
case .custom: return "Custom"
|
||||
case .never: return "Never"
|
||||
}
|
||||
}
|
||||
|
||||
var months: Int {
|
||||
switch self {
|
||||
case .monthly: return 1
|
||||
case .quarterly: return 3
|
||||
case .semiannual: return 6
|
||||
case .annual: return 12
|
||||
case .custom, .never: return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension InvestmentSource {
|
||||
/// Returns snapshots sorted by date descending (most recent first)
|
||||
/// Performance note: This sorts on every call. For repeated access, cache the result.
|
||||
var snapshotsArray: [Snapshot] {
|
||||
let set = snapshots as? Set<Snapshot> ?? []
|
||||
return set.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
/// Returns snapshots sorted by date ascending (oldest first)
|
||||
/// Performance note: This sorts on every call. For repeated access, cache the result.
|
||||
var sortedSnapshotsByDateAscending: [Snapshot] {
|
||||
let set = snapshots as? Set<Snapshot> ?? []
|
||||
return set.sorted { $0.date < $1.date }
|
||||
}
|
||||
|
||||
/// Returns the most recent snapshot without sorting all snapshots
|
||||
/// Performance: O(n) instead of O(n log n) for sorting
|
||||
var latestSnapshot: Snapshot? {
|
||||
guard let set = snapshots as? Set<Snapshot>, !set.isEmpty else { return nil }
|
||||
return set.max(by: { $0.date < $1.date })
|
||||
}
|
||||
|
||||
var latestValue: Decimal {
|
||||
latestSnapshot?.value?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var snapshotCount: Int {
|
||||
snapshots?.count ?? 0
|
||||
}
|
||||
|
||||
/// Returns transactions sorted by date descending
|
||||
/// Performance note: This sorts on every call. For repeated access, cache the result.
|
||||
var transactionsArray: [Transaction] {
|
||||
let set = transactions as? Set<Transaction> ?? []
|
||||
return set.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
var frequency: NotificationFrequency {
|
||||
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
|
||||
}
|
||||
|
||||
var nextReminderDate: Date? {
|
||||
if let account = account {
|
||||
return account.nextReminderDate
|
||||
}
|
||||
|
||||
guard frequency != .never else { return nil }
|
||||
|
||||
let months = frequency == .custom ? Int(customFrequencyMonths) : frequency.months
|
||||
guard let lastSnapshot = latestSnapshot else {
|
||||
return Date() // Remind now if no snapshots
|
||||
}
|
||||
|
||||
return Calendar.current.date(byAdding: .month, value: months, to: lastSnapshot.date)
|
||||
}
|
||||
|
||||
var needsUpdate: Bool {
|
||||
if let account = account {
|
||||
guard let nextDate = account.nextReminderDate else { return false }
|
||||
return Date() >= nextDate
|
||||
}
|
||||
|
||||
guard let nextDate = nextReminderDate else { return false }
|
||||
return Date() >= nextDate
|
||||
}
|
||||
|
||||
// MARK: - Performance Metrics
|
||||
|
||||
/// Calculates total return percentage. Performance: Uses O(n) min/max instead of sorting.
|
||||
var totalReturn: Decimal {
|
||||
guard let set = snapshots as? Set<Snapshot>, set.count >= 2 else {
|
||||
return Decimal.zero
|
||||
}
|
||||
|
||||
// Performance: Find min and max by date in one pass instead of sorting twice
|
||||
guard let first = set.min(by: { $0.date < $1.date }),
|
||||
let last = set.max(by: { $0.date < $1.date }),
|
||||
let firstValue = first.value?.decimalValue,
|
||||
firstValue != Decimal.zero else {
|
||||
return Decimal.zero
|
||||
}
|
||||
|
||||
let lastValue = last.value?.decimalValue ?? Decimal.zero
|
||||
return ((lastValue - firstValue) / firstValue) * 100
|
||||
}
|
||||
|
||||
/// Performance: Iterates snapshots directly without sorting
|
||||
var totalContributions: Decimal {
|
||||
guard let set = snapshots as? Set<Snapshot> else { return Decimal.zero }
|
||||
return set.reduce(Decimal.zero) { result, snapshot in
|
||||
result + (snapshot.contribution?.decimalValue ?? Decimal.zero)
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance: Iterates transactions directly without sorting
|
||||
var totalInvested: Decimal {
|
||||
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
|
||||
return set.reduce(Decimal.zero) { result, transaction in
|
||||
let amount = transaction.decimalAmount
|
||||
switch transaction.transactionType {
|
||||
case .buy:
|
||||
return result + amount
|
||||
case .sell:
|
||||
return result - amount
|
||||
default:
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance: Iterates transactions directly without sorting
|
||||
var totalDividends: Decimal {
|
||||
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
|
||||
return set.reduce(Decimal.zero) { result, transaction in
|
||||
transaction.transactionType == .dividend ? result + transaction.decimalAmount : result
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance: Iterates transactions directly without sorting
|
||||
var totalFees: Decimal {
|
||||
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
|
||||
return set.reduce(Decimal.zero) { result, transaction in
|
||||
transaction.transactionType == .fee ? result + transaction.decimalAmount : result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Account Scheduling
|
||||
|
||||
extension Account {
|
||||
var nextReminderDate: Date? {
|
||||
guard frequency != .never else { return nil }
|
||||
|
||||
let months = frequency == .custom ? Int(customFrequencyMonths) : frequency.months
|
||||
guard let latestSnapshotDate = sourcesArray
|
||||
.compactMap({ $0.latestSnapshot?.date })
|
||||
.max() else {
|
||||
return Date()
|
||||
}
|
||||
|
||||
return Calendar.current.date(byAdding: .month, value: months, to: latestSnapshotDate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generated accessors for snapshots
|
||||
|
||||
extension InvestmentSource {
|
||||
@objc(addSnapshotsObject:)
|
||||
@NSManaged public func addToSnapshots(_ value: Snapshot)
|
||||
|
||||
@objc(removeSnapshotsObject:)
|
||||
@NSManaged public func removeFromSnapshots(_ value: Snapshot)
|
||||
|
||||
@objc(addSnapshots:)
|
||||
@NSManaged public func addToSnapshots(_ values: NSSet)
|
||||
|
||||
@objc(removeSnapshots:)
|
||||
@NSManaged public func removeFromSnapshots(_ values: NSSet)
|
||||
|
||||
@objc(addTransactionsObject:)
|
||||
@NSManaged public func addToTransactions(_ value: Transaction)
|
||||
|
||||
@objc(removeTransactionsObject:)
|
||||
@NSManaged public func removeFromTransactions(_ value: Transaction)
|
||||
|
||||
@objc(addTransactions:)
|
||||
@NSManaged public func addToTransactions(_ values: NSSet)
|
||||
|
||||
@objc(removeTransactions:)
|
||||
@NSManaged public func removeFromTransactions(_ values: NSSet)
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
|
||||
<entity name="AppSettings" representedClassName="AppSettings" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="currency" attributeType="String" defaultValueString="EUR"/>
|
||||
<attribute name="defaultNotificationTime" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="enableAnalytics" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="inputMode" attributeType="String" defaultValueString="simple"/>
|
||||
<attribute name="lastSyncDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="onboardingCompleted" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="selectedAccountId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="showAllAccounts" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
</entity>
|
||||
<entity name="Account" representedClassName="Account" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="currency" optional="YES" attributeType="String"/>
|
||||
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="inputMode" attributeType="String" defaultValueString="simple"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
|
||||
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Goal" inverseName="account" inverseEntity="Goal"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="Category" representedClassName="Category" syncable="YES">
|
||||
<attribute name="colorHex" attributeType="String" defaultValueString="#3B82F6"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="icon" attributeType="String" defaultValueString="chart.pie.fill"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="sources" inverseEntity="Account"/>
|
||||
<relationship name="asset" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Asset" inverseName="sources" inverseEntity="Asset"/>
|
||||
<relationship name="category" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Category" inverseName="sources" inverseEntity="Category"/>
|
||||
<relationship name="snapshots" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Snapshot" inverseName="source" inverseEntity="Snapshot"/>
|
||||
<relationship name="transactions" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Transaction" inverseName="source" inverseEntity="Transaction"/>
|
||||
</entity>
|
||||
<entity name="Asset" representedClassName="Asset" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="currency" optional="YES" attributeType="String"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="symbol" optional="YES" attributeType="String"/>
|
||||
<attribute name="type" attributeType="String" defaultValueString="stock"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="asset" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="Transaction" representedClassName="Transaction" syncable="YES">
|
||||
<attribute name="amount" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="notes" optional="YES" attributeType="String"/>
|
||||
<attribute name="price" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="shares" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="type" attributeType="String" defaultValueString="buy"/>
|
||||
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="transactions" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="Goal" representedClassName="Goal" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="targetAmount" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
|
||||
</entity>
|
||||
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="YES">
|
||||
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
|
||||
<attribute name="calculatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="predictionData" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="sourceId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="validUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
</entity>
|
||||
<entity name="PremiumStatus" representedClassName="PremiumStatus" syncable="YES">
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="isFamilyShared" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="isPremium" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="lastVerificationDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="originalPurchaseDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="productIdentifier" optional="YES" attributeType="String"/>
|
||||
<attribute name="purchaseDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="transactionId" optional="YES" attributeType="String"/>
|
||||
</entity>
|
||||
<entity name="Snapshot" representedClassName="Snapshot" syncable="YES">
|
||||
<attribute name="contribution" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="notes" optional="YES" attributeType="String"/>
|
||||
<attribute name="value" optional="YES" attributeType="Decimal"/>
|
||||
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="snapshots" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
</model>
|
||||
@@ -0,0 +1,151 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(PredictionCache)
|
||||
public class PredictionCache: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<PredictionCache> {
|
||||
return NSFetchRequest<PredictionCache>(entityName: "PredictionCache")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var sourceId: UUID?
|
||||
@NSManaged public var algorithm: String
|
||||
@NSManaged public var predictionData: Data?
|
||||
@NSManaged public var calculatedAt: Date
|
||||
@NSManaged public var validUntil: Date
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
calculatedAt = Date()
|
||||
// Cache valid for 24 hours
|
||||
validUntil = Calendar.current.date(byAdding: .hour, value: 24, to: Date()) ?? Date()
|
||||
algorithm = PredictionAlgorithm.linear.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prediction Algorithm
|
||||
|
||||
enum PredictionAlgorithm: String, CaseIterable, Identifiable {
|
||||
case linear = "linear"
|
||||
case exponentialSmoothing = "exponential_smoothing"
|
||||
case movingAverage = "moving_average"
|
||||
case holtTrend = "holt_trend"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .linear: return "Linear Regression"
|
||||
case .exponentialSmoothing: return "Exponential Smoothing"
|
||||
case .movingAverage: return "Moving Average"
|
||||
case .holtTrend: return "Holt Trend"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .linear:
|
||||
return "Projects future values based on historical trend line"
|
||||
case .exponentialSmoothing:
|
||||
return "Gives more weight to recent data points"
|
||||
case .movingAverage:
|
||||
return "Smooths out short-term fluctuations"
|
||||
case .holtTrend:
|
||||
return "Captures level and trend changes over time"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension PredictionCache {
|
||||
var isValid: Bool {
|
||||
Date() < validUntil
|
||||
}
|
||||
|
||||
var algorithmType: PredictionAlgorithm {
|
||||
PredictionAlgorithm(rawValue: algorithm) ?? .linear
|
||||
}
|
||||
|
||||
var predictions: [Prediction]? {
|
||||
guard let data = predictionData else { return nil }
|
||||
return try? JSONDecoder().decode([Prediction].self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Static Helpers
|
||||
|
||||
extension PredictionCache {
|
||||
static func getCache(
|
||||
for sourceId: UUID?,
|
||||
algorithm: PredictionAlgorithm,
|
||||
in context: NSManagedObjectContext
|
||||
) -> PredictionCache? {
|
||||
let request: NSFetchRequest<PredictionCache> = PredictionCache.fetchRequest()
|
||||
|
||||
if let sourceId = sourceId {
|
||||
request.predicate = NSPredicate(
|
||||
format: "sourceId == %@ AND algorithm == %@",
|
||||
sourceId as CVarArg,
|
||||
algorithm.rawValue
|
||||
)
|
||||
} else {
|
||||
request.predicate = NSPredicate(
|
||||
format: "sourceId == nil AND algorithm == %@",
|
||||
algorithm.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
request.fetchLimit = 1
|
||||
|
||||
guard let cache = try? context.fetch(request).first,
|
||||
cache.isValid else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return cache
|
||||
}
|
||||
|
||||
static func saveCache(
|
||||
for sourceId: UUID?,
|
||||
algorithm: PredictionAlgorithm,
|
||||
predictions: [Prediction],
|
||||
in context: NSManagedObjectContext
|
||||
) {
|
||||
// Remove old cache
|
||||
let request: NSFetchRequest<PredictionCache> = PredictionCache.fetchRequest()
|
||||
if let sourceId = sourceId {
|
||||
request.predicate = NSPredicate(
|
||||
format: "sourceId == %@ AND algorithm == %@",
|
||||
sourceId as CVarArg,
|
||||
algorithm.rawValue
|
||||
)
|
||||
} else {
|
||||
request.predicate = NSPredicate(
|
||||
format: "sourceId == nil AND algorithm == %@",
|
||||
algorithm.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
if let oldCache = try? context.fetch(request) {
|
||||
oldCache.forEach { context.delete($0) }
|
||||
}
|
||||
|
||||
// Create new cache
|
||||
let cache = PredictionCache(context: context)
|
||||
cache.sourceId = sourceId
|
||||
cache.algorithm = algorithm.rawValue
|
||||
cache.predictionData = try? JSONEncoder().encode(predictions)
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
static func invalidateAll(in context: NSManagedObjectContext) {
|
||||
let request: NSFetchRequest<PredictionCache> = PredictionCache.fetchRequest()
|
||||
if let caches = try? context.fetch(request) {
|
||||
caches.forEach { context.delete($0) }
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(PremiumStatus)
|
||||
public class PremiumStatus: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<PremiumStatus> {
|
||||
return NSFetchRequest<PremiumStatus>(entityName: "PremiumStatus")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var isPremium: Bool
|
||||
@NSManaged public var purchaseDate: Date?
|
||||
@NSManaged public var productIdentifier: String?
|
||||
@NSManaged public var transactionId: String?
|
||||
@NSManaged public var isFamilyShared: Bool
|
||||
@NSManaged public var lastVerificationDate: Date?
|
||||
@NSManaged public var originalPurchaseDate: Date?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
isPremium = false
|
||||
isFamilyShared = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension PremiumStatus {
|
||||
var daysSincePurchase: Int? {
|
||||
guard let purchaseDate = purchaseDate else { return nil }
|
||||
return Calendar.current.dateComponents([.day], from: purchaseDate, to: Date()).day
|
||||
}
|
||||
|
||||
var needsVerification: Bool {
|
||||
guard let lastVerification = lastVerificationDate else { return true }
|
||||
let hoursSinceVerification = Calendar.current.dateComponents(
|
||||
[.hour],
|
||||
from: lastVerification,
|
||||
to: Date()
|
||||
).hour ?? 0
|
||||
return hoursSinceVerification >= 24
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Static Helpers
|
||||
|
||||
extension PremiumStatus {
|
||||
static func getOrCreate(in context: NSManagedObjectContext) -> PremiumStatus {
|
||||
let request: NSFetchRequest<PremiumStatus> = PremiumStatus.fetchRequest()
|
||||
request.fetchLimit = 1
|
||||
|
||||
if let existing = try? context.fetch(request).first {
|
||||
return existing
|
||||
}
|
||||
|
||||
let new = PremiumStatus(context: context)
|
||||
try? context.save()
|
||||
return new
|
||||
}
|
||||
|
||||
static func updateStatus(
|
||||
isPremium: Bool,
|
||||
productIdentifier: String?,
|
||||
transactionId: String?,
|
||||
isFamilyShared: Bool,
|
||||
in context: NSManagedObjectContext
|
||||
) {
|
||||
let status = getOrCreate(in: context)
|
||||
status.isPremium = isPremium
|
||||
status.productIdentifier = productIdentifier
|
||||
status.transactionId = transactionId
|
||||
status.isFamilyShared = isFamilyShared
|
||||
status.lastVerificationDate = Date()
|
||||
|
||||
if isPremium && status.purchaseDate == nil {
|
||||
status.purchaseDate = Date()
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Snapshot)
|
||||
public class Snapshot: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Snapshot> {
|
||||
return NSFetchRequest<Snapshot>(entityName: "Snapshot")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var date: Date
|
||||
@NSManaged public var value: NSDecimalNumber?
|
||||
@NSManaged public var contribution: NSDecimalNumber?
|
||||
@NSManaged public var notes: String?
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var source: InvestmentSource?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
date = Date()
|
||||
createdAt = Date()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Snapshot {
|
||||
var decimalValue: Decimal {
|
||||
value?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var decimalContribution: Decimal {
|
||||
contribution?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var formattedValue: String {
|
||||
CurrencyFormatter.format(value?.decimalValue ?? Decimal.zero, style: .currency, maximumFractionDigits: 2)
|
||||
}
|
||||
|
||||
var formattedDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var monthYearString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM yyyy"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Comparison with Previous
|
||||
|
||||
extension Snapshot {
|
||||
func change(from previousSnapshot: Snapshot?) -> Decimal {
|
||||
guard let previous = previousSnapshot,
|
||||
let previousValue = previous.value?.decimalValue,
|
||||
previousValue != Decimal.zero else {
|
||||
return Decimal.zero
|
||||
}
|
||||
|
||||
let currentValue = value?.decimalValue ?? Decimal.zero
|
||||
return currentValue - previousValue
|
||||
}
|
||||
|
||||
func percentageChange(from previousSnapshot: Snapshot?) -> Decimal {
|
||||
guard let previous = previousSnapshot,
|
||||
let previousValue = previous.value?.decimalValue,
|
||||
previousValue != Decimal.zero else {
|
||||
return Decimal.zero
|
||||
}
|
||||
|
||||
let currentValue = value?.decimalValue ?? Decimal.zero
|
||||
return ((currentValue - previousValue) / previousValue) * 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Transaction)
|
||||
public class Transaction: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Transaction> {
|
||||
return NSFetchRequest<Transaction>(entityName: "Transaction")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var date: Date
|
||||
@NSManaged public var type: String
|
||||
@NSManaged public var shares: NSDecimalNumber?
|
||||
@NSManaged public var price: NSDecimalNumber?
|
||||
@NSManaged public var amount: NSDecimalNumber?
|
||||
@NSManaged public var notes: String?
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var source: InvestmentSource?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
date = Date()
|
||||
type = TransactionType.buy.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
enum TransactionType: String, CaseIterable, Identifiable {
|
||||
case buy
|
||||
case sell
|
||||
case dividend
|
||||
case fee
|
||||
case transfer
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .buy: return "Buy"
|
||||
case .sell: return "Sell"
|
||||
case .dividend: return "Dividend"
|
||||
case .fee: return "Fee"
|
||||
case .transfer: return "Transfer"
|
||||
}
|
||||
}
|
||||
|
||||
var isInvestmentFlow: Bool {
|
||||
self == .buy || self == .sell
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Transaction {
|
||||
var decimalShares: Decimal {
|
||||
shares?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var decimalPrice: Decimal {
|
||||
price?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var decimalAmount: Decimal {
|
||||
if let amount = amount?.decimalValue, amount != 0 {
|
||||
return amount
|
||||
}
|
||||
return decimalShares * decimalPrice
|
||||
}
|
||||
|
||||
var transactionType: TransactionType {
|
||||
TransactionType(rawValue: type) ?? .buy
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import CoreData
|
||||
import CloudKit
|
||||
import Combine
|
||||
import WidgetKit
|
||||
|
||||
class CoreDataStack: ObservableObject {
|
||||
static let shared = CoreDataStack()
|
||||
|
||||
static let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal"
|
||||
static let cloudKitContainerIdentifier = "iCloud.com.alexandrevazquez.portfoliojournal"
|
||||
|
||||
private static var cloudKitEnabled: Bool {
|
||||
UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
|
||||
}
|
||||
|
||||
private static var appGroupEnabled: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
private static func migrateStoreIfNeeded(from sourceURL: URL, to destinationURL: URL) {
|
||||
let fileManager = FileManager.default
|
||||
guard fileManager.fileExists(atPath: sourceURL.path) else {
|
||||
return
|
||||
}
|
||||
|
||||
let sourceHasData = storeHasData(at: sourceURL)
|
||||
let destinationHasData = storeHasData(at: destinationURL)
|
||||
guard sourceHasData, !destinationHasData else { return }
|
||||
|
||||
removeStoreFilesIfNeeded(at: destinationURL)
|
||||
|
||||
let relatedSuffixes = ["", "-wal", "-shm"]
|
||||
for suffix in relatedSuffixes {
|
||||
let source = URL(fileURLWithPath: sourceURL.path + suffix)
|
||||
let destination = URL(fileURLWithPath: destinationURL.path + suffix)
|
||||
guard fileManager.fileExists(atPath: source.path),
|
||||
!fileManager.fileExists(atPath: destination.path) else {
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
try fileManager.copyItem(at: source, to: destination)
|
||||
} catch {
|
||||
print("Core Data store migration failed for \(source.lastPathComponent): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func removeStoreFilesIfNeeded(at url: URL) {
|
||||
let fileManager = FileManager.default
|
||||
let relatedSuffixes = ["", "-wal", "-shm"]
|
||||
for suffix in relatedSuffixes {
|
||||
let fileURL = URL(fileURLWithPath: url.path + suffix)
|
||||
guard fileManager.fileExists(atPath: fileURL.path) else { continue }
|
||||
do {
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
} catch {
|
||||
print("Failed to remove existing store file \(fileURL.lastPathComponent): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func storeHasData(at url: URL) -> Bool {
|
||||
let fileManager = FileManager.default
|
||||
guard fileManager.fileExists(atPath: url.path),
|
||||
let model = NSManagedObjectModel.mergedModel(from: [Bundle.main]) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let coordinator = NSPersistentStoreCoordinator(managedObjectModel: model)
|
||||
do {
|
||||
try coordinator.addPersistentStore(
|
||||
ofType: NSSQLiteStoreType,
|
||||
configurationName: nil,
|
||||
at: url,
|
||||
options: [NSReadOnlyPersistentStoreOption: true]
|
||||
)
|
||||
} catch {
|
||||
print("Failed to open store for data check: \(error)")
|
||||
return false
|
||||
}
|
||||
|
||||
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||
context.persistentStoreCoordinator = coordinator
|
||||
|
||||
let snapshotRequest = NSFetchRequest<NSManagedObject>(entityName: "Snapshot")
|
||||
snapshotRequest.resultType = .countResultType
|
||||
let snapshotCount = (try? context.count(for: snapshotRequest)) ?? 0
|
||||
if snapshotCount > 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
let sourceRequest = NSFetchRequest<NSManagedObject>(entityName: "InvestmentSource")
|
||||
sourceRequest.resultType = .countResultType
|
||||
let sourceCount = (try? context.count(for: sourceRequest)) ?? 0
|
||||
return sourceCount > 0
|
||||
}
|
||||
|
||||
private static func resolveStoreURL() -> URL {
|
||||
let defaultURL = NSPersistentContainer.defaultDirectoryURL()
|
||||
.appendingPathComponent("PortfolioJournal.sqlite")
|
||||
|
||||
guard appGroupEnabled,
|
||||
let appGroupURL = FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?
|
||||
.appendingPathComponent("PortfolioJournal.sqlite") else {
|
||||
if appGroupEnabled {
|
||||
print("App Group unavailable; using default store URL: \(defaultURL)")
|
||||
}
|
||||
return defaultURL
|
||||
}
|
||||
|
||||
migrateStoreIfNeeded(from: defaultURL, to: appGroupURL)
|
||||
return appGroupURL
|
||||
}
|
||||
|
||||
lazy var persistentContainer: NSPersistentContainer = {
|
||||
let container: NSPersistentContainer
|
||||
if Self.cloudKitEnabled {
|
||||
container = NSPersistentCloudKitContainer(name: "PortfolioJournal")
|
||||
} else {
|
||||
container = NSPersistentContainer(name: "PortfolioJournal")
|
||||
}
|
||||
|
||||
// App Group store URL for sharing with widgets. Fall back if not entitled.
|
||||
let storeURL = Self.resolveStoreURL()
|
||||
|
||||
let description = NSPersistentStoreDescription(url: storeURL)
|
||||
description.shouldMigrateStoreAutomatically = true
|
||||
description.shouldInferMappingModelAutomatically = true
|
||||
|
||||
if Self.cloudKitEnabled {
|
||||
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
|
||||
containerIdentifier: Self.cloudKitContainerIdentifier
|
||||
)
|
||||
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
||||
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
||||
}
|
||||
|
||||
container.persistentStoreDescriptions = [description]
|
||||
|
||||
container.loadPersistentStores { description, error in
|
||||
if let error = error as NSError? {
|
||||
// In production, handle this error appropriately
|
||||
print("Core Data failed to load: \(error), \(error.userInfo)")
|
||||
} else {
|
||||
print("Core Data loaded successfully at: \(description.url?.path ?? "unknown")")
|
||||
}
|
||||
}
|
||||
|
||||
// Merge policy - remote changes win
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
|
||||
// Performance optimizations
|
||||
container.viewContext.undoManager = nil
|
||||
container.viewContext.shouldDeleteInaccessibleFaults = true
|
||||
|
||||
if Self.cloudKitEnabled {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(processRemoteChanges),
|
||||
name: .NSPersistentStoreRemoteChange,
|
||||
object: container.persistentStoreCoordinator
|
||||
)
|
||||
}
|
||||
|
||||
return container
|
||||
}()
|
||||
|
||||
var viewContext: NSManagedObjectContext {
|
||||
return persistentContainer.viewContext
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Save Context
|
||||
|
||||
func save() {
|
||||
let context = viewContext
|
||||
guard context.hasChanges else { return }
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
let nsError = error as NSError
|
||||
print("Core Data save error: \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background Context
|
||||
|
||||
func newBackgroundContext() -> NSManagedObjectContext {
|
||||
let context = persistentContainer.newBackgroundContext()
|
||||
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
context.undoManager = nil
|
||||
return context
|
||||
}
|
||||
|
||||
func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) {
|
||||
persistentContainer.performBackgroundTask(block)
|
||||
}
|
||||
|
||||
// MARK: - Remote Change Handling
|
||||
|
||||
@objc private func processRemoteChanges(_ notification: Notification) {
|
||||
// Process remote changes on main context
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.objectWillChange.send()
|
||||
// Ensure changes are persisted to disk before refreshing widget
|
||||
self?.save()
|
||||
self?.refreshWidgetData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget Data Refresh
|
||||
|
||||
func refreshWidgetData() {
|
||||
if #available(iOS 14.0, *) {
|
||||
if Self.appGroupEnabled {
|
||||
checkpointWAL()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forces SQLite to checkpoint the WAL file, ensuring all changes are written to the main database file.
|
||||
/// This is necessary for the widget to see changes, as it opens the database read-only.
|
||||
private func checkpointWAL() {
|
||||
if viewContext.hasChanges {
|
||||
try? viewContext.save()
|
||||
}
|
||||
|
||||
let coordinator = persistentContainer.persistentStoreCoordinator
|
||||
coordinator.performAndWait {
|
||||
viewContext.refreshAllObjects()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared Container for Widgets
|
||||
|
||||
extension CoreDataStack {
|
||||
static var sharedStoreURL: URL? {
|
||||
guard appGroupEnabled else {
|
||||
let fallbackURL = NSPersistentContainer.defaultDirectoryURL()
|
||||
.appendingPathComponent("PortfolioJournal.sqlite")
|
||||
return fallbackURL
|
||||
}
|
||||
|
||||
if let appGroupURL = FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?
|
||||
.appendingPathComponent("PortfolioJournal.sqlite") {
|
||||
return appGroupURL
|
||||
}
|
||||
|
||||
let fallbackURL = NSPersistentContainer.defaultDirectoryURL()
|
||||
.appendingPathComponent("PortfolioJournal.sqlite")
|
||||
print("App Group unavailable for widgets; using default store URL: \(fallbackURL)")
|
||||
return fallbackURL
|
||||
}
|
||||
|
||||
/// Creates a lightweight Core Data stack for widgets (read-only)
|
||||
static func createWidgetContainer() -> NSPersistentContainer {
|
||||
let container = NSPersistentContainer(name: "PortfolioJournal")
|
||||
|
||||
guard let storeURL = sharedStoreURL else {
|
||||
fatalError("Unable to get shared store URL")
|
||||
}
|
||||
|
||||
let description = NSPersistentStoreDescription(url: storeURL)
|
||||
description.isReadOnly = true
|
||||
description.shouldMigrateStoreAutomatically = true
|
||||
description.shouldInferMappingModelAutomatically = true
|
||||
|
||||
container.persistentStoreDescriptions = [description]
|
||||
|
||||
container.loadPersistentStores { _, error in
|
||||
if let error = error {
|
||||
print("Widget Core Data failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
return container
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
enum InputMode: String, CaseIterable, Identifiable {
|
||||
case simple
|
||||
case detailed
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .simple: return "Simple"
|
||||
case .detailed: return "Detailed"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .simple:
|
||||
return "Enter a single total value per snapshot."
|
||||
case .detailed:
|
||||
return "Enter invested amount and gains to track additional metrics."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import Foundation
|
||||
|
||||
struct InvestmentMetrics {
|
||||
// Basic Metrics
|
||||
let totalValue: Decimal
|
||||
let totalContributions: Decimal
|
||||
let absoluteReturn: Decimal
|
||||
let percentageReturn: Decimal
|
||||
|
||||
// Advanced Metrics
|
||||
let cagr: Double // Compound Annual Growth Rate
|
||||
let twr: Double // Time-Weighted Return
|
||||
let volatility: Double // Standard deviation annualized
|
||||
let maxDrawdown: Double // Maximum peak-to-trough decline
|
||||
let sharpeRatio: Double // Risk-adjusted return
|
||||
let bestMonth: MonthlyReturn?
|
||||
let worstMonth: MonthlyReturn?
|
||||
let winRate: Double // Percentage of positive months
|
||||
let averageMonthlyReturn: Double
|
||||
|
||||
// Time period
|
||||
let startDate: Date?
|
||||
let endDate: Date?
|
||||
let totalMonths: Int
|
||||
|
||||
struct MonthlyReturn: Identifiable {
|
||||
let id = UUID()
|
||||
let date: Date
|
||||
let returnPercentage: Double
|
||||
|
||||
var formattedDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM yyyy"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var formattedReturn: String {
|
||||
String(format: "%.1f%%", returnPercentage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Formatting Helpers
|
||||
|
||||
extension InvestmentMetrics {
|
||||
var formattedTotalValue: String {
|
||||
formatCurrency(totalValue)
|
||||
}
|
||||
|
||||
var formattedAbsoluteReturn: String {
|
||||
let prefix = absoluteReturn >= 0 ? "+" : ""
|
||||
return prefix + formatCurrency(absoluteReturn)
|
||||
}
|
||||
|
||||
var formattedPercentageReturn: String {
|
||||
let prefix = percentageReturn >= 0 ? "+" : ""
|
||||
return String(format: "\(prefix)%.2f%%", NSDecimalNumber(decimal: percentageReturn).doubleValue)
|
||||
}
|
||||
|
||||
var formattedCAGR: String {
|
||||
String(format: "%.2f%%", cagr)
|
||||
}
|
||||
|
||||
var formattedTWR: String {
|
||||
String(format: "%.2f%%", twr)
|
||||
}
|
||||
|
||||
var formattedVolatility: String {
|
||||
String(format: "%.2f%%", volatility)
|
||||
}
|
||||
|
||||
var formattedMaxDrawdown: String {
|
||||
String(format: "%.2f%%", maxDrawdown)
|
||||
}
|
||||
|
||||
var formattedSharpeRatio: String {
|
||||
String(format: "%.2f", sharpeRatio)
|
||||
}
|
||||
|
||||
var formattedWinRate: String {
|
||||
String(format: "%.0f%%", winRate)
|
||||
}
|
||||
|
||||
var formattedAverageMonthlyReturn: String {
|
||||
let prefix = averageMonthlyReturn >= 0 ? "+" : ""
|
||||
return String(format: "\(prefix)%.2f%%", averageMonthlyReturn)
|
||||
}
|
||||
|
||||
private func formatCurrency(_ value: Decimal) -> String {
|
||||
CurrencyFormatter.format(value, style: .currency, maximumFractionDigits: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
extension InvestmentMetrics {
|
||||
static var empty: InvestmentMetrics {
|
||||
InvestmentMetrics(
|
||||
totalValue: 0,
|
||||
totalContributions: 0,
|
||||
absoluteReturn: 0,
|
||||
percentageReturn: 0,
|
||||
cagr: 0,
|
||||
twr: 0,
|
||||
volatility: 0,
|
||||
maxDrawdown: 0,
|
||||
sharpeRatio: 0,
|
||||
bestMonth: nil,
|
||||
worstMonth: nil,
|
||||
winRate: 0,
|
||||
averageMonthlyReturn: 0,
|
||||
startDate: nil,
|
||||
endDate: nil,
|
||||
totalMonths: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Metrics
|
||||
|
||||
struct CategoryMetrics: Identifiable {
|
||||
let id: UUID
|
||||
let categoryName: String
|
||||
let colorHex: String
|
||||
let icon: String
|
||||
let totalValue: Decimal
|
||||
let percentageOfPortfolio: Double
|
||||
let metrics: InvestmentMetrics
|
||||
|
||||
var formattedTotalValue: String {
|
||||
CurrencyFormatter.format(totalValue, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
var formattedPercentage: String {
|
||||
String(format: "%.1f%%", percentageOfPortfolio)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Portfolio Summary
|
||||
|
||||
struct PortfolioSummary {
|
||||
let totalValue: Decimal
|
||||
let totalContributions: Decimal
|
||||
let dayChange: Decimal
|
||||
let dayChangePercentage: Double
|
||||
let weekChange: Decimal
|
||||
let weekChangePercentage: Double
|
||||
let monthChange: Decimal
|
||||
let monthChangePercentage: Double
|
||||
let yearChange: Decimal
|
||||
let yearChangePercentage: Double
|
||||
let allTimeReturn: Decimal
|
||||
let allTimeReturnPercentage: Double
|
||||
let sourceCount: Int
|
||||
let lastUpdated: Date?
|
||||
|
||||
var formattedTotalValue: String {
|
||||
CurrencyFormatter.format(totalValue, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
var formattedDayChange: String {
|
||||
formatChange(dayChange, dayChangePercentage)
|
||||
}
|
||||
|
||||
var formattedMonthChange: String {
|
||||
formatChange(monthChange, monthChangePercentage)
|
||||
}
|
||||
|
||||
var formattedYearChange: String {
|
||||
formatChange(yearChange, yearChangePercentage)
|
||||
}
|
||||
|
||||
var formattedAllTimeReturn: String {
|
||||
formatChange(allTimeReturn, allTimeReturnPercentage)
|
||||
}
|
||||
|
||||
private func formatChange(_ absolute: Decimal, _ percentage: Double) -> String {
|
||||
let prefix = absolute >= 0 ? "+" : ""
|
||||
let absString = CurrencyFormatter.format(absolute, style: .currency, maximumFractionDigits: 0)
|
||||
let pctString = String(format: "%.2f%%", percentage)
|
||||
|
||||
return "\(prefix)\(absString) (\(prefix)\(pctString))"
|
||||
}
|
||||
|
||||
static var empty: PortfolioSummary {
|
||||
PortfolioSummary(
|
||||
totalValue: 0,
|
||||
totalContributions: 0,
|
||||
dayChange: 0,
|
||||
dayChangePercentage: 0,
|
||||
weekChange: 0,
|
||||
weekChangePercentage: 0,
|
||||
monthChange: 0,
|
||||
monthChangePercentage: 0,
|
||||
yearChange: 0,
|
||||
yearChangePercentage: 0,
|
||||
allTimeReturn: 0,
|
||||
allTimeReturnPercentage: 0,
|
||||
sourceCount: 0,
|
||||
lastUpdated: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
|
||||
struct MonthlyCheckInEntry: Codable, Equatable {
|
||||
var note: String?
|
||||
var rating: Int?
|
||||
var mood: MonthlyCheckInMood?
|
||||
var completionTime: Double?
|
||||
var createdAt: Double
|
||||
|
||||
var completionDate: Date? {
|
||||
completionTime.map { Date(timeIntervalSince1970: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
enum MonthlyCheckInMood: String, Codable, CaseIterable, Identifiable {
|
||||
case energized
|
||||
case confident
|
||||
case balanced
|
||||
case cautious
|
||||
case stressed
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .energized: return String(localized: "mood_energized_title")
|
||||
case .confident: return String(localized: "mood_confident_title")
|
||||
case .balanced: return String(localized: "mood_balanced_title")
|
||||
case .cautious: return String(localized: "mood_cautious_title")
|
||||
case .stressed: return String(localized: "mood_stressed_title")
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .energized: return "flame.fill"
|
||||
case .confident: return "hand.thumbsup.fill"
|
||||
case .balanced: return "leaf.fill"
|
||||
case .cautious: return "exclamationmark.triangle.fill"
|
||||
case .stressed: return "exclamationmark.octagon.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var detail: String {
|
||||
switch self {
|
||||
case .energized: return String(localized: "mood_energized_detail")
|
||||
case .confident: return String(localized: "mood_confident_detail")
|
||||
case .balanced: return String(localized: "mood_balanced_detail")
|
||||
case .cautious: return String(localized: "mood_cautious_detail")
|
||||
case .stressed: return String(localized: "mood_stressed_detail")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MonthlyCheckInAchievement: Identifiable, Equatable {
|
||||
let key: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let icon: String
|
||||
|
||||
var id: String { key }
|
||||
}
|
||||
|
||||
struct MonthlyCheckInAchievementStatus: Identifiable, Equatable {
|
||||
let achievement: MonthlyCheckInAchievement
|
||||
let isUnlocked: Bool
|
||||
|
||||
var id: String { achievement.key }
|
||||
}
|
||||
|
||||
struct MonthlyCheckInStats: Equatable {
|
||||
let currentStreak: Int
|
||||
let bestStreak: Int
|
||||
let onTimeCount: Int
|
||||
let totalCheckIns: Int
|
||||
let averageDaysBeforeDeadline: Double?
|
||||
let closestCutoffDays: Double?
|
||||
let recentMood: MonthlyCheckInMood?
|
||||
let achievements: [MonthlyCheckInAchievement]
|
||||
|
||||
static var empty: MonthlyCheckInStats {
|
||||
MonthlyCheckInStats(
|
||||
currentStreak: 0,
|
||||
bestStreak: 0,
|
||||
onTimeCount: 0,
|
||||
totalCheckIns: 0,
|
||||
averageDaysBeforeDeadline: nil,
|
||||
closestCutoffDays: nil,
|
||||
recentMood: nil,
|
||||
achievements: []
|
||||
)
|
||||
}
|
||||
|
||||
var onTimeRate: Double {
|
||||
guard totalCheckIns > 0 else { return 0 }
|
||||
return Double(onTimeCount) / Double(totalCheckIns)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
struct MonthlySummary {
|
||||
let periodLabel: String
|
||||
let startDate: Date
|
||||
let endDate: Date
|
||||
let startingValue: Decimal
|
||||
let endingValue: Decimal
|
||||
let contributions: Decimal
|
||||
let netPerformance: Decimal
|
||||
|
||||
var netPerformancePercentage: Double {
|
||||
let base = startingValue + contributions
|
||||
guard base > 0 else { return 0 }
|
||||
return NSDecimalNumber(decimal: netPerformance / base).doubleValue * 100
|
||||
}
|
||||
|
||||
var formattedStartingValue: String {
|
||||
CurrencyFormatter.format(startingValue, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
var formattedEndingValue: String {
|
||||
CurrencyFormatter.format(endingValue, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
var formattedContributions: String {
|
||||
CurrencyFormatter.format(contributions, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
var formattedNetPerformance: String {
|
||||
let prefix = netPerformance >= 0 ? "+" : ""
|
||||
let value = CurrencyFormatter.format(netPerformance, style: .currency, maximumFractionDigits: 0)
|
||||
return prefix + value
|
||||
}
|
||||
|
||||
var formattedNetPerformancePercentage: String {
|
||||
let prefix = netPerformance >= 0 ? "+" : ""
|
||||
return String(format: "\(prefix)%.2f%%", netPerformancePercentage)
|
||||
}
|
||||
|
||||
static var empty: MonthlySummary {
|
||||
MonthlySummary(
|
||||
periodLabel: "This Month",
|
||||
startDate: Date(),
|
||||
endDate: Date(),
|
||||
startingValue: 0,
|
||||
endingValue: 0,
|
||||
contributions: 0,
|
||||
netPerformance: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct PortfolioChange {
|
||||
let absolute: Decimal
|
||||
let percentage: Double
|
||||
let label: String
|
||||
|
||||
var formattedAbsolute: String {
|
||||
let prefix = absolute >= 0 ? "+" : ""
|
||||
let value = CurrencyFormatter.format(absolute, style: .currency, maximumFractionDigits: 0)
|
||||
return prefix + value
|
||||
}
|
||||
|
||||
var formattedPercentage: String {
|
||||
let prefix = absolute >= 0 ? "+" : ""
|
||||
return String(format: "\(prefix)%.2f%%", percentage)
|
||||
}
|
||||
|
||||
static var empty: PortfolioChange {
|
||||
PortfolioChange(absolute: 0, percentage: 0, label: "since last update")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
|
||||
struct Prediction: Codable, Identifiable {
|
||||
let id: UUID
|
||||
let date: Date
|
||||
let predictedValue: Decimal
|
||||
let algorithm: PredictionAlgorithm
|
||||
let confidenceInterval: ConfidenceInterval
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
date: Date,
|
||||
predictedValue: Decimal,
|
||||
algorithm: PredictionAlgorithm,
|
||||
confidenceInterval: ConfidenceInterval
|
||||
) {
|
||||
self.id = id
|
||||
self.date = date
|
||||
self.predictedValue = predictedValue
|
||||
self.algorithm = algorithm
|
||||
self.confidenceInterval = confidenceInterval
|
||||
}
|
||||
|
||||
struct ConfidenceInterval: Codable {
|
||||
let lower: Decimal
|
||||
let upper: Decimal
|
||||
|
||||
var range: Decimal {
|
||||
upper - lower
|
||||
}
|
||||
|
||||
var percentageWidth: Decimal {
|
||||
guard upper != 0 else { return 0 }
|
||||
return (range / upper) * 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prediction Algorithm Codable
|
||||
|
||||
extension PredictionAlgorithm: Codable {}
|
||||
|
||||
// MARK: - Prediction Helpers
|
||||
|
||||
extension Prediction {
|
||||
var formattedValue: String {
|
||||
CurrencyFormatter.format(predictedValue, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
var formattedDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM yyyy"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var formattedConfidenceRange: String {
|
||||
let lower = CurrencyFormatter.format(
|
||||
confidenceInterval.lower,
|
||||
style: .currency,
|
||||
maximumFractionDigits: 0
|
||||
)
|
||||
let upper = CurrencyFormatter.format(
|
||||
confidenceInterval.upper,
|
||||
style: .currency,
|
||||
maximumFractionDigits: 0
|
||||
)
|
||||
|
||||
return "\(lower) - \(upper)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prediction Result
|
||||
|
||||
struct PredictionResult {
|
||||
let predictions: [Prediction]
|
||||
let algorithm: PredictionAlgorithm
|
||||
let accuracy: Double // R-squared or similar metric
|
||||
let volatility: Double
|
||||
|
||||
var isHighConfidence: Bool {
|
||||
accuracy >= 0.7
|
||||
}
|
||||
|
||||
var confidenceLevel: ConfidenceLevel {
|
||||
switch accuracy {
|
||||
case 0.8...: return .high
|
||||
case 0.5..<0.8: return .medium
|
||||
default: return .low
|
||||
}
|
||||
}
|
||||
|
||||
enum ConfidenceLevel: String {
|
||||
case high = "High"
|
||||
case medium = "Medium"
|
||||
case low = "Low"
|
||||
|
||||
var color: String {
|
||||
switch self {
|
||||
case .high: return "#10B981"
|
||||
case .medium: return "#F59E0B"
|
||||
case .low: return "#EF4444"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user