quantum-hybrid-portfolio / web /src /lib /simulationEngine.js
rocRevyAreGoals15's picture
Initial HF Space deployment
9e89154
Raw
History Blame Contribute Delete
25.9 kB
/**
* Simulation engine for the Quantum Portfolio Lab.
* Contains seeded random, market data generation, QSW optimization,
* benchmark runners, equity curve simulation, VaR computation, and helpers.
*/
// ─── SEEDED RANDOM ───
let seed = 42;
export function seededRandom() {
seed = (seed * 16807 + 0) % 2147483647;
return (seed - 1) / 2147483646;
}
export function resetSeed(s = 42) {
seed = s;
}
// ─── DEFAULT TICKERS / SECTORS ───
export const DEFAULT_TICKERS = [
"AAPL","MSFT","GOOGL","AMZN","META","NVDA","TSLA","JPM","V","JNJ",
"PG","UNH","HD","MA","BAC","DIS","NFLX","KO","PFE","CVX",
"WMT","MRK","ABT","ADBE","NKE","PEP","T","VZ","PYPL","BRK",
];
export const DEFAULT_SECTORS = [
"Tech","Tech","Tech","Tech","Tech","Tech","Tech","Finance","Finance","Health",
"Consumer","Health","Consumer","Finance","Finance","Consumer","Tech","Consumer",
"Health","Energy","Consumer","Health","Health","Tech","Consumer","Consumer",
"Telecom","Telecom","Tech","Finance",
];
// ─── MARKET DATA GENERATION ───
export function generateMarketData(nAssets, nDays, regime, seedVal, customTickerList) {
resetSeed(seedVal);
const regimeParams = {
bull: { drift: 0.0008, vol: 0.012, corrBase: 0.3 },
bear: { drift: -0.0003, vol: 0.022, corrBase: 0.6 },
volatile: { drift: 0.0002, vol: 0.028, corrBase: 0.45 },
normal: { drift: 0.0004, vol: 0.015, corrBase: 0.35 },
};
const { drift, vol, corrBase } = regimeParams[regime];
const rawNames = (Array.isArray(customTickerList) && customTickerList.length > 0) ? customTickerList : DEFAULT_TICKERS;
const names = [];
for (let i = 0; i < nAssets; i++) names.push(rawNames[i] || `A${i}`);
const sectors = DEFAULT_SECTORS;
const assets = [];
for (let i = 0; i < nAssets; i++) {
const assetDrift = drift + (seededRandom() - 0.4) * 0.001;
const assetVol = vol * (0.7 + seededRandom() * 0.6);
const returns = [];
for (let d = 0; d < nDays; d++) {
const r = assetDrift + assetVol * (seededRandom() + seededRandom() + seededRandom() - 1.5) * 0.816;
returns.push(r);
}
const annReturn = returns.reduce((a, b) => a + b, 0) / nDays * 252;
const annVol = Math.sqrt(returns.map(r => r * r).reduce((a, b) => a + b, 0) / nDays) * Math.sqrt(252);
assets.push({ name: names[i] || `A${i}`, sector: sectors[i] || "Other", returns, annReturn, annVol, sharpe: annReturn / (annVol || 1) });
}
const corr = [];
for (let i = 0; i < nAssets; i++) {
corr[i] = [];
for (let j = 0; j < nAssets; j++) {
if (i === j) corr[i][j] = 1;
else if (j < i) corr[i][j] = corr[j][i];
else {
const sameSector = sectors[i] === sectors[j] ? 0.2 : 0;
corr[i][j] = Math.max(-0.3, Math.min(0.95, corrBase + sameSector + (seededRandom() - 0.5) * 0.4));
}
}
}
return { assets, corr, regime };
}
// ─── HELPER METRICS ───
export function calculatePortfolioScore(weights, data, objective) {
const n = data.assets.length;
let portReturn = 0, portVar = 0;
for (let i = 0; i < n; i++) {
portReturn += weights[i] * data.assets[i].annReturn;
for (let j = 0; j < n; j++) {
portVar += weights[i] * weights[j] * data.corr[i][j] * data.assets[i].annVol * data.assets[j].annVol;
}
}
const portVol = Math.sqrt(Math.max(0, portVar));
const sharpe = portReturn / (portVol || 1);
const diversification = 1 / (weights.reduce((a, b) => a + b * b, 0) || 1);
switch (objective) {
case 'diversification':
return sharpe * 0.7 + diversification * 0.3;
case 'momentum': {
const momentumScore = weights.reduce((sum, w, i) => sum + w * data.assets[i].sharpe, 0);
return sharpe * 0.6 + momentumScore * 0.4;
}
case 'conservative':
return (sharpe > 0 ? sharpe : 0) * 0.8 + (1 / (portVol || 1)) * 0.2;
default:
return sharpe * 0.5 + diversification * 0.3 + (portReturn > 0 ? portReturn : 0) * 0.2;
}
}
export function calculateDiversificationRatio(weights, data) {
const portfolioVol = Math.sqrt(weights.reduce((variance, wi, i) => {
return variance + wi * wi * data.assets[i].annVol * data.assets[i].annVol +
weights.reduce((innerSum, wj, j) => {
if (i !== j) return innerSum + wi * wj * data.corr[i][j] * data.assets[i].annVol * data.assets[j].annVol;
return innerSum;
}, 0);
}, 0));
const weightedAvgVol = weights.reduce((sum, w, i) => sum + w * data.assets[i].annVol, 0);
return portfolioVol > 0 ? weightedAvgVol / portfolioVol : 1.0;
}
export function calculateInformationRatio(weights, data) {
const n = data.assets.length;
const equalWeights = new Array(n).fill(1 / n);
const portfolioReturn = weights.reduce((sum, w, i) => sum + w * data.assets[i].annReturn, 0);
const benchmarkReturn = equalWeights.reduce((sum, w, i) => sum + w * data.assets[i].annReturn, 0);
let trackingError = 0;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
trackingError += (weights[i] - equalWeights[i]) * (weights[j] - equalWeights[j]) *
data.corr[i][j] * data.assets[i].annVol * data.assets[j].annVol;
}
}
trackingError = Math.sqrt(Math.max(0, trackingError));
const excessReturn = portfolioReturn - benchmarkReturn;
return trackingError > 0 ? excessReturn / trackingError : 0;
}
export function calculateAlpha(weights, data) {
const n = data.assets.length;
const portfolioReturn = weights.reduce((sum, w, i) => sum + w * data.assets[i].annReturn, 0);
const benchmarkReturn = data.assets.reduce((sum, asset) => sum + asset.annReturn, 0) / n;
return portfolioReturn - benchmarkReturn;
}
export function calculateBeta(weights, data) {
const n = data.assets.length;
const marketWeights = new Array(n).fill(1 / n);
let marketVar = 0;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
marketVar += marketWeights[i] * marketWeights[j] * data.corr[i][j] * data.assets[i].annVol * data.assets[j].annVol;
}
}
let cov = 0;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
cov += weights[i] * marketWeights[j] * data.corr[i][j] * data.assets[i].annVol * data.assets[j].annVol;
}
}
return marketVar > 0 ? cov / marketVar : 1.0;
}
export function calculateRiskContributions(weights, data) {
const n = data.assets.length;
const portfolioVol = Math.sqrt(weights.reduce((variance, wi, i) => {
return variance + wi * wi * data.assets[i].annVol * data.assets[i].annVol +
weights.reduce((innerSum, wj, j) => {
if (i !== j) return innerSum + wi * wj * data.corr[i][j] * data.assets[i].annVol * data.assets[j].annVol;
return innerSum;
}, 0);
}, 0));
const contributions = [];
for (let i = 0; i < n; i++) {
let marginalContribution = 0;
for (let j = 0; j < n; j++) {
marginalContribution += weights[j] * data.corr[i][j] * data.assets[i].annVol * data.assets[j].annVol;
}
contributions.push(weights[i] * marginalContribution / (portfolioVol || 1));
}
return contributions;
}
export function calculateSectorExposures(weights, data) {
const sectors = {};
for (let i = 0; i < data.assets.length; i++) {
const sector = data.assets[i].sector || 'Unknown';
sectors[sector] = (sectors[sector] || 0) + (weights[i] || 0);
}
return sectors;
}
export function calculateSharpe(weights, data) {
const n = data.assets.length;
let portReturn = 0, portVar = 0;
for (let i = 0; i < n; i++) {
portReturn += weights[i] * data.assets[i].annReturn;
for (let j = 0; j < n; j++) {
portVar += weights[i] * weights[j] * data.corr[i][j] * data.assets[i].annVol * data.assets[j].annVol;
}
}
const portVol = Math.sqrt(Math.max(0, portVar));
return portReturn / (portVol || 1);
}
// ─── QSW OPTIMIZATION ───
export function runEnhancedQSWOptimization(data, omega, evolutionTime, maxWeight, turnoverLimit, evolutionMethod = 'continuous', objective = 'balanced') {
const n = data.assets.length;
const weights = new Array(n).fill(0);
const sharpes = data.assets.map(a => Math.max(0.01, a.sharpe + 0.5));
const totalSharpe = sharpes.reduce((a, b) => a + b, 0);
for (let i = 0; i < n; i++) {
let potential = sharpes[i] / totalSharpe;
let coupling = 0;
for (let j = 0; j < n; j++) {
if (i !== j) {
const diversBenefit = (1 - Math.abs(data.corr[i][j])) * sharpes[j] / totalSharpe;
coupling += diversBenefit;
}
}
weights[i] = (1 - omega) * potential + omega * coupling / (n - 1);
}
switch (evolutionMethod) {
case 'discrete': {
const dtSteps = evolutionTime * 2;
for (let step = 0; step < dtSteps; step++) {
const newWeights = [...weights];
for (let i = 0; i < n; i++) {
let neighborSum = 0;
let neighborCount = 0;
for (let j = 0; j < n; j++) {
if (i !== j && Math.abs(data.corr[i][j]) > 0.1) { neighborSum += weights[j]; neighborCount++; }
}
if (neighborCount > 0) newWeights[i] = (weights[i] + neighborSum / neighborCount) / 2;
}
for (let i = 0; i < n; i++) weights[i] = newWeights[i];
}
break;
}
case 'decoherent': {
const decoherenceRate = 0.15;
for (let i = 0; i < n; i++) weights[i] = (1 - decoherenceRate) * weights[i] + decoherenceRate * (1 / n);
break;
}
case 'adiabatic': {
const adiabaticSteps = evolutionTime * 3;
for (let step = 0; step < adiabaticSteps; step++) {
const s = step / adiabaticSteps;
const currentOmega = omega * s;
for (let i = 0; i < n; i++) {
let potential = sharpes[i] / totalSharpe;
let coupling = 0;
for (let j = 0; j < n; j++) {
if (i !== j) {
const diversBenefit = (1 - Math.abs(data.corr[i][j])) * sharpes[j] / totalSharpe;
coupling += diversBenefit;
}
}
weights[i] = (1 - currentOmega) * potential + currentOmega * coupling / (n - 1);
}
}
break;
}
case 'variational': {
let bestWeights = [...weights];
let bestScore = calculatePortfolioScore(weights, data, objective);
for (let trial = 0; trial < 5; trial++) {
const trialOmega = omega * (0.8 + seededRandom() * 0.4);
const trialWeights = new Array(n).fill(0);
for (let i = 0; i < n; i++) {
let potential = sharpes[i] / totalSharpe;
let coupling = 0;
for (let j = 0; j < n; j++) {
if (i !== j) {
const diversBenefit = (1 - Math.abs(data.corr[i][j])) * sharpes[j] / totalSharpe;
coupling += diversBenefit;
}
}
trialWeights[i] = (1 - trialOmega) * potential + trialOmega * coupling / (n - 1);
}
for (let i = 0; i < n; i++) trialWeights[i] = Math.min(trialWeights[i], maxWeight);
for (let i = 0; i < n; i++) if (trialWeights[i] < 0.005) trialWeights[i] = 0;
const trialSum = trialWeights.reduce((a, b) => a + b, 0);
if (trialSum > 0) for (let i = 0; i < n; i++) trialWeights[i] /= trialSum;
const trialScore = calculatePortfolioScore(trialWeights, data, objective);
if (trialScore > bestScore) { bestScore = trialScore; bestWeights = [...trialWeights]; }
}
for (let i = 0; i < n; i++) weights[i] = bestWeights[i];
break;
}
default: {
const smoothFactor = Math.exp(-evolutionTime / 50);
const equalW = 1 / n;
for (let i = 0; i < n; i++) weights[i] = weights[i] * (1 - smoothFactor) + equalW * smoothFactor;
}
}
switch (objective) {
case 'diversification':
for (let i = 0; i < n; i++) weights[i] = Math.min(weights[i], maxWeight * 0.8);
break;
case 'momentum':
for (let i = 0; i < n; i++) weights[i] = Math.min(weights[i], maxWeight * 1.2);
break;
case 'conservative':
for (let i = 0; i < n; i++) weights[i] = Math.min(weights[i], maxWeight * 0.7);
break;
default:
for (let i = 0; i < n; i++) weights[i] = Math.min(weights[i], maxWeight);
}
for (let i = 0; i < n; i++) if (weights[i] < 0.005) weights[i] = 0;
const sum = weights.reduce((a, b) => a + b, 0);
if (sum === 0) { for (let i = 0; i < n; i++) weights[i] = 1 / n; }
else { for (let i = 0; i < n; i++) weights[i] /= sum; }
let portReturn = 0, portVar = 0;
for (let i = 0; i < n; i++) {
portReturn += weights[i] * data.assets[i].annReturn;
for (let j = 0; j < n; j++) {
portVar += weights[i] * weights[j] * data.corr[i][j] * data.assets[i].annVol * data.assets[j].annVol;
}
}
const portVol = Math.sqrt(Math.max(0, portVar));
const sharpe = portReturn / (portVol || 1);
return {
weights, portReturn, portVol, sharpe,
nActive: weights.filter(w => w > 0.005).length,
diversificationRatio: calculateDiversificationRatio(weights, data),
informationRatio: calculateInformationRatio(weights, data),
alpha: calculateAlpha(weights, data),
beta: calculateBeta(weights, data),
riskContributions: calculateRiskContributions(weights, data),
sectorExposures: calculateSectorExposures(weights, data),
};
}
// ─── QUANTUM ANNEALING ───
export function runQuantumAnnealingOptimization(data, maxWeight) {
const n = data.assets.length;
const weights = new Array(n).fill(0);
for (let i = 0; i < n; i++) weights[i] = seededRandom();
const sum = weights.reduce((a, b) => a + b, 0);
for (let i = 0; i < n; i++) weights[i] /= sum;
const iterations = 100;
let currentSharpe = calculateSharpe(weights, data);
for (let iter = 0; iter < iterations; iter++) {
const neighborWeights = [...weights];
const temp = 100 * Math.exp(-iter / 20);
for (let i = 0; i < n; i++) {
neighborWeights[i] += (seededRandom() - 0.5) * 0.1;
neighborWeights[i] = Math.max(0, Math.min(maxWeight, neighborWeights[i]));
}
const neighborSum = neighborWeights.reduce((a, b) => a + b, 0);
for (let i = 0; i < n; i++) neighborWeights[i] /= neighborSum;
const neighborSharpe = calculateSharpe(neighborWeights, data);
if (neighborSharpe > currentSharpe || Math.exp(-(currentSharpe - neighborSharpe) / temp) > seededRandom()) {
for (let i = 0; i < n; i++) weights[i] = neighborWeights[i];
currentSharpe = neighborSharpe;
}
}
for (let i = 0; i < n; i++) weights[i] = Math.min(weights[i], maxWeight);
for (let i = 0; i < n; i++) if (weights[i] < 0.005) weights[i] = 0;
const finalSum = weights.reduce((a, b) => a + b, 0);
if (finalSum > 0) for (let i = 0; i < n; i++) weights[i] /= finalSum;
let portReturn = 0, portVar = 0;
for (let i = 0; i < n; i++) {
portReturn += weights[i] * data.assets[i].annReturn;
for (let j = 0; j < n; j++) {
portVar += weights[i] * weights[j] * data.corr[i][j] * data.assets[i].annVol * data.assets[j].annVol;
}
}
const portVol = Math.sqrt(Math.max(0, portVar));
const sharpe = portReturn / (portVol || 1);
return {
weights, portReturn, portVol, sharpe,
nActive: weights.filter(w => w > 0.005).length,
diversificationRatio: calculateDiversificationRatio(weights, data),
informationRatio: calculateInformationRatio(weights, data),
alpha: calculateAlpha(weights, data),
beta: calculateBeta(weights, data),
riskContributions: calculateRiskContributions(weights, data),
sectorExposures: calculateSectorExposures(weights, data),
};
}
// ─── HRP (Hierarchical Risk Parity - LΓ³pez de Prado) ───
function computeHRPWeights(data) {
const n = data.assets.length;
if (n <= 1) return new Array(n).fill(1);
const dist = [];
for (let i = 0; i < n; i++) {
dist[i] = [];
for (let j = 0; j < n; j++) {
dist[i][j] = Math.sqrt(0.5 * (1 - (data.corr[i]?.[j] ?? (i === j ? 1 : 0))));
}
}
const clusters = Array.from({ length: n }, (_, i) => [i]);
const active = new Array(n).fill(true);
while (active.filter(Boolean).length > 1) {
let minDist = Infinity, mergeA = -1, mergeB = -1;
for (let i = 0; i < clusters.length; i++) {
if (!active[i]) continue;
for (let j = i + 1; j < clusters.length; j++) {
if (!active[j]) continue;
let d = Infinity;
for (const a of clusters[i]) {
for (const b of clusters[j]) {
d = Math.min(d, dist[a][b]);
}
}
if (d < minDist) { minDist = d; mergeA = i; mergeB = j; }
}
}
if (mergeA < 0) break;
clusters.push([...clusters[mergeA], ...clusters[mergeB]]);
active.push(true);
active[mergeA] = false;
active[mergeB] = false;
}
const sortedIndices = clusters[clusters.length - 1] || Array.from({ length: n }, (_, i) => i);
function clusterVariance(indices) {
if (indices.length === 0) return 1;
const invV = indices.map(i => 1 / (data.assets[i].annVol * data.assets[i].annVol || 1));
const ivSum = invV.reduce((a, b) => a + b, 0) || 1;
const w = invV.map(v => v / ivSum);
let variance = 0;
for (let ii = 0; ii < indices.length; ii++) {
for (let jj = 0; jj < indices.length; jj++) {
const i = indices[ii], j = indices[jj];
variance += w[ii] * w[jj] * (data.corr[i]?.[j] ?? (i === j ? 1 : 0)) * data.assets[i].annVol * data.assets[j].annVol;
}
}
return Math.max(1e-10, variance);
}
const weights = new Array(n).fill(1);
function recursiveBisect(items) {
if (items.length <= 1) return;
const mid = Math.floor(items.length / 2);
const left = items.slice(0, mid);
const right = items.slice(mid);
const vL = clusterVariance(left);
const vR = clusterVariance(right);
const alpha = 1 - vL / (vL + vR);
for (const i of left) weights[i] *= alpha;
for (const i of right) weights[i] *= (1 - alpha);
recursiveBisect(left);
recursiveBisect(right);
}
recursiveBisect(sortedIndices);
const sum = weights.reduce((a, b) => a + b, 0) || 1;
return weights.map(w => w / sum);
}
// ─── BENCHMARKS ───
export function runBenchmarks(data) {
const n = data.assets.length;
const calc = (w) => {
let r = 0, v = 0;
for (let i = 0; i < n; i++) { r += w[i] * data.assets[i].annReturn; for (let j = 0; j < n; j++) v += w[i] * w[j] * data.corr[i][j] * data.assets[i].annVol * data.assets[j].annVol; }
const vol = Math.sqrt(Math.max(0, v));
return { weights: w, portReturn: r, portVol: vol, sharpe: r / (vol || 1) };
};
const ew = new Array(n).fill(1 / n);
const invVol = data.assets.map(a => 1 / (a.annVol || 1));
const ivSum = invVol.reduce((a, b) => a + b, 0);
const mv = invVol.map(v => v / ivSum);
const riskBudget = data.assets.map(a => 1 / (a.annVol * a.annVol || 1));
const rbSum = riskBudget.reduce((a, b) => a + b, 0);
const rp = riskBudget.map(v => v / rbSum);
const sharpeW = data.assets.map(a => Math.max(0, a.sharpe));
const swSum = sharpeW.reduce((a, b) => a + b, 0) || 1;
const ms = sharpeW.map(v => v / swSum);
const hrpW = computeHRPWeights(data);
return {
equalWeight: { name: "Equal Weight", ...calc(ew) },
minVariance: { name: "Min Variance", ...calc(mv) },
riskParity: { name: "Risk Parity", ...calc(rp) },
maxSharpe: { name: "Max Sharpe", ...calc(ms) },
hrp: { name: "HRP", ...calc(hrpW) },
};
}
// ─── UNIFIED OPTIMISATION DISPATCHER ───
export function runOptimisation(data, opts = {}) {
const { objective = "hybrid", K, KScreen, KSelect, wMax = 0.20 } = opts;
const n = data.assets.length;
const maxWeight = wMax;
switch (objective) {
case "equal_weight": {
const w = new Array(n).fill(1 / n);
return _metrics(w, data, null);
}
case "markowitz":
case "min_variance":
case "target_return": {
const r = runEnhancedQSWOptimization(data, 0.3, 10, maxWeight, 0.2, "continuous", objective === "markowitz" ? "balanced" : "conservative");
return _metrics(r.weights, data, null);
}
case "hrp": {
const w = computeHRPWeights(data);
return _metrics(w, data, null);
}
case "qubo_sa": {
const k = K || Math.min(Math.ceil(n * 0.4), 8);
const selected = data.assets.map((a, i) => ({ i, score: a.sharpe })).sort((a, b) => b.score - a.score).slice(0, k).map(x => x.i);
const w = new Array(n).fill(0);
selected.forEach(i => { w[i] = 1 / k; });
return _metrics(w, data, { stage2_selected_idx: selected, stage2_qubo_obj: 0, stage1_screened_count: n, stage3_sharpe: 0 });
}
case "vqe": {
const r = runEnhancedQSWOptimization(data, 0.25, 8, maxWeight, 0.2, "continuous", "balanced");
return _metrics(r.weights, data, null);
}
case "hybrid":
default: {
const kScr = KScreen || Math.min(Math.ceil(n * 0.6), 15);
const kSel = KSelect || Math.min(Math.ceil(kScr * 0.5), 5);
const screened = data.assets.map((a, i) => ({ i, score: a.sharpe + (1 - a.annVol) * 0.5 })).sort((a, b) => b.score - a.score).slice(0, kScr);
const selected = screened.slice(0, kSel);
const w = new Array(n).fill(0);
selected.forEach(s => { w[s.i] = 1 / kSel; });
const selectedNames = selected.map(s => data.assets[s.i].name);
const r = _metrics(w, data, {
stage1_screened_count: kScr,
stage2_selected_idx: selected.map(s => s.i),
stage2_selected_names: selectedNames,
stage2_qubo_obj: 0,
stage3_sharpe: 0,
});
if (r.stage_info) r.stage_info.stage3_sharpe = r.sharpe;
return r;
}
}
}
function _metrics(weights, data, stageInfo) {
const n = data.assets.length;
let portReturn = 0, portVar = 0;
for (let i = 0; i < n; i++) {
portReturn += weights[i] * data.assets[i].annReturn;
for (let j = 0; j < n; j++) {
portVar += weights[i] * weights[j] * data.corr[i][j] * data.assets[i].annVol * data.assets[j].annVol;
}
}
const portVol = Math.sqrt(Math.max(0, portVar));
return { weights, portReturn, portVol, sharpe: portReturn / (portVol || 1), nActive: weights.filter(w => w > 0.005).length, stage_info: stageInfo };
}
/**
* Portfolio metrics from a weight vector (same Ξ£, ΞΌ as runOptimisation) β€” no optimizer call.
* @param {number[]} weights
* @param {object} data lab data with assets + corr
*/
export function portfolioMetricsFromWeights(weights, data) {
const w = weights.map((x) => Number(x) || 0);
return _metrics(w, data, null);
}
/**
* Clip each weight to [wMin, wMax], then renormalize so sum β‰ˆ 1 (two passes for stability).
* @param {number[]} weights
* @param {number} wMin
* @param {number} wMax
* @returns {number[]}
*/
export function clipNormalizeWeights(weights, wMin, wMax) {
const n = weights.length;
if (n === 0) return [];
let w = weights.map((x) => Math.max(wMin, Math.min(wMax, Number(x) || 0)));
let s = w.reduce((a, b) => a + b, 0);
if (s <= 1e-15) {
w = new Array(n).fill(1 / n);
w = w.map((x) => Math.max(wMin, Math.min(wMax, x)));
s = w.reduce((a, b) => a + b, 0);
}
if (s > 0) w = w.map((x) => x / s);
w = w.map((x) => Math.max(wMin, Math.min(wMax, x)));
s = w.reduce((a, b) => a + b, 0);
if (s > 0) w = w.map((x) => x / s);
return w;
}
export function computeHRPWeightsArr(data) {
return computeHRPWeights(data);
}
// ─── EQUITY CURVE ───
export function simulateEquityCurve(data, weights, nDays) {
const curve = [{ day: 0, value: 100 }];
let val = 100;
for (let d = 0; d < Math.min(nDays, data.assets[0]?.returns.length || 0); d++) {
let dayReturn = 0;
for (let i = 0; i < weights.length; i++) dayReturn += weights[i] * (data.assets[i]?.returns[d] || 0);
val *= (1 + dayReturn);
if (d % 5 === 0) curve.push({ day: d + 1, value: val });
}
return curve;
}
/**
* Per-asset equity simulation: tracks each position's dollar value through time.
* Returns { total: [{day,value}], perAsset: [{name,day0,...dayN}], finalPositions }.
*/
export function simulatePerAssetEquity(data, weights, nDays, notional = 100000) {
const n = weights.length;
const maxD = Math.min(nDays, data.assets[0]?.returns.length || 0);
const positions = weights.map((w) => w * notional);
const snapDays = [0];
const totalCurve = [{ day: 0, value: notional }];
for (let d = 0; d < maxD; d++) {
for (let i = 0; i < n; i++) {
positions[i] *= 1 + (data.assets[i]?.returns[d] || 0);
}
if (d % 5 === 0) {
const day = d + 1;
snapDays.push(day);
totalCurve.push({ day, value: positions.reduce((a, b) => a + b, 0) });
}
}
const finalTotal = positions.reduce((a, b) => a + b, 0);
const finalPositions = data.assets.map((a, i) => ({
name: a.name,
sector: a.sector,
initAlloc: weights[i] * notional,
currentValue: positions[i],
pnl: positions[i] - weights[i] * notional,
pnlPct: weights[i] > 0 ? (positions[i] / (weights[i] * notional) - 1) * 100 : 0,
weight: weights[i],
}));
return {
total: totalCurve,
finalPositions,
summary: {
notional,
currentValue: finalTotal,
totalPnl: finalTotal - notional,
totalReturnPct: (finalTotal / notional - 1) * 100,
},
};
}
// ─── VaR ───
export function computeVaR(data, weights, confidence) {
const n = weights.length;
const nSim = 2000;
const losses = [];
resetSeed(123);
for (let s = 0; s < nSim; s++) {
let portReturn = 0;
for (let i = 0; i < n; i++) {
const dayIdx = Math.floor(seededRandom() * (data.assets[i]?.returns.length || 1));
portReturn += weights[i] * (data.assets[i]?.returns[dayIdx] || 0);
}
losses.push(-portReturn);
}
losses.sort((a, b) => a - b);
const varIdx = Math.floor(nSim * confidence);
const var95 = losses[varIdx] || 0;
const cvar = losses.slice(varIdx).reduce((a, b) => a + b, 0) / (nSim - varIdx || 1);
return { var95: var95 * 100, cvar: cvar * 100 };
}