Spaces:
Sleeping
Sleeping
| // Safe Choices - Simulation Controller | |
| // ===== STATE ===== | |
| let currentSimType = 'single'; | |
| let currentView = 'simulation'; | |
| let simulationResults = null; | |
| let charts = {}; | |
| // ===== INITIALIZATION ===== | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initializeApp(); | |
| }); | |
| function initializeApp() { | |
| setupEventListeners(); | |
| updateEstimatedTime(); | |
| initializeEmptyCharts(); | |
| } | |
| function setupEventListeners() { | |
| // Number input constraints | |
| const numSimulations = document.getElementById('numSimulations'); | |
| numSimulations.addEventListener('input', () => { | |
| numSimulations.value = Math.min(Math.max(parseInt(numSimulations.value) || 10, 10), 100); | |
| updateEstimatedTime(); | |
| }); | |
| const numFunds = document.getElementById('numFunds'); | |
| numFunds.addEventListener('input', () => { | |
| numFunds.value = Math.min(Math.max(parseInt(numFunds.value) || 2, 2), 10); | |
| updateEstimatedTime(); | |
| }); | |
| // Target return custom input toggle | |
| const targetReturn = document.getElementById('targetReturn'); | |
| const customTarget = document.getElementById('customTarget'); | |
| targetReturn.addEventListener('change', () => { | |
| if (targetReturn.value === 'custom') { | |
| customTarget.classList.add('visible'); | |
| customTarget.value = '15'; | |
| } else { | |
| customTarget.classList.remove('visible'); | |
| } | |
| }); | |
| // Validate inputs on change | |
| document.querySelectorAll('.param-input').forEach(input => { | |
| input.addEventListener('input', validateInputs); | |
| }); | |
| } | |
| // ===== VIEW SWITCHING ===== | |
| function selectView(view) { | |
| currentView = view; | |
| // Update toggle buttons | |
| document.querySelectorAll('.toggle-btn').forEach(btn => { | |
| btn.classList.toggle('active', btn.dataset.view === view); | |
| }); | |
| // Show/hide content | |
| const simContent = document.getElementById('simulationContent'); | |
| const methContent = document.getElementById('methodologyContent'); | |
| if (view === 'simulation') { | |
| simContent.style.display = 'flex'; | |
| methContent.style.display = 'none'; | |
| document.querySelectorAll('.simulation-view-only').forEach(el => { | |
| el.classList.remove('hidden'); | |
| }); | |
| } else { | |
| simContent.style.display = 'none'; | |
| methContent.style.display = 'block'; | |
| document.querySelectorAll('.simulation-view-only').forEach(el => { | |
| el.classList.add('hidden'); | |
| }); | |
| } | |
| } | |
| // ===== SIMULATION TYPE SWITCHING ===== | |
| function selectSimulation(type) { | |
| currentSimType = type; | |
| // Update strategy cards | |
| document.querySelectorAll('.strategy-card').forEach(card => { | |
| card.classList.toggle('active', card.dataset.sim === type); | |
| }); | |
| // Show/hide conditional parameters | |
| document.querySelectorAll('.threshold-only').forEach(el => { | |
| el.classList.toggle('visible', type === 'threshold'); | |
| }); | |
| document.querySelectorAll('.multi-only').forEach(el => { | |
| el.classList.toggle('visible', type === 'multi'); | |
| }); | |
| document.querySelectorAll('.kelly-only').forEach(el => { | |
| el.classList.toggle('visible', type === 'kelly'); | |
| }); | |
| updateEstimatedTime(); | |
| } | |
| // ===== VALIDATION ===== | |
| function validateInputs() { | |
| const errors = []; | |
| const minProb7d = parseFloat(document.getElementById('minProb7d').value); | |
| const minProbCurrent = parseFloat(document.getElementById('minProbCurrent').value); | |
| if (minProb7d < 50 || minProb7d > 99) { | |
| errors.push('7-day probability must be between 50% and 99%'); | |
| } | |
| if (minProbCurrent < 50 || minProbCurrent > 99) { | |
| errors.push('Current probability must be between 50% and 99%'); | |
| } | |
| const runBtn = document.getElementById('runBtn'); | |
| runBtn.disabled = errors.length > 0; | |
| return errors.length === 0; | |
| } | |
| // ===== TIME ESTIMATION ===== | |
| function updateEstimatedTime() { | |
| const numSims = parseInt(document.getElementById('numSimulations').value) || 100; | |
| const numFunds = currentSimType === 'multi' ? (parseInt(document.getElementById('numFunds').value) || 5) : 1; | |
| let baseTime = numSims * 0.05; | |
| if (currentSimType === 'multi') { | |
| baseTime *= Math.sqrt(numFunds); | |
| } | |
| const seconds = Math.max(2, Math.ceil(baseTime)); | |
| const timeText = seconds < 60 ? `~${seconds}s` : `~${Math.ceil(seconds / 60)}min`; | |
| document.getElementById('estimatedTime').textContent = timeText; | |
| } | |
| // ===== SIMULATION EXECUTION ===== | |
| async function runSimulation() { | |
| if (!validateInputs()) return; | |
| const params = getSimulationParameters(); | |
| showProgress(); | |
| disableControls(); | |
| try { | |
| const results = await callSimulationAPI(params); | |
| simulationResults = results; | |
| displayResults(results); | |
| } catch (error) { | |
| console.error('Simulation error:', error); | |
| alert('Simulation failed: ' + (error.message || 'Unknown error')); | |
| } finally { | |
| hideProgress(); | |
| enableControls(); | |
| } | |
| } | |
| function getSimulationParameters() { | |
| const params = { | |
| simType: currentSimType, | |
| startingCapital: parseFloat(document.getElementById('startingCapital').value), | |
| numSimulations: parseInt(document.getElementById('numSimulations').value), | |
| startDate: document.getElementById('startDate').value, | |
| maxDuration: parseInt(document.getElementById('maxDuration').value), | |
| minProb7d: parseFloat(document.getElementById('minProb7d').value) / 100, | |
| minProbCurrent: parseFloat(document.getElementById('minProbCurrent').value) / 100, | |
| daysBefore: parseInt(document.getElementById('daysBefore').value), | |
| investmentProbability: parseFloat(document.getElementById('investmentProbability').value), | |
| minVolume: parseFloat(document.getElementById('minVolume').value) | |
| }; | |
| if (currentSimType === 'threshold') { | |
| const targetSelect = document.getElementById('targetReturn').value; | |
| if (targetSelect === 'custom') { | |
| params.targetReturn = parseFloat(document.getElementById('customTarget').value) / 100; | |
| } else { | |
| params.targetReturn = parseFloat(targetSelect) / 100; | |
| } | |
| } | |
| if (currentSimType === 'multi') { | |
| params.numFunds = parseInt(document.getElementById('numFunds').value); | |
| } | |
| if (currentSimType === 'kelly') { | |
| params.kellyFraction = parseFloat(document.getElementById('kellyFraction').value); | |
| params.edgeEstimate = document.getElementById('edgeEstimate').value; | |
| } | |
| return params; | |
| } | |
| async function callSimulationAPI(params) { | |
| const progressFill = document.getElementById('progressFill'); | |
| const progressPercent = document.getElementById('progressPercent'); | |
| const progressText = document.getElementById('progressText'); | |
| progressText.textContent = 'Connecting to server...'; | |
| progressFill.style.width = '10%'; | |
| progressPercent.textContent = '10%'; | |
| const requestBody = { | |
| simType: params.simType, | |
| startingCapital: params.startingCapital, | |
| numSimulations: params.numSimulations, | |
| startDate: params.startDate, | |
| maxDuration: params.maxDuration, | |
| minProb7d: params.minProb7d, | |
| minProbCurrent: params.minProbCurrent, | |
| daysBefore: params.daysBefore, | |
| investmentProbability: params.investmentProbability, | |
| minVolume: params.minVolume | |
| }; | |
| if (params.targetReturn !== undefined) { | |
| requestBody.targetReturn = params.targetReturn; | |
| } | |
| if (params.numFunds !== undefined) { | |
| requestBody.numFunds = params.numFunds; | |
| } | |
| progressText.textContent = 'Running simulation...'; | |
| progressFill.style.width = '30%'; | |
| progressPercent.textContent = '30%'; | |
| const response = await fetch('/api/simulate', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(requestBody) | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json().catch(() => ({ detail: response.statusText })); | |
| throw new Error(errorData.detail || `HTTP ${response.status}`); | |
| } | |
| progressText.textContent = 'Processing results...'; | |
| progressFill.style.width = '80%'; | |
| progressPercent.textContent = '80%'; | |
| const results = await response.json(); | |
| progressFill.style.width = '100%'; | |
| progressPercent.textContent = '100%'; | |
| progressText.textContent = 'Complete!'; | |
| return results; | |
| } | |
| // ===== RESULTS DISPLAY ===== | |
| function displayResults(results) { | |
| const { summary, parameters, runs } = results; | |
| // Show results section | |
| const resultsSection = document.getElementById('resultsSection'); | |
| resultsSection.classList.add('visible'); | |
| resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| // Calculate average return of survivors | |
| const survivors = runs.filter(r => !r.wentBust); | |
| const avgReturnSurvivors = survivors.length > 0 | |
| ? survivors.reduce((sum, r) => sum + r.totalReturn, 0) / survivors.length | |
| : null; | |
| // Primary metrics | |
| updateMetric('avgReturn', summary.avgReturn, true); | |
| updateMetric('avgReturnSurvivors', avgReturnSurvivors, true); | |
| updateMetric('successRate', summary.positiveReturnRate); | |
| updateMetric('bustRate', summary.bustRate, false, true); | |
| // Risk metrics | |
| document.getElementById('volatility').textContent = formatPercentage(summary.returnVolatility); | |
| document.getElementById('maxDrawdown').textContent = formatPercentage(summary.maxDrawdown); | |
| document.getElementById('percentile5').textContent = formatPercentage(summary.return5th || -1); | |
| document.getElementById('percentile95').textContent = formatPercentage(summary.return95th || 0); | |
| // Type-specific stats | |
| if (parameters.simType === 'multi') { | |
| document.querySelectorAll('.multi-stats, .multi-chart').forEach(el => { | |
| el.classList.add('visible'); | |
| }); | |
| document.getElementById('avgSurvivingFunds').textContent = | |
| `${summary.avgSurvivingFunds?.toFixed(1) || '--'} / ${parameters.numFunds}`; | |
| document.getElementById('survivorshipRate').textContent = formatPercentage(summary.survivorshipRate || 0); | |
| document.getElementById('diversificationBenefit').textContent = | |
| summary.returnVolatility < 0.3 ? 'Positive' : 'Limited'; | |
| } else { | |
| document.querySelectorAll('.multi-stats, .multi-chart').forEach(el => { | |
| el.classList.remove('visible'); | |
| }); | |
| } | |
| if (parameters.simType === 'threshold') { | |
| document.querySelectorAll('.threshold-stats').forEach(el => { | |
| el.classList.add('visible'); | |
| }); | |
| document.getElementById('targetReached').textContent = formatPercentage(summary.targetReachedRate || 0); | |
| document.getElementById('avgTimeToTarget').textContent = | |
| summary.avgTimeToTarget ? `${Math.round(summary.avgTimeToTarget)} days` : 'N/A'; | |
| document.getElementById('vsNeverStop').textContent = | |
| (summary.targetReachedRate || 0) > 0.5 ? 'Better' : 'Similar'; | |
| } else { | |
| document.querySelectorAll('.threshold-stats').forEach(el => { | |
| el.classList.remove('visible'); | |
| }); | |
| } | |
| if (parameters.simType === 'kelly') { | |
| document.querySelectorAll('.kelly-stats').forEach(el => { | |
| el.classList.add('visible'); | |
| }); | |
| document.getElementById('avgBetSize').textContent = formatPercentage(summary.avgBetSize || 0); | |
| document.getElementById('avgEdge').textContent = formatPercentage(summary.avgEdge || 0); | |
| document.getElementById('betsSkipped').textContent = | |
| summary.betsSkipped !== undefined ? `${summary.betsSkipped.toFixed(0)}` : '--'; | |
| } else { | |
| document.querySelectorAll('.kelly-stats').forEach(el => { | |
| el.classList.remove('visible'); | |
| }); | |
| } | |
| // Generate charts | |
| generateCharts(results); | |
| } | |
| function updateMetric(id, value, showSign = false, isRisk = false) { | |
| const el = document.getElementById(id); | |
| el.textContent = formatPercentage(value); | |
| // Add color classes | |
| el.classList.remove('positive', 'negative', 'high-risk'); | |
| if (isRisk) { | |
| if (value > 0.1) el.classList.add('high-risk'); | |
| } else if (showSign) { | |
| if (value > 0.02) el.classList.add('positive'); | |
| else if (value < -0.02) el.classList.add('negative'); | |
| } | |
| } | |
| // ===== CHARTS ===== | |
| function initializeEmptyCharts() { | |
| charts.return = createEmptyChart('returnChart', 'bar'); | |
| charts.capital = createEmptyChart('capitalChart', 'line'); | |
| charts.survivorship = createEmptyChart('survivorshipChart', 'bar'); | |
| } | |
| function createEmptyChart(canvasId, type) { | |
| const ctx = document.getElementById(canvasId).getContext('2d'); | |
| return new Chart(ctx, { | |
| type: type, | |
| data: { labels: [], datasets: [] }, | |
| options: getChartOptions(type) | |
| }); | |
| } | |
| function generateCharts(results) { | |
| const { runs, parameters } = results; | |
| // Destroy existing charts | |
| Object.values(charts).forEach(chart => chart?.destroy()); | |
| charts = {}; | |
| // Return distribution | |
| charts.return = createReturnDistributionChart(runs); | |
| // Capital evolution | |
| charts.capital = createCapitalEvolutionChart(runs); | |
| // Survivorship (multi-fund only) | |
| if (parameters.simType === 'multi') { | |
| charts.survivorship = createSurvivorshipChart(runs, parameters.numFunds); | |
| } else { | |
| charts.survivorship = createEmptyChart('survivorshipChart', 'bar'); | |
| } | |
| } | |
| function createReturnDistributionChart(runs) { | |
| const ctx = document.getElementById('returnChart').getContext('2d'); | |
| const returns = runs.map(r => r.totalReturn * 100); | |
| const minReturn = Math.min(...returns); | |
| const maxReturn = Math.max(...returns); | |
| const binStart = Math.floor(minReturn / 10) * 10; | |
| const binEnd = Math.min(Math.ceil(maxReturn / 10) * 10, 200); | |
| const bins = []; | |
| for (let i = binStart; i <= binEnd; i += 5) bins.push(i); | |
| const binCounts = new Array(bins.length).fill(0); | |
| returns.forEach(ret => { | |
| for (let i = 0; i < bins.length - 1; i++) { | |
| if (ret >= bins[i] && ret < bins[i + 1]) { | |
| binCounts[i]++; | |
| break; | |
| } | |
| } | |
| if (ret >= bins[bins.length - 1]) binCounts[bins.length - 1]++; | |
| }); | |
| return new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: bins.map(b => `${b}%`), | |
| datasets: [{ | |
| label: 'Frequency', | |
| data: binCounts, | |
| backgroundColor: 'rgba(59, 130, 246, 0.6)', | |
| borderColor: 'rgba(59, 130, 246, 1)', | |
| borderWidth: 1, | |
| borderRadius: 4 | |
| }] | |
| }, | |
| options: getChartOptions('bar') | |
| }); | |
| } | |
| function createCapitalEvolutionChart(runs) { | |
| const ctx = document.getElementById('capitalChart').getContext('2d'); | |
| const colors = [ | |
| { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' }, | |
| { border: '#10b981', bg: 'rgba(16, 185, 129, 0.1)' }, | |
| { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' }, | |
| { border: '#ef4444', bg: 'rgba(239, 68, 68, 0.1)' }, | |
| { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.1)' } | |
| ]; | |
| const sampleRuns = runs.slice(0, 5); | |
| const datasets = sampleRuns.map((run, i) => { | |
| let data = []; | |
| if (run.capitalHistory?.length > 0) { | |
| data = run.capitalHistory.map((item, idx) => ({ | |
| x: typeof item === 'object' ? item.day : idx, | |
| y: typeof item === 'object' ? item.capital : item | |
| })); | |
| } else { | |
| data = [{ x: 0, y: 10000 }, { x: 1, y: run.finalCapital }]; | |
| } | |
| return { | |
| label: `Run ${i + 1}`, | |
| data: data, | |
| borderColor: colors[i].border, | |
| backgroundColor: colors[i].bg, | |
| borderWidth: 2, | |
| fill: false, | |
| tension: 0.3, | |
| pointRadius: 0 | |
| }; | |
| }); | |
| return new Chart(ctx, { | |
| type: 'line', | |
| data: { datasets }, | |
| options: getChartOptions('line') | |
| }); | |
| } | |
| function createSurvivorshipChart(runs, numFunds) { | |
| const ctx = document.getElementById('survivorshipChart').getContext('2d'); | |
| const survivingCounts = runs.map(r => r.survivingFunds); | |
| const labels = []; | |
| const data = []; | |
| for (let i = 0; i <= numFunds; i++) { | |
| labels.push(i.toString()); | |
| data.push(survivingCounts.filter(c => c === i).length); | |
| } | |
| return new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: labels, | |
| datasets: [{ | |
| label: 'Frequency', | |
| data: data, | |
| backgroundColor: 'rgba(139, 92, 246, 0.6)', | |
| borderColor: 'rgba(139, 92, 246, 1)', | |
| borderWidth: 1, | |
| borderRadius: 4 | |
| }] | |
| }, | |
| options: getChartOptions('bar') | |
| }); | |
| } | |
| function getChartOptions(type) { | |
| const baseOptions = { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { | |
| legend: { | |
| display: type === 'line', | |
| labels: { | |
| color: '#9ca3af', | |
| font: { size: 11 }, | |
| usePointStyle: true, | |
| padding: 16 | |
| } | |
| } | |
| }, | |
| scales: { | |
| x: { | |
| grid: { color: 'rgba(255, 255, 255, 0.05)' }, | |
| ticks: { color: '#6b7280', font: { size: 10 } } | |
| }, | |
| y: { | |
| grid: { color: 'rgba(255, 255, 255, 0.05)' }, | |
| ticks: { color: '#6b7280', font: { size: 10 } } | |
| } | |
| } | |
| }; | |
| if (type === 'line') { | |
| baseOptions.scales.x.type = 'linear'; | |
| } | |
| return baseOptions; | |
| } | |
| // ===== EXPORT ===== | |
| function exportResults() { | |
| if (!simulationResults) return; | |
| const data = { | |
| timestamp: new Date().toISOString(), | |
| parameters: simulationResults.parameters, | |
| summary: simulationResults.summary, | |
| runs: simulationResults.runs | |
| }; | |
| const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `safe_choices_${currentSimType}_${Date.now()}.json`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| // ===== UI HELPERS ===== | |
| function showProgress() { | |
| document.getElementById('progressPanel').classList.add('visible'); | |
| document.getElementById('progressFill').style.width = '0%'; | |
| } | |
| function hideProgress() { | |
| setTimeout(() => { | |
| document.getElementById('progressPanel').classList.remove('visible'); | |
| }, 500); | |
| } | |
| function disableControls() { | |
| const runBtn = document.getElementById('runBtn'); | |
| runBtn.disabled = true; | |
| runBtn.classList.add('running'); | |
| document.querySelector('.run-text').textContent = 'Running...'; | |
| } | |
| function enableControls() { | |
| const runBtn = document.getElementById('runBtn'); | |
| runBtn.disabled = false; | |
| runBtn.classList.remove('running'); | |
| document.querySelector('.run-text').textContent = 'Run Simulation'; | |
| } | |
| function formatPercentage(value) { | |
| if (value === null || value === undefined || isNaN(value)) return '--'; | |
| return `${(value * 100).toFixed(1)}%`; | |
| } | |
| // ===== UTILITY FUNCTIONS ===== | |
| function mean(arr) { | |
| return arr.reduce((a, b) => a + b, 0) / arr.length; | |
| } | |
| function median(arr) { | |
| const sorted = [...arr].sort((a, b) => a - b); | |
| const mid = Math.floor(sorted.length / 2); | |
| return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; | |
| } | |
| function standardDeviation(arr) { | |
| const avg = mean(arr); | |
| return Math.sqrt(arr.reduce((sum, val) => sum + Math.pow(val - avg, 2), 0) / arr.length); | |
| } | |
| function percentile(arr, p) { | |
| const sorted = [...arr].sort((a, b) => a - b); | |
| const index = (p / 100) * (sorted.length - 1); | |
| const lower = Math.floor(index); | |
| const upper = Math.ceil(index); | |
| const weight = index % 1; | |
| return sorted[lower] * (1 - weight) + sorted[upper] * weight; | |
| } | |