/** * Walk-forward validation: time-stratified only, no look-ahead bias. * @param {Array<{predicted: number, timestamp: string}>} predictions * @param {Array<{actual: number, timestamp: string}>} actuals * @returns {object} Accuracy metrics */ function walkForwardValidate(predictions, actuals) { if (!predictions || !actuals || predictions.length === 0 || actuals.length === 0) { return { accuracy: 0, mae: 0, rmse: 0, n: 0, hit_rate: 0 }; } const paired = predictions.map((pred, i) => { const actual = actuals[i]; if (!actual) return null; return { predicted: pred.predicted, actual: actual.actual, timestamp: pred.timestamp }; }).filter(Boolean); // Sort by timestamp to enforce time-stratification paired.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); const n = paired.length; if (n === 0) return { accuracy: 0, mae: 0, rmse: 0, n: 0, hit_rate: 0 }; let totalError = 0; let totalSquaredError = 0; let hits = 0; for (const p of paired) { const error = Math.abs(p.predicted - p.actual); totalError += error; totalSquaredError += error * error; // Hit = within 10% of actual or within 1 unit if (error <= Math.max(Math.abs(p.actual) * 0.1, 1)) hits++; } return { accuracy: Math.round((hits / n) * 1000) / 1000, mae: Math.round((totalError / n) * 100) / 100, rmse: Math.round(Math.sqrt(totalSquaredError / n) * 100) / 100, n, hit_rate: Math.round((hits / n) * 1000) / 1000, }; } /** * Calculate Closing Line Value at multiple checkpoints. * @param {number} predictionLine - Our predicted line at time of prediction * @param {number} lineAt24h - Market line 24 hours before tip * @param {number} lineAtTip - Market line at tip-off * @returns {object} { clv_at_prediction, clv_at_24hr, clv_at_tip } */ function calculateCLV(predictionLine, lineAt24h, lineAtTip) { return { clv_at_prediction: Math.round((lineAtTip - predictionLine) * 100) / 100, clv_at_24hr: Math.round((lineAtTip - lineAt24h) * 100) / 100, clv_at_tip: 0, // By definition, CLV at tip is 0 (reference point) }; } /** * Check for model drift: 10 consecutive CLV below 0 triggers alert. * @param {Array} clvHistory - Array of CLV values, most recent last * @returns {object} { drift_detected, consecutive_negative, alert } */ function checkDrift(clvHistory) { if (!clvHistory || clvHistory.length === 0) { return { drift_detected: false, consecutive_negative: 0, alert: false }; } let consecutiveNeg = 0; // Count from the end for (let i = clvHistory.length - 1; i >= 0; i--) { if (clvHistory[i] < 0) { consecutiveNeg++; } else { break; } } return { drift_detected: consecutiveNeg >= 10, consecutive_negative: consecutiveNeg, alert: consecutiveNeg >= 10, }; } /** * Cap weight changes to prevent overfitting. * @param {number} currentWeight * @param {number} proposedWeight * @param {number} maxDelta - Maximum allowed change per cycle (default 0.05) * @returns {number} Capped weight */ function applyLearningRateCap(currentWeight, proposedWeight, maxDelta = 0.05) { const delta = proposedWeight - currentWeight; const clampedDelta = Math.max(-maxDelta, Math.min(maxDelta, delta)); return Math.round((currentWeight + clampedDelta) * 10000) / 10000; } module.exports = { walkForwardValidate, calculateCLV, checkDrift, applyLearningRateCap, };