106 lines
3.4 KiB
JavaScript
106 lines
3.4 KiB
JavaScript
/**
|
|
* 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<number>} 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,
|
|
};
|