Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
957
vendor/ruvector/examples/neural-trader/production/drl-portfolio-manager.js
vendored
Normal file
957
vendor/ruvector/examples/neural-trader/production/drl-portfolio-manager.js
vendored
Normal file
@@ -0,0 +1,957 @@
|
||||
/**
|
||||
* Deep Reinforcement Learning Portfolio Manager
|
||||
*
|
||||
* PRODUCTION: Ensemble of PPO, SAC, and A2C for dynamic portfolio allocation
|
||||
*
|
||||
* Research basis:
|
||||
* - A2C top performer for cumulative rewards (MDPI, 2024)
|
||||
* - PPO best for volatile markets, stable training
|
||||
* - SAC optimal for high-dimensional action spaces
|
||||
* - Ensemble methods achieve 15% higher returns
|
||||
*
|
||||
* Features:
|
||||
* - Multiple DRL algorithms (PPO, SAC, A2C)
|
||||
* - Risk-adjusted rewards (Sharpe, Sortino, Max Drawdown)
|
||||
* - Dynamic rebalancing based on market regime
|
||||
* - Experience replay and target networks
|
||||
*/
|
||||
|
||||
// Portfolio Configuration
|
||||
const portfolioConfig = {
|
||||
// Environment settings
|
||||
environment: {
|
||||
numAssets: 10,
|
||||
lookbackWindow: 30,
|
||||
rebalanceFrequency: 'daily',
|
||||
transactionCost: 0.001,
|
||||
slippage: 0.0005
|
||||
},
|
||||
|
||||
// Agent configurations
|
||||
agents: {
|
||||
ppo: {
|
||||
enabled: true,
|
||||
clipEpsilon: 0.2,
|
||||
entropyCoef: 0.01,
|
||||
valueLossCoef: 0.5,
|
||||
maxGradNorm: 0.5
|
||||
},
|
||||
sac: {
|
||||
enabled: true,
|
||||
alpha: 0.2, // Temperature parameter
|
||||
tau: 0.005, // Soft update coefficient
|
||||
targetUpdateFreq: 1
|
||||
},
|
||||
a2c: {
|
||||
enabled: true,
|
||||
entropyCoef: 0.01,
|
||||
valueLossCoef: 0.5,
|
||||
numSteps: 5
|
||||
}
|
||||
},
|
||||
|
||||
// Training settings
|
||||
training: {
|
||||
learningRate: 0.0003,
|
||||
gamma: 0.99, // Discount factor
|
||||
batchSize: 64,
|
||||
bufferSize: 100000,
|
||||
hiddenDim: 128,
|
||||
numEpisodes: 1000
|
||||
},
|
||||
|
||||
// Risk management
|
||||
risk: {
|
||||
maxPositionSize: 0.3, // Max 30% in single asset
|
||||
minCashReserve: 0.05, // Keep 5% in cash
|
||||
maxDrawdown: 0.15, // Stop at 15% drawdown
|
||||
rewardType: 'sharpe' // sharpe, sortino, returns, drawdown
|
||||
},
|
||||
|
||||
// Ensemble settings
|
||||
ensemble: {
|
||||
method: 'weighted_average', // weighted_average, voting, adaptive
|
||||
weights: { ppo: 0.35, sac: 0.35, a2c: 0.30 }
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Experience Replay Buffer
|
||||
* Stores transitions for off-policy learning
|
||||
*/
|
||||
class ReplayBuffer {
|
||||
constructor(capacity) {
|
||||
this.capacity = capacity;
|
||||
this.buffer = [];
|
||||
this.position = 0;
|
||||
}
|
||||
|
||||
push(state, action, reward, nextState, done) {
|
||||
if (this.buffer.length < this.capacity) {
|
||||
this.buffer.push(null);
|
||||
}
|
||||
this.buffer[this.position] = { state, action, reward, nextState, done };
|
||||
this.position = (this.position + 1) % this.capacity;
|
||||
}
|
||||
|
||||
sample(batchSize) {
|
||||
const batch = [];
|
||||
const indices = new Set();
|
||||
|
||||
while (indices.size < Math.min(batchSize, this.buffer.length)) {
|
||||
indices.add(Math.floor(Math.random() * this.buffer.length));
|
||||
}
|
||||
|
||||
for (const idx of indices) {
|
||||
batch.push(this.buffer[idx]);
|
||||
}
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this.buffer.length;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Neural Network for Policy/Value estimation
|
||||
*/
|
||||
class NeuralNetwork {
|
||||
constructor(inputDim, hiddenDim, outputDim) {
|
||||
this.inputDim = inputDim;
|
||||
this.hiddenDim = hiddenDim;
|
||||
this.outputDim = outputDim;
|
||||
|
||||
// Xavier initialization
|
||||
const scale1 = Math.sqrt(2.0 / (inputDim + hiddenDim));
|
||||
const scale2 = Math.sqrt(2.0 / (hiddenDim + outputDim));
|
||||
|
||||
this.W1 = this.initMatrix(inputDim, hiddenDim, scale1);
|
||||
this.b1 = new Array(hiddenDim).fill(0);
|
||||
this.W2 = this.initMatrix(hiddenDim, hiddenDim, scale1);
|
||||
this.b2 = new Array(hiddenDim).fill(0);
|
||||
this.W3 = this.initMatrix(hiddenDim, outputDim, scale2);
|
||||
this.b3 = new Array(outputDim).fill(0);
|
||||
}
|
||||
|
||||
initMatrix(rows, cols, scale) {
|
||||
return Array(rows).fill(null).map(() =>
|
||||
Array(cols).fill(null).map(() => (Math.random() - 0.5) * 2 * scale)
|
||||
);
|
||||
}
|
||||
|
||||
relu(x) {
|
||||
return Math.max(0, x);
|
||||
}
|
||||
|
||||
forward(input) {
|
||||
// Layer 1
|
||||
const h1 = new Array(this.hiddenDim).fill(0);
|
||||
for (let i = 0; i < this.hiddenDim; i++) {
|
||||
h1[i] = this.b1[i];
|
||||
for (let j = 0; j < this.inputDim; j++) {
|
||||
h1[i] += input[j] * this.W1[j][i];
|
||||
}
|
||||
h1[i] = this.relu(h1[i]);
|
||||
}
|
||||
|
||||
// Layer 2
|
||||
const h2 = new Array(this.hiddenDim).fill(0);
|
||||
for (let i = 0; i < this.hiddenDim; i++) {
|
||||
h2[i] = this.b2[i];
|
||||
for (let j = 0; j < this.hiddenDim; j++) {
|
||||
h2[i] += h1[j] * this.W2[j][i];
|
||||
}
|
||||
h2[i] = this.relu(h2[i]);
|
||||
}
|
||||
|
||||
// Output layer
|
||||
const output = new Array(this.outputDim).fill(0);
|
||||
for (let i = 0; i < this.outputDim; i++) {
|
||||
output[i] = this.b3[i];
|
||||
for (let j = 0; j < this.hiddenDim; j++) {
|
||||
output[i] += h2[j] * this.W3[j][i];
|
||||
}
|
||||
}
|
||||
|
||||
return { output, h1, h2 };
|
||||
}
|
||||
|
||||
softmax(arr) {
|
||||
let max = arr[0];
|
||||
for (let i = 1; i < arr.length; i++) if (arr[i] > max) max = arr[i];
|
||||
const exp = arr.map(x => Math.exp(x - max));
|
||||
const sum = exp.reduce((a, b) => a + b, 0);
|
||||
return sum > 0 ? exp.map(x => x / sum) : arr.map(() => 1 / arr.length);
|
||||
}
|
||||
|
||||
// Simple gradient update (for demonstration)
|
||||
update(gradients, learningRate) {
|
||||
// Update W3
|
||||
for (let i = 0; i < this.W3.length; i++) {
|
||||
for (let j = 0; j < this.W3[i].length; j++) {
|
||||
if (gradients.W3 && gradients.W3[i]) {
|
||||
this.W3[i][j] -= learningRate * gradients.W3[i][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Soft update for target networks
|
||||
softUpdate(sourceNetwork, tau) {
|
||||
for (let i = 0; i < this.W1.length; i++) {
|
||||
for (let j = 0; j < this.W1[i].length; j++) {
|
||||
this.W1[i][j] = tau * sourceNetwork.W1[i][j] + (1 - tau) * this.W1[i][j];
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < this.W2.length; i++) {
|
||||
for (let j = 0; j < this.W2[i].length; j++) {
|
||||
this.W2[i][j] = tau * sourceNetwork.W2[i][j] + (1 - tau) * this.W2[i][j];
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < this.W3.length; i++) {
|
||||
for (let j = 0; j < this.W3[i].length; j++) {
|
||||
this.W3[i][j] = tau * sourceNetwork.W3[i][j] + (1 - tau) * this.W3[i][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PPO Agent
|
||||
* Proximal Policy Optimization - stable training in volatile markets
|
||||
*/
|
||||
class PPOAgent {
|
||||
constructor(stateDim, actionDim, config) {
|
||||
this.config = config;
|
||||
this.stateDim = stateDim;
|
||||
this.actionDim = actionDim;
|
||||
|
||||
// Actor (policy) network
|
||||
this.actor = new NeuralNetwork(stateDim, config.training.hiddenDim, actionDim);
|
||||
|
||||
// Critic (value) network
|
||||
this.critic = new NeuralNetwork(stateDim, config.training.hiddenDim, 1);
|
||||
|
||||
// Old policy for importance sampling
|
||||
this.oldActor = new NeuralNetwork(stateDim, config.training.hiddenDim, actionDim);
|
||||
this.copyWeights(this.actor, this.oldActor);
|
||||
|
||||
this.memory = [];
|
||||
}
|
||||
|
||||
copyWeights(source, target) {
|
||||
target.W1 = source.W1.map(row => [...row]);
|
||||
target.W2 = source.W2.map(row => [...row]);
|
||||
target.W3 = source.W3.map(row => [...row]);
|
||||
target.b1 = [...source.b1];
|
||||
target.b2 = [...source.b2];
|
||||
target.b3 = [...source.b3];
|
||||
}
|
||||
|
||||
getAction(state) {
|
||||
const { output } = this.actor.forward(state);
|
||||
|
||||
// Softmax to get probabilities
|
||||
const probs = this.actor.softmax(output);
|
||||
|
||||
// Add exploration noise
|
||||
const epsilon = 0.1;
|
||||
const noisyProbs = probs.map(p => p * (1 - epsilon) + epsilon / this.actionDim);
|
||||
|
||||
// Normalize to ensure valid distribution
|
||||
const sum = noisyProbs.reduce((a, b) => a + b, 0);
|
||||
const normalizedProbs = noisyProbs.map(p => p / sum);
|
||||
|
||||
// Sample action
|
||||
const random = Math.random();
|
||||
let cumsum = 0;
|
||||
for (let i = 0; i < normalizedProbs.length; i++) {
|
||||
cumsum += normalizedProbs[i];
|
||||
if (random < cumsum) {
|
||||
return { action: i, probs: normalizedProbs };
|
||||
}
|
||||
}
|
||||
|
||||
return { action: this.actionDim - 1, probs: normalizedProbs };
|
||||
}
|
||||
|
||||
getValue(state) {
|
||||
const { output } = this.critic.forward(state);
|
||||
return output[0];
|
||||
}
|
||||
|
||||
store(state, action, reward, nextState, done, logProb) {
|
||||
this.memory.push({ state, action, reward, nextState, done, logProb });
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this.memory.length < this.config.training.batchSize) return;
|
||||
|
||||
// Calculate returns and advantages
|
||||
const returns = [];
|
||||
let R = 0;
|
||||
|
||||
for (let i = this.memory.length - 1; i >= 0; i--) {
|
||||
R = this.memory[i].reward + this.config.training.gamma * R * (1 - this.memory[i].done);
|
||||
returns.unshift(R);
|
||||
}
|
||||
|
||||
// Normalize returns
|
||||
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
|
||||
const std = Math.sqrt(returns.reduce((a, b) => a + (b - mean) ** 2, 0) / returns.length) || 1;
|
||||
const normalizedReturns = returns.map(r => (r - mean) / std);
|
||||
|
||||
// PPO update (simplified)
|
||||
for (const transition of this.memory) {
|
||||
const value = this.getValue(transition.state);
|
||||
const advantage = normalizedReturns[this.memory.indexOf(transition)] - value;
|
||||
|
||||
// Ratio for importance sampling
|
||||
const { output: newOutput } = this.actor.forward(transition.state);
|
||||
const newProbs = this.actor.softmax(newOutput);
|
||||
const { output: oldOutput } = this.oldActor.forward(transition.state);
|
||||
const oldProbs = this.oldActor.softmax(oldOutput);
|
||||
|
||||
const ratio = newProbs[transition.action] / (oldProbs[transition.action] + 1e-10);
|
||||
|
||||
// Clipped objective
|
||||
const clipEpsilon = this.config.agents.ppo.clipEpsilon;
|
||||
const clippedRatio = Math.max(1 - clipEpsilon, Math.min(1 + clipEpsilon, ratio));
|
||||
const loss = -Math.min(ratio * advantage, clippedRatio * advantage);
|
||||
}
|
||||
|
||||
// Copy current policy to old policy
|
||||
this.copyWeights(this.actor, this.oldActor);
|
||||
|
||||
// Clear memory
|
||||
this.memory = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SAC Agent
|
||||
* Soft Actor-Critic - entropy regularization for exploration
|
||||
*/
|
||||
class SACAgent {
|
||||
constructor(stateDim, actionDim, config) {
|
||||
this.config = config;
|
||||
this.stateDim = stateDim;
|
||||
this.actionDim = actionDim;
|
||||
|
||||
// Actor network
|
||||
this.actor = new NeuralNetwork(stateDim, config.training.hiddenDim, actionDim * 2); // mean + std
|
||||
|
||||
// Twin Q networks
|
||||
this.q1 = new NeuralNetwork(stateDim + actionDim, config.training.hiddenDim, 1);
|
||||
this.q2 = new NeuralNetwork(stateDim + actionDim, config.training.hiddenDim, 1);
|
||||
|
||||
// Target Q networks
|
||||
this.q1Target = new NeuralNetwork(stateDim + actionDim, config.training.hiddenDim, 1);
|
||||
this.q2Target = new NeuralNetwork(stateDim + actionDim, config.training.hiddenDim, 1);
|
||||
|
||||
// Copy weights to targets
|
||||
this.q1Target.softUpdate(this.q1, 1.0);
|
||||
this.q2Target.softUpdate(this.q2, 1.0);
|
||||
|
||||
// Replay buffer
|
||||
this.buffer = new ReplayBuffer(config.training.bufferSize);
|
||||
|
||||
// Temperature (entropy coefficient)
|
||||
this.alpha = config.agents.sac.alpha;
|
||||
}
|
||||
|
||||
getAction(state, deterministic = false) {
|
||||
const { output } = this.actor.forward(state);
|
||||
|
||||
// Split into mean and log_std
|
||||
const mean = output.slice(0, this.actionDim);
|
||||
const logStd = output.slice(this.actionDim).map(x => Math.max(-20, Math.min(2, x)));
|
||||
|
||||
if (deterministic) {
|
||||
// Return mean as action (softmax for portfolio weights)
|
||||
return { action: this.actor.softmax(mean), mean, logStd };
|
||||
}
|
||||
|
||||
// Sample from Gaussian
|
||||
const std = logStd.map(x => Math.exp(x));
|
||||
const noise = mean.map(() => this.gaussianNoise());
|
||||
const sampledAction = mean.map((m, i) => m + std[i] * noise[i]);
|
||||
|
||||
// Softmax for portfolio weights
|
||||
const action = this.actor.softmax(sampledAction);
|
||||
|
||||
return { action, mean, logStd, noise };
|
||||
}
|
||||
|
||||
gaussianNoise() {
|
||||
// Box-Muller transform
|
||||
const u1 = Math.random();
|
||||
const u2 = Math.random();
|
||||
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
||||
}
|
||||
|
||||
store(state, action, reward, nextState, done) {
|
||||
this.buffer.push(state, action, reward, nextState, done);
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this.buffer.length < this.config.training.batchSize) return;
|
||||
|
||||
const batch = this.buffer.sample(this.config.training.batchSize);
|
||||
|
||||
for (const { state, action, reward, nextState, done } of batch) {
|
||||
// Skip terminal states where nextState is null
|
||||
if (!nextState || done) continue;
|
||||
|
||||
// Get next action
|
||||
const { action: nextAction, logStd } = this.getAction(nextState);
|
||||
|
||||
// Target Q values
|
||||
const nextInput = [...nextState, ...nextAction];
|
||||
const q1Target = this.q1Target.forward(nextInput).output[0];
|
||||
const q2Target = this.q2Target.forward(nextInput).output[0];
|
||||
const minQTarget = Math.min(q1Target, q2Target);
|
||||
|
||||
// Entropy term
|
||||
const entropy = logStd.reduce((a, b) => a + b, 0);
|
||||
|
||||
// Target value
|
||||
const targetQ = reward + this.config.training.gamma * (1 - done) * (minQTarget - this.alpha * entropy);
|
||||
|
||||
// Current Q values
|
||||
const currentInput = [...state, ...action];
|
||||
const q1Current = this.q1.forward(currentInput).output[0];
|
||||
const q2Current = this.q2.forward(currentInput).output[0];
|
||||
|
||||
// Q loss (simplified - in practice would compute gradients)
|
||||
const q1Loss = (q1Current - targetQ) ** 2;
|
||||
const q2Loss = (q2Current - targetQ) ** 2;
|
||||
}
|
||||
|
||||
// Soft update target networks
|
||||
const tau = this.config.agents.sac.tau;
|
||||
this.q1Target.softUpdate(this.q1, tau);
|
||||
this.q2Target.softUpdate(this.q2, tau);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A2C Agent
|
||||
* Advantage Actor-Critic - synchronous, top performer for cumulative returns
|
||||
*/
|
||||
class A2CAgent {
|
||||
constructor(stateDim, actionDim, config) {
|
||||
this.config = config;
|
||||
this.stateDim = stateDim;
|
||||
this.actionDim = actionDim;
|
||||
|
||||
// Shared network with actor and critic heads
|
||||
this.network = new NeuralNetwork(stateDim, config.training.hiddenDim, actionDim + 1);
|
||||
|
||||
this.memory = [];
|
||||
this.numSteps = config.agents.a2c.numSteps;
|
||||
}
|
||||
|
||||
getAction(state) {
|
||||
const { output } = this.network.forward(state);
|
||||
|
||||
// Split outputs
|
||||
const actionLogits = output.slice(0, this.actionDim);
|
||||
const value = output[this.actionDim];
|
||||
|
||||
// Softmax for action probabilities
|
||||
const probs = this.network.softmax(actionLogits);
|
||||
|
||||
// Sample action
|
||||
const random = Math.random();
|
||||
let cumsum = 0;
|
||||
let action = this.actionDim - 1;
|
||||
|
||||
for (let i = 0; i < probs.length; i++) {
|
||||
cumsum += probs[i];
|
||||
if (random < cumsum) {
|
||||
action = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { action, probs, value };
|
||||
}
|
||||
|
||||
getValue(state) {
|
||||
const { output } = this.network.forward(state);
|
||||
return output[this.actionDim];
|
||||
}
|
||||
|
||||
store(state, action, reward, nextState, done, value) {
|
||||
this.memory.push({ state, action, reward, nextState, done, value });
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this.memory.length < this.numSteps) return;
|
||||
|
||||
// Calculate returns and advantages
|
||||
const lastValue = this.memory[this.memory.length - 1].done
|
||||
? 0
|
||||
: this.getValue(this.memory[this.memory.length - 1].nextState);
|
||||
|
||||
const returns = [];
|
||||
let R = lastValue;
|
||||
|
||||
for (let i = this.memory.length - 1; i >= 0; i--) {
|
||||
R = this.memory[i].reward + this.config.training.gamma * R * (1 - this.memory[i].done);
|
||||
returns.unshift(R);
|
||||
}
|
||||
|
||||
// Calculate advantages
|
||||
const advantages = this.memory.map((m, i) => returns[i] - m.value);
|
||||
|
||||
// Update (simplified)
|
||||
let actorLoss = 0;
|
||||
let criticLoss = 0;
|
||||
|
||||
for (let i = 0; i < this.memory.length; i++) {
|
||||
const { action, probs } = this.getAction(this.memory[i].state);
|
||||
const advantage = advantages[i];
|
||||
|
||||
// Actor loss
|
||||
actorLoss -= Math.log(probs[this.memory[i].action] + 1e-10) * advantage;
|
||||
|
||||
// Critic loss
|
||||
const value = this.getValue(this.memory[i].state);
|
||||
criticLoss += (returns[i] - value) ** 2;
|
||||
}
|
||||
|
||||
// Entropy bonus
|
||||
const entropy = this.memory.reduce((sum, m) => {
|
||||
const { probs } = this.getAction(m.state);
|
||||
return sum - probs.reduce((s, p) => s + p * Math.log(p + 1e-10), 0);
|
||||
}, 0);
|
||||
|
||||
// Clear memory
|
||||
this.memory = [];
|
||||
|
||||
return { actorLoss, criticLoss, entropy };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Portfolio Environment
|
||||
* Simulates portfolio management with realistic constraints
|
||||
*/
|
||||
class PortfolioEnvironment {
|
||||
constructor(priceData, config) {
|
||||
this.priceData = priceData;
|
||||
this.config = config;
|
||||
this.numAssets = priceData.length;
|
||||
this.numDays = priceData[0].length;
|
||||
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.currentStep = this.config.environment.lookbackWindow;
|
||||
this.portfolio = new Array(this.numAssets).fill(1 / this.numAssets);
|
||||
this.cash = 0;
|
||||
this.portfolioValue = 1.0;
|
||||
this.initialValue = 1.0;
|
||||
this.history = [];
|
||||
this.returns = [];
|
||||
this.peakValue = 1.0;
|
||||
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
getState() {
|
||||
const state = [];
|
||||
|
||||
// Price returns for lookback window
|
||||
for (let a = 0; a < this.numAssets; a++) {
|
||||
for (let t = this.currentStep - 5; t < this.currentStep; t++) {
|
||||
const ret = (this.priceData[a][t] - this.priceData[a][t - 1]) / this.priceData[a][t - 1];
|
||||
state.push(ret);
|
||||
}
|
||||
}
|
||||
|
||||
// Current portfolio weights
|
||||
state.push(...this.portfolio);
|
||||
|
||||
// Portfolio metrics
|
||||
state.push(this.portfolioValue - this.initialValue); // P&L
|
||||
state.push((this.peakValue - this.portfolioValue) / this.peakValue); // Drawdown
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
step(action) {
|
||||
// Action is portfolio weights (already normalized via softmax)
|
||||
const newWeights = Array.isArray(action) ? action : this.indexToWeights(action);
|
||||
|
||||
// Calculate transaction costs
|
||||
const turnover = this.portfolio.reduce((sum, w, i) => sum + Math.abs(w - newWeights[i]), 0);
|
||||
const txCost = turnover * this.config.environment.transactionCost;
|
||||
|
||||
// Update portfolio
|
||||
this.portfolio = newWeights;
|
||||
|
||||
// Calculate returns
|
||||
let portfolioReturn = 0;
|
||||
for (let a = 0; a < this.numAssets; a++) {
|
||||
const assetReturn = (this.priceData[a][this.currentStep] - this.priceData[a][this.currentStep - 1])
|
||||
/ this.priceData[a][this.currentStep - 1];
|
||||
portfolioReturn += this.portfolio[a] * assetReturn;
|
||||
}
|
||||
|
||||
// Apply transaction costs
|
||||
portfolioReturn -= txCost;
|
||||
|
||||
// Update portfolio value
|
||||
this.portfolioValue *= (1 + portfolioReturn);
|
||||
this.peakValue = Math.max(this.peakValue, this.portfolioValue);
|
||||
this.returns.push(portfolioReturn);
|
||||
|
||||
// Calculate reward based on config
|
||||
let reward = this.calculateReward(portfolioReturn);
|
||||
|
||||
// Record history
|
||||
this.history.push({
|
||||
step: this.currentStep,
|
||||
weights: [...this.portfolio],
|
||||
value: this.portfolioValue,
|
||||
return: portfolioReturn,
|
||||
reward
|
||||
});
|
||||
|
||||
// Move to next step
|
||||
this.currentStep++;
|
||||
const done = this.currentStep >= this.numDays - 1;
|
||||
|
||||
// Check drawdown constraint
|
||||
const drawdown = (this.peakValue - this.portfolioValue) / this.peakValue;
|
||||
if (drawdown >= this.config.risk.maxDrawdown) {
|
||||
reward -= 1; // Penalty for exceeding drawdown
|
||||
}
|
||||
|
||||
return {
|
||||
state: done ? null : this.getState(),
|
||||
reward,
|
||||
done,
|
||||
info: {
|
||||
portfolioValue: this.portfolioValue,
|
||||
drawdown,
|
||||
turnover
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
indexToWeights(actionIndex) {
|
||||
// Convert discrete action to portfolio weights
|
||||
// For simplicity, predefined allocation strategies
|
||||
const strategies = [
|
||||
new Array(this.numAssets).fill(1 / this.numAssets), // Equal weight
|
||||
[0.5, ...new Array(this.numAssets - 1).fill(0.5 / (this.numAssets - 1))], // Concentrated
|
||||
[0.3, 0.3, ...new Array(this.numAssets - 2).fill(0.4 / (this.numAssets - 2))] // Balanced
|
||||
];
|
||||
|
||||
return strategies[actionIndex % strategies.length];
|
||||
}
|
||||
|
||||
calculateReward(portfolioReturn) {
|
||||
switch (this.config.risk.rewardType) {
|
||||
case 'sharpe':
|
||||
if (this.returns.length < 10) return portfolioReturn;
|
||||
const mean = this.returns.reduce((a, b) => a + b, 0) / this.returns.length;
|
||||
const std = Math.sqrt(this.returns.reduce((a, b) => a + (b - mean) ** 2, 0) / this.returns.length) || 1;
|
||||
return mean / std * Math.sqrt(252);
|
||||
|
||||
case 'sortino':
|
||||
if (this.returns.length < 10) return portfolioReturn;
|
||||
const meanRet = this.returns.reduce((a, b) => a + b, 0) / this.returns.length;
|
||||
const downside = this.returns.filter(r => r < 0);
|
||||
const downsideStd = downside.length > 0
|
||||
? Math.sqrt(downside.reduce((a, b) => a + b ** 2, 0) / downside.length)
|
||||
: 1;
|
||||
return meanRet / downsideStd * Math.sqrt(252);
|
||||
|
||||
case 'drawdown':
|
||||
const dd = (this.peakValue - this.portfolioValue) / this.peakValue;
|
||||
return portfolioReturn - 0.1 * dd;
|
||||
|
||||
default:
|
||||
return portfolioReturn;
|
||||
}
|
||||
}
|
||||
|
||||
getStats() {
|
||||
const totalReturn = (this.portfolioValue - this.initialValue) / this.initialValue;
|
||||
const annualizedReturn = totalReturn * 252 / this.returns.length;
|
||||
|
||||
const mean = this.returns.reduce((a, b) => a + b, 0) / this.returns.length;
|
||||
const std = Math.sqrt(this.returns.reduce((a, b) => a + (b - mean) ** 2, 0) / this.returns.length) || 1;
|
||||
const sharpe = mean / std * Math.sqrt(252);
|
||||
|
||||
const maxDrawdown = this.history.reduce((max, h) => {
|
||||
const dd = (this.peakValue - h.value) / this.peakValue;
|
||||
return Math.max(max, dd);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
totalReturn: totalReturn * 100,
|
||||
annualizedReturn: annualizedReturn * 100,
|
||||
sharpe,
|
||||
maxDrawdown: maxDrawdown * 100,
|
||||
numTrades: this.history.length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensemble Portfolio Manager
|
||||
* Combines multiple DRL agents for robust portfolio management
|
||||
*/
|
||||
class EnsemblePortfolioManager {
|
||||
constructor(config = portfolioConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
initialize(stateDim, actionDim) {
|
||||
this.agents = {};
|
||||
|
||||
if (this.config.agents.ppo.enabled) {
|
||||
this.agents.ppo = new PPOAgent(stateDim, actionDim, this.config);
|
||||
}
|
||||
|
||||
if (this.config.agents.sac.enabled) {
|
||||
this.agents.sac = new SACAgent(stateDim, actionDim, this.config);
|
||||
}
|
||||
|
||||
if (this.config.agents.a2c.enabled) {
|
||||
this.agents.a2c = new A2CAgent(stateDim, actionDim, this.config);
|
||||
}
|
||||
}
|
||||
|
||||
getEnsembleAction(state) {
|
||||
const actions = {};
|
||||
const weights = this.config.ensemble.weights;
|
||||
|
||||
// Get action from each agent
|
||||
for (const [name, agent] of Object.entries(this.agents)) {
|
||||
if (agent.getAction) {
|
||||
const result = agent.getAction(state);
|
||||
actions[name] = Array.isArray(result.action)
|
||||
? result.action
|
||||
: this.indexToWeights(result.action);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensemble combination
|
||||
const numAssets = Object.values(actions)[0].length;
|
||||
const ensembleAction = new Array(numAssets).fill(0);
|
||||
|
||||
for (const [name, action] of Object.entries(actions)) {
|
||||
const weight = weights[name] || 1 / Object.keys(actions).length;
|
||||
for (let i = 0; i < numAssets; i++) {
|
||||
ensembleAction[i] += weight * action[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize
|
||||
const sum = ensembleAction.reduce((a, b) => a + b, 0);
|
||||
return ensembleAction.map(w => w / sum);
|
||||
}
|
||||
|
||||
indexToWeights(actionIndex) {
|
||||
const numAssets = this.config.environment.numAssets;
|
||||
return new Array(numAssets).fill(1 / numAssets);
|
||||
}
|
||||
|
||||
train(priceData, numEpisodes = 100) {
|
||||
const env = new PortfolioEnvironment(priceData, this.config);
|
||||
const stateDim = env.getState().length;
|
||||
const actionDim = priceData.length;
|
||||
|
||||
this.initialize(stateDim, actionDim);
|
||||
|
||||
const episodeReturns = [];
|
||||
|
||||
for (let episode = 0; episode < numEpisodes; episode++) {
|
||||
let state = env.reset();
|
||||
let episodeReward = 0;
|
||||
|
||||
while (state) {
|
||||
// Get ensemble action
|
||||
const action = this.getEnsembleAction(state);
|
||||
|
||||
// Step environment
|
||||
const { state: nextState, reward, done, info } = env.step(action);
|
||||
|
||||
// Store experience in each agent
|
||||
for (const agent of Object.values(this.agents)) {
|
||||
if (agent.store) {
|
||||
if (agent instanceof PPOAgent) {
|
||||
agent.store(state, action, reward, nextState, done, 0);
|
||||
} else if (agent instanceof SACAgent) {
|
||||
agent.store(state, action, reward, nextState, done ? 1 : 0);
|
||||
} else if (agent instanceof A2CAgent) {
|
||||
agent.store(state, action, reward, nextState, done ? 1 : 0, agent.getValue(state));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
episodeReward += reward;
|
||||
state = nextState;
|
||||
}
|
||||
|
||||
// Update agents
|
||||
for (const agent of Object.values(this.agents)) {
|
||||
if (agent.update) {
|
||||
agent.update();
|
||||
}
|
||||
}
|
||||
|
||||
episodeReturns.push(env.getStats().totalReturn);
|
||||
|
||||
if ((episode + 1) % 20 === 0) {
|
||||
const avgReturn = episodeReturns.slice(-20).reduce((a, b) => a + b, 0) / 20;
|
||||
console.log(` Episode ${episode + 1}/${numEpisodes}, Avg Return: ${avgReturn.toFixed(2)}%`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
finalStats: env.getStats(),
|
||||
episodeReturns
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate synthetic price data
|
||||
*/
|
||||
function generatePriceData(numAssets, numDays, seed = 42) {
|
||||
let rng = seed;
|
||||
const random = () => { rng = (rng * 9301 + 49297) % 233280; return rng / 233280; };
|
||||
|
||||
const prices = [];
|
||||
|
||||
for (let a = 0; a < numAssets; a++) {
|
||||
const assetPrices = [100];
|
||||
const drift = (random() - 0.5) * 0.0005;
|
||||
const volatility = 0.01 + random() * 0.02;
|
||||
|
||||
for (let d = 1; d < numDays; d++) {
|
||||
const returns = drift + volatility * (random() + random() - 1);
|
||||
assetPrices.push(assetPrices[d - 1] * (1 + returns));
|
||||
}
|
||||
|
||||
prices.push(assetPrices);
|
||||
}
|
||||
|
||||
return prices;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('═'.repeat(70));
|
||||
console.log('DEEP REINFORCEMENT LEARNING PORTFOLIO MANAGER');
|
||||
console.log('═'.repeat(70));
|
||||
console.log();
|
||||
|
||||
// 1. Generate price data
|
||||
console.log('1. Data Generation:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const priceData = generatePriceData(10, 500);
|
||||
console.log(` Assets: ${priceData.length}`);
|
||||
console.log(` Days: ${priceData[0].length}`);
|
||||
console.log();
|
||||
|
||||
// 2. Environment setup
|
||||
console.log('2. Environment Setup:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const env = new PortfolioEnvironment(priceData, portfolioConfig);
|
||||
const initialState = env.getState();
|
||||
|
||||
console.log(` State dimension: ${initialState.length}`);
|
||||
console.log(` Action dimension: ${priceData.length}`);
|
||||
console.log(` Lookback window: ${portfolioConfig.environment.lookbackWindow}`);
|
||||
console.log(` Transaction cost: ${(portfolioConfig.environment.transactionCost * 100).toFixed(2)}%`);
|
||||
console.log();
|
||||
|
||||
// 3. Agent configurations
|
||||
console.log('3. Agent Configurations:');
|
||||
console.log('─'.repeat(70));
|
||||
console.log(' PPO: clip_ε=0.2, entropy=0.01, stable training');
|
||||
console.log(' SAC: α=0.2, τ=0.005, entropy regularization');
|
||||
console.log(' A2C: n_steps=5, synchronous updates');
|
||||
console.log(` Ensemble: weighted average (PPO:35%, SAC:35%, A2C:30%)`);
|
||||
console.log();
|
||||
|
||||
// 4. Training simulation
|
||||
console.log('4. Training Simulation (50 episodes):');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const manager = new EnsemblePortfolioManager(portfolioConfig);
|
||||
const trainingResult = manager.train(priceData, 50);
|
||||
|
||||
console.log();
|
||||
console.log(' Training completed');
|
||||
console.log();
|
||||
|
||||
// 5. Final statistics
|
||||
console.log('5. Final Portfolio Statistics:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const stats = trainingResult.finalStats;
|
||||
console.log(` Total Return: ${stats.totalReturn.toFixed(2)}%`);
|
||||
console.log(` Annualized Return: ${stats.annualizedReturn.toFixed(2)}%`);
|
||||
console.log(` Sharpe Ratio: ${stats.sharpe.toFixed(2)}`);
|
||||
console.log(` Max Drawdown: ${stats.maxDrawdown.toFixed(2)}%`);
|
||||
console.log(` Num Trades: ${stats.numTrades}`);
|
||||
console.log();
|
||||
|
||||
// 6. Benchmark comparison
|
||||
console.log('6. Benchmark Comparison:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
// Equal weight benchmark
|
||||
const equalWeightReturn = priceData.reduce((sum, asset) => {
|
||||
return sum + (asset[asset.length - 1] / asset[30] - 1) / priceData.length;
|
||||
}, 0) * 100;
|
||||
|
||||
console.log(` DRL Portfolio: ${stats.totalReturn.toFixed(2)}%`);
|
||||
console.log(` Equal Weight: ${equalWeightReturn.toFixed(2)}%`);
|
||||
console.log(` Outperformance: ${(stats.totalReturn - equalWeightReturn).toFixed(2)}%`);
|
||||
console.log();
|
||||
|
||||
// 7. Episode returns
|
||||
console.log('7. Learning Progress (Last 10 Episodes):');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const lastReturns = trainingResult.episodeReturns.slice(-10);
|
||||
console.log(' Episode │ Return');
|
||||
console.log('─'.repeat(70));
|
||||
lastReturns.forEach((ret, i) => {
|
||||
const episode = trainingResult.episodeReturns.length - 10 + i + 1;
|
||||
console.log(` ${episode.toString().padStart(7)} │ ${ret.toFixed(2).padStart(8)}%`);
|
||||
});
|
||||
console.log();
|
||||
|
||||
console.log('═'.repeat(70));
|
||||
console.log('DRL Portfolio Manager demonstration completed');
|
||||
console.log('═'.repeat(70));
|
||||
}
|
||||
|
||||
export {
|
||||
EnsemblePortfolioManager,
|
||||
PPOAgent,
|
||||
SACAgent,
|
||||
A2CAgent,
|
||||
PortfolioEnvironment,
|
||||
ReplayBuffer,
|
||||
NeuralNetwork,
|
||||
portfolioConfig
|
||||
};
|
||||
|
||||
main().catch(console.error);
|
||||
645
vendor/ruvector/examples/neural-trader/production/fractional-kelly.js
vendored
Normal file
645
vendor/ruvector/examples/neural-trader/production/fractional-kelly.js
vendored
Normal file
@@ -0,0 +1,645 @@
|
||||
/**
|
||||
* Fractional Kelly Criterion Engine
|
||||
*
|
||||
* PRODUCTION: Foundation for optimal bet sizing in trading and sports betting
|
||||
*
|
||||
* Research-backed implementation:
|
||||
* - Full Kelly leads to ruin in practice (Dotan, 2024)
|
||||
* - 1/5th Kelly achieved 98% ROI in NBA betting simulations
|
||||
* - 1/8th Kelly recommended for conservative strategies
|
||||
*
|
||||
* Features:
|
||||
* - Multiple Kelly fractions (1/2, 1/4, 1/5, 1/8)
|
||||
* - Calibration-aware adjustments
|
||||
* - Multi-bet portfolio optimization
|
||||
* - Risk-of-ruin calculations
|
||||
* - Drawdown protection
|
||||
*/
|
||||
|
||||
// Kelly Configuration
|
||||
const kellyConfig = {
|
||||
// Fraction strategies
|
||||
fractions: {
|
||||
aggressive: 0.5, // Half Kelly
|
||||
moderate: 0.25, // Quarter Kelly
|
||||
conservative: 0.2, // Fifth Kelly (recommended)
|
||||
ultraSafe: 0.125 // Eighth Kelly
|
||||
},
|
||||
|
||||
// Risk management
|
||||
risk: {
|
||||
maxBetFraction: 0.05, // Never bet more than 5% of bankroll
|
||||
minEdge: 0.01, // Minimum 1% edge required
|
||||
maxDrawdown: 0.25, // Stop at 25% drawdown
|
||||
confidenceThreshold: 0.6 // Minimum model confidence
|
||||
},
|
||||
|
||||
// Bankroll management
|
||||
bankroll: {
|
||||
initial: 10000,
|
||||
reserveRatio: 0.1, // Keep 10% as reserve
|
||||
rebalanceThreshold: 0.2 // Rebalance when 20% deviation
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Kelly Criterion Calculator
|
||||
* Optimal bet sizing for positive expected value bets
|
||||
*/
|
||||
class KellyCriterion {
|
||||
constructor(config = kellyConfig) {
|
||||
this.config = config;
|
||||
this.bankroll = config.bankroll.initial;
|
||||
this.peakBankroll = this.bankroll;
|
||||
this.history = [];
|
||||
this.stats = {
|
||||
totalBets: 0,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
totalWagered: 0,
|
||||
totalProfit: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate full Kelly fraction
|
||||
* f* = (bp - q) / b
|
||||
* where b = decimal odds - 1, p = win probability, q = 1 - p
|
||||
*/
|
||||
calculateFullKelly(winProbability, decimalOdds) {
|
||||
const b = decimalOdds - 1; // Net odds
|
||||
const p = winProbability;
|
||||
const q = 1 - p;
|
||||
|
||||
const kelly = (b * p - q) / b;
|
||||
return Math.max(0, kelly); // Never negative
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate fractional Kelly with safety bounds
|
||||
*/
|
||||
calculateFractionalKelly(winProbability, decimalOdds, fraction = 'conservative') {
|
||||
const fullKelly = this.calculateFullKelly(winProbability, decimalOdds);
|
||||
|
||||
if (fullKelly <= 0) {
|
||||
return { stake: 0, edge: 0, fullKelly: 0, reason: 'negative_ev' };
|
||||
}
|
||||
|
||||
const fractionValue = typeof fraction === 'number'
|
||||
? fraction
|
||||
: this.config.fractions[fraction] || 0.2;
|
||||
|
||||
let adjustedKelly = fullKelly * fractionValue;
|
||||
|
||||
// Apply maximum bet constraint
|
||||
adjustedKelly = Math.min(adjustedKelly, this.config.risk.maxBetFraction);
|
||||
|
||||
// Calculate edge
|
||||
const edge = (winProbability * decimalOdds) - 1;
|
||||
|
||||
// Check minimum edge requirement
|
||||
if (edge < this.config.risk.minEdge) {
|
||||
return { stake: 0, edge, fullKelly, reason: 'insufficient_edge' };
|
||||
}
|
||||
|
||||
// Calculate actual stake
|
||||
const availableBankroll = this.bankroll * (1 - this.config.bankroll.reserveRatio);
|
||||
const stake = availableBankroll * adjustedKelly;
|
||||
|
||||
return {
|
||||
stake: Math.round(stake * 100) / 100,
|
||||
stakePercent: adjustedKelly * 100,
|
||||
fullKelly: fullKelly * 100,
|
||||
fractionalKelly: adjustedKelly * 100,
|
||||
edge: edge * 100,
|
||||
expectedValue: stake * edge,
|
||||
fraction: fractionValue,
|
||||
reason: 'approved'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Kelly for calibrated probability models
|
||||
* Adjusts for model confidence/calibration quality
|
||||
*/
|
||||
calculateCalibratedKelly(predictedProb, calibrationScore, decimalOdds, fraction = 'conservative') {
|
||||
// Shrink probability toward 0.5 based on calibration quality
|
||||
// Perfect calibration (1.0) = use predicted prob
|
||||
// Poor calibration (0.5) = shrink significantly toward 0.5
|
||||
const shrinkage = 1 - calibrationScore;
|
||||
const adjustedProb = predictedProb * (1 - shrinkage * 0.5) + 0.5 * shrinkage * 0.5;
|
||||
|
||||
// Only bet if confidence exceeds threshold
|
||||
if (calibrationScore < this.config.risk.confidenceThreshold) {
|
||||
return {
|
||||
stake: 0,
|
||||
reason: 'low_calibration',
|
||||
calibrationScore,
|
||||
adjustedProb
|
||||
};
|
||||
}
|
||||
|
||||
const result = this.calculateFractionalKelly(adjustedProb, decimalOdds, fraction);
|
||||
return {
|
||||
...result,
|
||||
originalProb: predictedProb,
|
||||
adjustedProb,
|
||||
calibrationScore
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-bet Kelly (simultaneous independent bets)
|
||||
* Reduces individual stakes to account for correlation risk
|
||||
*/
|
||||
calculateMultiBetKelly(bets, fraction = 'conservative') {
|
||||
if (bets.length === 0) return [];
|
||||
|
||||
// Calculate individual Kelly for each bet
|
||||
const individualBets = bets.map(bet => ({
|
||||
...bet,
|
||||
kelly: this.calculateFractionalKelly(bet.winProbability, bet.decimalOdds, fraction)
|
||||
}));
|
||||
|
||||
// Filter to positive EV bets only
|
||||
const positiveBets = individualBets.filter(b => b.kelly.stake > 0);
|
||||
|
||||
if (positiveBets.length === 0) return individualBets;
|
||||
|
||||
// Apply correlation adjustment (reduce stakes when many bets)
|
||||
// Use sqrt(n) scaling to account for diversification
|
||||
const correlationFactor = 1 / Math.sqrt(positiveBets.length);
|
||||
|
||||
// Total stake shouldn't exceed max bet fraction
|
||||
const totalKelly = positiveBets.reduce((sum, b) => sum + b.kelly.fractionalKelly / 100, 0);
|
||||
const scaleFactor = totalKelly > this.config.risk.maxBetFraction
|
||||
? this.config.risk.maxBetFraction / totalKelly
|
||||
: 1;
|
||||
|
||||
return individualBets.map(bet => {
|
||||
if (bet.kelly.stake === 0) return bet;
|
||||
|
||||
const adjustedStake = bet.kelly.stake * correlationFactor * scaleFactor;
|
||||
return {
|
||||
...bet,
|
||||
kelly: {
|
||||
...bet.kelly,
|
||||
originalStake: bet.kelly.stake,
|
||||
stake: Math.round(adjustedStake * 100) / 100,
|
||||
correlationAdjustment: correlationFactor,
|
||||
portfolioScaling: scaleFactor
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate risk of ruin given betting strategy
|
||||
*/
|
||||
calculateRiskOfRuin(winProbability, decimalOdds, betFraction, targetMultiple = 2) {
|
||||
const p = winProbability;
|
||||
const q = 1 - p;
|
||||
const b = decimalOdds - 1;
|
||||
|
||||
// Simplified risk of ruin formula
|
||||
// R = (q/p)^(bankroll/unit)
|
||||
if (p <= q / b) {
|
||||
return 1; // Negative EV = certain ruin
|
||||
}
|
||||
|
||||
const edge = b * p - q;
|
||||
const variance = p * q * (b + 1) ** 2;
|
||||
const sharpe = edge / Math.sqrt(variance);
|
||||
|
||||
// Approximate risk of ruin using normal approximation
|
||||
const unitsToTarget = Math.log(targetMultiple) / Math.log(1 + betFraction * edge);
|
||||
const riskOfRuin = Math.exp(-2 * edge * unitsToTarget / variance);
|
||||
|
||||
return Math.min(1, Math.max(0, riskOfRuin));
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a bet and update bankroll
|
||||
*/
|
||||
placeBet(stake, decimalOdds, won) {
|
||||
if (stake > this.bankroll) {
|
||||
throw new Error('Insufficient bankroll');
|
||||
}
|
||||
|
||||
const profit = won ? stake * (decimalOdds - 1) : -stake;
|
||||
this.bankroll += profit;
|
||||
this.peakBankroll = Math.max(this.peakBankroll, this.bankroll);
|
||||
|
||||
this.stats.totalBets++;
|
||||
this.stats.totalWagered += stake;
|
||||
this.stats.totalProfit += profit;
|
||||
if (won) this.stats.wins++;
|
||||
else this.stats.losses++;
|
||||
|
||||
this.history.push({
|
||||
timestamp: Date.now(),
|
||||
stake,
|
||||
decimalOdds,
|
||||
won,
|
||||
profit,
|
||||
bankroll: this.bankroll
|
||||
});
|
||||
|
||||
// Check drawdown protection
|
||||
const drawdown = (this.peakBankroll - this.bankroll) / this.peakBankroll;
|
||||
if (drawdown >= this.config.risk.maxDrawdown) {
|
||||
return {
|
||||
...this.getStats(),
|
||||
warning: 'max_drawdown_reached',
|
||||
drawdown: drawdown * 100
|
||||
};
|
||||
}
|
||||
|
||||
return this.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current statistics
|
||||
*/
|
||||
getStats() {
|
||||
const drawdown = (this.peakBankroll - this.bankroll) / this.peakBankroll;
|
||||
const roi = this.stats.totalWagered > 0
|
||||
? (this.stats.totalProfit / this.stats.totalWagered) * 100
|
||||
: 0;
|
||||
const winRate = this.stats.totalBets > 0
|
||||
? (this.stats.wins / this.stats.totalBets) * 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
bankroll: Math.round(this.bankroll * 100) / 100,
|
||||
peakBankroll: Math.round(this.peakBankroll * 100) / 100,
|
||||
drawdown: Math.round(drawdown * 10000) / 100,
|
||||
totalBets: this.stats.totalBets,
|
||||
wins: this.stats.wins,
|
||||
losses: this.stats.losses,
|
||||
winRate: Math.round(winRate * 100) / 100,
|
||||
totalWagered: Math.round(this.stats.totalWagered * 100) / 100,
|
||||
totalProfit: Math.round(this.stats.totalProfit * 100) / 100,
|
||||
roi: Math.round(roi * 100) / 100
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate betting strategy
|
||||
*/
|
||||
simulate(bets, fraction = 'conservative') {
|
||||
const results = [];
|
||||
|
||||
for (const bet of bets) {
|
||||
const kelly = this.calculateFractionalKelly(bet.winProbability, bet.decimalOdds, fraction);
|
||||
|
||||
if (kelly.stake > 0) {
|
||||
const outcome = this.placeBet(kelly.stake, bet.decimalOdds, bet.actualWin);
|
||||
results.push({
|
||||
bet,
|
||||
kelly,
|
||||
outcome,
|
||||
bankroll: this.bankroll
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
finalStats: this.getStats(),
|
||||
betResults: results
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset bankroll to initial state
|
||||
*/
|
||||
reset() {
|
||||
this.bankroll = this.config.bankroll.initial;
|
||||
this.peakBankroll = this.bankroll;
|
||||
this.history = [];
|
||||
this.stats = {
|
||||
totalBets: 0,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
totalWagered: 0,
|
||||
totalProfit: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sports Betting Kelly Extension
|
||||
* Specialized for sports betting markets
|
||||
*/
|
||||
class SportsBettingKelly extends KellyCriterion {
|
||||
constructor(config = kellyConfig) {
|
||||
super(config);
|
||||
this.marketEfficiency = 0.95; // Assume 95% efficient markets
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert American odds to decimal
|
||||
*/
|
||||
americanToDecimal(americanOdds) {
|
||||
if (americanOdds > 0) {
|
||||
return (americanOdds / 100) + 1;
|
||||
} else {
|
||||
return (100 / Math.abs(americanOdds)) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate implied probability from odds
|
||||
*/
|
||||
impliedProbability(decimalOdds) {
|
||||
return 1 / decimalOdds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate edge over market
|
||||
*/
|
||||
calculateEdge(modelProbability, decimalOdds) {
|
||||
const impliedProb = this.impliedProbability(decimalOdds);
|
||||
return modelProbability - impliedProb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find value bets from model predictions vs market odds
|
||||
*/
|
||||
findValueBets(predictions, marketOdds, minEdge = 0.02) {
|
||||
const valueBets = [];
|
||||
|
||||
for (let i = 0; i < predictions.length; i++) {
|
||||
const pred = predictions[i];
|
||||
const odds = marketOdds[i];
|
||||
|
||||
// Check home team value
|
||||
const homeEdge = this.calculateEdge(pred.homeWinProb, odds.homeDecimal);
|
||||
if (homeEdge >= minEdge) {
|
||||
valueBets.push({
|
||||
matchId: pred.matchId,
|
||||
selection: 'home',
|
||||
modelProbability: pred.homeWinProb,
|
||||
decimalOdds: odds.homeDecimal,
|
||||
edge: homeEdge,
|
||||
kelly: this.calculateFractionalKelly(pred.homeWinProb, odds.homeDecimal)
|
||||
});
|
||||
}
|
||||
|
||||
// Check away team value
|
||||
const awayEdge = this.calculateEdge(pred.awayWinProb, odds.awayDecimal);
|
||||
if (awayEdge >= minEdge) {
|
||||
valueBets.push({
|
||||
matchId: pred.matchId,
|
||||
selection: 'away',
|
||||
modelProbability: pred.awayWinProb,
|
||||
decimalOdds: odds.awayDecimal,
|
||||
edge: awayEdge,
|
||||
kelly: this.calculateFractionalKelly(pred.awayWinProb, odds.awayDecimal)
|
||||
});
|
||||
}
|
||||
|
||||
// Check draw if applicable
|
||||
if (pred.drawProb && odds.drawDecimal) {
|
||||
const drawEdge = this.calculateEdge(pred.drawProb, odds.drawDecimal);
|
||||
if (drawEdge >= minEdge) {
|
||||
valueBets.push({
|
||||
matchId: pred.matchId,
|
||||
selection: 'draw',
|
||||
modelProbability: pred.drawProb,
|
||||
decimalOdds: odds.drawDecimal,
|
||||
edge: drawEdge,
|
||||
kelly: this.calculateFractionalKelly(pred.drawProb, odds.drawDecimal)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return valueBets.sort((a, b) => b.edge - a.edge);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trading Kelly Extension
|
||||
* Specialized for financial market position sizing
|
||||
*/
|
||||
class TradingKelly extends KellyCriterion {
|
||||
constructor(config = kellyConfig) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate position size for a trade
|
||||
* Uses expected return and win rate from historical analysis
|
||||
*/
|
||||
calculatePositionSize(winRate, avgWin, avgLoss, accountSize = null) {
|
||||
const bankroll = accountSize || this.bankroll;
|
||||
|
||||
// Convert to Kelly inputs
|
||||
// For trading: b = avgWin/avgLoss (reward/risk ratio)
|
||||
const b = avgWin / Math.abs(avgLoss);
|
||||
const p = winRate;
|
||||
const q = 1 - p;
|
||||
|
||||
const fullKelly = (b * p - q) / b;
|
||||
|
||||
if (fullKelly <= 0) {
|
||||
return {
|
||||
positionSize: 0,
|
||||
reason: 'negative_expectancy',
|
||||
expectancy: (winRate * avgWin) + ((1 - winRate) * avgLoss)
|
||||
};
|
||||
}
|
||||
|
||||
const fractionValue = this.config.fractions.conservative;
|
||||
let adjustedKelly = fullKelly * fractionValue;
|
||||
adjustedKelly = Math.min(adjustedKelly, this.config.risk.maxBetFraction);
|
||||
|
||||
const positionSize = bankroll * adjustedKelly;
|
||||
const expectancy = (winRate * avgWin) + ((1 - winRate) * avgLoss);
|
||||
|
||||
return {
|
||||
positionSize: Math.round(positionSize * 100) / 100,
|
||||
positionPercent: adjustedKelly * 100,
|
||||
fullKelly: fullKelly * 100,
|
||||
rewardRiskRatio: b,
|
||||
winRate: winRate * 100,
|
||||
expectancy,
|
||||
expectancyPercent: expectancy * 100
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate optimal leverage using Kelly
|
||||
*/
|
||||
calculateOptimalLeverage(expectedReturn, volatility, riskFreeRate = 0.05) {
|
||||
// Kelly for continuous returns: f* = (μ - r) / σ²
|
||||
const excessReturn = expectedReturn - riskFreeRate;
|
||||
const kelly = excessReturn / (volatility * volatility);
|
||||
|
||||
// Apply fraction and caps
|
||||
const fractionValue = this.config.fractions.conservative;
|
||||
let adjustedLeverage = kelly * fractionValue;
|
||||
|
||||
// Cap leverage at reasonable levels
|
||||
const maxLeverage = 3.0;
|
||||
adjustedLeverage = Math.min(adjustedLeverage, maxLeverage);
|
||||
adjustedLeverage = Math.max(adjustedLeverage, 0);
|
||||
|
||||
return {
|
||||
optimalLeverage: Math.round(adjustedLeverage * 100) / 100,
|
||||
fullKellyLeverage: Math.round(kelly * 100) / 100,
|
||||
sharpeRatio: excessReturn / volatility,
|
||||
expectedReturn: expectedReturn * 100,
|
||||
volatility: volatility * 100
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Demo and test
|
||||
async function main() {
|
||||
console.log('═'.repeat(70));
|
||||
console.log('FRACTIONAL KELLY CRITERION ENGINE');
|
||||
console.log('═'.repeat(70));
|
||||
console.log();
|
||||
|
||||
// 1. Basic Kelly calculations
|
||||
console.log('1. Basic Kelly Calculations:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const kelly = new KellyCriterion();
|
||||
|
||||
// Example: 55% win probability, 2.0 decimal odds (even money)
|
||||
const basic = kelly.calculateFractionalKelly(0.55, 2.0);
|
||||
console.log(' Win Prob: 55%, Odds: 2.0 (even money)');
|
||||
console.log(` Full Kelly: ${basic.fullKelly.toFixed(2)}%`);
|
||||
console.log(` 1/5th Kelly: ${basic.fractionalKelly.toFixed(2)}%`);
|
||||
console.log(` Recommended Stake: $${basic.stake.toFixed(2)}`);
|
||||
console.log(` Edge: ${basic.edge.toFixed(2)}%`);
|
||||
console.log();
|
||||
|
||||
// 2. Calibrated Kelly (for ML models)
|
||||
console.log('2. Calibrated Kelly (ML Model Adjustment):');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const calibrated = kelly.calculateCalibratedKelly(0.60, 0.85, 2.0);
|
||||
console.log(' Model Prediction: 60%, Calibration Score: 0.85');
|
||||
console.log(` Adjusted Prob: ${(calibrated.adjustedProb * 100).toFixed(2)}%`);
|
||||
console.log(` Recommended Stake: $${calibrated.stake.toFixed(2)}`);
|
||||
console.log();
|
||||
|
||||
// 3. Multi-bet portfolio
|
||||
console.log('3. Multi-Bet Portfolio:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const multiBets = kelly.calculateMultiBetKelly([
|
||||
{ id: 1, winProbability: 0.55, decimalOdds: 2.0 },
|
||||
{ id: 2, winProbability: 0.52, decimalOdds: 2.1 },
|
||||
{ id: 3, winProbability: 0.58, decimalOdds: 1.9 },
|
||||
{ id: 4, winProbability: 0.51, decimalOdds: 2.2 }
|
||||
]);
|
||||
|
||||
console.log(' Bet │ Win Prob │ Odds │ Individual │ Portfolio │ Final Stake');
|
||||
console.log('─'.repeat(70));
|
||||
for (const bet of multiBets) {
|
||||
if (bet.kelly.stake > 0) {
|
||||
console.log(` ${bet.id} │ ${(bet.winProbability * 100).toFixed(0)}% │ ${bet.decimalOdds.toFixed(1)} │ $${bet.kelly.originalStake?.toFixed(2) || bet.kelly.stake.toFixed(2)} │ ${(bet.kelly.correlationAdjustment * 100 || 100).toFixed(0)}% │ $${bet.kelly.stake.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
|
||||
// 4. Risk of ruin analysis
|
||||
console.log('4. Risk of Ruin Analysis:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const strategies = [
|
||||
{ name: 'Full Kelly', fraction: 1.0 },
|
||||
{ name: 'Half Kelly', fraction: 0.5 },
|
||||
{ name: '1/5th Kelly', fraction: 0.2 },
|
||||
{ name: '1/8th Kelly', fraction: 0.125 }
|
||||
];
|
||||
|
||||
console.log(' Strategy │ Bet Size │ Risk of Ruin (2x target)');
|
||||
console.log('─'.repeat(70));
|
||||
for (const strat of strategies) {
|
||||
const fullKelly = kelly.calculateFullKelly(0.55, 2.0);
|
||||
const betFraction = fullKelly * strat.fraction;
|
||||
const ror = kelly.calculateRiskOfRuin(0.55, 2.0, betFraction, 2);
|
||||
console.log(` ${strat.name.padEnd(12)} │ ${(betFraction * 100).toFixed(2)}% │ ${(ror * 100).toFixed(2)}%`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// 5. Sports betting simulation
|
||||
console.log('5. Sports Betting Simulation (100 bets):');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const sportsKelly = new SportsBettingKelly();
|
||||
|
||||
// Generate simulated bets with 55% edge
|
||||
const simulatedBets = [];
|
||||
let rng = 42;
|
||||
const random = () => { rng = (rng * 9301 + 49297) % 233280; return rng / 233280; };
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const trueProb = 0.50 + random() * 0.15; // 50-65% true probability
|
||||
const odds = 1.8 + random() * 0.4; // 1.8-2.2 odds
|
||||
const actualWin = random() < trueProb;
|
||||
|
||||
simulatedBets.push({
|
||||
winProbability: trueProb,
|
||||
decimalOdds: odds,
|
||||
actualWin
|
||||
});
|
||||
}
|
||||
|
||||
// Run simulations with different Kelly fractions
|
||||
const fractions = ['aggressive', 'moderate', 'conservative', 'ultraSafe'];
|
||||
console.log(' Fraction │ Final Bankroll │ ROI │ Max Drawdown');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
for (const frac of fractions) {
|
||||
sportsKelly.reset();
|
||||
sportsKelly.simulate(simulatedBets, frac);
|
||||
const stats = sportsKelly.getStats();
|
||||
console.log(` ${frac.padEnd(12)} │ $${stats.bankroll.toFixed(2).padStart(12)} │ ${stats.roi.toFixed(1).padStart(6)}% │ ${stats.drawdown.toFixed(1)}%`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// 6. Trading position sizing
|
||||
console.log('6. Trading Position Sizing:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const tradingKelly = new TradingKelly();
|
||||
|
||||
const position = tradingKelly.calculatePositionSize(0.55, 0.02, -0.015, 100000);
|
||||
console.log(' Win Rate: 55%, Avg Win: 2%, Avg Loss: -1.5%');
|
||||
console.log(` Reward/Risk Ratio: ${position.rewardRiskRatio.toFixed(2)}`);
|
||||
console.log(` Position Size: $${position.positionSize.toFixed(2)} (${position.positionPercent.toFixed(2)}%)`);
|
||||
console.log(` Expectancy: ${position.expectancyPercent.toFixed(2)}% per trade`);
|
||||
console.log();
|
||||
|
||||
// 7. Optimal leverage
|
||||
console.log('7. Optimal Leverage Calculation:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const leverage = tradingKelly.calculateOptimalLeverage(0.12, 0.18, 0.05);
|
||||
console.log(' Expected Return: 12%, Volatility: 18%, Risk-Free: 5%');
|
||||
console.log(` Sharpe Ratio: ${leverage.sharpeRatio.toFixed(2)}`);
|
||||
console.log(` Full Kelly Leverage: ${leverage.fullKellyLeverage.toFixed(2)}x`);
|
||||
console.log(` Recommended (1/5): ${leverage.optimalLeverage.toFixed(2)}x`);
|
||||
console.log();
|
||||
|
||||
console.log('═'.repeat(70));
|
||||
console.log('Fractional Kelly engine demonstration completed');
|
||||
console.log('═'.repeat(70));
|
||||
}
|
||||
|
||||
// Export for use as module
|
||||
export {
|
||||
KellyCriterion,
|
||||
SportsBettingKelly,
|
||||
TradingKelly,
|
||||
kellyConfig
|
||||
};
|
||||
|
||||
main().catch(console.error);
|
||||
912
vendor/ruvector/examples/neural-trader/production/hybrid-lstm-transformer.js
vendored
Normal file
912
vendor/ruvector/examples/neural-trader/production/hybrid-lstm-transformer.js
vendored
Normal file
@@ -0,0 +1,912 @@
|
||||
/**
|
||||
* Hybrid LSTM-Transformer Stock Predictor
|
||||
*
|
||||
* PRODUCTION: State-of-the-art architecture combining:
|
||||
* - LSTM for temporal dependencies (87-93% directional accuracy)
|
||||
* - Transformer attention for sentiment/news signals
|
||||
* - Multi-head attention for cross-feature relationships
|
||||
*
|
||||
* Research basis:
|
||||
* - Hybrid models outperform pure LSTM (Springer, 2024)
|
||||
* - Temporal Fusion Transformer for probabilistic forecasting
|
||||
* - FinBERT-style sentiment integration
|
||||
*/
|
||||
|
||||
// Model Configuration
|
||||
const hybridConfig = {
|
||||
lstm: {
|
||||
inputSize: 10, // OHLCV + technical features
|
||||
hiddenSize: 64,
|
||||
numLayers: 2,
|
||||
dropout: 0.2,
|
||||
bidirectional: false
|
||||
},
|
||||
|
||||
transformer: {
|
||||
dModel: 64,
|
||||
numHeads: 4,
|
||||
numLayers: 2,
|
||||
ffDim: 128,
|
||||
dropout: 0.1,
|
||||
maxSeqLength: 50
|
||||
},
|
||||
|
||||
fusion: {
|
||||
method: 'concat_attention', // concat, attention, gating
|
||||
outputDim: 32
|
||||
},
|
||||
|
||||
training: {
|
||||
learningRate: 0.001,
|
||||
batchSize: 32,
|
||||
epochs: 100,
|
||||
patience: 10,
|
||||
validationSplit: 0.2
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* LSTM Cell Implementation
|
||||
* Captures temporal dependencies in price data
|
||||
*/
|
||||
class LSTMCell {
|
||||
constructor(inputSize, hiddenSize) {
|
||||
this.inputSize = inputSize;
|
||||
this.hiddenSize = hiddenSize;
|
||||
this.combinedSize = inputSize + hiddenSize;
|
||||
|
||||
// Initialize weights (Xavier initialization)
|
||||
const scale = Math.sqrt(2.0 / this.combinedSize);
|
||||
this.Wf = this.initMatrix(hiddenSize, this.combinedSize, scale);
|
||||
this.Wi = this.initMatrix(hiddenSize, this.combinedSize, scale);
|
||||
this.Wc = this.initMatrix(hiddenSize, this.combinedSize, scale);
|
||||
this.Wo = this.initMatrix(hiddenSize, this.combinedSize, scale);
|
||||
|
||||
this.bf = new Array(hiddenSize).fill(1); // Forget gate bias = 1
|
||||
this.bi = new Array(hiddenSize).fill(0);
|
||||
this.bc = new Array(hiddenSize).fill(0);
|
||||
this.bo = new Array(hiddenSize).fill(0);
|
||||
|
||||
// Pre-allocate working arrays (avoid allocation in hot path)
|
||||
this._combined = new Array(this.combinedSize);
|
||||
this._f = new Array(hiddenSize);
|
||||
this._i = new Array(hiddenSize);
|
||||
this._cTilde = new Array(hiddenSize);
|
||||
this._o = new Array(hiddenSize);
|
||||
this._h = new Array(hiddenSize);
|
||||
this._c = new Array(hiddenSize);
|
||||
}
|
||||
|
||||
initMatrix(rows, cols, scale) {
|
||||
const matrix = new Array(rows);
|
||||
for (let i = 0; i < rows; i++) {
|
||||
matrix[i] = new Array(cols);
|
||||
for (let j = 0; j < cols; j++) {
|
||||
matrix[i][j] = (Math.random() - 0.5) * 2 * scale;
|
||||
}
|
||||
}
|
||||
return matrix;
|
||||
}
|
||||
|
||||
// Inline sigmoid (avoids function call overhead)
|
||||
forward(x, hPrev, cPrev) {
|
||||
const hiddenSize = this.hiddenSize;
|
||||
const inputSize = this.inputSize;
|
||||
const combinedSize = this.combinedSize;
|
||||
|
||||
// Reuse pre-allocated combined array
|
||||
const combined = this._combined;
|
||||
for (let j = 0; j < inputSize; j++) combined[j] = x[j];
|
||||
for (let j = 0; j < hiddenSize; j++) combined[inputSize + j] = hPrev[j];
|
||||
|
||||
// Compute all gates with manual loops (faster than map/reduce)
|
||||
const f = this._f, i = this._i, cTilde = this._cTilde, o = this._o;
|
||||
|
||||
for (let g = 0; g < hiddenSize; g++) {
|
||||
// Forget gate
|
||||
let sumF = this.bf[g];
|
||||
const rowF = this.Wf[g];
|
||||
for (let j = 0; j < combinedSize; j++) sumF += rowF[j] * combined[j];
|
||||
const clampedF = Math.max(-500, Math.min(500, sumF));
|
||||
f[g] = 1 / (1 + Math.exp(-clampedF));
|
||||
|
||||
// Input gate
|
||||
let sumI = this.bi[g];
|
||||
const rowI = this.Wi[g];
|
||||
for (let j = 0; j < combinedSize; j++) sumI += rowI[j] * combined[j];
|
||||
const clampedI = Math.max(-500, Math.min(500, sumI));
|
||||
i[g] = 1 / (1 + Math.exp(-clampedI));
|
||||
|
||||
// Candidate
|
||||
let sumC = this.bc[g];
|
||||
const rowC = this.Wc[g];
|
||||
for (let j = 0; j < combinedSize; j++) sumC += rowC[j] * combined[j];
|
||||
const clampedC = Math.max(-500, Math.min(500, sumC));
|
||||
const exC = Math.exp(2 * clampedC);
|
||||
cTilde[g] = (exC - 1) / (exC + 1);
|
||||
|
||||
// Output gate
|
||||
let sumO = this.bo[g];
|
||||
const rowO = this.Wo[g];
|
||||
for (let j = 0; j < combinedSize; j++) sumO += rowO[j] * combined[j];
|
||||
const clampedO = Math.max(-500, Math.min(500, sumO));
|
||||
o[g] = 1 / (1 + Math.exp(-clampedO));
|
||||
}
|
||||
|
||||
// Cell state and hidden state
|
||||
const c = this._c, h = this._h;
|
||||
for (let g = 0; g < hiddenSize; g++) {
|
||||
c[g] = f[g] * cPrev[g] + i[g] * cTilde[g];
|
||||
const clampedCg = Math.max(-500, Math.min(500, c[g]));
|
||||
const exCg = Math.exp(2 * clampedCg);
|
||||
h[g] = o[g] * ((exCg - 1) / (exCg + 1));
|
||||
}
|
||||
|
||||
// Return copies to avoid mutation issues
|
||||
return { h: h.slice(), c: c.slice() };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LSTM Layer
|
||||
* Processes sequential data through multiple timesteps
|
||||
*/
|
||||
class LSTMLayer {
|
||||
constructor(inputSize, hiddenSize, returnSequences = false) {
|
||||
this.cell = new LSTMCell(inputSize, hiddenSize);
|
||||
this.hiddenSize = hiddenSize;
|
||||
this.returnSequences = returnSequences;
|
||||
}
|
||||
|
||||
forward(sequence) {
|
||||
let h = new Array(this.hiddenSize).fill(0);
|
||||
let c = new Array(this.hiddenSize).fill(0);
|
||||
const outputs = [];
|
||||
|
||||
for (const x of sequence) {
|
||||
const result = this.cell.forward(x, h, c);
|
||||
h = result.h;
|
||||
c = result.c;
|
||||
if (this.returnSequences) {
|
||||
outputs.push([...h]);
|
||||
}
|
||||
}
|
||||
|
||||
return this.returnSequences ? outputs : h;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-Head Attention
|
||||
* Captures relationships between different time points and features
|
||||
*/
|
||||
class MultiHeadAttention {
|
||||
constructor(dModel, numHeads) {
|
||||
this.dModel = dModel;
|
||||
this.numHeads = numHeads;
|
||||
this.headDim = Math.floor(dModel / numHeads);
|
||||
|
||||
// Initialize projection weights
|
||||
const scale = Math.sqrt(2.0 / dModel);
|
||||
this.Wq = this.initMatrix(dModel, dModel, scale);
|
||||
this.Wk = this.initMatrix(dModel, dModel, scale);
|
||||
this.Wv = this.initMatrix(dModel, dModel, scale);
|
||||
this.Wo = this.initMatrix(dModel, dModel, scale);
|
||||
}
|
||||
|
||||
initMatrix(rows, cols, scale) {
|
||||
const matrix = [];
|
||||
for (let i = 0; i < rows; i++) {
|
||||
matrix[i] = [];
|
||||
for (let j = 0; j < cols; j++) {
|
||||
matrix[i][j] = (Math.random() - 0.5) * 2 * scale;
|
||||
}
|
||||
}
|
||||
return matrix;
|
||||
}
|
||||
|
||||
// Cache-friendly matmul (i-k-j loop order)
|
||||
matmul(a, b) {
|
||||
if (a.length === 0 || b.length === 0) return [];
|
||||
const rowsA = a.length;
|
||||
const colsA = a[0].length;
|
||||
const colsB = b[0].length;
|
||||
|
||||
// Pre-allocate result
|
||||
const result = new Array(rowsA);
|
||||
for (let i = 0; i < rowsA; i++) {
|
||||
result[i] = new Array(colsB).fill(0);
|
||||
}
|
||||
|
||||
// Cache-friendly loop order: i-k-j
|
||||
for (let i = 0; i < rowsA; i++) {
|
||||
const rowA = a[i];
|
||||
const rowR = result[i];
|
||||
for (let k = 0; k < colsA; k++) {
|
||||
const aik = rowA[k];
|
||||
const rowB = b[k];
|
||||
for (let j = 0; j < colsB; j++) {
|
||||
rowR[j] += aik * rowB[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Optimized softmax (no map/reduce)
|
||||
softmax(arr) {
|
||||
const n = arr.length;
|
||||
if (n === 0) return [];
|
||||
if (n === 1) return [1.0];
|
||||
|
||||
let max = arr[0];
|
||||
for (let i = 1; i < n; i++) if (arr[i] > max) max = arr[i];
|
||||
|
||||
const exp = new Array(n);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
exp[i] = Math.exp(arr[i] - max);
|
||||
sum += exp[i];
|
||||
}
|
||||
|
||||
if (sum === 0 || !isFinite(sum)) {
|
||||
const uniform = 1.0 / n;
|
||||
for (let i = 0; i < n; i++) exp[i] = uniform;
|
||||
return exp;
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) exp[i] /= sum;
|
||||
return exp;
|
||||
}
|
||||
|
||||
forward(query, key, value) {
|
||||
const seqLen = query.length;
|
||||
|
||||
// Project Q, K, V
|
||||
const Q = this.matmul(query, this.Wq);
|
||||
const K = this.matmul(key, this.Wk);
|
||||
const V = this.matmul(value, this.Wv);
|
||||
|
||||
// Scaled dot-product attention
|
||||
const scale = Math.sqrt(this.headDim);
|
||||
const scores = [];
|
||||
|
||||
for (let i = 0; i < seqLen; i++) {
|
||||
scores[i] = [];
|
||||
for (let j = 0; j < seqLen; j++) {
|
||||
let dot = 0;
|
||||
for (let k = 0; k < this.dModel; k++) {
|
||||
dot += Q[i][k] * K[j][k];
|
||||
}
|
||||
scores[i][j] = dot / scale;
|
||||
}
|
||||
}
|
||||
|
||||
// Softmax attention weights
|
||||
const attnWeights = scores.map(row => this.softmax(row));
|
||||
|
||||
// Apply attention to values
|
||||
const attended = this.matmul(attnWeights, V);
|
||||
|
||||
// Output projection
|
||||
return this.matmul(attended, this.Wo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed-Forward Network
|
||||
*/
|
||||
class FeedForward {
|
||||
constructor(dModel, ffDim) {
|
||||
this.dModel = dModel;
|
||||
this.ffDim = ffDim;
|
||||
const scale1 = Math.sqrt(2.0 / dModel);
|
||||
const scale2 = Math.sqrt(2.0 / ffDim);
|
||||
|
||||
this.W1 = this.initMatrix(dModel, ffDim, scale1);
|
||||
this.W2 = this.initMatrix(ffDim, dModel, scale2);
|
||||
this.b1 = new Array(ffDim).fill(0);
|
||||
this.b2 = new Array(dModel).fill(0);
|
||||
|
||||
// Pre-allocate hidden layer
|
||||
this._hidden = new Array(ffDim);
|
||||
}
|
||||
|
||||
initMatrix(rows, cols, scale) {
|
||||
const matrix = new Array(rows);
|
||||
for (let i = 0; i < rows; i++) {
|
||||
matrix[i] = new Array(cols);
|
||||
for (let j = 0; j < cols; j++) {
|
||||
matrix[i][j] = (Math.random() - 0.5) * 2 * scale;
|
||||
}
|
||||
}
|
||||
return matrix;
|
||||
}
|
||||
|
||||
forward(x) {
|
||||
const ffDim = this.ffDim;
|
||||
const dModel = this.dModel;
|
||||
const xLen = x.length;
|
||||
const hidden = this._hidden;
|
||||
|
||||
// First linear + ReLU (manual loop)
|
||||
for (let i = 0; i < ffDim; i++) {
|
||||
let sum = this.b1[i];
|
||||
for (let j = 0; j < xLen; j++) {
|
||||
sum += x[j] * this.W1[j][i];
|
||||
}
|
||||
hidden[i] = sum > 0 ? sum : 0; // Inline ReLU
|
||||
}
|
||||
|
||||
// Second linear
|
||||
const output = new Array(dModel);
|
||||
for (let i = 0; i < dModel; i++) {
|
||||
let sum = this.b2[i];
|
||||
for (let j = 0; j < ffDim; j++) {
|
||||
sum += hidden[j] * this.W2[j][i];
|
||||
}
|
||||
output[i] = sum;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformer Encoder Layer
|
||||
*/
|
||||
class TransformerEncoderLayer {
|
||||
constructor(dModel, numHeads, ffDim) {
|
||||
this.attention = new MultiHeadAttention(dModel, numHeads);
|
||||
this.feedForward = new FeedForward(dModel, ffDim);
|
||||
this.dModel = dModel;
|
||||
}
|
||||
|
||||
// Optimized layerNorm (no map/reduce)
|
||||
layerNorm(x, eps = 1e-6) {
|
||||
const n = x.length;
|
||||
if (n === 0) return [];
|
||||
|
||||
// Compute mean
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n; i++) sum += x[i];
|
||||
const mean = sum / n;
|
||||
|
||||
// Compute variance
|
||||
let varSum = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = x[i] - mean;
|
||||
varSum += d * d;
|
||||
}
|
||||
const invStd = 1.0 / Math.sqrt(varSum / n + eps);
|
||||
|
||||
// Normalize
|
||||
const out = new Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
out[i] = (x[i] - mean) * invStd;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
forward(x) {
|
||||
// Self-attention with residual
|
||||
const attended = this.attention.forward(x, x, x);
|
||||
const afterAttn = x.map((row, i) =>
|
||||
this.layerNorm(row.map((v, j) => v + attended[i][j]))
|
||||
);
|
||||
|
||||
// Feed-forward with residual
|
||||
return afterAttn.map(row => {
|
||||
const ff = this.feedForward.forward(row);
|
||||
return this.layerNorm(row.map((v, j) => v + ff[j]));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature Extractor
|
||||
* Extracts technical indicators from OHLCV data
|
||||
*/
|
||||
class FeatureExtractor {
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
extract(candles) {
|
||||
const features = [];
|
||||
|
||||
for (let i = 1; i < candles.length; i++) {
|
||||
const curr = candles[i];
|
||||
const prev = candles[i - 1];
|
||||
|
||||
// Basic features
|
||||
const returns = (curr.close - prev.close) / prev.close;
|
||||
const logReturns = Math.log(curr.close / prev.close);
|
||||
const range = (curr.high - curr.low) / curr.close;
|
||||
const bodyRatio = Math.abs(curr.close - curr.open) / (curr.high - curr.low + 1e-10);
|
||||
|
||||
// Volume features
|
||||
const volumeChange = prev.volume > 0 ? (curr.volume - prev.volume) / prev.volume : 0;
|
||||
const volumeMA = this.movingAverage(candles.slice(Math.max(0, i - 20), i + 1).map(c => c.volume));
|
||||
const volumeRatio = volumeMA > 0 ? curr.volume / volumeMA : 1;
|
||||
|
||||
// Momentum
|
||||
let momentum = 0;
|
||||
if (i >= 10) {
|
||||
const lookback = candles[i - 10];
|
||||
momentum = (curr.close - lookback.close) / lookback.close;
|
||||
}
|
||||
|
||||
// Volatility (20-day rolling)
|
||||
let volatility = 0;
|
||||
if (i >= 20) {
|
||||
const returns20 = [];
|
||||
for (let j = i - 19; j <= i; j++) {
|
||||
returns20.push((candles[j].close - candles[j - 1].close) / candles[j - 1].close);
|
||||
}
|
||||
volatility = this.stdDev(returns20);
|
||||
}
|
||||
|
||||
// RSI proxy
|
||||
let rsi = 0.5;
|
||||
if (i >= 14) {
|
||||
let gains = 0, losses = 0;
|
||||
for (let j = i - 13; j <= i; j++) {
|
||||
const change = candles[j].close - candles[j - 1].close;
|
||||
if (change > 0) gains += change;
|
||||
else losses -= change;
|
||||
}
|
||||
const avgGain = gains / 14;
|
||||
const avgLoss = losses / 14;
|
||||
rsi = avgLoss > 0 ? avgGain / (avgGain + avgLoss) : 1;
|
||||
}
|
||||
|
||||
// Trend (SMA ratio)
|
||||
let trend = 0;
|
||||
if (i >= 20) {
|
||||
const sma20 = this.movingAverage(candles.slice(i - 19, i + 1).map(c => c.close));
|
||||
trend = (curr.close - sma20) / sma20;
|
||||
}
|
||||
|
||||
features.push([
|
||||
returns,
|
||||
logReturns,
|
||||
range,
|
||||
bodyRatio,
|
||||
volumeChange,
|
||||
volumeRatio,
|
||||
momentum,
|
||||
volatility,
|
||||
rsi,
|
||||
trend
|
||||
]);
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
movingAverage(arr) {
|
||||
if (arr.length === 0) return 0;
|
||||
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||
}
|
||||
|
||||
stdDev(arr) {
|
||||
if (arr.length < 2) return 0;
|
||||
const mean = this.movingAverage(arr);
|
||||
const variance = arr.reduce((sum, x) => sum + (x - mean) ** 2, 0) / arr.length;
|
||||
return Math.sqrt(variance);
|
||||
}
|
||||
|
||||
normalize(features) {
|
||||
if (features.length === 0) return features;
|
||||
|
||||
const numFeatures = features[0].length;
|
||||
const means = new Array(numFeatures).fill(0);
|
||||
const stds = new Array(numFeatures).fill(0);
|
||||
|
||||
// Calculate means
|
||||
for (const row of features) {
|
||||
for (let i = 0; i < numFeatures; i++) {
|
||||
means[i] += row[i];
|
||||
}
|
||||
}
|
||||
means.forEach((_, i) => means[i] /= features.length);
|
||||
|
||||
// Calculate stds
|
||||
for (const row of features) {
|
||||
for (let i = 0; i < numFeatures; i++) {
|
||||
stds[i] += (row[i] - means[i]) ** 2;
|
||||
}
|
||||
}
|
||||
stds.forEach((_, i) => stds[i] = Math.sqrt(stds[i] / features.length) || 1);
|
||||
|
||||
// Normalize
|
||||
return features.map(row =>
|
||||
row.map((v, i) => (v - means[i]) / stds[i])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hybrid LSTM-Transformer Model
|
||||
* Combines temporal (LSTM) and attention (Transformer) mechanisms
|
||||
*/
|
||||
class HybridLSTMTransformer {
|
||||
constructor(config = hybridConfig) {
|
||||
this.config = config;
|
||||
|
||||
// LSTM branch for temporal patterns
|
||||
this.lstm = new LSTMLayer(
|
||||
config.lstm.inputSize,
|
||||
config.lstm.hiddenSize,
|
||||
true // Return sequences for fusion
|
||||
);
|
||||
|
||||
// Transformer branch for attention patterns
|
||||
this.transformerLayers = [];
|
||||
for (let i = 0; i < config.transformer.numLayers; i++) {
|
||||
this.transformerLayers.push(new TransformerEncoderLayer(
|
||||
config.transformer.dModel,
|
||||
config.transformer.numHeads,
|
||||
config.transformer.ffDim
|
||||
));
|
||||
}
|
||||
|
||||
// Feature extractor
|
||||
this.featureExtractor = new FeatureExtractor();
|
||||
|
||||
// Fusion layer weights
|
||||
const fusionInputSize = config.lstm.hiddenSize + config.transformer.dModel;
|
||||
const scale = Math.sqrt(2.0 / fusionInputSize);
|
||||
this.fusionW = Array(fusionInputSize).fill(null).map(() =>
|
||||
Array(config.fusion.outputDim).fill(null).map(() => (Math.random() - 0.5) * 2 * scale)
|
||||
);
|
||||
this.fusionB = new Array(config.fusion.outputDim).fill(0);
|
||||
|
||||
// Output layer
|
||||
this.outputW = new Array(config.fusion.outputDim).fill(null).map(() => (Math.random() - 0.5) * 0.1);
|
||||
this.outputB = 0;
|
||||
|
||||
// Training state
|
||||
this.trained = false;
|
||||
this.trainingHistory = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Project features to transformer dimension
|
||||
*/
|
||||
projectFeatures(features, targetDim) {
|
||||
const inputDim = features[0].length;
|
||||
if (inputDim === targetDim) return features;
|
||||
|
||||
// Simple linear projection
|
||||
const projW = Array(inputDim).fill(null).map(() =>
|
||||
Array(targetDim).fill(null).map(() => (Math.random() - 0.5) * 0.1)
|
||||
);
|
||||
|
||||
return features.map(row => {
|
||||
const projected = new Array(targetDim).fill(0);
|
||||
for (let i = 0; i < targetDim; i++) {
|
||||
for (let j = 0; j < inputDim; j++) {
|
||||
projected[i] += row[j] * projW[j][i];
|
||||
}
|
||||
}
|
||||
return projected;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward pass through the hybrid model
|
||||
*/
|
||||
forward(features) {
|
||||
// LSTM branch
|
||||
const lstmOutput = this.lstm.forward(features);
|
||||
|
||||
// Transformer branch
|
||||
let transformerInput = this.projectFeatures(features, this.config.transformer.dModel);
|
||||
for (const layer of this.transformerLayers) {
|
||||
transformerInput = layer.forward(transformerInput);
|
||||
}
|
||||
const transformerOutput = transformerInput[transformerInput.length - 1];
|
||||
|
||||
// Get last LSTM output
|
||||
const lstmFinal = Array.isArray(lstmOutput[0])
|
||||
? lstmOutput[lstmOutput.length - 1]
|
||||
: lstmOutput;
|
||||
|
||||
// Fusion: concatenate and project
|
||||
const fused = [...lstmFinal, ...transformerOutput];
|
||||
const fusionOutput = new Array(this.config.fusion.outputDim).fill(0);
|
||||
|
||||
for (let i = 0; i < this.config.fusion.outputDim; i++) {
|
||||
fusionOutput[i] = this.fusionB[i];
|
||||
for (let j = 0; j < fused.length; j++) {
|
||||
fusionOutput[i] += fused[j] * this.fusionW[j][i];
|
||||
}
|
||||
fusionOutput[i] = Math.tanh(fusionOutput[i]); // Activation
|
||||
}
|
||||
|
||||
// Output: single prediction
|
||||
let output = this.outputB;
|
||||
for (let i = 0; i < fusionOutput.length; i++) {
|
||||
output += fusionOutput[i] * this.outputW[i];
|
||||
}
|
||||
|
||||
return {
|
||||
prediction: Math.tanh(output), // -1 to 1 (bearish to bullish)
|
||||
confidence: Math.abs(Math.tanh(output)),
|
||||
lstmFeatures: lstmFinal,
|
||||
transformerFeatures: transformerOutput,
|
||||
fusedFeatures: fusionOutput
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Predict from raw candle data
|
||||
*/
|
||||
predict(candles) {
|
||||
if (candles.length < 30) {
|
||||
return { error: 'Insufficient data', minRequired: 30 };
|
||||
}
|
||||
|
||||
// Extract and normalize features
|
||||
const features = this.featureExtractor.extract(candles);
|
||||
const normalized = this.featureExtractor.normalize(features);
|
||||
|
||||
// Take last N for sequence
|
||||
const seqLength = Math.min(normalized.length, this.config.transformer.maxSeqLength);
|
||||
const sequence = normalized.slice(-seqLength);
|
||||
|
||||
// Forward pass
|
||||
const result = this.forward(sequence);
|
||||
|
||||
// Convert to trading signal
|
||||
const signal = result.prediction > 0.1 ? 'BUY'
|
||||
: result.prediction < -0.1 ? 'SELL'
|
||||
: 'HOLD';
|
||||
|
||||
return {
|
||||
signal,
|
||||
prediction: result.prediction,
|
||||
confidence: result.confidence,
|
||||
direction: result.prediction > 0 ? 'bullish' : 'bearish',
|
||||
strength: Math.abs(result.prediction)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple training simulation (gradient-free optimization)
|
||||
*/
|
||||
train(trainingData, labels) {
|
||||
const epochs = this.config.training.epochs;
|
||||
const patience = this.config.training.patience;
|
||||
let bestLoss = Infinity;
|
||||
let patienceCounter = 0;
|
||||
|
||||
console.log(' Training hybrid model...');
|
||||
|
||||
for (let epoch = 0; epoch < epochs; epoch++) {
|
||||
let totalLoss = 0;
|
||||
|
||||
for (let i = 0; i < trainingData.length; i++) {
|
||||
const result = this.forward(trainingData[i]);
|
||||
const loss = (result.prediction - labels[i]) ** 2;
|
||||
totalLoss += loss;
|
||||
|
||||
// Simple weight perturbation (evolutionary approach)
|
||||
if (loss > 0.1) {
|
||||
const perturbation = 0.001 * (1 - epoch / epochs);
|
||||
this.perturbWeights(perturbation);
|
||||
}
|
||||
}
|
||||
|
||||
const avgLoss = totalLoss / trainingData.length;
|
||||
this.trainingHistory.push({ epoch, loss: avgLoss });
|
||||
|
||||
if (avgLoss < bestLoss) {
|
||||
bestLoss = avgLoss;
|
||||
patienceCounter = 0;
|
||||
} else {
|
||||
patienceCounter++;
|
||||
if (patienceCounter >= patience) {
|
||||
console.log(` Early stopping at epoch ${epoch + 1}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ((epoch + 1) % 20 === 0) {
|
||||
console.log(` Epoch ${epoch + 1}/${epochs}, Loss: ${avgLoss.toFixed(6)}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.trained = true;
|
||||
return { finalLoss: bestLoss, epochs: this.trainingHistory.length };
|
||||
}
|
||||
|
||||
perturbWeights(scale) {
|
||||
// Perturb fusion weights
|
||||
for (let i = 0; i < this.fusionW.length; i++) {
|
||||
for (let j = 0; j < this.fusionW[i].length; j++) {
|
||||
this.fusionW[i][j] += (Math.random() - 0.5) * scale;
|
||||
}
|
||||
}
|
||||
|
||||
// Perturb output weights
|
||||
for (let i = 0; i < this.outputW.length; i++) {
|
||||
this.outputW[i] += (Math.random() - 0.5) * scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate synthetic market data for testing
|
||||
*/
|
||||
function generateSyntheticData(n, seed = 42) {
|
||||
let rng = seed;
|
||||
const random = () => { rng = (rng * 9301 + 49297) % 233280; return rng / 233280; };
|
||||
|
||||
const candles = [];
|
||||
let price = 100;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const trend = Math.sin(i / 50) * 0.002; // Cyclical trend
|
||||
const noise = (random() - 0.5) * 0.03;
|
||||
const returns = trend + noise;
|
||||
|
||||
const open = price;
|
||||
price = price * (1 + returns);
|
||||
const high = Math.max(open, price) * (1 + random() * 0.01);
|
||||
const low = Math.min(open, price) * (1 - random() * 0.01);
|
||||
const volume = 1000000 * (0.5 + random());
|
||||
|
||||
candles.push({
|
||||
timestamp: Date.now() - (n - i) * 60000,
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close: price,
|
||||
volume
|
||||
});
|
||||
}
|
||||
|
||||
return candles;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('═'.repeat(70));
|
||||
console.log('HYBRID LSTM-TRANSFORMER STOCK PREDICTOR');
|
||||
console.log('═'.repeat(70));
|
||||
console.log();
|
||||
|
||||
// 1. Generate test data
|
||||
console.log('1. Data Generation:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const candles = generateSyntheticData(500);
|
||||
console.log(` Generated ${candles.length} candles`);
|
||||
console.log(` Price range: $${Math.min(...candles.map(c => c.low)).toFixed(2)} - $${Math.max(...candles.map(c => c.high)).toFixed(2)}`);
|
||||
console.log();
|
||||
|
||||
// 2. Feature extraction
|
||||
console.log('2. Feature Extraction:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const model = new HybridLSTMTransformer();
|
||||
const features = model.featureExtractor.extract(candles);
|
||||
const normalized = model.featureExtractor.normalize(features);
|
||||
|
||||
console.log(` Raw features: ${features.length} timesteps × ${features[0].length} features`);
|
||||
console.log(` Feature names: returns, logReturns, range, bodyRatio, volumeChange,`);
|
||||
console.log(` volumeRatio, momentum, volatility, rsi, trend`);
|
||||
console.log();
|
||||
|
||||
// 3. Model architecture
|
||||
console.log('3. Model Architecture:');
|
||||
console.log('─'.repeat(70));
|
||||
console.log(` LSTM Branch:`);
|
||||
console.log(` - Input: ${hybridConfig.lstm.inputSize} features`);
|
||||
console.log(` - Hidden: ${hybridConfig.lstm.hiddenSize} units`);
|
||||
console.log(` - Layers: ${hybridConfig.lstm.numLayers}`);
|
||||
console.log();
|
||||
console.log(` Transformer Branch:`);
|
||||
console.log(` - Model dim: ${hybridConfig.transformer.dModel}`);
|
||||
console.log(` - Heads: ${hybridConfig.transformer.numHeads}`);
|
||||
console.log(` - Layers: ${hybridConfig.transformer.numLayers}`);
|
||||
console.log(` - FF dim: ${hybridConfig.transformer.ffDim}`);
|
||||
console.log();
|
||||
console.log(` Fusion: ${hybridConfig.fusion.method} → ${hybridConfig.fusion.outputDim} dims`);
|
||||
console.log();
|
||||
|
||||
// 4. Forward pass test
|
||||
console.log('4. Forward Pass Test:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const sequence = normalized.slice(-50);
|
||||
const result = model.forward(sequence);
|
||||
|
||||
console.log(` Prediction: ${result.prediction.toFixed(4)}`);
|
||||
console.log(` Confidence: ${(result.confidence * 100).toFixed(1)}%`);
|
||||
console.log(` LSTM features: [${result.lstmFeatures.slice(0, 5).map(v => v.toFixed(3)).join(', ')}...]`);
|
||||
console.log(` Transformer features: [${result.transformerFeatures.slice(0, 5).map(v => v.toFixed(3)).join(', ')}...]`);
|
||||
console.log();
|
||||
|
||||
// 5. Prediction from raw data
|
||||
console.log('5. End-to-End Prediction:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const prediction = model.predict(candles);
|
||||
|
||||
console.log(` Signal: ${prediction.signal}`);
|
||||
console.log(` Direction: ${prediction.direction}`);
|
||||
console.log(` Strength: ${(prediction.strength * 100).toFixed(1)}%`);
|
||||
console.log(` Confidence: ${(prediction.confidence * 100).toFixed(1)}%`);
|
||||
console.log();
|
||||
|
||||
// 6. Rolling predictions
|
||||
console.log('6. Rolling Predictions (Last 10 Windows):');
|
||||
console.log('─'.repeat(70));
|
||||
console.log(' Window │ Price │ Signal │ Strength │ Direction');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
for (let i = candles.length - 10; i < candles.length; i++) {
|
||||
const window = candles.slice(0, i + 1);
|
||||
const pred = model.predict(window);
|
||||
if (!pred.error) {
|
||||
console.log(` ${i.toString().padStart(5)} │ $${window[window.length - 1].close.toFixed(2).padStart(6)} │ ${pred.signal.padEnd(4)} │ ${(pred.strength * 100).toFixed(1).padStart(5)}% │ ${pred.direction}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
|
||||
// 7. Backtest simulation
|
||||
console.log('7. Simple Backtest Simulation:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
let position = 0;
|
||||
let cash = 10000;
|
||||
let holdings = 0;
|
||||
|
||||
for (let i = 50; i < candles.length; i++) {
|
||||
const window = candles.slice(0, i + 1);
|
||||
const pred = model.predict(window);
|
||||
const price = candles[i].close;
|
||||
|
||||
if (!pred.error && pred.confidence > 0.3) {
|
||||
if (pred.signal === 'BUY' && position <= 0) {
|
||||
const shares = Math.floor(cash * 0.95 / price);
|
||||
if (shares > 0) {
|
||||
holdings += shares;
|
||||
cash -= shares * price;
|
||||
position = 1;
|
||||
}
|
||||
} else if (pred.signal === 'SELL' && position >= 0 && holdings > 0) {
|
||||
cash += holdings * price;
|
||||
holdings = 0;
|
||||
position = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalValue = cash + holdings * candles[candles.length - 1].close;
|
||||
const buyHoldValue = 10000 * (candles[candles.length - 1].close / candles[50].close);
|
||||
|
||||
console.log(` Initial: $10,000.00`);
|
||||
console.log(` Final: $${finalValue.toFixed(2)}`);
|
||||
console.log(` Return: ${((finalValue / 10000 - 1) * 100).toFixed(2)}%`);
|
||||
console.log(` Buy & Hold: $${buyHoldValue.toFixed(2)} (${((buyHoldValue / 10000 - 1) * 100).toFixed(2)}%)`);
|
||||
console.log();
|
||||
|
||||
console.log('═'.repeat(70));
|
||||
console.log('Hybrid LSTM-Transformer demonstration completed');
|
||||
console.log('═'.repeat(70));
|
||||
}
|
||||
|
||||
export {
|
||||
HybridLSTMTransformer,
|
||||
LSTMLayer,
|
||||
LSTMCell,
|
||||
MultiHeadAttention,
|
||||
TransformerEncoderLayer,
|
||||
FeatureExtractor,
|
||||
hybridConfig
|
||||
};
|
||||
|
||||
main().catch(console.error);
|
||||
722
vendor/ruvector/examples/neural-trader/production/sentiment-alpha.js
vendored
Normal file
722
vendor/ruvector/examples/neural-trader/production/sentiment-alpha.js
vendored
Normal file
@@ -0,0 +1,722 @@
|
||||
/**
|
||||
* Sentiment Alpha Pipeline
|
||||
*
|
||||
* PRODUCTION: LLM-based sentiment analysis for trading alpha generation
|
||||
*
|
||||
* Research basis:
|
||||
* - 3% annual excess returns from sentiment (2024)
|
||||
* - 50.63% return over 28 months (backtested)
|
||||
* - FinBERT embeddings outperform technical signals
|
||||
*
|
||||
* Features:
|
||||
* - Multi-source sentiment aggregation (news, social, earnings)
|
||||
* - Sentiment scoring and signal generation
|
||||
* - Calibration for trading decisions
|
||||
* - Integration with Kelly criterion for sizing
|
||||
*/
|
||||
|
||||
// Sentiment Configuration
|
||||
const sentimentConfig = {
|
||||
// Source weights
|
||||
sources: {
|
||||
news: { weight: 0.40, decay: 0.95 }, // News articles
|
||||
social: { weight: 0.25, decay: 0.90 }, // Social media
|
||||
earnings: { weight: 0.25, decay: 0.99 }, // Earnings calls
|
||||
analyst: { weight: 0.10, decay: 0.98 } // Analyst reports
|
||||
},
|
||||
|
||||
// Sentiment thresholds
|
||||
thresholds: {
|
||||
strongBullish: 0.6,
|
||||
bullish: 0.3,
|
||||
neutral: [-0.1, 0.1],
|
||||
bearish: -0.3,
|
||||
strongBearish: -0.6
|
||||
},
|
||||
|
||||
// Signal generation
|
||||
signals: {
|
||||
minConfidence: 0.6,
|
||||
lookbackDays: 7,
|
||||
smoothingWindow: 3,
|
||||
contrarianThreshold: 0.8 // Extreme sentiment = contrarian signal
|
||||
},
|
||||
|
||||
// Alpha calibration
|
||||
calibration: {
|
||||
historicalAccuracy: 0.55, // Historical prediction accuracy
|
||||
shrinkageFactor: 0.3 // Shrink extreme predictions
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lexicon-based Sentiment Analyzer
|
||||
* Fast, interpretable sentiment scoring
|
||||
*/
|
||||
class LexiconAnalyzer {
|
||||
constructor() {
|
||||
// Financial sentiment lexicon (simplified)
|
||||
this.positiveWords = new Set([
|
||||
'growth', 'profit', 'gains', 'bullish', 'upgrade', 'beat', 'exceeded',
|
||||
'outperform', 'strong', 'surge', 'rally', 'breakthrough', 'innovation',
|
||||
'record', 'momentum', 'optimistic', 'recovery', 'expansion', 'success',
|
||||
'opportunity', 'positive', 'increase', 'improve', 'advance', 'boost'
|
||||
]);
|
||||
|
||||
this.negativeWords = new Set([
|
||||
'loss', 'decline', 'bearish', 'downgrade', 'miss', 'below', 'weak',
|
||||
'underperform', 'crash', 'plunge', 'risk', 'concern', 'warning',
|
||||
'recession', 'inflation', 'uncertainty', 'volatility', 'default',
|
||||
'bankruptcy', 'negative', 'decrease', 'drop', 'fall', 'cut', 'layoff'
|
||||
]);
|
||||
|
||||
this.intensifiers = new Set([
|
||||
'very', 'extremely', 'significantly', 'strongly', 'substantially',
|
||||
'dramatically', 'sharply', 'massive', 'huge', 'major'
|
||||
]);
|
||||
|
||||
this.negators = new Set([
|
||||
'not', 'no', 'never', 'neither', 'without', 'hardly', 'barely'
|
||||
]);
|
||||
}
|
||||
|
||||
// Optimized analyze (avoids regex, minimizes allocations)
|
||||
analyze(text) {
|
||||
const lowerText = text.toLowerCase();
|
||||
let score = 0;
|
||||
let positiveCount = 0;
|
||||
let negativeCount = 0;
|
||||
let intensifierActive = false;
|
||||
let negatorActive = false;
|
||||
let wordCount = 0;
|
||||
|
||||
// Extract words without regex (faster)
|
||||
let wordStart = -1;
|
||||
const len = lowerText.length;
|
||||
|
||||
for (let i = 0; i <= len; i++) {
|
||||
const c = i < len ? lowerText.charCodeAt(i) : 32; // Space at end
|
||||
const isWordChar = (c >= 97 && c <= 122) || (c >= 48 && c <= 57) || c === 95; // a-z, 0-9, _
|
||||
|
||||
if (isWordChar && wordStart === -1) {
|
||||
wordStart = i;
|
||||
} else if (!isWordChar && wordStart !== -1) {
|
||||
const word = lowerText.slice(wordStart, i);
|
||||
wordStart = -1;
|
||||
wordCount++;
|
||||
|
||||
// Check for intensifiers and negators
|
||||
if (this.intensifiers.has(word)) {
|
||||
intensifierActive = true;
|
||||
continue;
|
||||
}
|
||||
if (this.negators.has(word)) {
|
||||
negatorActive = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Score sentiment words
|
||||
let wordScore = 0;
|
||||
if (this.positiveWords.has(word)) {
|
||||
wordScore = 1;
|
||||
positiveCount++;
|
||||
} else if (this.negativeWords.has(word)) {
|
||||
wordScore = -1;
|
||||
negativeCount++;
|
||||
}
|
||||
|
||||
// Apply modifiers
|
||||
if (wordScore !== 0) {
|
||||
if (intensifierActive) wordScore *= 1.5;
|
||||
if (negatorActive) wordScore *= -1;
|
||||
score += wordScore;
|
||||
}
|
||||
|
||||
// Reset modifiers
|
||||
intensifierActive = false;
|
||||
negatorActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize score
|
||||
const totalSentimentWords = positiveCount + negativeCount;
|
||||
const normalizedScore = totalSentimentWords > 0
|
||||
? score / (totalSentimentWords * 1.5)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
score: Math.max(-1, Math.min(1, normalizedScore)),
|
||||
positiveCount,
|
||||
negativeCount,
|
||||
totalWords: wordCount,
|
||||
confidence: Math.min(1, totalSentimentWords / 10)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedding-based Sentiment Analyzer
|
||||
* Simulates FinBERT-style deep learning analysis
|
||||
*/
|
||||
class EmbeddingAnalyzer {
|
||||
constructor() {
|
||||
// Simulated embedding weights (in production, use actual model)
|
||||
this.embeddingDim = 64;
|
||||
this.sentimentProjection = Array(this.embeddingDim).fill(null)
|
||||
.map(() => (Math.random() - 0.5) * 0.1);
|
||||
}
|
||||
|
||||
// Simulate text embedding
|
||||
embed(text) {
|
||||
const words = text.toLowerCase().split(/\s+/);
|
||||
const embedding = new Array(this.embeddingDim).fill(0);
|
||||
|
||||
// Simple hash-based embedding simulation
|
||||
for (const word of words) {
|
||||
const hash = this.hashString(word);
|
||||
for (let i = 0; i < this.embeddingDim; i++) {
|
||||
embedding[i] += Math.sin(hash * (i + 1)) / words.length;
|
||||
}
|
||||
}
|
||||
|
||||
return embedding;
|
||||
}
|
||||
|
||||
hashString(str) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||
hash = hash & hash;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
analyze(text) {
|
||||
const embedding = this.embed(text);
|
||||
|
||||
// Project to sentiment score
|
||||
let score = 0;
|
||||
for (let i = 0; i < this.embeddingDim; i++) {
|
||||
score += embedding[i] * this.sentimentProjection[i];
|
||||
}
|
||||
|
||||
// Normalize
|
||||
score = Math.tanh(score * 10);
|
||||
|
||||
return {
|
||||
score,
|
||||
embedding: embedding.slice(0, 8), // Return first 8 dims
|
||||
confidence: Math.abs(score)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentiment Source Aggregator
|
||||
* Combines multiple sentiment sources with decay
|
||||
*/
|
||||
class SentimentAggregator {
|
||||
constructor(config = sentimentConfig) {
|
||||
this.config = config;
|
||||
this.lexiconAnalyzer = new LexiconAnalyzer();
|
||||
this.embeddingAnalyzer = new EmbeddingAnalyzer();
|
||||
this.sentimentHistory = new Map(); // symbol -> sentiment history
|
||||
}
|
||||
|
||||
// Add sentiment observation
|
||||
addObservation(symbol, source, text, timestamp = Date.now()) {
|
||||
if (!this.sentimentHistory.has(symbol)) {
|
||||
this.sentimentHistory.set(symbol, []);
|
||||
}
|
||||
|
||||
// Analyze with both methods
|
||||
const lexicon = this.lexiconAnalyzer.analyze(text);
|
||||
const embedding = this.embeddingAnalyzer.analyze(text);
|
||||
|
||||
// Combine scores
|
||||
const combinedScore = 0.4 * lexicon.score + 0.6 * embedding.score;
|
||||
const combinedConfidence = Math.sqrt(lexicon.confidence * embedding.confidence);
|
||||
|
||||
const observation = {
|
||||
timestamp,
|
||||
source,
|
||||
score: combinedScore,
|
||||
confidence: combinedConfidence,
|
||||
lexiconScore: lexicon.score,
|
||||
embeddingScore: embedding.score,
|
||||
text: text.substring(0, 100)
|
||||
};
|
||||
|
||||
this.sentimentHistory.get(symbol).push(observation);
|
||||
|
||||
// Limit history size
|
||||
const history = this.sentimentHistory.get(symbol);
|
||||
if (history.length > 1000) {
|
||||
history.splice(0, history.length - 1000);
|
||||
}
|
||||
|
||||
return observation;
|
||||
}
|
||||
|
||||
// Get aggregated sentiment for symbol
|
||||
getAggregatedSentiment(symbol, lookbackMs = 7 * 24 * 60 * 60 * 1000) {
|
||||
const history = this.sentimentHistory.get(symbol);
|
||||
if (!history || history.length === 0) {
|
||||
return { score: 0, confidence: 0, count: 0 };
|
||||
}
|
||||
|
||||
const cutoff = Date.now() - lookbackMs;
|
||||
const recent = history.filter(h => h.timestamp >= cutoff);
|
||||
|
||||
if (recent.length === 0) {
|
||||
return { score: 0, confidence: 0, count: 0 };
|
||||
}
|
||||
|
||||
// Weight by source, recency, and confidence
|
||||
let weightedSum = 0;
|
||||
let totalWeight = 0;
|
||||
const sourceCounts = {};
|
||||
|
||||
for (const obs of recent) {
|
||||
const sourceConfig = this.config.sources[obs.source] || { weight: 0.25, decay: 0.95 };
|
||||
const age = (Date.now() - obs.timestamp) / (24 * 60 * 60 * 1000); // days
|
||||
const decayFactor = Math.pow(sourceConfig.decay, age);
|
||||
|
||||
const weight = sourceConfig.weight * decayFactor * obs.confidence;
|
||||
|
||||
weightedSum += obs.score * weight;
|
||||
totalWeight += weight;
|
||||
|
||||
sourceCounts[obs.source] = (sourceCounts[obs.source] || 0) + 1;
|
||||
}
|
||||
|
||||
const aggregatedScore = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
||||
const confidence = Math.min(1, totalWeight / 2); // Confidence based on weight
|
||||
|
||||
return {
|
||||
score: aggregatedScore,
|
||||
confidence,
|
||||
count: recent.length,
|
||||
sourceCounts,
|
||||
dominant: Object.entries(sourceCounts).sort((a, b) => b[1] - a[1])[0]?.[0]
|
||||
};
|
||||
}
|
||||
|
||||
// Generate trading signal
|
||||
generateSignal(symbol) {
|
||||
const sentiment = this.getAggregatedSentiment(symbol);
|
||||
|
||||
if (sentiment.confidence < this.config.signals.minConfidence) {
|
||||
return {
|
||||
signal: 'HOLD',
|
||||
reason: 'low_confidence',
|
||||
sentiment
|
||||
};
|
||||
}
|
||||
|
||||
// Check for contrarian opportunity (extreme sentiment)
|
||||
if (Math.abs(sentiment.score) >= this.config.signals.contrarianThreshold) {
|
||||
return {
|
||||
signal: sentiment.score > 0 ? 'CONTRARIAN_SELL' : 'CONTRARIAN_BUY',
|
||||
reason: 'extreme_sentiment',
|
||||
sentiment,
|
||||
warning: 'Contrarian signal - high risk'
|
||||
};
|
||||
}
|
||||
|
||||
// Standard signals
|
||||
const thresholds = this.config.thresholds;
|
||||
let signal, strength;
|
||||
|
||||
if (sentiment.score >= thresholds.strongBullish) {
|
||||
signal = 'STRONG_BUY';
|
||||
strength = 'high';
|
||||
} else if (sentiment.score >= thresholds.bullish) {
|
||||
signal = 'BUY';
|
||||
strength = 'medium';
|
||||
} else if (sentiment.score <= thresholds.strongBearish) {
|
||||
signal = 'STRONG_SELL';
|
||||
strength = 'high';
|
||||
} else if (sentiment.score <= thresholds.bearish) {
|
||||
signal = 'SELL';
|
||||
strength = 'medium';
|
||||
} else {
|
||||
signal = 'HOLD';
|
||||
strength = 'low';
|
||||
}
|
||||
|
||||
return {
|
||||
signal,
|
||||
strength,
|
||||
sentiment,
|
||||
calibratedProbability: this.calibrateProbability(sentiment.score)
|
||||
};
|
||||
}
|
||||
|
||||
// Calibrate sentiment to win probability
|
||||
calibrateProbability(sentimentScore) {
|
||||
// Map sentiment [-1, 1] to probability [0.3, 0.7]
|
||||
// Apply shrinkage toward 0.5
|
||||
const rawProb = 0.5 + sentimentScore * 0.2;
|
||||
const shrinkage = this.config.calibration.shrinkageFactor;
|
||||
const calibrated = rawProb * (1 - shrinkage) + 0.5 * shrinkage;
|
||||
|
||||
return Math.max(0.3, Math.min(0.7, calibrated));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* News Sentiment Stream Processor
|
||||
* Processes incoming news for real-time sentiment
|
||||
*/
|
||||
class NewsSentimentStream {
|
||||
constructor(config = sentimentConfig) {
|
||||
this.aggregator = new SentimentAggregator(config);
|
||||
this.alerts = [];
|
||||
}
|
||||
|
||||
// Process news item
|
||||
processNews(item) {
|
||||
const { symbol, headline, source, timestamp } = item;
|
||||
|
||||
const observation = this.aggregator.addObservation(
|
||||
symbol,
|
||||
source || 'news',
|
||||
headline,
|
||||
timestamp || Date.now()
|
||||
);
|
||||
|
||||
// Check for significant sentiment
|
||||
if (Math.abs(observation.score) >= 0.5 && observation.confidence >= 0.6) {
|
||||
this.alerts.push({
|
||||
timestamp: Date.now(),
|
||||
symbol,
|
||||
score: observation.score,
|
||||
headline: headline.substring(0, 80)
|
||||
});
|
||||
}
|
||||
|
||||
return observation;
|
||||
}
|
||||
|
||||
// Process batch of news
|
||||
processBatch(items) {
|
||||
return items.map(item => this.processNews(item));
|
||||
}
|
||||
|
||||
// Get signals for all tracked symbols
|
||||
getAllSignals() {
|
||||
const signals = {};
|
||||
|
||||
for (const symbol of this.aggregator.sentimentHistory.keys()) {
|
||||
signals[symbol] = this.aggregator.generateSignal(symbol);
|
||||
}
|
||||
|
||||
return signals;
|
||||
}
|
||||
|
||||
// Get recent alerts
|
||||
getAlerts(limit = 10) {
|
||||
return this.alerts.slice(-limit);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alpha Factor Calculator
|
||||
* Converts sentiment to tradeable alpha factors
|
||||
*/
|
||||
class AlphaFactorCalculator {
|
||||
constructor(config = sentimentConfig) {
|
||||
this.config = config;
|
||||
this.factorHistory = new Map();
|
||||
}
|
||||
|
||||
// Calculate sentiment momentum factor
|
||||
sentimentMomentum(sentimentHistory, window = 5) {
|
||||
if (sentimentHistory.length < window) return 0;
|
||||
|
||||
const recent = sentimentHistory.slice(-window);
|
||||
const older = sentimentHistory.slice(-window * 2, -window);
|
||||
|
||||
const recentAvg = recent.reduce((a, b) => a + b.score, 0) / recent.length;
|
||||
const olderAvg = older.length > 0
|
||||
? older.reduce((a, b) => a + b.score, 0) / older.length
|
||||
: recentAvg;
|
||||
|
||||
return recentAvg - olderAvg;
|
||||
}
|
||||
|
||||
// Calculate sentiment reversal factor
|
||||
sentimentReversal(sentimentHistory, threshold = 0.7) {
|
||||
if (sentimentHistory.length < 2) return 0;
|
||||
|
||||
const current = sentimentHistory[sentimentHistory.length - 1].score;
|
||||
const previous = sentimentHistory[sentimentHistory.length - 2].score;
|
||||
|
||||
// Large move in opposite direction = reversal
|
||||
if (Math.abs(current) > threshold && Math.sign(current) !== Math.sign(previous)) {
|
||||
return -current; // Contrarian
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Calculate sentiment dispersion (disagreement among sources)
|
||||
sentimentDispersion(observations) {
|
||||
if (observations.length < 2) return 0;
|
||||
|
||||
const scores = observations.map(o => o.score);
|
||||
const mean = scores.reduce((a, b) => a + b, 0) / scores.length;
|
||||
const variance = scores.reduce((a, b) => a + (b - mean) ** 2, 0) / scores.length;
|
||||
|
||||
return Math.sqrt(variance);
|
||||
}
|
||||
|
||||
// Calculate composite alpha factor
|
||||
calculateAlpha(symbol, aggregator) {
|
||||
const history = aggregator.sentimentHistory.get(symbol);
|
||||
if (!history || history.length < 5) {
|
||||
return { alpha: 0, confidence: 0, factors: {} };
|
||||
}
|
||||
|
||||
const sentiment = aggregator.getAggregatedSentiment(symbol);
|
||||
const momentum = this.sentimentMomentum(history);
|
||||
const reversal = this.sentimentReversal(history);
|
||||
const dispersion = this.sentimentDispersion(history.slice(-10));
|
||||
|
||||
// Composite alpha
|
||||
const levelWeight = 0.4;
|
||||
const momentumWeight = 0.3;
|
||||
const reversalWeight = 0.2;
|
||||
const dispersionPenalty = 0.1;
|
||||
|
||||
const alpha = (
|
||||
levelWeight * sentiment.score +
|
||||
momentumWeight * momentum +
|
||||
reversalWeight * reversal -
|
||||
dispersionPenalty * dispersion
|
||||
);
|
||||
|
||||
const confidence = sentiment.confidence * (1 - 0.5 * dispersion);
|
||||
|
||||
return {
|
||||
alpha: Math.max(-1, Math.min(1, alpha)),
|
||||
confidence,
|
||||
factors: {
|
||||
level: sentiment.score,
|
||||
momentum,
|
||||
reversal,
|
||||
dispersion
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate synthetic news for testing
|
||||
*/
|
||||
function generateSyntheticNews(symbols, numItems, seed = 42) {
|
||||
let rng = seed;
|
||||
const random = () => { rng = (rng * 9301 + 49297) % 233280; return rng / 233280; };
|
||||
|
||||
const headlines = {
|
||||
positive: [
|
||||
'{symbol} reports strong quarterly earnings, beats estimates',
|
||||
'{symbol} announces major partnership, stock surges',
|
||||
'Analysts upgrade {symbol} citing growth momentum',
|
||||
'{symbol} expands into new markets, revenue growth expected',
|
||||
'{symbol} innovation breakthrough drives optimistic outlook',
|
||||
'Record demand for {symbol} products exceeds forecasts'
|
||||
],
|
||||
negative: [
|
||||
'{symbol} misses earnings expectations, guidance lowered',
|
||||
'{symbol} faces regulatory concerns, shares decline',
|
||||
'Analysts downgrade {symbol} amid market uncertainty',
|
||||
'{symbol} announces layoffs as demand weakens',
|
||||
'{symbol} warns of supply chain risks impacting profits',
|
||||
'Investor concern grows over {symbol} debt levels'
|
||||
],
|
||||
neutral: [
|
||||
'{symbol} maintains steady performance in Q4',
|
||||
'{symbol} announces routine management changes',
|
||||
'{symbol} confirms participation in industry conference',
|
||||
'{symbol} files standard regulatory documents'
|
||||
]
|
||||
};
|
||||
|
||||
const sources = ['news', 'social', 'analyst', 'earnings'];
|
||||
const news = [];
|
||||
|
||||
for (let i = 0; i < numItems; i++) {
|
||||
const symbol = symbols[Math.floor(random() * symbols.length)];
|
||||
const sentiment = random();
|
||||
let category;
|
||||
|
||||
if (sentiment < 0.35) category = 'negative';
|
||||
else if (sentiment < 0.65) category = 'neutral';
|
||||
else category = 'positive';
|
||||
|
||||
const templates = headlines[category];
|
||||
const headline = templates[Math.floor(random() * templates.length)]
|
||||
.replace('{symbol}', symbol);
|
||||
|
||||
news.push({
|
||||
symbol,
|
||||
headline,
|
||||
source: sources[Math.floor(random() * sources.length)],
|
||||
timestamp: Date.now() - Math.floor(random() * 7 * 24 * 60 * 60 * 1000)
|
||||
});
|
||||
}
|
||||
|
||||
return news;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('═'.repeat(70));
|
||||
console.log('SENTIMENT ALPHA PIPELINE');
|
||||
console.log('═'.repeat(70));
|
||||
console.log();
|
||||
|
||||
// 1. Initialize analyzers
|
||||
console.log('1. Analyzer Initialization:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const lexicon = new LexiconAnalyzer();
|
||||
const embedding = new EmbeddingAnalyzer();
|
||||
const stream = new NewsSentimentStream();
|
||||
const alphaCalc = new AlphaFactorCalculator();
|
||||
|
||||
console.log(' Lexicon Analyzer: Financial sentiment lexicon loaded');
|
||||
console.log(' Embedding Analyzer: 64-dim embeddings configured');
|
||||
console.log(' Stream Processor: Ready for real-time processing');
|
||||
console.log();
|
||||
|
||||
// 2. Test lexicon analysis
|
||||
console.log('2. Lexicon Analysis Examples:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const testTexts = [
|
||||
'Strong earnings beat expectations, revenue growth accelerates',
|
||||
'Company warns of significant losses amid declining demand',
|
||||
'Quarterly results in line with modest estimates'
|
||||
];
|
||||
|
||||
for (const text of testTexts) {
|
||||
const result = lexicon.analyze(text);
|
||||
const sentiment = result.score > 0.3 ? 'Positive' : result.score < -0.3 ? 'Negative' : 'Neutral';
|
||||
console.log(` "${text.substring(0, 50)}..."`);
|
||||
console.log(` → Score: ${result.score.toFixed(3)}, Confidence: ${result.confidence.toFixed(2)}, ${sentiment}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
// 3. Generate and process synthetic news
|
||||
console.log('3. Synthetic News Processing:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const symbols = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA'];
|
||||
const news = generateSyntheticNews(symbols, 50);
|
||||
|
||||
const processed = stream.processBatch(news);
|
||||
console.log(` Processed ${processed.length} news items`);
|
||||
console.log(` Symbols tracked: ${symbols.join(', ')}`);
|
||||
console.log();
|
||||
|
||||
// 4. Aggregated sentiment
|
||||
console.log('4. Aggregated Sentiment by Symbol:');
|
||||
console.log('─'.repeat(70));
|
||||
console.log(' Symbol │ Score │ Confidence │ Count │ Dominant Source');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
for (const symbol of symbols) {
|
||||
const agg = stream.aggregator.getAggregatedSentiment(symbol);
|
||||
const dominant = agg.dominant || 'N/A';
|
||||
console.log(` ${symbol.padEnd(6)} │ ${agg.score.toFixed(3).padStart(7)} │ ${agg.confidence.toFixed(2).padStart(10)} │ ${agg.count.toString().padStart(5)} │ ${dominant}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// 5. Trading signals
|
||||
console.log('5. Trading Signals:');
|
||||
console.log('─'.repeat(70));
|
||||
console.log(' Symbol │ Signal │ Strength │ Calibrated Prob');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const signals = stream.getAllSignals();
|
||||
for (const [symbol, sig] of Object.entries(signals)) {
|
||||
const prob = sig.calibratedProbability ? (sig.calibratedProbability * 100).toFixed(1) + '%' : 'N/A';
|
||||
console.log(` ${symbol.padEnd(6)} │ ${(sig.signal || 'HOLD').padEnd(12)} │ ${(sig.strength || 'low').padEnd(8)} │ ${prob}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// 6. Alpha factors
|
||||
console.log('6. Alpha Factor Analysis:');
|
||||
console.log('─'.repeat(70));
|
||||
console.log(' Symbol │ Alpha │ Conf │ Level │ Momentum │ Dispersion');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
for (const symbol of symbols) {
|
||||
const alpha = alphaCalc.calculateAlpha(symbol, stream.aggregator);
|
||||
if (alpha.factors.level !== undefined) {
|
||||
console.log(` ${symbol.padEnd(6)} │ ${alpha.alpha.toFixed(3).padStart(6)} │ ${alpha.confidence.toFixed(2).padStart(5)} │ ${alpha.factors.level.toFixed(3).padStart(6)} │ ${alpha.factors.momentum.toFixed(3).padStart(8)} │ ${alpha.factors.dispersion.toFixed(3).padStart(10)}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
|
||||
// 7. Recent alerts
|
||||
console.log('7. Recent Sentiment Alerts:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
const alerts = stream.getAlerts(5);
|
||||
if (alerts.length > 0) {
|
||||
for (const alert of alerts) {
|
||||
const direction = alert.score > 0 ? '↑' : '↓';
|
||||
console.log(` ${direction} ${alert.symbol}: ${alert.headline}`);
|
||||
}
|
||||
} else {
|
||||
console.log(' No significant sentiment alerts');
|
||||
}
|
||||
console.log();
|
||||
|
||||
// 8. Integration example
|
||||
console.log('8. Kelly Criterion Integration Example:');
|
||||
console.log('─'.repeat(70));
|
||||
|
||||
// Simulated odds for AAPL
|
||||
const aaplSignal = signals['AAPL'];
|
||||
if (aaplSignal && aaplSignal.calibratedProbability) {
|
||||
const decimalOdds = 2.0; // Even money
|
||||
const winProb = aaplSignal.calibratedProbability;
|
||||
|
||||
// Calculate Kelly
|
||||
const b = decimalOdds - 1;
|
||||
const fullKelly = (b * winProb - (1 - winProb)) / b;
|
||||
const fifthKelly = fullKelly * 0.2;
|
||||
|
||||
console.log(` AAPL Signal: ${aaplSignal.signal}`);
|
||||
console.log(` Calibrated Win Prob: ${(winProb * 100).toFixed(1)}%`);
|
||||
console.log(` At 2.0 odds (even money):`);
|
||||
console.log(` Full Kelly: ${(fullKelly * 100).toFixed(2)}%`);
|
||||
console.log(` 1/5th Kelly: ${(fifthKelly * 100).toFixed(2)}%`);
|
||||
|
||||
if (fifthKelly > 0) {
|
||||
console.log(` → Recommended: BET ${(fifthKelly * 100).toFixed(1)}% of bankroll`);
|
||||
} else {
|
||||
console.log(` → Recommended: NO BET (negative EV)`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
|
||||
console.log('═'.repeat(70));
|
||||
console.log('Sentiment Alpha Pipeline demonstration completed');
|
||||
console.log('═'.repeat(70));
|
||||
}
|
||||
|
||||
export {
|
||||
SentimentAggregator,
|
||||
NewsSentimentStream,
|
||||
AlphaFactorCalculator,
|
||||
LexiconAnalyzer,
|
||||
EmbeddingAnalyzer,
|
||||
sentimentConfig
|
||||
};
|
||||
|
||||
main().catch(console.error);
|
||||
Reference in New Issue
Block a user