Files
vyndr/src/services/modelTrainer.js
T

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,
};