/**
* Portfolio Optimization Quickstart - Frontend Application
*/
// =============================================================================
// Global State
// =============================================================================
let autoRefreshIntervalId = null;
let demoDataId = "SMALL";
let scheduleId = null;
let loadedPlan = null;
// Chart instances
let sectorChart = null;
let returnsChart = null;
// Sorting state for stock table
let sortColumn = 'predictedReturn';
let sortDirection = 'desc';
// Sector colors (consistent across charts and badges)
const SECTOR_COLORS = {
'Technology': '#3b82f6',
'Healthcare': '#10b981',
'Finance': '#eab308',
'Energy': '#ef4444',
'Consumer': '#a855f7'
};
// =============================================================================
// Initialization
// =============================================================================
$(document).ready(function() {
// Initialize header/footer with nav tabs
replaceQuickstartSolverForgeAutoHeaderFooter();
// Initialize Bootstrap tooltips
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
[...tooltipTriggerList].map(el => new bootstrap.Tooltip(el));
// Load initial data
loadDemoData();
// Event handlers
$("#solveButton").click(solve);
$("#stopSolvingButton").click(stopSolving);
$("#analyzeButton").click(analyze);
$("#dataSelector").change(function() {
demoDataId = $(this).val();
loadDemoData();
});
$("#showSelectedOnly").change(renderAllStocksTable);
// Sortable table headers
$("#allStocksTable").closest("table").find("th[data-sort]").click(function() {
const column = $(this).data("sort");
if (sortColumn === column) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortColumn = column;
sortDirection = 'desc';
}
updateSortIndicators();
renderAllStocksTable();
});
// Initialize advanced configuration if available (from config.js)
if (typeof initAdvancedConfig === 'function') {
initAdvancedConfig();
}
});
// =============================================================================
// jQuery AJAX Extensions
// =============================================================================
$.ajaxSetup({
contentType: "application/json",
accepts: {
json: "application/json"
}
});
$.put = function(url, data) {
return $.ajax({
url: url,
type: "PUT",
data: JSON.stringify(data),
contentType: "application/json"
});
};
$.delete = function(url) {
return $.ajax({
url: url,
type: "DELETE"
});
};
// =============================================================================
// Data Loading
// =============================================================================
function loadDemoData() {
$.getJSON("/demo-data/" + demoDataId)
.done(function(data) {
loadedPlan = data;
scheduleId = null;
// Apply advanced config if available (from config.js)
if (typeof applyConfigToLoadedPlan === 'function') {
applyConfigToLoadedPlan();
}
refreshUI();
updateScore("Score: ?");
})
.fail(function(xhr) {
showError("Failed to load demo data", xhr);
});
}
// =============================================================================
// Solving
// =============================================================================
function solve() {
if (!loadedPlan) {
showSimpleError("Please load demo data first");
return;
}
$.ajax({
url: "/portfolios",
type: "POST",
data: JSON.stringify(loadedPlan),
contentType: "application/json"
})
.done(function(data) {
scheduleId = data;
refreshSolvingButtons(true);
})
.fail(function(xhr) {
showError("Failed to start solver", xhr);
});
}
function stopSolving() {
if (!scheduleId) return;
$.delete("/portfolios/" + scheduleId)
.done(function(data) {
loadedPlan = data;
refreshSolvingButtons(false);
refreshUI();
})
.fail(function(xhr) {
showError("Failed to stop solver", xhr);
});
}
function refreshSolvingButtons(solving) {
if (solving) {
$("#solveButton").hide();
$("#stopSolvingButton").show();
$("#solvingSpinner").addClass("active");
$(".kpi-value").addClass("solving-pulse");
if (autoRefreshIntervalId == null) {
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
}
} else {
$("#solveButton").show();
$("#stopSolvingButton").hide();
$("#solvingSpinner").removeClass("active");
$(".kpi-value").removeClass("solving-pulse");
if (autoRefreshIntervalId != null) {
clearInterval(autoRefreshIntervalId);
autoRefreshIntervalId = null;
}
}
}
function refreshSchedule() {
if (!scheduleId) return;
$.getJSON("/portfolios/" + scheduleId)
.done(function(data) {
loadedPlan = data;
refreshUI();
// Check if solver is done
if (data.solverStatus === "NOT_SOLVING") {
refreshSolvingButtons(false);
}
})
.fail(function(xhr) {
showError("Failed to refresh solution", xhr);
refreshSolvingButtons(false);
});
}
// =============================================================================
// Score Analysis
// =============================================================================
function analyze() {
if (!loadedPlan) {
showSimpleError("Please load demo data first");
return;
}
$.put("/portfolios/analyze", loadedPlan)
.done(function(data) {
showConstraintAnalysis(data);
})
.fail(function(xhr) {
showError("Failed to analyze constraints", xhr);
});
}
function showConstraintAnalysis(analysis) {
const tbody = $("#weightModalContent tbody");
tbody.empty();
// Update modal label with score
const score = loadedPlan.score || "?";
$("#weightModalLabel").text(score);
// Sort constraints: hard first, then by score impact
const constraints = analysis.constraints || [];
constraints.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "HARD" ? -1 : 1;
}
return Math.abs(b.score) - Math.abs(a.score);
});
constraints.forEach(function(c) {
const icon = c.score === 0
? ''
: '';
const typeClass = c.type === "HARD" ? "text-danger" : "text-warning";
const scoreClass = c.score === 0 ? "text-success" : "text-danger";
tbody.append(`
| ${icon} |
${c.name} |
${c.type} |
${c.weight || '-'} |
${c.matchCount || 0} |
${c.score} |
`);
});
// Show modal
const modal = new bootstrap.Modal(document.getElementById('weightModal'));
modal.show();
}
// =============================================================================
// UI Refresh
// =============================================================================
function refreshUI() {
updateKPIs();
updateScore();
updateCharts();
renderSelectedStocksTable();
renderAllStocksTable();
}
function updateKPIs() {
if (!loadedPlan || !loadedPlan.stocks) {
resetKPIs();
return;
}
const stocks = loadedPlan.stocks;
const selected = stocks.filter(s => s.selected);
const selectedCount = selected.length;
// Get target from plan, config module, or default to 20
let targetCount = loadedPlan.targetPositionCount || 20;
if (typeof getTargetStockCount === 'function') {
targetCount = getTargetStockCount();
}
// Selected stocks count (show target)
const selectedEl = $("#selectedCount");
selectedEl.text(selectedCount + "/" + targetCount);
if (selectedCount === targetCount) {
selectedEl.removeClass("text-warning").addClass("text-success");
} else if (selectedCount > 0) {
selectedEl.removeClass("text-success").addClass("text-warning");
} else {
selectedEl.removeClass("text-success text-warning");
}
$("#selectedBadge").text(selectedCount + " selected");
// Stock count badge
$("#stockCountBadge").text(stocks.length + " stocks");
// Use metrics from backend if available
if (loadedPlan.metrics) {
updateKPIsFromMetrics(loadedPlan.metrics);
} else {
// Fallback: calculate locally
updateKPIsLocally(selected, selectedCount);
}
}
function resetKPIs() {
$("#selectedCount").text("0/20");
$("#expectedReturn").text("0.00%").removeClass("positive negative");
$("#sectorCount").text("0");
$("#diversificationScore").text("0%").removeClass("text-success text-warning text-danger");
$("#maxSectorExposure").text("0%");
$("#returnVolatility").text("0.00%");
$("#sharpeProxy").text("0.00");
$("#herfindahlIndex").text("0.000");
$("#selectedBadge").text("0 selected");
}
function updateKPIsFromMetrics(metrics) {
// Expected return
const returnEl = $("#expectedReturn");
returnEl.text((metrics.expectedReturn * 100).toFixed(2) + "%");
returnEl.removeClass("positive negative");
returnEl.addClass(metrics.expectedReturn >= 0 ? "positive" : "negative");
// Sector count
$("#sectorCount").text(metrics.sectorCount);
// Diversification score (0-100%)
const divScore = (metrics.diversificationScore * 100).toFixed(0);
const divEl = $("#diversificationScore");
divEl.text(divScore + "%");
divEl.removeClass("text-success text-warning text-danger");
if (metrics.diversificationScore >= 0.7) {
divEl.addClass("text-success");
} else if (metrics.diversificationScore >= 0.5) {
divEl.addClass("text-warning");
} else {
divEl.addClass("text-danger");
}
// Max sector exposure
$("#maxSectorExposure").text((metrics.maxSectorExposure * 100).toFixed(1) + "%");
// Return volatility
$("#returnVolatility").text((metrics.returnVolatility * 100).toFixed(2) + "%");
// Sharpe proxy
const sharpeEl = $("#sharpeProxy");
sharpeEl.text(metrics.sharpeProxy.toFixed(2));
sharpeEl.removeClass("text-success text-warning text-danger");
if (metrics.sharpeProxy >= 1.0) {
sharpeEl.addClass("text-success");
} else if (metrics.sharpeProxy >= 0.5) {
sharpeEl.addClass("text-warning");
}
// HHI (Herfindahl-Hirschman Index)
const hhiEl = $("#herfindahlIndex");
hhiEl.text(metrics.herfindahlIndex.toFixed(3));
hhiEl.removeClass("text-success text-warning text-danger");
if (metrics.herfindahlIndex < 0.15) {
hhiEl.addClass("text-success");
} else if (metrics.herfindahlIndex < 0.25) {
hhiEl.addClass("text-warning");
} else {
hhiEl.addClass("text-danger");
}
}
function updateKPIsLocally(selected, selectedCount) {
// Expected return (weighted average)
const expectedReturn = selectedCount > 0
? selected.reduce((sum, s) => sum + s.predictedReturn, 0) / selectedCount
: 0;
const returnEl = $("#expectedReturn");
returnEl.text((expectedReturn * 100).toFixed(2) + "%");
returnEl.removeClass("positive negative");
returnEl.addClass(expectedReturn >= 0 ? "positive" : "negative");
// Sector count
const sectors = new Set(selected.map(s => s.sector));
$("#sectorCount").text(sectors.size);
if (selectedCount > 0) {
// Calculate sector weights and HHI locally
const sectorCounts = {};
selected.forEach(s => {
sectorCounts[s.sector] = (sectorCounts[s.sector] || 0) + 1;
});
const sectorWeights = Object.values(sectorCounts).map(c => c / selectedCount);
const hhi = sectorWeights.reduce((sum, w) => sum + w * w, 0);
const divScore = ((1 - hhi) * 100).toFixed(0);
const maxSector = Math.max(...sectorWeights) * 100;
$("#diversificationScore").text(divScore + "%");
$("#maxSectorExposure").text(maxSector.toFixed(1) + "%");
$("#herfindahlIndex").text(hhi.toFixed(3));
// Calculate volatility locally
const returns = selected.map(s => s.predictedReturn);
const meanReturn = returns.reduce((a, b) => a + b, 0) / returns.length;
const variance = returns.reduce((sum, r) => sum + Math.pow(r - meanReturn, 2), 0) / returns.length;
const volatility = Math.sqrt(variance);
$("#returnVolatility").text((volatility * 100).toFixed(2) + "%");
$("#sharpeProxy").text(volatility > 0 ? (expectedReturn / volatility).toFixed(2) : "0.00");
} else {
$("#diversificationScore").text("0%");
$("#maxSectorExposure").text("0%");
$("#returnVolatility").text("0.00%");
$("#sharpeProxy").text("0.00");
$("#herfindahlIndex").text("0.000");
}
}
function updateScore() {
if (!loadedPlan || !loadedPlan.score) {
$("#score").text("Score: ?");
return;
}
const score = loadedPlan.score;
const match = score.match(/(-?\d+)hard\/(-?\d+)soft/);
if (match) {
const hard = parseInt(match[1]);
const soft = parseInt(match[2]);
const isFeasible = hard === 0;
const scoreHtml = `
${hard}hard
${soft}soft
`;
$("#score").html(scoreHtml);
} else {
$("#score").text("Score: " + score);
}
}
// =============================================================================
// Charts
// =============================================================================
function updateCharts() {
updateSectorChart();
updateReturnsChart();
}
function updateSectorChart() {
const ctx = document.getElementById('sectorChart');
if (!ctx) return;
if (!loadedPlan || !loadedPlan.stocks) {
if (sectorChart) {
sectorChart.destroy();
sectorChart = null;
}
return;
}
const selected = loadedPlan.stocks.filter(s => s.selected);
if (selected.length === 0) {
if (sectorChart) {
sectorChart.destroy();
sectorChart = null;
}
return;
}
// Calculate sector counts
const sectorCounts = {};
selected.forEach(s => {
sectorCounts[s.sector] = (sectorCounts[s.sector] || 0) + 1;
});
const labels = Object.keys(sectorCounts);
const data = labels.map(s => (sectorCounts[s] / selected.length) * 100);
const colors = labels.map(s => SECTOR_COLORS[s] || '#64748b');
if (sectorChart) {
sectorChart.destroy();
}
sectorChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: colors,
borderWidth: 3,
borderColor: '#fff',
hoverOffset: 8
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '60%',
plugins: {
legend: {
position: 'right',
labels: {
padding: 16,
usePointStyle: true,
pointStyle: 'circle'
}
},
tooltip: {
callbacks: {
label: function(context) {
const value = context.raw.toFixed(1);
const count = sectorCounts[context.label];
return `${context.label}: ${value}% (${count} stocks)`;
}
}
}
}
}
});
}
function updateReturnsChart() {
const ctx = document.getElementById('returnsChart');
if (!ctx) return;
if (!loadedPlan || !loadedPlan.stocks) {
if (returnsChart) {
returnsChart.destroy();
returnsChart = null;
}
return;
}
const selected = loadedPlan.stocks.filter(s => s.selected);
if (selected.length === 0) {
if (returnsChart) {
returnsChart.destroy();
returnsChart = null;
}
return;
}
// Sort by return and take top 10
const top10 = [...selected]
.sort((a, b) => b.predictedReturn - a.predictedReturn)
.slice(0, 10);
const labels = top10.map(s => s.stockId);
const data = top10.map(s => s.predictedReturn * 100);
const colors = top10.map(s => SECTOR_COLORS[s.sector] || '#64748b');
if (returnsChart) {
returnsChart.destroy();
}
returnsChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Predicted Return (%)',
data: data,
backgroundColor: colors,
borderRadius: 6,
borderSkipped: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context) {
const stock = top10[context.dataIndex];
return `${stock.stockName}: ${context.raw.toFixed(2)}%`;
}
}
}
},
scales: {
x: {
beginAtZero: true,
grid: {
display: true,
color: 'rgba(0,0,0,0.05)'
},
title: {
display: true,
text: 'Predicted Return (%)',
color: '#64748b'
}
},
y: {
grid: {
display: false
}
}
}
}
});
}
// =============================================================================
// Tables
// =============================================================================
function renderSelectedStocksTable() {
const tbody = $("#selectedStocksTable");
if (!loadedPlan || !loadedPlan.stocks) {
tbody.html(`
|
No data loaded
|
`);
return;
}
const selected = loadedPlan.stocks
.filter(s => s.selected)
.sort((a, b) => b.predictedReturn - a.predictedReturn);
if (selected.length === 0) {
tbody.html(`
|
No stocks selected yet
|
`);
return;
}
const weightPerStock = 100 / selected.length;
tbody.html(selected.map(stock => {
const returnClass = stock.predictedReturn >= 0 ? 'return-positive' : 'return-negative';
const returnSign = stock.predictedReturn >= 0 ? '+' : '';
return `
| ${stock.stockId} |
${stock.stockName} |
${stock.sector} |
${returnSign}${(stock.predictedReturn * 100).toFixed(2)}% |
${weightPerStock.toFixed(2)}% |
`;
}).join(''));
}
function renderAllStocksTable() {
const tbody = $("#allStocksTable");
const showSelectedOnly = $("#showSelectedOnly").is(":checked");
if (!loadedPlan || !loadedPlan.stocks) {
tbody.html(`
|
No data loaded
|
`);
return;
}
let stocks = loadedPlan.stocks;
// Filter if needed
if (showSelectedOnly) {
stocks = stocks.filter(s => s.selected);
}
// Sort
stocks = [...stocks].sort((a, b) => {
let aVal = a[sortColumn];
let bVal = b[sortColumn];
// Handle boolean (selected)
if (sortColumn === 'selected') {
aVal = a.selected ? 1 : 0;
bVal = b.selected ? 1 : 0;
}
// Handle strings
if (typeof aVal === 'string') {
aVal = aVal.toLowerCase();
bVal = bVal.toLowerCase();
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
if (stocks.length === 0) {
tbody.html(`
|
No stocks match the filter
|
`);
return;
}
// Calculate weight per stock
const selectedCount = loadedPlan.stocks.filter(s => s.selected).length;
const weightPerStock = selectedCount > 0 ? (100 / selectedCount) : 0;
tbody.html(stocks.map(stock => {
const returnClass = stock.predictedReturn >= 0 ? 'return-positive' : 'return-negative';
const returnSign = stock.predictedReturn >= 0 ? '+' : '';
const rowClass = stock.selected ? 'stock-selected' : '';
const weight = stock.selected ? weightPerStock.toFixed(2) : '0.00';
return `
|
${stock.selected ? 'Yes' : 'No'}
|
${stock.stockId} |
${stock.stockName} |
${stock.sector} |
${returnSign}${(stock.predictedReturn * 100).toFixed(2)}% |
${weight}% |
`;
}).join(''));
}
function updateSortIndicators() {
const table = $("#allStocksTable").closest("table");
table.find("th").removeClass("sorted");
table.find("th i").removeClass("fa-sort-up fa-sort-down").addClass("fa-sort");
const th = table.find(`th[data-sort="${sortColumn}"]`);
th.addClass("sorted");
th.find("i")
.removeClass("fa-sort")
.addClass(sortDirection === 'asc' ? 'fa-sort-up' : 'fa-sort-down');
}
// =============================================================================
// Header/Footer with Nav Tabs
// =============================================================================
function replaceQuickstartSolverForgeAutoHeaderFooter() {
const solverforgeHeader = $("header#solverforge-auto-header");
if (solverforgeHeader != null) {
solverforgeHeader.css("background-color", "#ffffff");
solverforgeHeader.append(
$(``)
);
}
const solverforgeFooter = $("footer#solverforge-auto-footer");
if (solverforgeFooter != null) {
solverforgeFooter.append(
$(``)
);
}
}
// =============================================================================
// Utility Functions
// =============================================================================
function copyCode(button) {
const codeBlock = $(button).closest('.code-block').find('code');
const text = codeBlock.text();
navigator.clipboard.writeText(text).then(function() {
const originalHtml = $(button).html();
$(button).html(' Copied!');
setTimeout(function() {
$(button).html(originalHtml);
}, 2000);
});
}
// Make copyCode available globally
window.copyCode = copyCode;