All files / forecast auto-select.ts

100% Statements 60/60
100% Branches 51/51
100% Functions 5/5
100% Lines 60/60

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83                1x 25x 25x 25x 25x 25x   25x     25x 25x 25x 25x 25x 25x 25x   25x 26x 20x 20x   26x   18x 26x   16x 20x   9x 16x   7x 26x     25x 9x 9x 8x 9x 184x 2218x 2218x 1469x 2218x 184x 8x 9x 9x     25x 16x 16x 13x 13x 16x   11x 16x 33x 27x 33x 971x 971x 27x 27x 27x 11x 16x 25x  
/**
 * AutoModelSelector — selects the best forecasting model based on data characteristics.
 *
 * Analyzes trend (Mann-Kendall τ), seasonality (ACF peak), length, and intermittency.
 * Thresholds derived from forecasting literature (Assimakopoulos 2010, Syntetos 2005).
 */
import type { DataPoint } from '../types.js'
 
export class AutoModelSelector {
  private enabled: boolean
  private shortThreshold: number
  private seasonalityThreshold: number
  private trendThreshold: number
  private intermittencyThreshold: number
 
  constructor(enabled = true, opts?: {
    shortThreshold?: number; seasonalityThreshold?: number
    trendThreshold?: number; intermittencyThreshold?: number
  }) {
    this.enabled = enabled
    this.shortThreshold = opts?.shortThreshold ?? 20
    this.seasonalityThreshold = opts?.seasonalityThreshold ?? 0.3
    this.trendThreshold = opts?.trendThreshold ?? 0.5
    this.intermittencyThreshold = opts?.intermittencyThreshold ?? 0.3
  }
 
  select(context: DataPoint[]): string {
    if (!this.enabled || context.length === 0) return 'AutoForecaster'
    const values = context.map(p => p.value)
    const n = values.length
 
    if (n < this.shortThreshold) return 'ThetaForecaster'
 
    const zeroRatio = values.filter(v => v === 0 || Math.abs(v) < 1e-10).length / n
    if (zeroRatio > this.intermittencyThreshold) return 'CrostonForecaster'
 
    const seasonality = this.computeSeasonality(values)
    if (seasonality > this.seasonalityThreshold) return 'AutoETSForecaster'
 
    const trend = this.computeTrend(values)
    if (Math.abs(trend) > this.trendThreshold) return 'AutoARIMAForecaster'
 
    return 'AutoForecaster'
  }
 
  /** Compute trend strength via Mann-Kendall τ simplified. */
  private computeTrend(values: number[]): number {
    const n = values.length
    if (n < 3) return 0
    let concordant = 0; let discordant = 0
    for (let i = 0; i < n - 1; i++) {
      for (let j = i + 1; j < Math.min(i + 20, n); j++) {
        const diff = values[j]! - values[i]!
        if (diff > 0) concordant++
        else if (diff < 0) discordant++
      }
    }
    const total = concordant + discordant
    return total > 0 ? (concordant - discordant) / total : 0
  }
 
  /** Compute seasonality strength via ACF at candidate lags. */
  private computeSeasonality(values: number[]): number {
    const n = values.length
    if (n < 15) return 0
    const mean = values.reduce((a, b) => a + b, 0) / n
    const variance = values.reduce((s, v) => s + (v - mean) ** 2, 0) / n
    if (variance < 1e-10) return 0
 
    let maxAcf = 0
    for (const lag of [7, 12, 24]) {
      if (lag >= n / 2) continue
      let acf = 0
      for (let i = 0; i < n - lag; i++) {
        acf += (values[i]! - mean) * (values[i + lag]! - mean)
      }
      acf /= (n - lag) * variance
      maxAcf = Math.max(maxAcf, Math.abs(acf))
    }
    return maxAcf
  }
}