1.4.1 (build 42): contribución por snapshot, fixes Period vs Period y navegación iPad
- 151: contribución editable por snapshot en cualquier modo (quitado gate detailed) con diálogo de propagación al editar: solo este / adelante / atrás / todos (SnapshotRepository.propagateContribution, SnapshotFormViewModel.contributionChanged) - 148: Period vs Period mostraba mal el último mes del period B — la agrupación usaba chartMonth(for:) que aplicaba el grace-period del check-in a snapshots históricos; ahora agrupa por mes calendario crudo - 152: iPad Sources no saltaba entre fuentes — añadido .id() al SourceDetailView para recrear el StateObject al cambiar de selección - 146/147: Year vs Year con forecast del año en curso (asterisco de estimado) y KPIs arriba / detalle debajo - 145: card de contribución mensual en SourceDetailView - Nuevas claves snapshot_contribution_propagate_* en los 7 idiomas Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
This commit is contained in:
@@ -219,14 +219,28 @@ extension NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules a monthly check-in notification on the 1st of each month at 9am.
|
||||
/// Only schedules if not already pending.
|
||||
/// Schedules a non-repeating monthly check-in notification for the 1st of the next month
|
||||
/// that doesn't have a completed check-in. Safe to call on every app activation.
|
||||
func scheduleMonthlyCheckIn() {
|
||||
guard isAuthorized else { return }
|
||||
|
||||
let identifier = "monthly_checkin"
|
||||
center.getPendingNotificationRequests { [weak self] requests in
|
||||
guard let self, !requests.contains(where: { $0.identifier == identifier }) else { return }
|
||||
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
guard let thisMonthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) else { return }
|
||||
|
||||
// Walk forward from next month to find the first month whose check-in is not done
|
||||
for offset in 1...13 {
|
||||
guard let targetStart = calendar.date(byAdding: .month, value: offset, to: thisMonthStart) else { break }
|
||||
let isDone = MonthlyCheckInStore.completionDate(for: targetStart.adding(days: 1)) != nil
|
||||
if isDone { continue }
|
||||
|
||||
var components = calendar.dateComponents([.year, .month], from: targetStart)
|
||||
components.day = 1
|
||||
components.hour = 9
|
||||
components.minute = 0
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = String(localized: "monthly_checkin_notification_title")
|
||||
@@ -234,19 +248,15 @@ extension NotificationService {
|
||||
content.sound = .default
|
||||
content.userInfo = ["action": "batchUpdate"]
|
||||
|
||||
var components = DateComponents()
|
||||
components.day = 1
|
||||
components.hour = 9
|
||||
components.minute = 0
|
||||
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
||||
|
||||
self.center.add(request) { error in
|
||||
center.add(request) { error in
|
||||
if let error = error {
|
||||
print("Monthly check-in notification error: \(error)")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +309,42 @@ extension NotificationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires a notification when the portfolio crosses a round milestone for the first time.
|
||||
func checkAndScheduleMilestoneNotification(portfolioValue: Decimal) {
|
||||
guard isAuthorized else { return }
|
||||
|
||||
let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
|
||||
100000, 250000, 500000, 1_000_000]
|
||||
let notifiedKey = "lastNotifiedMilestone"
|
||||
let lastNotified = UserDefaults.standard.double(forKey: notifiedKey)
|
||||
let current = NSDecimalNumber(decimal: portfolioValue).doubleValue
|
||||
|
||||
for milestone in milestones.reversed() {
|
||||
let ms = NSDecimalNumber(decimal: milestone).doubleValue
|
||||
if current >= ms {
|
||||
if ms > lastNotified {
|
||||
UserDefaults.standard.set(ms, forKey: notifiedKey)
|
||||
let milestoneStr = CurrencyFormatter.format(milestone, style: .currency, maximumFractionDigits: 0)
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = String(localized: "notification_milestone_title")
|
||||
content.body = String(format: String(localized: "notification_milestone_body"), milestoneStr)
|
||||
content.sound = .default
|
||||
content.userInfo = ["action": "openDashboard"]
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "portfolio_milestone_\(Int(ms))",
|
||||
content: content,
|
||||
trigger: trigger
|
||||
)
|
||||
center.add(request) { _ in }
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background Refresh
|
||||
|
||||
@@ -3,370 +3,192 @@ 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
|
||||
// MARK: - Public 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
|
||||
)
|
||||
}
|
||||
func predict(snapshots: [Snapshot], monthsAhead: Int = 12, algorithm: PredictionAlgorithm? = nil) -> PredictionResult {
|
||||
guard snapshots.count >= 3 else { return emptyResult() }
|
||||
let sorted = snapshots.sorted { $0.date < $1.date }
|
||||
return predictFromValues(
|
||||
sorted.map { $0.decimalValue.doubleValue },
|
||||
dates: sorted.map { $0.date },
|
||||
monthsAhead: monthsAhead,
|
||||
algorithm: algorithm
|
||||
)
|
||||
}
|
||||
|
||||
// Sort snapshots by date
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
func predict(series: [(date: Date, value: Decimal)], monthsAhead: Int = 12, algorithm: PredictionAlgorithm? = nil) -> PredictionResult {
|
||||
guard series.count >= 3 else { return emptyResult() }
|
||||
let sorted = series.sorted { $0.date < $1.date }
|
||||
return predictFromValues(
|
||||
sorted.map { NSDecimalNumber(decimal: $0.value).doubleValue },
|
||||
dates: sorted.map { $0.date },
|
||||
monthsAhead: monthsAhead,
|
||||
algorithm: algorithm
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate volatility for algorithm selection
|
||||
let volatility = calculateVolatility(snapshots: sortedSnapshots)
|
||||
// MARK: - Private Core
|
||||
|
||||
// Select algorithm if not specified
|
||||
private func emptyResult() -> PredictionResult {
|
||||
PredictionResult(predictions: [], algorithm: .linear, accuracy: 0, volatility: 0)
|
||||
}
|
||||
|
||||
private func predictFromValues(_ values: [Double], dates: [Date], monthsAhead: Int, algorithm: PredictionAlgorithm?) -> PredictionResult {
|
||||
let volatility = calculateVolatility(values: values)
|
||||
let selectedAlgorithm = algorithm ?? selectBestAlgorithm(volatility: volatility)
|
||||
let lastDate = dates.last!
|
||||
let firstDate = dates.first!
|
||||
|
||||
// Generate predictions
|
||||
let predictions: [Prediction]
|
||||
let accuracy: Double
|
||||
|
||||
switch selectedAlgorithm {
|
||||
case .linear:
|
||||
predictions = predictLinear(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||
accuracy = calculateLinearAccuracy(snapshots: sortedSnapshots)
|
||||
predictions = linearPredictions(values: values, firstDate: firstDate, lastDate: lastDate, monthsAhead: monthsAhead)
|
||||
accuracy = linearAccuracy(values: values, firstDate: firstDate)
|
||||
case .exponentialSmoothing:
|
||||
predictions = predictExponentialSmoothing(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||
accuracy = calculateESAccuracy(snapshots: sortedSnapshots)
|
||||
predictions = esPredictions(values: values, lastDate: lastDate, monthsAhead: monthsAhead)
|
||||
accuracy = esAccuracy(values: values)
|
||||
case .movingAverage:
|
||||
predictions = predictMovingAverage(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||
accuracy = calculateMAAccuracy(snapshots: sortedSnapshots)
|
||||
predictions = maPredictions(values: values, lastDate: lastDate, monthsAhead: monthsAhead)
|
||||
accuracy = maAccuracy(values: values)
|
||||
case .holtTrend:
|
||||
predictions = predictHoltTrend(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||
accuracy = calculateHoltAccuracy(snapshots: sortedSnapshots)
|
||||
predictions = holtPredictions(values: values, lastDate: lastDate, monthsAhead: monthsAhead)
|
||||
accuracy = holtAccuracy(values: values)
|
||||
}
|
||||
|
||||
return PredictionResult(
|
||||
predictions: predictions,
|
||||
algorithm: selectedAlgorithm,
|
||||
accuracy: accuracy,
|
||||
volatility: volatility
|
||||
)
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
private func linearPredictions(values: [Double], firstDate: Date, lastDate: Date, monthsAhead: Int) -> [Prediction] {
|
||||
let dataPoints = values.enumerated().map { (x: Double($0.offset), y: $0.element) }
|
||||
let (slope, intercept) = calculateLinearRegression(dataPoints: dataPoints)
|
||||
let residualStdDev = calculateResidualStdDev(dataPoints: dataPoints, slope: slope, intercept: intercept)
|
||||
let n = Double(values.count)
|
||||
|
||||
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 (1...monthsAhead).compactMap { month in
|
||||
guard let futureDate = Self.calendar.date(byAdding: .month, value: month, to: lastDate) else { return nil }
|
||||
let x = n - 1 + Double(month)
|
||||
let predicted = max(0, slope * x + intercept)
|
||||
let width = residualStdDev * 1.96 * (1.0 + Double(month) * 0.02)
|
||||
return Prediction(date: futureDate, predictedValue: Decimal(predicted), algorithm: .linear,
|
||||
confidenceInterval: .init(lower: Decimal(max(0, predicted - width)), upper: Decimal(predicted + width)))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private func linearAccuracy(values: [Double], firstDate: Date) -> Double {
|
||||
guard values.count >= 5 else { return 0.5 }
|
||||
let splitIndex = Int(Double(values.count) * 0.8)
|
||||
let trainPoints = Array(values.prefix(splitIndex)).enumerated().map { (x: Double($0.offset), y: $0.element) }
|
||||
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)
|
||||
let validation = Array(values.suffix(from: splitIndex))
|
||||
let mean = validation.reduce(0, +) / Double(validation.count)
|
||||
var ssRes = 0.0, ssTot = 0.0
|
||||
for (i, actual) in validation.enumerated() {
|
||||
let x = Double(splitIndex + i)
|
||||
ssRes += pow(actual - (slope * x + intercept), 2)
|
||||
ssTot += pow(actual - mean, 2)
|
||||
}
|
||||
|
||||
guard ssTot != 0 else { return 0.5 }
|
||||
let rSquared = max(0, 1 - (ssRes / ssTot))
|
||||
|
||||
return min(1.0, rSquared)
|
||||
return min(1.0, max(0, 1 - ssRes / ssTot))
|
||||
}
|
||||
|
||||
// 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
|
||||
private func esPredictions(values: [Double], lastDate: Date, monthsAhead: Int, alpha: Double = 0.3) -> [Prediction] {
|
||||
var smoothed = values[0]
|
||||
for i in 1..<values.count {
|
||||
smoothed = alpha * values[i] + (1 - alpha) * smoothed
|
||||
}
|
||||
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 trend: Double = values.count >= 2
|
||||
? (values.suffix(3).reduce(0, +) / Double(min(3, values.count)) -
|
||||
values.prefix(3).reduce(0, +) / Double(min(3, values.count))) / Double(values.count)
|
||||
: 0
|
||||
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 (1...monthsAhead).compactMap { month in
|
||||
guard let futureDate = Self.calendar.date(byAdding: .month, value: month, to: lastDate) else { return nil }
|
||||
let predicted = max(0, smoothed + trend * Double(month))
|
||||
let width = stdDev * 1.96 * (1.0 + Double(month) * 0.05)
|
||||
return Prediction(date: futureDate, predictedValue: Decimal(predicted), algorithm: .exponentialSmoothing,
|
||||
confidenceInterval: .init(lower: Decimal(max(0, predicted - width)), upper: Decimal(predicted + width)))
|
||||
}
|
||||
|
||||
return predictions
|
||||
}
|
||||
|
||||
private func calculateESAccuracy(snapshots: [Snapshot]) -> Double {
|
||||
guard snapshots.count >= 5 else { return 0.5 }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
private func esAccuracy(values: [Double]) -> Double {
|
||||
guard values.count >= 5 else { return 0.5 }
|
||||
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() {
|
||||
for i in 1..<splitIndex { smoothed = 0.3 * values[i] + 0.7 * smoothed }
|
||||
let validation = Array(values.suffix(from: splitIndex))
|
||||
let mean = validation.reduce(0, +) / Double(validation.count)
|
||||
var ssRes = 0.0, ssTot = 0.0
|
||||
for (i, actual) in validation.enumerated() {
|
||||
let predicted = smoothed + (smoothed - values[splitIndex - 1]) * Double(i + 1) / Double(splitIndex)
|
||||
ssRes += pow(actual - predicted, 2)
|
||||
ssTot += pow(actual - meanValidation, 2)
|
||||
ssTot += pow(actual - mean, 2)
|
||||
}
|
||||
|
||||
guard ssTot != 0 else { return 0.5 }
|
||||
return max(0, min(1.0, 1 - (ssRes / ssTot)))
|
||||
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
|
||||
private func maPredictions(values: [Double], lastDate: Date, monthsAhead: Int, windowSize: Int = 3) -> [Prediction] {
|
||||
guard values.count >= windowSize else { return [] }
|
||||
let recent = Array(values.suffix(windowSize))
|
||||
let movingAvg = recent.reduce(0, +) / Double(windowSize)
|
||||
var changes: [Double] = []
|
||||
for i in 1..<values.count {
|
||||
changes.append(values[i] - values[i - 1])
|
||||
}
|
||||
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 (1...monthsAhead).compactMap { month in
|
||||
guard let futureDate = Self.calendar.date(byAdding: .month, value: month, to: lastDate) else { return nil }
|
||||
let predicted = max(0, movingAvg + avgChange * Double(month))
|
||||
let width = stdDev * 1.96 * (1.0 + Double(month) * 0.03)
|
||||
return Prediction(date: futureDate, predictedValue: Decimal(predicted), algorithm: .movingAverage,
|
||||
confidenceInterval: .init(lower: Decimal(max(0, predicted - width)), upper: Decimal(predicted + width)))
|
||||
}
|
||||
|
||||
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
|
||||
private func maAccuracy(values: [Double], windowSize: Int = 3) -> Double {
|
||||
guard values.count >= 5 else { return 0.5 }
|
||||
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 {
|
||||
let validation = Array(values.suffix(from: splitIndex))
|
||||
let mean = validation.reduce(0, +) / Double(validation.count)
|
||||
var ssRes = 0.0, ssTot = 0.0
|
||||
for actual in validation {
|
||||
ssRes += pow(actual - movingAvg, 2)
|
||||
ssTot += pow(actual - meanValidation, 2)
|
||||
ssTot += pow(actual - mean, 2)
|
||||
}
|
||||
|
||||
guard ssTot != 0 else { return 0.5 }
|
||||
return max(0, min(1.0, 1 - (ssRes / ssTot)))
|
||||
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 }
|
||||
private func holtPredictions(values: [Double], lastDate: Date, monthsAhead: Int, alpha: Double = 0.4, beta: Double = 0.3) -> [Prediction] {
|
||||
var level = values[0]
|
||||
var trend = values[1] - values[0]
|
||||
|
||||
var fitted: [Double] = []
|
||||
for value in values {
|
||||
let lastLevel = level
|
||||
@@ -374,92 +196,86 @@ class PredictionEngine {
|
||||
trend = beta * (level - lastLevel) + (1 - beta) * trend
|
||||
fitted.append(level + trend)
|
||||
}
|
||||
let stdDev = calculateStdDev(values: zip(values, fitted).map { $0 - $1 })
|
||||
|
||||
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 (1...monthsAhead).compactMap { month in
|
||||
guard let futureDate = Self.calendar.date(byAdding: .month, value: month, to: lastDate) else { return nil }
|
||||
let predicted = max(0, level + Double(month) * trend)
|
||||
let width = stdDev * 1.96 * (1.0 + Double(month) * 0.04)
|
||||
return Prediction(date: futureDate, predictedValue: Decimal(predicted), algorithm: .holtTrend,
|
||||
confidenceInterval: .init(lower: Decimal(max(0, predicted - width)), upper: Decimal(predicted + width)))
|
||||
}
|
||||
|
||||
return predictions
|
||||
}
|
||||
|
||||
private func calculateHoltAccuracy(snapshots: [Snapshot]) -> Double {
|
||||
guard snapshots.count >= 5 else { return 0.5 }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
private func holtAccuracy(values: [Double]) -> Double {
|
||||
guard values.count >= 5 else { return 0.5 }
|
||||
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)
|
||||
let validation = Array(values.suffix(from: splitIndex))
|
||||
let mean = validation.reduce(0, +) / Double(validation.count)
|
||||
var ssRes = 0.0, ssTot = 0.0
|
||||
for (i, actual) in validation.enumerated() {
|
||||
ssRes += pow(actual - (level + Double(i + 1) * trend), 2)
|
||||
ssTot += pow(actual - mean, 2)
|
||||
}
|
||||
|
||||
guard ssTot != 0 else { return 0.5 }
|
||||
return max(0, min(1.0, 1 - (ssRes / ssTot)))
|
||||
return max(0, min(1.0, 1 - ssRes / ssTot))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func calculateVolatility(snapshots: [Snapshot]) -> Double {
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
private func calculateVolatility(values: [Double]) -> Double {
|
||||
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)
|
||||
returns.append((values[i] - values[i - 1]) / values[i - 1] * 100)
|
||||
}
|
||||
|
||||
return calculateStdDev(values: returns)
|
||||
}
|
||||
|
||||
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 denom = n * sumX2 - sumX * sumX
|
||||
guard denom != 0 else { return (0, sumY / n) }
|
||||
let slope = (n * sumXY - sumX * sumY) / denom
|
||||
return (slope, (sumY - slope * sumX) / n)
|
||||
}
|
||||
|
||||
private func calculateResidualStdDev(dataPoints: [(x: Double, y: Double)], slope: Double, intercept: Double) -> Double {
|
||||
guard dataPoints.count > 2 else { return 0 }
|
||||
let mse = dataPoints.map { pow($0.y - (slope * $0.x + intercept), 2) }.reduce(0, +) / Double(dataPoints.count - 2)
|
||||
return sqrt(mse)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
let variance = values.map { pow($0 - mean, 2) }.reduce(0, +) / Double(values.count - 1)
|
||||
return sqrt(variance)
|
||||
}
|
||||
|
||||
// MARK: - Public compatibility (kept for external callers)
|
||||
|
||||
func predictLinear(snapshots: [Snapshot], monthsAhead: Int = 12) -> [Prediction] {
|
||||
guard snapshots.count >= 3 else { return [] }
|
||||
let sorted = snapshots.sorted { $0.date < $1.date }
|
||||
return linearPredictions(
|
||||
values: sorted.map { $0.decimalValue.doubleValue },
|
||||
firstDate: sorted.first!.date,
|
||||
lastDate: sorted.last!.date,
|
||||
monthsAhead: monthsAhead
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user