institutional-trader/backend/src/backtest/model/logistic.js

154 lines
4.2 KiB
JavaScript

import fs from 'fs';
function sigmoid(z) {
// Cap z to avoid overflow
if (z > 20) return 1.0;
if (z < -20) return 0.0;
return 1 / (1 + Math.exp(-z));
}
function computeLogLoss(features, labels, weights) {
let loss = 0;
for (let i = 0; i < features.length; i++) {
const f = features[i];
let z = weights[0];
for (let j = 0; j < f.length; j++) z += weights[j + 1] * f[j];
const p = sigmoid(z);
// clip p to avoid log(0)
const pSafe = Math.max(1e-15, Math.min(1 - 1e-15, p));
loss += -labels[i] * Math.log(pSafe) - (1 - labels[i]) * Math.log(1 - pSafe);
}
return loss / features.length;
}
export class LogisticModel {
constructor() {
this.weights = null;
this.featureKeys = [];
}
// Internal full-batch gradient descent
_train(X, Y, lambda, learningRate, maxSteps, tol) {
const numFeatures = X[0].length;
let w = new Array(numFeatures + 1).fill(0.0);
const N = X.length;
let prevLoss = Infinity;
for (let step = 0; step < maxSteps; step++) {
const grad = new Array(numFeatures + 1).fill(0.0);
for (let i = 0; i < N; i++) {
let z = w[0];
for (let j = 0; j < numFeatures; j++) z += w[j + 1] * X[i][j];
const p = sigmoid(z);
const err = p - Y[i];
grad[0] += err;
for (let j = 0; j < numFeatures; j++) {
grad[j + 1] += err * X[i][j];
}
}
// Add L2 penalty (skip intercept grad[0])
for (let j = 0; j < numFeatures; j++) {
grad[j + 1] += lambda * w[j + 1];
}
// Update weights
let maxDelta = 0;
for (let j = 0; j < w.length; j++) {
const update = (learningRate * grad[j]) / N;
w[j] -= update;
if (Math.abs(update) > maxDelta) maxDelta = Math.abs(update);
}
if (maxDelta < tol) {
// Converged
break;
}
}
return w;
}
fit(featuresArray, labels) {
if (!featuresArray.length) return;
this.featureKeys = Object.keys(featuresArray[0]);
// Convert to 2D array
const X = featuresArray.map(f => this.featureKeys.map(k => f[k]));
const Y = labels;
const N = X.length;
// 1. Carve a 80/20 validation slice from TRAIN to tune lambda
const splitIdx = Math.floor(N * 0.8);
const X_train = X.slice(0, splitIdx);
const Y_train = Y.slice(0, splitIdx);
const X_val = X.slice(splitIdx);
const Y_val = Y.slice(splitIdx);
// 2. Tune lambda
const lambdas = [0.01, 0.1, 1.0, 10.0, 100.0, 500.0, 1000.0];
let bestLambda = 0;
let bestLoss = Infinity;
console.log(`\nTuning L2 Regularization (Lambda) on Validation Slice...`);
for (const l of lambdas) {
const w = this._train(X_train, Y_train, l, 0.5, 2000, 1e-5);
const loss = computeLogLoss(X_val, Y_val, w);
console.log(` Lambda ${l.toString().padEnd(6)} -> LogLoss: ${loss.toFixed(4)}`);
if (loss < bestLoss) {
bestLoss = loss;
bestLambda = l;
}
}
console.log(`Selected Best Lambda: ${bestLambda}`);
// 3. Refit on FULL TRAIN set using best lambda
this.weights = this._train(X, Y, bestLambda, 0.5, 5000, 1e-5);
return this.getCoefficients();
}
predict(featuresArray) {
if (!this.weights) throw new Error("Model not trained");
const probs = [];
for (let i = 0; i < featuresArray.length; i++) {
const f = featuresArray[i];
let z = this.weights[0]; // intercept
for (let j = 0; j < this.featureKeys.length; j++) {
z += this.weights[j + 1] * f[this.featureKeys[j]];
}
probs.push(sigmoid(z));
}
return probs;
}
getCoefficients() {
if (!this.weights) return {};
const coeffs = { intercept: this.weights[0] };
for (let i = 0; i < this.featureKeys.length; i++) {
coeffs[this.featureKeys[i]] = this.weights[i + 1];
}
return coeffs;
}
save(filepath) {
fs.writeFileSync(filepath, JSON.stringify({
featureKeys: this.featureKeys,
weights: this.weights
}, null, 2));
}
load(filepath) {
if (!fs.existsSync(filepath)) return;
const data = JSON.parse(fs.readFileSync(filepath, 'utf8'));
this.featureKeys = data.featureKeys;
this.weights = data.weights;
}
}