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:
alexandrev-tibco
2026-06-30 16:51:03 +02:00
parent 54dfd8a8e3
commit 7a18dd8360
48 changed files with 2854 additions and 608 deletions
@@ -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
+155 -339
View File
@@ -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
)
}
}