// ============================================================ // MERIDIAN PIN GATE // To change your default PIN: update DEFAULT_PIN_HASH below // Generate a new hash: open browser console and run: // sha256('yourpin').then(h => console.log(h)) // Current default PIN: 1234 // ============================================================ const PIN_STORAGE_KEY = 'meridian_pin_hash'; const PIN_LOCKOUT_KEY = 'meridian_lockout'; const PIN_ATTEMPTS_KEY = 'meridian_attempts'; const MAX_ATTEMPTS = 3; const LOCKOUT_MINUTES = 10; const INACTIVITY_MS = 15 * 60 * 1000; // 15 minutes // SHA-256 of '1234' — this is the fallback if no PIN has been set yet const DEFAULT_PIN_HASH = '03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4'; let pinBuffer = ''; let pinSetupStep = 0; // 0=normal, 1=first entry for setup, 2=confirm let pinSetupFirst = ''; let inactivityTimer = null; async function sha256(str) { const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)); return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join(''); } function getStoredHash() { try { return localStorage.getItem(PIN_STORAGE_KEY) || DEFAULT_PIN_HASH; } catch(e) { return DEFAULT_PIN_HASH; } } function isFirstTime() { try { return !localStorage.getItem(PIN_STORAGE_KEY); } catch(e) { return false; } } function getLockout() { try { const data = JSON.parse(localStorage.getItem(PIN_LOCKOUT_KEY) || '{}'); if (data.until && Date.now() < data.until) return data.until; return null; } catch(e) { return null; } } function setLockout() { const until = Date.now() + LOCKOUT_MINUTES * 60000; try { localStorage.setItem(PIN_LOCKOUT_KEY, JSON.stringify({until})); } catch(e) {} } function clearLockout() { try { localStorage.removeItem(PIN_LOCKOUT_KEY); localStorage.removeItem(PIN_ATTEMPTS_KEY); } catch(e) {} } function getAttempts() { try { return parseInt(localStorage.getItem(PIN_ATTEMPTS_KEY) || '0'); } catch(e) { return 0; } } function incrementAttempts() { const n = getAttempts() + 1; try { localStorage.setItem(PIN_ATTEMPTS_KEY, n); } catch(e) {} return n; } function updateDots() { for (let i = 0; i < 4; i++) { const d = document.getElementById('d' + i); if (!d) return; d.className = 'pin-dot' + (i < pinBuffer.length ? ' filled' : ''); } } function setDotError() { for (let i = 0; i < 4; i++) { const d = document.getElementById('d' + i); if (d) d.className = 'pin-dot error'; } } function setMsg(msg, color) { const el = document.getElementById('pinMsg'); if (el) { el.textContent = msg; el.style.color = color || '#55524c'; } } function pinPress(digit) { // Check lockout const lockUntil = getLockout(); if (lockUntil) { const mins = Math.ceil((lockUntil - Date.now()) / 60000); setMsg('LOCKED — TRY IN ' + mins + ' MIN', '#d16666'); return; } if (pinBuffer.length >= 4) return; pinBuffer += digit; updateDots(); if (pinBuffer.length === 4) { setTimeout(() => submitPin(), 120); } } function pinClear() { pinBuffer = ''; updateDots(); setMsg(pinSetupStep === 1 ? 'SET NEW PIN' : pinSetupStep === 2 ? 'CONFIRM PIN' : 'ENTER PIN'); } function pinBackspace() { pinBuffer = pinBuffer.slice(0,-1); updateDots(); } async function submitPin() { const hash = await sha256(pinBuffer); // SETUP MODE — first time, no PIN stored if (isFirstTime() || pinSetupStep > 0) { if (pinSetupStep === 0) { // Start setup pinSetupStep = 1; pinSetupFirst = hash; pinBuffer = ''; updateDots(); setMsg('CONFIRM YOUR PIN', '#e8a838'); return; } if (pinSetupStep === 1) { if (hash !== pinSetupFirst) { // Mismatch setDotError(); document.getElementById('pinGate').classList.add('shake'); setTimeout(() => { document.getElementById('pinGate').classList.remove('shake'); }, 400); pinBuffer = ''; pinSetupStep = 1; pinSetupFirst = ''; updateDots(); setMsg("PINS DON'T MATCH — TRY AGAIN", '#d16666'); setTimeout(() => { setMsg('SET YOUR PIN', '#e8a838'); }, 1800); return; } // Confirmed — save try { localStorage.setItem(PIN_STORAGE_KEY, pinSetupFirst); } catch(e) {} pinSetupStep = 0; pinSetupFirst = ''; document.getElementById('pinSetupNotice').style.display = 'none'; unlockDashboard(); return; } } // NORMAL UNLOCK MODE const stored = getStoredHash(); if (hash === stored) { clearLockout(); unlockDashboard(); } else { const attempts = incrementAttempts(); const remaining = MAX_ATTEMPTS - attempts; setDotError(); document.getElementById('pinGate').classList.add('shake'); setTimeout(() => { document.getElementById('pinGate').classList.remove('shake'); }, 400); pinBuffer = ''; updateDots(); if (remaining <= 0) { setLockout(); setMsg('LOCKED FOR ' + LOCKOUT_MINUTES + ' MINUTES', '#d16666'); } else { setMsg('WRONG PIN · ' + remaining + ' ATTEMPT' + (remaining>1?'S':'') + ' LEFT', '#d16666'); setTimeout(() => setMsg('ENTER PIN'), 2000); } } } function unlockDashboard() { const gate = document.getElementById('pinGate'); gate.classList.add('unlocking'); setTimeout(() => { gate.style.display = 'none'; startInactivityTimer(); }, 340); } // ---- Inactivity auto-lock ---- function startInactivityTimer() { resetInactivityTimer(); ['mousemove','keydown','click','scroll','touchstart'].forEach(evt => document.addEventListener(evt, resetInactivityTimer, { passive:true }) ); } function resetInactivityTimer() { clearTimeout(inactivityTimer); inactivityTimer = setTimeout(lockDashboard, INACTIVITY_MS); } function lockDashboard() { pinBuffer = ''; pinSetupStep = 0; updateDots(); setMsg('SESSION EXPIRED — ENTER PIN', '#e8a838'); document.getElementById('pinGate').style.display = 'flex'; document.getElementById('pinGate').style.opacity = '1'; document.getElementById('pinGate').style.transform = 'none'; document.getElementById('pinGate').classList.remove('unlocking'); } // ---- Change PIN (callable from settings) ---- function openChangePinModal() { document.getElementById('changePinModal').style.display = 'flex'; document.getElementById('cp_current').value = ''; document.getElementById('cp_new').value = ''; document.getElementById('cp_confirm').value = ''; document.getElementById('changePinError').textContent = ''; document.getElementById('cp_current').focus(); } function closeChangePinModal() { document.getElementById('changePinModal').style.display = 'none'; } async function submitChangePin() { const current = document.getElementById('cp_current').value.trim(); const newPin = document.getElementById('cp_new').value.trim(); const confirm = document.getElementById('cp_confirm').value.trim(); const errEl = document.getElementById('changePinError'); if (!/^\d{4}$/.test(current)) { errEl.textContent = 'Current PIN must be 4 digits'; return; } if (!/^\d{4}$/.test(newPin)) { errEl.textContent = 'New PIN must be 4 digits'; return; } if (newPin !== confirm) { errEl.textContent = "New PINs don't match"; return; } const currentHash = await sha256(current); if (currentHash !== getStoredHash()) { errEl.textContent = 'Current PIN is incorrect'; return; } const newHash = await sha256(newPin); try { localStorage.setItem(PIN_STORAGE_KEY, newHash); } catch(e) {} errEl.style.color = '#6fb285'; errEl.textContent = '✓ PIN CHANGED SUCCESSFULLY'; setTimeout(closeChangePinModal, 1200); } // ---- Keyboard support ---- document.addEventListener('keydown', e => { if (document.getElementById('pinGate').style.display === 'none') return; // Don't intercept if change-pin modal is open if (document.getElementById('changePinModal').style.display === 'flex') return; if (e.key >= '0' && e.key <= '9') pinPress(e.key); else if (e.key === 'Backspace') pinBackspace(); else if (e.key === 'Escape') pinClear(); }); // ---- Init on load ---- window.addEventListener('load', () => { const lockUntil = getLockout(); if (lockUntil) { const mins = Math.ceil((lockUntil - Date.now()) / 60000); setMsg('LOCKED — TRY IN ' + mins + ' MINUTES', '#d16666'); } else if (isFirstTime()) { document.getElementById('pinSetupNotice').style.display = 'block'; setMsg('SET YOUR PIN', '#e8a838'); } else { setMsg('ENTER PIN'); } }); // Live clock function updateClock() { const now = new Date(); const opts = { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'America/New_York' }; document.getElementById('clock').textContent = now.toLocaleTimeString('en-US', opts) + ' EST'; } updateClock(); setInterval(updateClock, 1000); // ==== Roth Conversion Calculator ==== const incomeSlider = document.getElementById('incomeSlider'); const rothSlider = document.getElementById('rothSlider'); const incomeVal = document.getElementById('incomeVal'); const rothVal = document.getElementById('rothVal'); const magiVal = document.getElementById('magiVal'); const marginalRate = document.getElementById('marginalRate'); const fedTax = document.getElementById('fedTax'); const effRate = document.getElementById('effRate'); const bracketInd = document.getElementById('bracketInd'); const irmaaTiers = document.querySelectorAll('.irmaa-tier'); const irmaaStatus = document.getElementById('irmaaStatus'); // 2026 MFJ brackets (approximate, illustrative) const brackets = [ { limit: 23850, rate: 0.10 }, { limit: 96950, rate: 0.12 }, { limit: 206700, rate: 0.22 }, { limit: 394600, rate: 0.24 }, { limit: 501050, rate: 0.32 }, { limit: 751600, rate: 0.35 }, { limit: Infinity, rate: 0.37 } ]; // Visual bracket widths (must sum to 100) const bracketWidths = [10, 15, 20, 20, 15, 12, 8]; const bracketRates = [10, 12, 22, 24, 32, 35, 37]; // scale max for indicator const indicatorMax = 600000; function fmtCurrency(n) { return '$' + Math.round(n).toLocaleString(); } function calcTax(income) { let tax = 0; let prev = 0; let marginal = 0.10; for (const b of brackets) { if (income > b.limit) { tax += (b.limit - prev) * b.rate; prev = b.limit; marginal = b.rate; } else { tax += (income - prev) * b.rate; marginal = b.rate; break; } } return { tax, marginal }; } function updateIrmaa(magi) { const limits = [206000, 258000, 322000, 386000, Infinity]; let activeIdx = 0; for (let i = 0; i < limits.length; i++) { if (magi < limits[i]) { activeIdx = i; break; } } irmaaTiers.forEach((el, i) => { el.classList.toggle('active', i === activeIdx); }); const statusText = [ 'TIER 0 · CLEAR', 'TIER 1 · CAUTION', 'TIER 2 · CAUTION', 'TIER 3 · WARNING', 'TIER 4 · MAX' ][activeIdx]; const statusClass = activeIdx === 0 ? 'clear' : (activeIdx >= 3 ? 'danger' : 'warn'); irmaaStatus.className = 'status ' + statusClass; irmaaStatus.textContent = statusText; } function update() { const income = parseInt(incomeSlider.value); const roth = parseInt(rothSlider.value); const magi = income + roth; incomeVal.textContent = fmtCurrency(income); rothVal.textContent = fmtCurrency(roth); magiVal.textContent = fmtCurrency(magi); const { tax, marginal } = calcTax(magi); fedTax.textContent = fmtCurrency(tax); marginalRate.textContent = (marginal * 100).toFixed(0) + '%'; effRate.textContent = magi > 0 ? (tax / magi * 100).toFixed(1) + '%' : '0%'; // Color-code marginal rate marginalRate.classList.remove('warn', 'safe'); if (marginal >= 0.32) marginalRate.classList.add('warn'); else if (marginal <= 0.12) marginalRate.classList.add('safe'); // Position indicator on bracket bar // Map MAGI to bracket visual position let position = 0; let cumWidth = 0; for (let i = 0; i < brackets.length; i++) { const b = brackets[i]; const w = bracketWidths[i]; const bracketMin = i === 0 ? 0 : brackets[i-1].limit; const bracketMax = b.limit === Infinity ? 900000 : b.limit; if (magi <= bracketMax) { const within = (magi - bracketMin) / (bracketMax - bracketMin); position = cumWidth + within * w; break; } cumWidth += w; if (i === brackets.length - 1) position = 98; } bracketInd.style.left = Math.min(Math.max(position, 0), 99) + '%'; updateIrmaa(magi); } incomeSlider.addEventListener('input', update); rothSlider.addEventListener('input', update); update(); // ==== Risk Tolerance Slider ==== const riskProfiles = [ { name: 'Conservative', us: 20, intl: 10, bonds: 65, reit: 5, ret: 4.8, vol: 6.5, worst: -7, horizon: '3-5 yrs', horizonSub: 'Capital preservation focus' }, { name: 'Moderate Conservative',us: 35, intl: 15, bonds: 45, reit: 5, ret: 5.8, vol: 8.5, worst: -11, horizon: '5-7 yrs', horizonSub: 'Balanced income & growth' }, { name: 'Moderate', us: 45, intl: 15, bonds: 35, reit: 5, ret: 6.8, vol: 10.5, worst: -14, horizon: '7-10 yrs', horizonSub: 'Growth with stability buffer' }, { name: 'Moderate Aggressive', us: 60, intl: 20, bonds: 18, reit: 2, ret: 7.8, vol: 13, worst: -17, horizon: '10+ yrs', horizonSub: 'Accepts equity drawdown cycles' }, { name: 'Aggressive', us: 72, intl: 23, bonds: 3, reit: 2, ret: 8.7, vol: 16, worst: -24, horizon: '15+ yrs', horizonSub: 'Maximum long-term compounding' } ]; let TOTAL_AUM = 2653462; const riskSlider = document.getElementById('riskSlider'); // Current holdings for rebalance delta calc (post NIO sale, all to ETFs) // Assume starting from 100% cash after liquidation function calcRebalanceTrades(profile) { const targetUS = TOTAL_AUM * profile.us / 100; const targetIntl = TOTAL_AUM * profile.intl / 100; const targetBonds = TOTAL_AUM * profile.bonds / 100; const targetREIT = TOTAL_AUM * profile.reit / 100; return [ { action: 'BUY', sym: 'VTI', desc: 'US Total Stock Market', amt: targetUS }, { action: 'BUY', sym: 'VXUS', desc: 'Total International', amt: targetIntl }, { action: 'BUY', sym: 'BND', desc: 'US Total Bond Market', amt: targetBonds }, { action: 'BUY', sym: 'VNQ', desc: 'Real Estate (REIT)', amt: targetREIT } ]; } function fmtK(n) { if (n >= 1e6) return '$' + (n/1e6).toFixed(2) + 'M'; if (n >= 1e3) return '$' + Math.round(n/1e3) + 'K'; return '$' + Math.round(n).toLocaleString(); } function updateRiskDisplay() { const idx = parseInt(riskSlider.value) - 1; const p = riskProfiles[idx]; document.getElementById('riskProfileName').textContent = p.name; document.getElementById('currentRiskLabel').textContent = p.name; document.getElementById('projRiskLabel').textContent = p.name; // Allocation bars document.getElementById('barUS').style.width = p.us + '%'; document.getElementById('barIntl').style.width = p.intl + '%'; document.getElementById('barBonds').style.width = p.bonds + '%'; document.getElementById('barREIT').style.width = p.reit + '%'; document.getElementById('pctUS').textContent = p.us + '%'; document.getElementById('pctIntl').textContent = p.intl + '%'; document.getElementById('pctBonds').textContent = p.bonds + '%'; document.getElementById('pctREIT').textContent = p.reit + '%'; // Stats document.getElementById('riskExpReturn').textContent = p.ret.toFixed(1) + '%'; document.getElementById('riskStdDev').textContent = '± ' + p.vol + '% volatility (1σ)'; document.getElementById('riskWorst').textContent = p.worst + '%'; document.getElementById('riskHorizon').textContent = p.horizon; document.getElementById('riskHorizonSub').textContent = p.horizonSub; // Rebalance trades const trades = calcRebalanceTrades(p); const rebalanceHtml = trades.map(t => `
${t.action}
${t.sym} ${t.desc}
${fmtK(t.amt)} · ${((t.amt/TOTAL_AUM)*100).toFixed(0)}%
`).join(''); document.getElementById('rebalanceTrades').innerHTML = rebalanceHtml + `
→ Execute inside IRAs · zero tax · rebalance back to these targets when drift exceeds ±5%
`; // Projections (1, 3, 5 years) // Use lognormal approximation: median = P0 * (1+r)^t, range uses t-year vol const horizons = [1, 3, 5]; horizons.forEach((t, i) => { const key = i === 0 ? '1' : (i === 1 ? '3' : '5'); const median = TOTAL_AUM * Math.pow(1 + p.ret/100, t); const tVol = p.vol / 100 * Math.sqrt(t); const low = median * Math.exp(-1.645 * tVol); const high = median * Math.exp(1.645 * tVol); document.getElementById(`proj${key}low`).textContent = fmtK(low); document.getElementById(`proj${key}med`).textContent = fmtK(median); document.getElementById(`proj${key}high`).textContent = fmtK(high); const gain = median - TOTAL_AUM; const gainPct = (gain / TOTAL_AUM * 100); const color = gain >= 0 ? 'var(--green)' : 'var(--red)'; document.getElementById(`proj${key}gain`).innerHTML = `Median gain: ${gain >= 0 ? '+' : ''}${fmtK(gain)} (${gainPct >= 0 ? '+' : ''}${gainPct.toFixed(1)}%)`; }); // Fan chart — map value to y (40=$5.5M, 220=$2M, linear) function valToY(v) { const vMil = v / 1e6; return 220 - (vMil - 2) * (180 / 3.5); } const medians = horizons.map(t => TOTAL_AUM * Math.pow(1 + p.ret/100, t)); const lows = horizons.map((t, i) => medians[i] * Math.exp(-1.645 * p.vol/100 * Math.sqrt(t))); const highs = horizons.map((t, i) => medians[i] * Math.exp(1.645 * p.vol/100 * Math.sqrt(t))); // Status quo (0.98% actual drag from Q1 2026 fee statement) const sqRet = p.ret - 0.98; const sqMedians = horizons.map(t => TOTAL_AUM * Math.pow(1 + sqRet/100, t)); const xs = [40, 145, 325, 505]; const startY = valToY(TOTAL_AUM); // Fan area: lows forward, highs back let fanPath = `M 40 ${startY.toFixed(1)}`; horizons.forEach((_, i) => { fanPath += ` L ${xs[i+1]} ${Math.max(valToY(lows[i]), 40).toFixed(1)}`; }); for (let i = horizons.length - 1; i >= 0; i--) { fanPath += ` L ${xs[i+1]} ${Math.max(valToY(highs[i]), 40).toFixed(1)}`; } fanPath += ' Z'; document.getElementById('fanArea').setAttribute('d', fanPath); // Median path let medPath = `M 40 ${startY.toFixed(1)}`; horizons.forEach((_, i) => { medPath += ` L ${xs[i+1]} ${Math.max(valToY(medians[i]), 40).toFixed(1)}`; }); document.getElementById('fanMedian').setAttribute('d', medPath); // Status quo path let sqPath = `M 40 ${startY.toFixed(1)}`; horizons.forEach((_, i) => { sqPath += ` L ${xs[i+1]} ${Math.max(valToY(sqMedians[i]), 40).toFixed(1)}`; }); document.getElementById('fanSQ').setAttribute('d', sqPath); // Endpoint const endY = Math.max(valToY(medians[2]), 40); document.getElementById('fanEnd').setAttribute('cy', endY); document.getElementById('fanEndLabel').setAttribute('y', endY - 4); document.getElementById('fanEndLabel').textContent = fmtK(medians[2]); } riskSlider.addEventListener('input', () => { updateRiskDisplay(); updatePhasedPlan(); }); updateRiskDisplay(); // ==== Phased Deployment Planner ==== const pilotSlider = document.getElementById('pilotSlider'); const rolloutSlider = document.getElementById('rolloutSlider'); let IRA_AUM = 2483866; // Approximate current ETF prices (ticker tape reference) const prices = { VTI: 298.42, VXUS: 64.18, BND: 73.85, VNQ: 93.4 }; function fmtMoney(n) { return '$' + Math.round(n).toLocaleString(); } function updatePhasedPlan() { const pilotSize = parseInt(pilotSlider.value); const rolloutMonths = parseInt(rolloutSlider.value); const p = riskProfiles[parseInt(riskSlider.value) - 1]; document.getElementById('pilotVal').textContent = fmtMoney(pilotSize); document.getElementById('rolloutVal').textContent = rolloutMonths; document.getElementById('planHeader').textContent = fmtK(pilotSize) + ' pilot → ' + rolloutMonths + '-mo rollout'; // Pilot trades by risk profile const pilotAllocs = [ { sym: 'VTI', name: 'US Total Stock Market', pct: p.us }, { sym: 'VXUS', name: 'Total International', pct: p.intl }, { sym: 'BND', name: 'US Total Bond Market', pct: p.bonds }, { sym: 'VNQ', name: 'Real Estate (REIT)', pct: p.reit } ]; const pilotHtml = pilotAllocs.map(a => { const amt = pilotSize * a.pct / 100; return `
BUY
${a.sym} ${a.name}
${fmtMoney(amt)} · ${a.pct}%
`; }).join(''); document.getElementById('pilotTrades').innerHTML = pilotHtml; // Share-count breakdown const shareLines = pilotAllocs.map(a => { const amt = pilotSize * a.pct / 100; const shares = amt / prices[a.sym]; if (amt < 1) return ''; return `${a.sym} → buy ${shares.toFixed(2)} shares @ $${prices[a.sym].toFixed(2)} = ${fmtMoney(amt)}`; }).filter(x => x).join('
'); document.getElementById('shareBreakdown').innerHTML = shareLines; // Tranche schedule const remaining = IRA_AUM - pilotSize; const trancheAmt = remaining / rolloutMonths; const monthNames = ['May','Jun','Jul','Aug','Sep','Oct','Nov','Dec','Jan','Feb','Mar','Apr']; let tableHtml = ''; for (let i = 1; i <= rolloutMonths; i++) { const cumulative = pilotSize + trancheAmt * i; const wrapRemaining = Math.max(IRA_AUM - cumulative, 0); const pctComplete = (cumulative / IRA_AUM * 100).toFixed(0); const monthLabel = monthNames[(i - 1) % 12] + ' ' + (i > 8 ? '2027' : '2026'); tableHtml += ` ${monthLabel} ${fmtMoney(trancheAmt)} ${fmtMoney(cumulative)} ${fmtMoney(wrapRemaining)} ${pctComplete}% `; } document.getElementById('trancheTableBody').innerHTML = tableHtml; // Stats const totalWeeks = 1 + rolloutMonths * 4; const endMonthIdx = (3 + rolloutMonths) % 12; // starting from April const endYear = (3 + rolloutMonths) >= 9 ? '2027' : '2026'; const endMonthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; document.getElementById('rolloutEndDate').textContent = endMonthNames[endMonthIdx] + ' ' + endYear; document.getElementById('rolloutEndSub').textContent = '~' + totalWeeks + ' weeks from today'; // Fee during transition: avg unmoved AUM × ER differential × time // Wrap-fund ER ~0.52%, target ETF ER ~0.04%, differential 0.48% const erDiff = 0.0048; const avgUnmoved = (IRA_AUM - pilotSize) / 2; // linear decline approximation const feeCost = avgUnmoved * erDiff * (rolloutMonths / 12); document.getElementById('feeDuringTransition').textContent = '~' + fmtMoney(feeCost); // Cost of waiting per month (wrap ER on full remaining AUM) const costMonth = (IRA_AUM - pilotSize) * erDiff / 12; document.getElementById('costPerMonth').textContent = '~' + fmtMoney(costMonth) + '/mo'; } pilotSlider.addEventListener('input', updatePhasedPlan); rolloutSlider.addEventListener('input', updatePhasedPlan); updatePhasedPlan(); // ==== Tab navigation ==== const navItems = document.querySelectorAll('.nav-item[data-target]'); const sections = document.querySelectorAll('.section'); navItems.forEach(item => { item.addEventListener('click', () => { const target = item.getAttribute('data-target'); if (!target) return; // update active nav navItems.forEach(n => n.classList.remove('active')); item.classList.add('active'); // swap sections sections.forEach(s => s.classList.remove('active')); const next = document.getElementById('section-' + target); if (next) next.classList.add('active'); // AI engine: auto-render skills on first visit if (target === 'ai-engine') { setTimeout(() => { if (typeof renderSkills === 'function') renderSkills(); if (typeof checkApiKeyOnLoad === 'function') checkApiKeyOnLoad(); if (typeof renderHoldingsManager === 'function') renderHoldingsManager(); if (typeof updateAISystemPrompt === 'function') updateAISystemPrompt(); }, 250); } // scroll to top of main content window.scrollTo({ top: 0, behavior: 'smooth' }); }); }); // ==== VALUES EDITOR ==== const fmtInt = n => Math.round(n).toLocaleString('en-US'); const fmt2 = n => n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const fmtPct = (n, d=2) => n.toFixed(d); const fmtM = n => (n >= 1e6 ? (n/1e6).toFixed(2) + 'M' : (n/1e3).toFixed(0) + 'K'); const dateNice = (iso) => { const d = new Date(iso + 'T12:00:00'); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); }; function setKey(k, v) { document.querySelectorAll(`[data-k="${k}"]`).forEach(el => { el.textContent = v; }); } function applyValues() { const rollover = parseFloat(document.getElementById('ed_rollover').value) || 0; const roth = parseFloat(document.getElementById('ed_roth').value) || 0; const nio = parseFloat(document.getElementById('ed_nio').value) || 0; const nioLoss = parseFloat(document.getElementById('ed_nioLoss').value) || 0; const feeRoll = parseFloat(document.getElementById('ed_feeRoll').value) || 0; const feeRoth = parseFloat(document.getElementById('ed_feeRoth').value) || 0; const mmCash = parseFloat(document.getElementById('ed_mmCash').value) || 0; const buffer = parseFloat(document.getElementById('ed_cashBuffer').value) || 0; const vti = parseFloat(document.getElementById('ed_vti').value) || 0; const vxus = parseFloat(document.getElementById('ed_vxus').value) || 0; const bnd = parseFloat(document.getElementById('ed_bnd').value) || 0; const vnq = parseFloat(document.getElementById('ed_vnq').value) || 0; const dateIso = document.getElementById('ed_date').value || '2026-04-20'; const individualDust = 12; const iraAUM = rollover + roth; const totalAUM = rollover + roth + nio + mmCash + individualDust; // Annualized fees const feeRollY = feeRoll * 4; const feeRothY = feeRoth * 4; const feeTotQ = feeRoll + feeRoth; const feeTotY = feeRollY + feeRothY; // Rates const rateRoll = rollover > 0 ? (feeRollY / rollover * 100) : 0; const rateRoth = roth > 0 ? (feeRothY / roth * 100) : 0; const rateBlended = iraAUM > 0 ? (feeTotY / iraAUM * 100) : 0; // Fund ER estimate (0.52% of IRA AUM) const erAnnual = iraAUM * 0.0052; const feeAnnualTotal = feeTotY + erAnnual; const feeBlendedPct = iraAUM > 0 ? (feeAnnualTotal / iraAUM * 100) : 0; // Account pct of total const rollPct = totalAUM > 0 ? (rollover / totalAUM * 100) : 0; const rothPct = totalAUM > 0 ? (roth / totalAUM * 100) : 0; const nioPct = totalAUM > 0 ? (nio / totalAUM * 100) : 0; const mmPct = totalAUM > 0 ? (mmCash / totalAUM * 100) : 0; // Deployment math const deployable = Math.max(mmCash - buffer, 0); const fmtK = (n) => n >= 1e6 ? (n/1e6).toFixed(2) + 'M' : (n/1e3).toFixed(0) + 'K'; // Tax-aware allocation: 50% VTI, 25% VXUS, 20% VTEB, 5% VUG const allocVTI = deployable * 0.50; const allocVXUS = deployable * 0.25; const allocVTEB = deployable * 0.20; const allocVUG = deployable * 0.05; // 3-month DCA (Month 1 and 2 rounded to 1000s, Month 3 = residual) const dcaMonth = Math.round(deployable / 3 / 1000) * 1000; const dcaM1 = dcaMonth; const dcaM2 = dcaMonth; const dcaM3 = deployable - (dcaMonth * 2); // Yield math const mmYieldBefore = mmCash * 0.045; const blendedYield = (allocVTI * 0.013 + allocVXUS * 0.029 + allocVTEB * 0.030 + allocVUG * 0.006) + (buffer * 0.045); const mmYieldAfter = blendedYield; const mmGrowth = deployable * 0.068; // Update DOM setKey('dateLabel', dateNice(dateIso)); setKey('totalAUM', fmtInt(totalAUM)); setKey('rollover', fmtInt(rollover)); setKey('roth', fmtInt(roth)); setKey('nio', fmtInt(nio)); setKey('mmCash', fmtInt(mmCash)); setKey('mmCashLabel', fmtK(mmCash)); setKey('mmCashBig', fmtK(mmCash)); setKey('mmCashHero', fmtInt(mmCash)); setKey('bufferLabel', fmtK(buffer)); setKey('bufferHero', fmtInt(buffer)); setKey('deployHero', fmtInt(deployable)); setKey('deployTbl', fmtK(deployable)); setKey('alloc_VTI', '$' + fmtInt(allocVTI)); setKey('alloc_VXUS', '$' + fmtInt(allocVXUS)); setKey('alloc_VTEB', '$' + fmtInt(allocVTEB)); setKey('alloc_VUG', '$' + fmtInt(allocVUG)); setKey('dca_m1', fmtInt(dcaM1)); setKey('dca_m2', fmtInt(dcaM2)); setKey('dca_m3', fmtInt(dcaM3)); setKey('mmYieldBefore', fmtInt(mmYieldBefore)); setKey('mmYieldAfter', fmtInt(mmYieldAfter)); setKey('mmGrowth', fmtInt(mmGrowth)); setKey('rollPct', fmtPct(rollPct, 1)); setKey('rothPct', fmtPct(rothPct, 1)); setKey('nioPct', fmtPct(nioPct, 2)); setKey('mmPct', fmtPct(mmPct, 2)); setKey('nioLoss', fmtInt(nioLoss)); setKey('feeRoll', fmt2(feeRoll)); setKey('feeRoth', fmt2(feeRoth)); setKey('feeRollAnnual', fmt2(feeRollY)); setKey('feeRothAnnual', fmt2(feeRothY)); setKey('feeRollRate', fmtPct(rateRoll, 3)); setKey('feeRothRate', fmtPct(rateRoth, 3)); setKey('feeTotalQ', fmt2(feeTotQ)); setKey('feeTotalY', fmt2(feeTotY)); setKey('feeTotalY2', fmtInt(feeTotY)); setKey('feeBlendedRate', fmtPct(rateBlended, 3)); setKey('feeBlendedPct', fmtPct(feeBlendedPct, 2)); setKey('feeBlendedPct2', fmtPct(feeBlendedPct, 2)); setKey('advBlendedPct', fmtPct(rateBlended, 2)); setKey('advBlendedPct2', fmtPct(rateBlended, 2)); setKey('feeAnnualTotal', fmtInt(feeAnnualTotal)); setKey('feeAnnualTotal2', fmtInt(feeAnnualTotal)); setKey('erAnnual', fmtInt(erAnnual)); setKey('iraAUMLabel', fmtM(iraAUM)); // Update live JS state so phased-deployment planner recomputes correctly IRA_AUM = iraAUM; TOTAL_AUM = totalAUM; prices.VTI = vti; prices.VXUS = vxus; prices.BND = bnd; prices.VNQ = vnq; if (typeof updatePhasedPlan === 'function') updatePhasedPlan(); if (typeof updateRiskDisplay === 'function') updateRiskDisplay(); // Reflect input values in DOM attrs so they survive outerHTML serialization ['ed_date','ed_rollover','ed_roth','ed_nio','ed_nioLoss','ed_mmCash','ed_cashBuffer','ed_feeRoll','ed_feeRoth','ed_vti','ed_vxus','ed_bnd','ed_vnq'] .forEach(id => { const el = document.getElementById(id); el.setAttribute('value', el.value); }); const status = document.getElementById('ed_status'); status.textContent = '✓ Applied · values refreshed in current view'; status.classList.add('ok'); setTimeout(() => { status.classList.remove('ok'); status.textContent = 'Edit fields above, then Apply · Download saves a new HTML file with your values baked in'; }, 4000); } function downloadUpdated() { // Ensure all value attributes reflect current inputs applyValues(); // Clone so we can rewrite the embedded script's baked-in constants const htmlClone = document.documentElement.cloneNode(true); const scriptEl = htmlClone.querySelector('script:last-of-type'); if (scriptEl) { const vti = parseFloat(document.getElementById('ed_vti').value); const vxus = parseFloat(document.getElementById('ed_vxus').value); const bnd = parseFloat(document.getElementById('ed_bnd').value); const vnq = parseFloat(document.getElementById('ed_vnq').value); const iraAUM = parseFloat(document.getElementById('ed_rollover').value) + parseFloat(document.getElementById('ed_roth').value); const mmCashVal = parseFloat(document.getElementById('ed_mmCash').value) || 0; const totalAUM = iraAUM + parseFloat(document.getElementById('ed_nio').value) + mmCashVal + 12; let src = scriptEl.textContent; src = src.replace(/(const|let) TOTAL_AUM = \d+(\.\d+)?;/, `let TOTAL_AUM = ${Math.round(totalAUM)};`); src = src.replace(/(const|let) IRA_AUM = \d+(\.\d+)?;/, `let IRA_AUM = ${Math.round(iraAUM)};`); src = src.replace(/const prices = \{[^}]+\};/, `const prices = { VTI: ${vti}, VXUS: ${vxus}, BND: ${bnd}, VNQ: ${vnq} };`); scriptEl.textContent = src; } const html = '\n' + htmlClone.outerHTML; const blob = new Blob([html], { type: 'text/html;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); const dateTag = document.getElementById('ed_date').value || new Date().toISOString().slice(0,10); a.href = url; a.download = `meridian-dashboard-${dateTag}.html`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); const status = document.getElementById('ed_status'); status.textContent = '✓ Downloaded · replace the old HTML file with this one'; status.classList.add('ok'); setTimeout(() => { status.classList.remove('ok'); status.textContent = 'Edit fields above, then Apply · Download saves a new HTML file with your values baked in'; }, 5000); } // Update the download regex to match either const or let document.getElementById('ed_apply').addEventListener('click', applyValues); document.getElementById('ed_download').addEventListener('click', downloadUpdated); // ============================================================ // AI SIGNAL ENGINE — Skills · Agents · Orchestrator · Chat // ============================================================ // AI clock sync const aiClockEl = document.getElementById('ai-clock'); if (aiClockEl) { setInterval(() => { const now = new Date(); aiClockEl.textContent = now.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false,timeZone:'America/New_York'}) + ' EST'; }, 1000); } // ---- Portfolio context baked in ---- // ============================================================ // DYNAMIC HOLDINGS REGISTRY — single source of truth // Add / edit / remove tickers — everything re-renders // ============================================================ const DEFAULT_HOLDINGS = [ { sym:'VTI', name:'Vanguard Total Stock Market', shares:5167, price:298.42, target:60, change:+0.64, type:'ETF', sector:'US Equity' }, { sym:'VXUS', name:'Vanguard Total International', shares:6878, price:64.18, target:20, change:+0.31, type:'ETF', sector:'Intl Equity' }, { sym:'BND', name:'Vanguard Total Bond Market', shares:5747, price:73.85, target:20, change:-0.08, type:'ETF', sector:'Bonds' }, { sym:'VNQ', name:'Vanguard Real Estate', shares:844, price:88.12, target:0, change:+0.22, type:'ETF', sector:'Real Estate' }, ]; // Load from localStorage or use defaults function loadHoldings() { try { const saved = localStorage.getItem('meridian_holdings'); return saved ? JSON.parse(saved) : DEFAULT_HOLDINGS.map(h => ({...h})); } catch(e) { return DEFAULT_HOLDINGS.map(h => ({...h})); } } function saveHoldings(h) { try { localStorage.setItem('meridian_holdings', JSON.stringify(h)); } catch(e) {} } let HOLDINGS = loadHoldings(); // Derived portfolio stats function getPortfolioStats() { const totalVal = HOLDINGS.reduce((s,h) => s + h.shares * h.price, 0); return HOLDINGS.map(h => ({ ...h, value: h.shares * h.price, weight: totalVal > 0 ? (h.shares * h.price / totalVal * 100) : 0, drift: totalVal > 0 ? (h.shares * h.price / totalVal * 100) - h.target : 0, totalAUM: totalVal })); } // ---- MA data generator (simulated per ticker) ---- function getMaData(sym, price) { // Deterministic seed per symbol for consistent values const seed = sym.split('').reduce((a,c) => a + c.charCodeAt(0), 0); const rand = (n, s) => ((seed * s * 9301 + 49297) % 233280) / 233280 * n; const ma200 = price * (0.88 + rand(0.18, 3)); const ma50 = price * (0.93 + rand(0.14, 7)); const priceAbove200 = price > ma200; const ma50Above200 = ma50 > ma200; let cross, crossLabel, signal, signalTxt; if (priceAbove200 && ma50Above200) { const goldenGap = (ma50 - ma200) / ma200; if (goldenGap > 0.05) { cross='golden'; crossLabel='🟢 Golden Cross'; signal='bull'; signalTxt='BULLISH'; } else { cross='bull'; crossLabel='✅ Bullish'; signal='bull'; signalTxt='BULLISH'; } } else if (!priceAbove200 && !ma50Above200) { const deathGap = (ma200 - ma50) / ma200; if (deathGap > 0.03) { cross='death'; crossLabel='🔴 Death Cross'; signal='bear'; signalTxt='BEARISH'; } else { cross='bear'; crossLabel='⚠️ Caution'; signal='caut'; signalTxt='CAUTION'; } } else { cross='bear'; crossLabel='⚠️ Mixed'; signal='caut'; signalTxt='WATCH'; } // Fake sparkline const spark = Array.from({length:15}, (_,i) => { const t = i/14; const noise = ((seed * (i+1) * 1301) % 100) / 100 - 0.5; return price * (0.94 + t*0.06 + noise*0.02); }); return { sym, price, ma50, ma200, cross, crossLabel, signal, signalTxt, spark, note: `${priceAbove200?'Price above':'Price below'} 200d MA ($${ma200.toFixed(2)}). 50d MA ($${ma50.toFixed(2)}) ${ma50Above200?'above':'below'} 200d. ${cross==='golden'?'Uptrend intact — hold or add on dips.':cross==='death'?'Downtrend in force — no new adds until cross reverses.':'Monitor for confirmation.'}` }; } // ---- RSI/MACD simulator per ticker ---- function getIndicators(sym, price, change) { const seed = sym.split('').reduce((a,c) => a + c.charCodeAt(0), 0); const rsi = 25 + ((seed * 7919) % 50); const macd = ((seed * 1013) % 200 - 100) / 100 * 1.5; const rsySig = rsi < 30 ? 'bull' : rsi > 70 ? 'bear' : 'neu'; const rsyTxt = rsi < 30 ? 'OVERSOLD' : rsi > 70 ? 'OVERBOUGHT' : 'NEUTRAL'; const maSig = macd > 0 ? 'bull' : 'caut'; const maTxt = macd > 0 ? 'BULLISH X' : 'BEARISH X'; const sent = 20 + ((seed * 3571) % 60); const sentTxt = sent < 30 ? `Fear ${sent}` : sent > 60 ? `Greed ${sent}` : `Neutral ${sent}`; const sentSig = sent < 30 ? 'bull' : sent > 60 ? 'caut' : 'neu'; const atr = (price * 0.012 + ((seed*1777)%100)/100*0.01).toFixed(2); const overall = rsi < 35 ? 'ACCUMULATE' : macd > 0 ? 'HOLD' : rsi > 65 ? 'TRIM' : 'HOLD'; const overallSig = overall === 'ACCUMULATE' ? 'bull' : overall === 'TRIM' ? 'bear' : 'neu'; return { sym, rsi:rsi.toFixed(1), macd:macd.toFixed(2), rsySig, rsyTxt, maSig, maTxt, sent:sentTxt, sentSig, atr, overall, overallSig }; } // ============================================================ // HOLDINGS MANAGER UI // ============================================================ function renderHoldingsManager() { const el = document.getElementById('holdingsManagerBody'); if (!el) return; const stats = getPortfolioStats(); const totalAUM = stats.reduce((s,h) => s + h.value, 0); let html = `
${stats.length} HOLDINGS · $${Math.round(totalAUM).toLocaleString()} TOTAL AUM
`; stats.forEach((h, idx) => { const driftColor = Math.abs(h.drift) > 5 ? 'var(--red)' : Math.abs(h.drift) > 2 ? 'var(--amber)' : 'var(--ink-dim)'; const driftSign = h.drift >= 0 ? '+' : ''; const ind = getIndicators(h.sym, h.price, h.change); const sigColor = { bull:'var(--green)', bear:'var(--red)', neu:'var(--ink-dim)', caut:'var(--amber)' }; html += ` `; }); // Total row html += `
TICKER NAME SHARES PRICE VALUE WEIGHT TARGET DRIFT SECTOR ACTIONS
${h.sym} ${h.name} ${h.shares.toLocaleString()} $${h.price.toFixed(2)} $${Math.round(h.value).toLocaleString()} ${h.weight.toFixed(1)}% ${h.target}% ${driftSign}${h.drift.toFixed(1)}% ${h.sector || 'Other'}
TOTAL $${Math.round(totalAUM).toLocaleString()} 100%
ALLOCATION
${stats.map((h,i) => { const colors = ['#5a8fc4','#6fb285','#e8a838','#d16666','#a78bfa','#64b5f6','#f06292','#4db6ac','#ffb74d','#90a4ae']; return `
`; }).join('')}
${stats.map((h,i) => { const colors = ['#5a8fc4','#6fb285','#e8a838','#d16666','#a78bfa','#64b5f6','#f06292','#4db6ac','#ffb74d','#90a4ae']; return ` ${h.sym} ${h.weight.toFixed(1)}%`; }).join('')}
`; el.innerHTML = html; } // ---- Known tickers lookup table (offline-first, no API needed) ---- const TICKER_DB = { // ETFs 'VTI' :{ name:'Vanguard Total Stock Market ETF', price:298.42, sector:'US Equity', change:0.64 }, 'VXUS':{ name:'Vanguard Total International ETF', price:64.18, sector:'Intl Equity', change:0.31 }, 'BND' :{ name:'Vanguard Total Bond Market ETF', price:73.85, sector:'Bonds', change:-0.08 }, 'VNQ' :{ name:'Vanguard Real Estate ETF', price:88.12, sector:'Real Estate', change:0.22 }, 'VOO' :{ name:'Vanguard S&P 500 ETF', price:524.85, sector:'US Equity', change:0.58 }, 'SPY' :{ name:'SPDR S&P 500 ETF', price:524.10, sector:'US Equity', change:0.57 }, 'QQQ' :{ name:'Invesco Nasdaq-100 ETF', price:462.30, sector:'Tech', change:0.82 }, 'IVV' :{ name:'iShares Core S&P 500 ETF', price:525.20, sector:'US Equity', change:0.57 }, 'SCHD':{ name:'Schwab US Dividend Equity ETF', price:107.14, sector:'US Equity', change:0.21 }, 'EMB' :{ name:'iShares JP Morgan EM Bond ETF', price:93.75, sector:'Bonds', change:0.12 }, 'GLD' :{ name:'SPDR Gold Shares ETF', price:429.50, sector:'Hard Assets', change:0.62 }, 'SLV' :{ name:'iShares Silver Trust ETF', price:30.18, sector:'Hard Assets', change:0.44 }, 'COPX':{ name:'Global X Copper Miners ETF', price:43.21, sector:'Hard Assets', change:0.31 }, 'ITA' :{ name:'iShares US Aerospace & Defense ETF', price:138.20, sector:'Defense', change:0.44 }, 'XLK' :{ name:'Technology Select Sector SPDR ETF', price:232.10, sector:'Tech', change:0.91 }, 'XLE' :{ name:'Energy Select Sector SPDR ETF', price:91.40, sector:'Energy', change:-0.32 }, 'XLV' :{ name:'Health Care Select Sector SPDR ETF', price:148.20, sector:'Healthcare', change:0.15 }, 'XLF' :{ name:'Financial Select Sector SPDR ETF', price:51.30, sector:'US Equity', change:0.38 }, 'SGOV':{ name:'iShares 0-3 Month Treasury Bond ETF', price:100.52, sector:'Bonds', change:0.01 }, 'TLT' :{ name:'iShares 20+ Year Treasury Bond ETF', price:88.40, sector:'Bonds', change:-0.22 }, 'VTIP':{ name:'Vanguard Short-Term Inflation ETF', price:47.80, sector:'Bonds', change:0.08 }, 'IBIT':{ name:'iShares Bitcoin Trust ETF', price:58.42, sector:'Crypto', change:1.24 }, // Stocks 'NVDA':{ name:'Nvidia Corporation', price:225.61, sector:'Tech', change:0.94 }, 'AAPL':{ name:'Apple Inc.', price:211.45, sector:'Tech', change:0.32 }, 'MSFT':{ name:'Microsoft Corporation', price:447.20, sector:'Tech', change:0.48 }, 'AMZN':{ name:'Amazon.com Inc.', price:214.80, sector:'Tech', change:0.67 }, 'GOOGL':{ name:'Alphabet Inc. Class A', price:179.30, sector:'Tech', change:0.52 }, 'META':{ name:'Meta Platforms Inc.', price:618.40, sector:'Tech', change:0.71 }, 'TSLA':{ name:'Tesla Inc.', price:248.50, sector:'Tech', change:1.82 }, 'BRK.B':{ name:'Berkshire Hathaway Inc. Class B', price:467.20, sector:'US Equity', change:0.18 }, 'JPM' :{ name:'JPMorgan Chase & Co.', price:268.40, sector:'US Equity', change:0.44 }, 'V' :{ name:'Visa Inc.', price:349.10, sector:'US Equity', change:0.29 }, 'JNJ' :{ name:'Johnson & Johnson', price:148.20, sector:'Healthcare', change:-0.12 }, 'PG' :{ name:'Procter & Gamble Co.', price:171.80, sector:'US Equity', change:0.22 }, 'LMT' :{ name:'Lockheed Martin Corporation', price:516.50, sector:'Defense', change:-0.38 }, 'RTX' :{ name:'RTX Corporation', price:138.20, sector:'Defense', change:0.41 }, 'ANET':{ name:'Arista Networks Inc.', price:112.40, sector:'Tech', change:0.88 }, 'VRT' :{ name:'Vertiv Holdings Co.', price:124.30, sector:'Tech', change:1.12 }, 'MRVL':{ name:'Marvell Technology Inc.', price:87.50, sector:'Tech', change:0.94 }, 'OKLO':{ name:'Oklo Inc.', price:21.43, sector:'Energy', change:2.14 }, 'RKLB':{ name:'Rocket Lab USA Inc.', price:20.80, sector:'Tech', change:1.88 }, 'SCHW':{ name:'Charles Schwab Corporation', price:82.10, sector:'US Equity', change:0.31 }, 'WMT' :{ name:'Walmart Inc.', price:98.40, sector:'US Equity', change:0.18 }, 'HD' :{ name:'The Home Depot Inc.', price:384.20, sector:'US Equity', change:0.24 }, }; // Auto-lookup when ticker is entered async function lookupTicker(sym) { sym = (sym || '').trim().toUpperCase(); if (!sym || sym.length < 1) return; const statusEl = document.getElementById('hf_lookupStatus'); const nameEl = document.getElementById('hf_name'); const priceEl = document.getElementById('hf_price'); const sourceEl = document.getElementById('hf_priceSource'); const sectorEl = document.getElementById('hf_sector'); const changeEl = document.getElementById('hf_change'); // Helper to set status const setStatus = (msg, color) => { if (statusEl) { statusEl.textContent = msg; statusEl.style.color = color || 'var(--ink-faint)'; } }; const setSource = (msg, color) => { if (sourceEl) { sourceEl.textContent = msg; sourceEl.style.color = color || 'var(--green)'; } }; // ── Fast path: offline DB cache ── // Still check it first for speed, but ALWAYS fall through to live if user // typed something we don't recognise if (TICKER_DB[sym] && nameEl.value === '' && priceEl.value === '') { const t = TICKER_DB[sym]; nameEl.value = t.name; priceEl.value = t.price; if (sectorEl) sectorEl.value = t.sector; if (changeEl) changeEl.value = t.change || 0; setSource('✓ CACHED', 'var(--green)'); setStatus('✓ ' + t.name + ' · $' + t.price + ' (cached — click LOOKUP for live price)', 'var(--green)'); updateValuePreview(); document.getElementById('hf_shares').focus(); // Still fire a background live refresh } // ── Live lookup — try 3 sources in order ── setStatus('Looking up ' + sym + '…', 'var(--amber)'); setSource('…'); // Source 1: Yahoo Finance via corsproxy.io (works from file:// and localhost) const tryYahoo = async () => { const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(sym)}?interval=1d&range=5d&includePrePost=false`; const res = await fetch('https://corsproxy.io/?' + encodeURIComponent(url), { signal: AbortSignal.timeout(5000) }); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); const meta = data?.chart?.result?.[0]?.meta; if (!meta || !meta.regularMarketPrice) throw new Error('No price data'); return { name: meta.longName || meta.shortName || sym, price: meta.regularMarketPrice, change: meta.regularMarketChangePercent || 0, currency: meta.currency || 'USD', exchange: meta.exchangeName || '', source: '✓ YAHOO LIVE' }; }; // Source 2: Yahoo Finance via allorigins proxy (second CORS proxy) const tryYahooAlt = async () => { const inner = `https://query2.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(sym)}?interval=1d&range=1d`; const url = `https://api.allorigins.win/get?url=${encodeURIComponent(inner)}`; const res = await fetch(url, { signal: AbortSignal.timeout(6000) }); const json = await res.json(); const data = JSON.parse(json.contents || '{}'); const meta = data?.chart?.result?.[0]?.meta; if (!meta || !meta.regularMarketPrice) throw new Error('No data'); return { name: meta.longName || meta.shortName || sym, price: meta.regularMarketPrice, change: meta.regularMarketChangePercent || 0, currency: meta.currency || 'USD', exchange: meta.exchangeName || '', source: '✓ LIVE (ALT)' }; }; // Source 3: Finnhub free quote (no key needed for basic quotes) const tryFinnhub = async () => { const res = await fetch(`https://finnhub.io/api/v1/quote?symbol=${encodeURIComponent(sym)}&token=d0qsmkqad3i8q1r0i8ggd0qsmkqad3i8q1r0i8h`, { signal: AbortSignal.timeout(5000) }); const data = await res.json(); if (!data.c || data.c === 0) throw new Error('No price'); const changeP = data.dp || 0; return { name: sym, // Finnhub basic doesn't return names price: data.c, change: changeP, currency: 'USD', exchange: '', source: '✓ FINNHUB LIVE' }; }; // Try sources in sequence, stop at first success const sources = [tryYahoo, tryYahooAlt, tryFinnhub]; let result = null; let lastError = ''; for (const tryFn of sources) { try { result = await tryFn(); break; } catch(e) { lastError = e.message; continue; } } if (result) { // Always overwrite with live data nameEl.value = result.name !== sym ? result.name : (nameEl.value || sym); priceEl.value = result.price.toFixed(2); if (changeEl) changeEl.value = result.change.toFixed(2); // Auto-detect sector from name if unknown if (sectorEl && sectorEl.value === 'US Equity') { const n = result.name.toLowerCase(); if (/bond|treasury|fixed.income|debt/.test(n)) sectorEl.value = 'Bonds'; else if (/bitcoin|crypto|ethereum/.test(n)) sectorEl.value = 'Crypto'; else if (/gold|silver|copper|metal|mining/.test(n)) sectorEl.value = 'Hard Assets'; else if (/real.estate|reit|property/.test(n)) sectorEl.value = 'Real Estate'; else if (/defense|aerospace|lockheed|raytheon|northrop/.test(n)) sectorEl.value = 'Defense'; else if (/pharma|health|medical|biotech|clinical/.test(n)) sectorEl.value = 'Healthcare'; else if (/energy|oil|gas|petroleum|solar|nuclear/.test(n)) sectorEl.value = 'Energy'; else if (/nvidia|software|tech|semiconductor|cloud|microsoft|apple|alphabet|meta|amazon/.test(n)) sectorEl.value = 'Tech'; else if (/international|global|emerging|world/.test(n)) sectorEl.value = 'Intl Equity'; } const badge = result.currency !== 'USD' ? ` · ${result.currency}` : ''; const exch = result.exchange ? ` · ${result.exchange}` : ''; setSource(result.source, 'var(--green)'); setStatus(`✓ ${nameEl.value} · $${result.price.toFixed(2)}${badge}${exch}`, 'var(--green)'); updateValuePreview(); document.getElementById('hf_shares').focus(); } else { // All sources failed — let user enter manually, don't block them setSource(''); setStatus( `⚠ Could not auto-fetch "${sym}" (${lastError}) — enter price manually and click SAVE`, 'var(--amber)' ); // Clear only price if it was auto-filled from cache and is now stale document.getElementById('hf_shares').focus(); } } // Live value preview as you type shares/price function updateValuePreview() { const shares = parseFloat(document.getElementById('hf_shares').value); const price = parseFloat(document.getElementById('hf_price').value); const el = document.getElementById('hf_valuePreview'); if (!el) return; if (!isNaN(shares) && !isNaN(price) && shares > 0 && price > 0) { const val = shares * price; el.textContent = '$' + val.toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2}); el.style.color = 'var(--green)'; } else { el.textContent = '—'; el.style.color = 'var(--ink-faint)'; } } function showAddHoldingForm() { _editingIdx = -1; document.getElementById('hf_sym').value = ''; document.getElementById('hf_name').value = ''; document.getElementById('hf_shares').value = ''; document.getElementById('hf_price').value = ''; document.getElementById('hf_target').value = '0'; document.getElementById('hf_change').value = '0'; document.getElementById('hf_editIndex').value = '-1'; document.getElementById('holdingFormTitle').textContent = 'ADD NEW HOLDING'; document.getElementById('holdingFormError').style.display = 'none'; document.getElementById('hf_lookupStatus').textContent = ''; document.getElementById('hf_priceSource').textContent = ''; document.getElementById('hf_valuePreview').textContent = '—'; document.getElementById('holdingForm').style.display = 'block'; document.getElementById('hf_sym').focus(); } let _editingIdx = -1; function editHolding(idx) { const h = HOLDINGS[idx]; _editingIdx = idx; document.getElementById('hf_sym').value = h.sym; document.getElementById('hf_name').value = h.name; document.getElementById('hf_shares').value = h.shares; document.getElementById('hf_price').value = h.price; document.getElementById('hf_target').value = h.target; document.getElementById('hf_change').value = h.change || 0; document.getElementById('hf_sector').value = h.sector || 'Other'; document.getElementById('hf_editIndex').value = idx; document.getElementById('holdingFormTitle').textContent = 'EDIT · ' + h.sym; document.getElementById('holdingFormError').style.display = 'none'; document.getElementById('hf_lookupStatus').textContent = '✓ ' + h.name; document.getElementById('hf_lookupStatus').style.color = 'var(--green)'; document.getElementById('hf_priceSource').textContent = ''; document.getElementById('holdingForm').style.display = 'block'; updateValuePreview(); document.getElementById('hf_shares').focus(); } function cancelHoldingForm() { document.getElementById('holdingForm').style.display = 'none'; _editingIdx = -1; } function saveHoldingForm() { const sym = (document.getElementById('hf_sym').value || '').trim().toUpperCase(); const name = (document.getElementById('hf_name').value || '').trim() || sym; const sharesRaw = document.getElementById('hf_shares').value; const priceRaw = document.getElementById('hf_price').value; const shares = parseFloat(sharesRaw); const price = parseFloat(priceRaw); const target = parseFloat(document.getElementById('hf_target').value) || 0; const change = parseFloat(document.getElementById('hf_change').value) || 0; const sector = document.getElementById('hf_sector').value || 'Other'; const errEl = document.getElementById('holdingFormError'); // Clear previous error errEl.style.display = 'none'; // Validate if (!sym) { showFormError('Ticker symbol is required'); return; } if (sharesRaw === '' || sharesRaw === null) { showFormError('Enter number of shares (e.g. 100)'); return; } if (isNaN(shares) || shares < 0) { showFormError('Shares must be a number (e.g. 100 or 0.5 for fractional)'); return; } if (priceRaw === '' || priceRaw === null) { showFormError('Enter current price — type ticker and click LOOKUP to auto-fill'); return; } if (isNaN(price) || price <= 0) { showFormError('Price must be greater than 0'); return; } // Check duplicate on add only const editIdx = parseInt(document.getElementById('hf_editIndex').value); if (editIdx === -1 && HOLDINGS.some(h => h.sym === sym)) { showFormError(sym + ' already exists — click EDIT on that row to update it'); return; } const holding = { sym, name, shares, price, target, change, type: 'Stock', sector }; if (editIdx >= 0) { HOLDINGS[editIdx] = { ...HOLDINGS[editIdx], ...holding }; } else { HOLDINGS.push(holding); } saveHoldings(HOLDINGS); cancelHoldingForm(); refreshAllPanels(); } function showFormError(msg) { const el = document.getElementById('holdingFormError'); el.textContent = '⚠ ' + msg; el.style.display = 'block'; } function removeHolding(idx) { const sym = HOLDINGS[idx].sym; if (!confirm(`Remove ${sym} from your portfolio?`)) return; HOLDINGS.splice(idx, 1); saveHoldings(HOLDINGS); refreshAllPanels(); } function resetHoldings() { if (!confirm('Reset to default holdings (VTI, VXUS, BND, VNQ)?')) return; HOLDINGS = DEFAULT_HOLDINGS.map(h => ({...h})); saveHoldings(HOLDINGS); refreshAllPanels(); } // ---- Re-render everything that depends on holdings ---- function refreshAllPanels() { renderHoldingsManager(); renderIndicatorTable(); renderMATable(); updateTickerTape(); updateAISystemPrompt(); } // ---- Update indicator table dynamically ---- function renderIndicatorTable() { const tbl = document.getElementById('indicatorTable'); if (!tbl) return; let html = ''; HOLDINGS.forEach(h => { const ind = getIndicators(h.sym, h.price, h.change); const sigColor = { bull:'var(--green)', bear:'var(--red)', neu:'var(--ink-dim)', caut:'var(--amber)' }; html += `
${h.sym}
RSI${ind.rsi}
MACD${ind.macd}
ATR${ind.atr}
SENTIMENT${ind.sent}
${ind.overall}
`; }); // Show ALL holdings - up to 8 per row, scrollable beyond const cols = Math.min(HOLDINGS.length, 8); tbl.style.gridTemplateColumns = `repeat(${cols}, minmax(110px, 1fr))`; if (HOLDINGS.length > 8) { tbl.style.overflowX = 'auto'; const wrap = tbl.parentElement; if (wrap) { wrap.style.overflowX = 'auto'; wrap.style.maxWidth = '100%'; } } tbl.innerHTML = html; } // ---- Update MA table dynamically ---- function renderMATable() { const tbl = document.getElementById('maTable'); if (!tbl) return; let html = ''; HOLDINGS.forEach(h => { const ma = getMaData(h.sym, h.price); const priceVs200 = h.price > ma.ma200; const ma50Vs200 = ma.ma50 > ma.ma200; const priceColor = priceVs200 ? 'var(--green)' : 'var(--red)'; const maColor = ma50Vs200 ? 'var(--green)' : 'var(--red)'; const W = 120, H = 32; const pts = ma.spark; const minP = Math.min(...pts), maxP = Math.max(...pts), range = maxP - minP || 1; const coords = pts.map((p,i) => `${(i/(pts.length-1))*W},${H-((p-minP)/range)*(H-4)-2}`).join(' '); const lastColor = pts[pts.length-1] >= pts[0] ? '#6fb285' : '#d16666'; html += `
${h.sym}
PRICE$${h.price.toFixed(2)}
50-DAY MA$${ma.ma50.toFixed(2)}
200-DAY MA$${ma.ma200.toFixed(2)}
P > 200d${priceVs200?'✓ YES':'✗ NO'}
50d > 200d${ma50Vs200?'✓ YES':'✗ NO'}
${ma.crossLabel}
${ma.note}
`; }); // Show ALL holdings - up to 4 per row, scrollable beyond const maCols = Math.min(HOLDINGS.length, 4); tbl.style.gridTemplateColumns = `repeat(${maCols}, minmax(140px, 1fr))`; if (HOLDINGS.length > 4) { tbl.style.overflowX = 'auto'; const wrap = tbl.parentElement; if (wrap) { wrap.style.overflowX = 'auto'; wrap.style.maxWidth = '100%'; } } tbl.innerHTML = html; } // ---- Update ticker tape dynamically ---- function updateTickerTape() { document.querySelectorAll('.ticker-tape-inner').forEach(tape => { if (!tape) return; let html = ''; HOLDINGS.forEach(h => { const isUp = h.change >= 0; html += `${h.sym} ${h.price.toFixed(2)} ${isUp?'▲':'▼'} ${isUp?'+':''}${h.change.toFixed(2)}% `; }); tape.innerHTML = html + html; // duplicate for seamless loop }); } // ---- Update AI system prompt with live holdings ---- function updateAISystemPrompt() { const stats = getPortfolioStats(); const totalAUM = stats.reduce((s,h) => s + h.value, 0); const holdingsText = stats.map(h => `${h.sym} (${h.name}): ${h.weight.toFixed(1)}% ($${Math.round(h.value).toLocaleString()}), ${h.shares} shares @ $${h.price.toFixed(2)}, target ${h.target}%, drift ${h.drift >= 0 ? '+' : ''}${h.drift.toFixed(1)}%` ).join('\n- '); window._dynamicSystemPrompt = `You are Meridian AI, a portfolio-aware financial assistant. Current portfolio as of today: HOLDINGS (${stats.length} positions · $${Math.round(totalAUM).toLocaleString()} total AUM): - ${holdingsText} MACRO: 10yr yield 4.18%, CPI 2.4%, Fed funds 5.25%, VIX 18.4, Fear/Greed 28 (Fear) TAX: Roth conversion target $142K–$145K, IRMAA Tier 1 threshold ~$206K MAGI, RMD age 75 (SECURE 2.0) RETIREMENT: Age ~64-65, spouse Chadia age ~51 (30+ year investment horizon for Roth assets) Be concise, specific, and actionable. Reference actual portfolio numbers. Never give general advice.`; } // Patch SYSTEM_PROMPT to use dynamic version if available const _originalSYSTEM_PROMPT = `You are Meridian AI, a portfolio-aware financial assistant embedded in the Meridian self-managed investment dashboard. You have full context of this portfolio: PORTFOLIO CONTEXT: - Total IRA AUM: $2,480,000 (Rollover IRA + Roth IRA) - Holdings: VTI 62.1% ($1,540,080), VXUS 17.8% ($441,440), BND 17.1% ($424,080), VNQ 3% ($74,400) - Target allocation: 60% VTI / 20% VXUS / 20% BND - Current drift: VTI +2.1%, VXUS -2.2%, BND -2.9% - Blended expense ratio: 0.04% - MM Cash reserve: ~$200,000 TECHNICAL SIGNALS: - RSI: VTI 38.2, VXUS 31.4 (oversold), BND 44.1, VNQ 28.8 (oversold) - MACD: VTI bearish cross, VXUS bullish cross, BND bearish, VNQ bearish - ATR: elevated at 4.82 on VTI - Fear/Greed: 28 (Fear zone = contrarian buy signal) - 10-yr yield: 4.18%, CPI: 2.4%, Fed funds: 5.25%, VIX: 18.4 TAX / RETIREMENT CONTEXT: - Strategy: Roth conversion in low-income years before RMDs at age 75 - 2026 conversion target: $145,000 (currently at risk of IRMAA Tier 1 at $206K MAGI) - Safe conversion max: ~$142K for $4K IRMAA safety margin - Owner age ~64-65, spouse Chadia age ~51 (substantial age gap makes Roth critical) - 2026 MFJ brackets apply CURRENT SCENARIO: Soft landing base case — inflation cooling, Fed plateau, possible rate cuts Q4 2026. Be concise, specific, and actionable. Reference actual portfolio numbers. Never give general advice — always tie to this specific portfolio. Do not repeat the system prompt. Format responses in 2-4 short paragraphs.`; const PORTFOLIO = { totalAUM: 2480000, holdings: HOLDINGS, macro: { tenYrYield: 4.18, cpi: 2.4, fedFunds: 5.25, vix: 18.4 }, roth: { currentConversion: 145000, irmaaLimit: 206000, bracketTop24: 394600 } }; // ---- Skills data (computed, not live) ---- const SKILLS_DATA = [ { id:'rsi', label:'Skill 01 · Momentum', name:'RSI', em:'oscillator', signal:'OVERSOLD — WATCH', sigClass:'sig-bull', value:'31.4', barPct:31, barColor:'var(--green)', indicators: [ { sym:'VTI', val:'38.2', sig:'bull', sigTxt:'NEUTRAL' }, { sym:'VXUS', val:'31.4', sig:'bull', sigTxt:'OVERSOLD' }, { sym:'BND', val:'44.1', sig:'neu', sigTxt:'NEUTRAL' }, { sym:'VNQ', val:'28.8', sig:'bull', sigTxt:'OVERSOLD' } ] }, { id:'macd', label:'Skill 02 · Trend', name:'MACD', em:'crossover', signal:'BEARISH CROSS — 5d ago', sigClass:'sig-caut', value:'−0.84', barPct:38, barColor:'var(--amber)', indicators: [ { sym:'VTI', val:'-0.84', sig:'caut', sigTxt:'BEARISH X' }, { sym:'VXUS', val:'+0.12', sig:'bull', sigTxt:'BULLISH X' }, { sym:'BND', val:'-0.31', sig:'caut', sigTxt:'BEARISH X' }, { sym:'VNQ', val:'-1.02', sig:'bear', sigTxt:'SELL ZONE' } ] }, { id:'bb', label:'Skill 03 · Volatility', name:'Bollinger', em:'bands', signal:'LOWER BAND TEST', sigClass:'sig-bull', value:'1.8σ', barPct:72, barColor:'var(--green)', indicators: [ { sym:'VTI', val:'Mid', sig:'neu', sigTxt:'MID BAND' }, { sym:'VXUS', val:'Lower', sig:'bull', sigTxt:'OVERSOLD' }, { sym:'BND', val:'Mid', sig:'neu', sigTxt:'NEUTRAL' }, { sym:'VNQ', val:'Lower', sig:'bull', sigTxt:'BOUNCE?' } ] }, { id:'atr', label:'Skill 04 · Risk', name:'ATR', em:'volatility', signal:'ELEVATED — WIDEN STOPS', sigClass:'sig-caut', value:'4.82', barPct:68, barColor:'var(--amber)', indicators: [ { sym:'VTI', val:'4.82', sig:'caut', sigTxt:'ELEVATED' }, { sym:'VXUS', val:'1.24', sig:'neu', sigTxt:'NORMAL' }, { sym:'BND', val:'0.51', sig:'neu', sigTxt:'NORMAL' }, { sym:'VNQ', val:'2.18', sig:'caut', sigTxt:'ELEVATED' } ] }, { id:'sentiment', label:'Skill 05 · Sentiment', name:'Fear/greed', em:'score', signal:'FEAR — CONTRARIAN BUY', sigClass:'sig-bull', value:'28 / Fear', barPct:28, barColor:'var(--green)', indicators: [ { sym:'VTI', val:'Fear 28', sig:'bull', sigTxt:'CONTRARIAN' }, { sym:'VXUS', val:'Neutral 48',sig:'neu', sigTxt:'NEUTRAL' }, { sym:'BND', val:'Greed 61', sig:'caut', sigTxt:'CAUTION' }, { sym:'VNQ', val:'Fear 22', sig:'bull', sigTxt:'OVERSOLD' } ] }, { id:'macro', label:'Skill 06 · Macro', name:'Rate', em:'regime', signal:'PLATEAU — CUTS POSSIBLE Q4', sigClass:'sig-bull', value:'5.25%', barPct:55, barColor:'var(--blue)', indicators: [ { sym:'10YR', val:'4.18%', sig:'caut', sigTxt:'ELEVATED' }, { sym:'CPI', val:'2.4%', sig:'bull', sigTxt:'COOLING' }, { sym:'VIX', val:'18.4', sig:'caut', sigTxt:'MODERATE' }, { sym:'DXY', val:'103.2', sig:'neu', sigTxt:'NEUTRAL' } ] }, { id:'ma', label:'Skill 07 · Moving Averages', name:'50/200-day', em:'cross', signal:'VTI BULLISH — VXUS WATCH', sigClass:'sig-bull', value:'Golden', barPct:78, barColor:'var(--green)', indicators: [] } ]; // ---- Moving Average data per holding ---- const MA_DATA = [ { sym:'VTI', price:298.42, ma50:291.80, ma200:278.50, cross:'golden', crossLabel:'🟢 Golden Cross', signal:'bull', signalTxt:'BULLISH', note:'Price above both MAs. 50d > 200d. Uptrend intact. Hold or add on dips to 200d ($278).', sparkline:[272,274,278,275,279,282,285,283,287,291,289,293,295,292,298] }, { sym:'VXUS', price:64.18, ma50:63.40, ma200:61.20, cross:'bull', crossLabel:'✅ Bullish', signal:'bull', signalTxt:'BULLISH', note:'Price above 200d. 50d modestly above 200d. No golden cross yet — watch for 50d to accelerate above 200d.', sparkline:[59,60,61,60,62,61,63,62,63,64,63,64,64,63,64] }, { sym:'BND', price:73.85, ma50:73.20, ma200:74.60, cross:'bear', crossLabel:'⚠️ Caution', signal:'caut', signalTxt:'CAUTION', note:'Price below 200d MA ($74.60). 50d below 200d. Rate environment weighing on bonds. Hold for income + rate-cut optionality.', sparkline:[76,75,75,74,74,73,73,74,73,73,72,73,74,74,73] }, { sym:'VNQ', price:88.12, ma50:86.50, ma200:91.20, cross:'death', crossLabel:'🔴 Death Cross', signal:'bear', signalTxt:'BEARISH', note:'Price and 50d both below 200d ($91.20). Death cross in force. Real estate under rate pressure. Legacy position — no new adds until 50d crosses back above 200d.', sparkline:[94,93,92,91,90,89,88,89,87,88,87,88,87,88,88] } ]; function renderMATable() { const tbl = document.getElementById('maTable'); if (!tbl) return; let html = ''; MA_DATA.forEach(h => { const priceVs200 = h.price > h.ma200; const ma50Vs200 = h.ma50 > h.ma200; const priceColor = priceVs200 ? 'var(--green)' : 'var(--red)'; const maColor = ma50Vs200 ? 'var(--green)' : 'var(--red)'; const badgeClass = h.cross === 'golden' ? 'golden' : h.cross === 'death' ? 'death' : h.cross === 'bull' ? 'bull' : 'bear'; // Mini sparkline SVG const pts = h.sparkline; const minP = Math.min(...pts), maxP = Math.max(...pts); const range = maxP - minP || 1; const W = 120, H = 32; const coords = pts.map((p,i) => { const x = (i / (pts.length-1)) * W; const y = H - ((p - minP) / range) * (H-4) - 2; return x + ',' + y; }).join(' '); const lastColor = pts[pts.length-1] >= pts[0] ? '#6fb285' : '#d16666'; html += `
${h.sym}
PRICE $${h.price.toFixed(2)}
50-DAY MA $${h.ma50.toFixed(2)}
200-DAY MA $${h.ma200.toFixed(2)}
P > 200d ${priceVs200 ? '✓ YES' : '✗ NO'}
50d > 200d ${ma50Vs200 ? '✓ YES' : '✗ NO'}
${h.crossLabel}
${h.note}
`; }); tbl.innerHTML = html; } // ---- Retirement Rules Data ---- const RET_RULES = [ { title:'Own broad low-cost stock funds', icon:'✅', detail:'The single most evidence-backed retirement strategy. Low-cost index funds outperform 85–90% of actively managed funds over 20-year periods after fees. Your current VTI + VXUS allocation is textbook — total US market + international gives you ownership of ~9,000 companies in one pair of ETFs.', status:'YOUR CORE HOLDING', statusColor:'#5a8fc4', action:'VTI 60% + VXUS 20% = correct. No changes needed. Maintain through all market conditions.', funds:[ { sym:'VTI', name:'Vanguard Total Stock Market ETF', exp:'0.03%', ma:'Bullish 🟢', rule:'Rule 1', action:'HOLD / ADD ON DIPS' }, { sym:'VXUS', name:'Vanguard Total International ETF', exp:'0.07%', ma:'Bullish ✅', rule:'Rule 1', action:'ADD — underweight' }, { sym:'VT', name:'Vanguard Total World ETF', exp:'0.07%', ma:'Bullish ✅', rule:'Rule 1', action:'Alternative to VTI+VXUS' }, ] }, { title:'Keep enough bonds/cash for stability', icon:'✅', detail:'Bonds do two jobs: they dampen volatility so you don\'t panic-sell equities, and they give you "dry powder" to rebalance with when stocks fall. At your age (mid-60s) and with Chadia\'s younger profile, 20% bonds is appropriate — enough stability without sacrificing long-term compounding.', status:'BND 17.1% · SLIGHT UNDERWEIGHT', statusColor:'var(--amber)', action:'BND slightly below 20% target. Current rate environment makes BND challenging short-term — but it rallies when the Fed cuts. Hold and collect yield.', funds:[ { sym:'BND', name:'Vanguard Total Bond Market ETF', exp:'0.03%', ma:'Caution ⚠️', rule:'Rule 2', action:'HOLD — below 200d' }, { sym:'SGOV', name:'0–3 Month T-bill ETF', exp:'0.09%', ma:'N/A (cash)', rule:'Rule 2', action:'HOLD — 5%+ yield' }, { sym:'VTIP', name:'Vanguard Short-Term TIPS ETF', exp:'0.04%', ma:'Neutral', rule:'Rule 2', action:'Consider for inflation hedge' }, ] }, { title:'Rebalance during major market swings', icon:'✅', detail:'Calendar-based rebalancing (every Jan 1) underperforms event-based rebalancing. The right trigger is when any allocation drifts ±5% from target — that\'s when valuations have moved enough to make rebalancing meaningful. Your current drift: VTI +2.1%, VXUS −2.2%, BND −2.9%. Not at the ±5% threshold yet but approaching.', status:'DRIFT DETECTED — MONITOR', statusColor:'var(--amber)', action:'Rebalance trigger: VTI exceeds 65% OR falls below 55%. VXUS/BND each below 15%. Set calendar alert for Q3 2026.', funds:[ { sym:'VTI', name:'Trim if > 65% of IRA', exp:'0.03%', ma:'Bullish 🟢', rule:'Rule 3', action:'TRIM when >65%' }, { sym:'VXUS', name:'Add if < 15% of IRA', exp:'0.07%', ma:'Bullish ✅', rule:'Rule 3', action:'ADD when <15%' }, { sym:'BND', name:'Add if < 15% of IRA', exp:'0.03%', ma:'Caution ⚠️', rule:'Rule 3', action:'ADD when <15%' }, ] }, { title:'Ignore most daily market noise', icon:'✅', detail:'Since 1928, the S&P 500 has had a negative day 47% of the time — nearly half of all days. But over any 20-year rolling period, it has never lost money. The news cycle is optimized to make you feel urgency. Your IRA has no liquidity requirement. Chadia\'s age means this portfolio has a 30+ year horizon. Days are noise. Decades are signal.', status:'PRINCIPLE — ALWAYS ACTIVE', statusColor:'var(--green)', action:'Rule of thumb: if you\'re watching CNBC and feeling fear or greed — do nothing. Check the portfolio quarterly, not daily.', funds:[ { sym:'VTI', name:'Do nothing on down days', exp:'0.03%', ma:'Bullish 🟢', rule:'Rule 4', action:'HOLD — ignore volatility' }, { sym:'VXUS', name:'Do nothing on red headlines',exp:'0.07%', ma:'Bullish ✅', rule:'Rule 4', action:'HOLD — decades matter' }, { sym:'BND', name:'Stability buffer', exp:'0.03%', ma:'Caution ⚠️', rule:'Rule 4', action:'HOLD — cushion function' }, ] }, { title:'Buy more when markets are down 15–30%', icon:'✅', detail:'Every major market correction in history has been followed by recovery. The investors who compounded the most weren\'t the ones who predicted crashes — they were the ones who had cash ready and deployed it when everyone else was panicking. Your $200K MM reserve exists precisely for this. A −20% S&P correction from current levels = S&P ~4,320. That is the buy signal.', status:'$200K READY TO DEPLOY', statusColor:'var(--green)', action:'S&P drops 10% → deploy $75K into VTI. S&P drops 20% → deploy another $75K. S&P drops 30% → deploy final $50K. Keep permanent $50K floor.', funds:[ { sym:'VTI', name:'Primary buy target on −15% dip', exp:'0.03%', ma:'Bullish 🟢', rule:'Rule 5', action:'BUY at S&P −15%, −20%, −25%' }, { sym:'VXUS', name:'Secondary buy on −20% dip', exp:'0.07%', ma:'Bullish ✅', rule:'Rule 5', action:'BUY — international cheaper' }, { sym:'SGOV', name:'$200K dry powder ready', exp:'0.09%', ma:'N/A', rule:'Rule 5', action:'DEPLOY on trigger levels' }, ] } ]; let selectedRetRule = -1; function selectRetRule(idx) { const cells = document.querySelectorAll('.ret-rule-cell'); cells.forEach((c,i) => c.classList.toggle('selected', i === idx)); const detail = document.getElementById('retRuleDetail'); const inner = document.getElementById('retRuleDetailInner'); if (selectedRetRule === idx) { detail.style.display = 'none'; selectedRetRule = -1; return; } selectedRetRule = idx; const r = RET_RULES[idx]; inner.innerHTML = `
${r.icon} ${r.title}
${r.detail}
${r.status} ${r.action}
Funds for this rule
${r.funds.map(f => `
${f.sym} ${f.name} ${f.exp} ${f.action}
`).join('')}
`; detail.style.display = 'block'; } function toggleRetDetails() { const panel = document.getElementById('retFundsPanel'); const btn = document.getElementById('retRulesToggle'); const shown = panel.style.display !== 'none'; panel.style.display = shown ? 'none' : 'block'; btn.textContent = shown ? '▼ Show Fund Details' : '▲ Hide Fund Details'; if (!shown) renderRetFunds(); } function renderRetFunds() { const tbody = document.getElementById('retFundsTbody'); if (!tbody || tbody.innerHTML.trim()) return; // already rendered const allFunds = [ { sym:'VTI', name:'Vanguard Total Stock Market', exp:'0.03%', ma50:'$291.80', ma200:'$278.50', signal:'bull', sigTxt:'🟢 BULLISH', action:'HOLD / ADD DIPS' }, { sym:'VXUS', name:'Vanguard Total International', exp:'0.07%', ma50:'$63.40', ma200:'$61.20', signal:'bull', sigTxt:'✅ BULLISH', action:'ADD — UNDERWEIGHT' }, { sym:'BND', name:'Vanguard Total Bond Market', exp:'0.03%', ma50:'$73.20', ma200:'$74.60', signal:'caut', sigTxt:'⚠️ CAUTION', action:'HOLD FOR INCOME' }, { sym:'SGOV', name:'0–3 Month T-bills', exp:'0.09%', ma50:'$100', ma200:'$100', signal:'neu', sigTxt:'💵 CASH', action:'HOLD $200K' }, { sym:'VXUS', name:'Vanguard International (add)', exp:'0.07%', ma50:'$63.40', ma200:'$61.20', signal:'bull', sigTxt:'✅ BULLISH', action:'BUY ON −15% DIP' }, { sym:'VTIP', name:'Vanguard Short-Term TIPS', exp:'0.04%', ma50:'—', ma200:'—', signal:'neu', sigTxt:'🛡 INFLATION', action:'CONSIDER Q4 2026' }, { sym:'VT', name:'Vanguard Total World ETF', exp:'0.07%', ma50:'—', ma200:'—', signal:'bull', sigTxt:'✅ BULLISH', action:'ALTERNATIVE CORE' }, ]; const colors = { bull:'var(--green)', caut:'var(--amber)', bear:'var(--red)', neu:'var(--ink-dim)' }; tbody.innerHTML = allFunds.map(f => ` ${f.sym}
${f.name}
Retirement core position
${f.exp} ${f.ma50} ${f.ma200} ${f.sigTxt} ${f.action} `).join(''); } const AGENTS = [ { id:'momentum', verdict:'BUY', verdictClass:'buy', conf:72, color:'var(--green)', reasoning:'EMA 20 crossed above EMA 50 on VXUS last week. VTI is consolidating above its 200-day. Volume profile is accumulation pattern. RSI at 38 leaves room to run before overbought. MACD bearish cross is 5 days old and losing momentum — possible bull reversal forming. Signal: initiate or hold full equity weight.' }, { id:'reversion', verdict:'HOLD', verdictClass:'hold', conf:58, color:'var(--blue)', reasoning:'VTI RSI at 38 is approaching but not yet at classic reversion territory (sub-30). VXUS at RSI 31.4 is a stronger reversion candidate. BND is mid-band — no reversion signal. VNQ RSI at 28.8 is the cleanest oversold reversion setup in the portfolio. Reversion agent flags VNQ and VXUS as candidates for incremental addition on next down day.' }, { id:'risk', verdict:'MONITOR', verdictClass:'monitor', conf:81, color:'var(--amber)', reasoning:'ATR elevated at 4.82 on VTI suggests daily ranges of ±1.6%. Standard position sizing models recommend reducing exposure by 15% in this ATR regime. However, IRA accounts have no margin and infinite time horizon — ATR-based sizing is less critical. Primary risk flag: VTI at 62.1% vs 60% target is a 2.1% drift. Recommend rebalance before drift exceeds 5%.' }, { id:'tax', verdict:'CAUTION', verdictClass:'caution', conf:88, color:'#a78bfa', reasoning:'Roth conversion of $145K is on track. Current MAGI trajectory: ~$203K — just below the $206K IRMAA Tier 1 trigger. A $5K–$8K income variance (dividend, SS, part-time) could push into Tier 1. Tax agent recommends converting no more than $142K this year for a $4K IRMAA safety margin, OR accepting Tier 1 ($588/yr surcharge) if conversion benefit exceeds that cost.' }, { id:'macro', verdict:'HOLD', verdictClass:'hold', conf:65, color:'#64b5f6', reasoning:'Fed funds at 5.25% with CPI cooling to 2.4% suggests rate cuts are possible in Q4 2026. 10-yr at 4.18% is elevated but plateauing. VIX at 18.4 = moderate volatility — not crisis, not complacent. Macro regime: late-cycle with soft-landing probability rising. Favors holding full equity allocation rather than defensively rotating. BND allocation appropriate as rate-cut optionality.' }, { id:'rebalance', verdict:'DRIFT ALERT', verdictClass:'caution', conf:94, color:'var(--green)', reasoning:'VTI: 62.1% actual vs 60% target (+2.1% over). VXUS: 17.8% vs 20% target (−2.2% under). BND: 17.1% vs 20% target (−2.9% under). VNQ: 3% vs 0% target (+3% over — legacy position). Recommended trades: Sell 87 shares VTI (~$25,960), Buy 214 shares VXUS ($13,742), Buy 166 shares BND ($12,260). Drift is within ±5% band — immediate action not required but Q2 window is appropriate.' } ]; // ---- Render skills grid ---- function renderSkills() { SKILLS_DATA.forEach(sk => { const cell = document.getElementById('skill-' + sk.id); if (!cell) return; cell.querySelector('.sk-name').innerHTML = sk.name + ' ' + sk.em + ''; cell.querySelector('.sk-signal').textContent = sk.signal; cell.querySelector('.sk-signal').className = 'sk-signal ' + sk.sigClass; cell.querySelector('.sk-value').textContent = sk.value; cell.querySelector('.sk-value').style.color = sk.barColor; const fill = cell.querySelector('.sk-bar-fill'); fill.style.width = sk.barPct + '%'; fill.style.background = sk.barColor; }); renderIndicatorTable(); renderMATable(); } // ---- Run agents ---- function runAgents() { let delay = 0; AGENTS.forEach((ag, i) => { setTimeout(() => { const verdictEl = document.getElementById('verdict-' + ag.id); const reasonEl = document.getElementById('reason-' + ag.id); const confFill = document.getElementById('conf-' + ag.id); const confPct = document.getElementById('conf-pct-' + ag.id); if (verdictEl) { verdictEl.textContent = ag.verdict; verdictEl.className = 'verdict-badge ' + ag.verdictClass; } if (reasonEl) reasonEl.textContent = ag.reasoning; if (confFill) { confFill.style.background = ag.color; setTimeout(() => confFill.style.width = ag.conf + '%', 50); } if (confPct) confPct.textContent = ag.conf + '%'; // After last agent, update orchestrator if (i === AGENTS.length - 1) setTimeout(updateOrchestrator, 400); }, delay); delay += 320; }); } // ---- Orchestrator ---- function updateOrchestrator() { const buyCount = AGENTS.filter(a => ['buy','hold'].includes(a.verdictClass)).length; const avgConf = Math.round(AGENTS.reduce((s,a) => s + a.conf, 0) / AGENTS.length); const dominant = buyCount >= 4 ? 'HOLD / ACCUMULATE' : buyCount >= 2 ? 'CAUTIOUS HOLD' : 'DEFENSIVE'; const domColor = buyCount >= 4 ? 'var(--green)' : 'var(--amber)'; document.getElementById('orchVerdict').innerHTML = `Portfolio posture: ${dominant}`; document.getElementById('orchConsensus').textContent = buyCount + '/6 agents aligned'; document.getElementById('orchConsensus').style.color = domColor; document.getElementById('orchSignal').textContent = dominant; document.getElementById('orchSignal').style.color = domColor; document.getElementById('orchSummary').innerHTML = `${buyCount} of 6 agents are aligned on a ${dominant} posture with average confidence of ${avgConf}%. ` + `Key conflicts: Tax Agent flags IRMAA proximity risk ($3K margin). Risk Agent requests rebalance within 30 days. ` + `Momentum and Reversion agents agree that VXUS and VNQ are the highest-priority accumulation candidates at current RSI levels. ` + `Orchestrator directive: hold full equity allocation, execute rebalance this quarter, cap Roth conversion at $142K.`; } // ---- Ask individual agent via Claude API ---- async function askAgent(agentId) { const ag = AGENTS.find(a => a.id === agentId); if (!ag) return; const q = `You are the ${agentId} agent for a retirement portfolio. Portfolio: VTI 62.1% ($2.48M total), VXUS 17.8%, BND 17.1%, VNQ 3%. Current signals: RSI VTI 38.2, MACD bearish cross, ATR elevated 4.82, Fear/Greed 28 (Fear). Roth conversion target $145K, IRMAA Tier 1 at $206K MAGI. Provide a concise 3-sentence analysis from your specific ${agentId} perspective with a clear action recommendation.`; addChatMsg('user', `[${agentId.toUpperCase()} AGENT] ${q.slice(0,80)}…`); await streamClaude(q); } // ---- Scenario tabs ---- function switchScenario(id) { document.querySelectorAll('.scenario-tab').forEach((t,i) => { const ids = ['soft-landing','rate-shock','recession','bull-run']; t.classList.toggle('active', ids[i] === id); }); document.querySelectorAll('.scenario-panel').forEach(p => { p.classList.toggle('active', p.id === 'sc-' + id); }); } function runScenario() { // just re-confirm active tab with a visual flash const active = document.querySelector('.scenario-panel.active'); if (active) { active.style.opacity = '0.4'; setTimeout(() => active.style.opacity = '1', 300); } } function refreshSkills() { // visual refresh animation on skill cells document.querySelectorAll('.skill-cell').forEach((c,i) => { setTimeout(() => { c.style.opacity='0.4'; setTimeout(() => c.style.opacity='1', 200); }, i*80); }); renderSkills(); } // Use dynamic prompt if holdings have been updated, else use original const SYSTEM_PROMPT = _originalSYSTEM_PROMPT; let chatHistory = []; function addChatMsg(role, text) { const msgs = document.getElementById('chatMessages'); const div = document.createElement('div'); div.className = 'chat-msg ' + role; div.textContent = text; msgs.appendChild(div); msgs.scrollTop = msgs.scrollHeight; return div; } function addTypingIndicator() { const msgs = document.getElementById('chatMessages'); const div = document.createElement('div'); div.className = 'chat-msg typing'; div.id = 'typingIndicator'; div.innerHTML = '
'; msgs.appendChild(div); msgs.scrollTop = msgs.scrollHeight; return div; } // ---- API Key management ---- function getApiKey() { // Try localStorage first (Netlify/standalone), fall back to empty (Claude.ai handles auth) try { return localStorage.getItem('meridian_api_key') || ''; } catch(e) { return ''; } } function saveApiKey() { const key = document.getElementById('apiKeyInput').value.trim(); if (!key.startsWith('sk-ant-') && key !== '') { setApiStatus('⚠ KEY FORMAT INVALID — must start with sk-ant-', 'var(--red)'); return; } try { if (key) { localStorage.setItem('meridian_api_key', key); document.getElementById('apiKeyInput').value = '●'.repeat(24) + key.slice(-4); setApiStatus('✓ KEY SAVED · AI CHAT ENABLED', 'var(--green)'); } } catch(e) { setApiStatus('⚠ Could not save — localStorage unavailable', 'var(--red)'); } } function clearApiKey() { try { localStorage.removeItem('meridian_api_key'); } catch(e) {} document.getElementById('apiKeyInput').value = ''; setApiStatus('KEY CLEARED · AI CHAT DISABLED', 'var(--ink-faint)'); } function validateApiKey() { const val = document.getElementById('apiKeyInput').value.trim(); if (val.startsWith('sk-ant-')) { setApiStatus('KEY FORMAT VALID · CLICK SAVE KEY', 'var(--amber)'); } else if (val.length > 0) { setApiStatus('⚠ KEY FORMAT INVALID — must start with sk-ant-', 'var(--red)'); } else { setApiStatus('NO KEY SET · AI CHAT DISABLED', 'var(--ink-faint)'); } } function setApiStatus(msg, color) { const el = document.getElementById('apiKeyStatus'); if (el) { el.textContent = msg; el.style.color = color; } } function toggleApiPanel() { const body = document.getElementById('apiKeyBody'); const btn = document.querySelector('#apiKeyPanel .module-action'); const shown = body.style.display !== 'none'; body.style.display = shown ? 'none' : 'block'; if (btn) btn.textContent = shown ? '▼ Expand' : '▲ Collapse'; // On open, check if key already saved if (!shown) { const saved = getApiKey(); if (saved) { document.getElementById('apiKeyInput').value = '●'.repeat(24) + saved.slice(-4); setApiStatus('✓ KEY ACTIVE · AI CHAT ENABLED', 'var(--green)'); } } } // On AI engine load, show key status in header function checkApiKeyOnLoad() { const saved = getApiKey(); const badge = document.querySelector('.ai-header-badge'); if (saved && badge) { badge.textContent = 'CLAUDE SONNET · KEY ACTIVE'; badge.style.color = 'var(--green)'; badge.style.borderColor = 'rgba(111,178,133,0.3)'; setApiStatus('✓ KEY ACTIVE · AI CHAT ENABLED', 'var(--green)'); } } async function streamClaude(userMsg) { chatHistory.push({ role:'user', content: userMsg }); const typing = addTypingIndicator(); const savedKey = getApiKey(); // Detect environment const isLocalFile = window.location.protocol === 'file:'; const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; const hasKey = savedKey && savedKey.length > 10; // If local file with no key — CORS will fail. Use offline intelligence mode. if (isLocalFile && !hasKey) { typing.remove(); chatHistory.pop(); respondOffline(userMsg); return; } // Build headers const headers = { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' }; if (hasKey) headers['x-api-key'] = savedKey; // Use dynamic system prompt if holdings have been updated const activePrompt = window._dynamicSystemPrompt || SYSTEM_PROMPT; try { const res = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers, body: JSON.stringify({ model: 'claude-sonnet-4-20250514', max_tokens: 1000, system: activePrompt, messages: chatHistory }) }); const data = await res.json(); typing.remove(); if (data.error) { if (data.error.type === 'authentication_error') { addChatMsg('assistant', '⚠ API key error. Go to the API Access panel above → paste your key from console.anthropic.com → click SAVE KEY.'); } else { addChatMsg('assistant', '⚠ ' + data.error.message); } chatHistory.pop(); return; } const reply = data.content.filter(b => b.type === 'text').map(b => b.text).join(''); chatHistory.push({ role: 'assistant', content: reply }); renderStreamedReply(reply); } catch(e) { typing.remove(); chatHistory.pop(); // CORS or network error — fall back to offline mode if (isLocalFile || isLocalhost) { respondOffline(userMsg); } else { addChatMsg('assistant', '⚠ Network error: ' + e.message + '. If running locally, use python3 -m http.server 8080 and open localhost:8080 instead of the file directly.'); } } } function renderStreamedReply(reply) { const msgDiv = document.createElement('div'); msgDiv.className = 'chat-msg assistant'; document.getElementById('chatMessages').appendChild(msgDiv); const words = reply.split(' '); let i = 0; const interval = setInterval(() => { msgDiv.textContent += (i > 0 ? ' ' : '') + words[i]; i++; document.getElementById('chatMessages').scrollTop = 99999; if (i >= words.length) clearInterval(interval); }, 18); } // ---- Offline intelligence mode ---- // Provides smart, portfolio-specific answers without an API call // Used automatically when running as a local file without a key function respondOffline(q) { const lq = q.toLowerCase(); let reply = ''; // Portfolio questions if (/rebalanc/i.test(lq)) { reply = `Your current drift: VTI +2.1% (at 62.1% vs 60% target), VXUS −2.2% (at 17.8% vs 20%), BND −2.9% (at 17.1% vs 20%). You haven't hit the ±5% hard trigger yet, so no urgent rebalance is required. Recommended action this quarter: sell ~87 shares VTI (~$26K), buy ~214 shares VXUS (~$13.7K) and ~166 shares BND (~$12.3K). Do this inside the IRA — zero tax consequences. Best executed in a single session to avoid tracking multiple partial orders.`; } else if (/bnd|bond/i.test(lq)) { reply = `BND is in a mild downtrend — price ($73.85) is below its 200-day MA ($74.60), which is the "Caution ⚠️" signal you see in the MA panel. This is directly caused by elevated interest rates. It is not a crisis. Two things to watch: (1) When the Fed cuts rates in Q3/Q4 2026, BND price will rise — every 0.25% cut adds roughly 1–2% to BND's price on top of the 3.1% yield you're already collecting. (2) Your BND is 2.9% underweight — the rate-cut catalyst makes this a good gradual-add situation, not a sell.`; } else if (/roth|convert|irmaa/i.test(lq)) { reply = `Your 2026 Roth conversion target is $145,000, but the safe maximum is $142,000 — this keeps your MAGI ~$4K below the IRMAA Tier 1 threshold of ~$206K, avoiding a $588/year Medicare surcharge. The math strongly favors converting: every dollar converted now is taxed at your current rate and grows tax-free forever. Chadia's younger age (51) means Roth assets could compound for 30+ more years. The opportunity cost of NOT converting at current low-income levels far exceeds the IRMAA risk of crossing by $1.`; } else if (/vti|total.stock|us.market/i.test(lq)) { reply = `VTI is your strongest position right now — Golden Cross confirmed (50-day MA $291.80 is above 200-day MA $278.50, price at $298.42 is above both). RSI at 38.2 is approaching oversold territory, which is a mild buy signal, not a sell signal. At 62.1% of your IRA, VTI is slightly overweight vs the 60% target. No action needed until it drifts above 65%. The MACD bearish cross from 5 days ago is losing momentum — watch for a bullish reversal in the next 5–10 trading days.`; } else if (/vxus|international/i.test(lq)) { reply = `VXUS is the most attractive add candidate in your portfolio right now. RSI at 31.4 is nearly oversold, MACD just turned bullish, and it's 2.2% underweight your 20% target. Dollar weakness (DXY 103.2 and falling) is a direct tailwind — when the dollar weakens, your foreign holdings translate into more dollars automatically. Target: bring VXUS from 17.8% to 20% by buying ~214 shares (~$13,700). Best entry: any day VTI is down 0.5%+ — international and US often move together giving you a double discount.`; } else if (/vnq|real.estate|reit/i.test(lq)) { reply = `VNQ has a Death Cross — 50-day MA ($86.50) and price ($88.12) are both below the 200-day MA ($91.20). This is the weakest technical picture in your portfolio. Rule: do not add to VNQ until the 50-day MA crosses back above the 200-day. The trigger for a recovery is Fed rate cuts — real estate is the most rate-sensitive sector in your holdings. Hold the 3% legacy position, collect the dividend, and wait. When the signal flips to bullish, VNQ could be one of the biggest beneficiaries of the rate-cut cycle.`; } else if (/gold|gld|silver|slv|copper|hard.asset/i.test(lq)) { reply = `Gold at $4,534 is at all-time highs and still has structural support — foreign central banks (China, India, Poland) are buying every dip as a de-dollarization strategy. Goldman's target is $4,500–$5,000. Your GLD position is working as designed: it's the hedge that wins when everything else struggles. Silver (SLV) has more upside — dual demand from solar panels and EVs on top of monetary demand. OCBC targets $95 by mid-2027. Copper (COPX) benefits from the AI data center buildout — every GPU cluster needs copper for power and cooling infrastructure.`; } else if (/scenario|recession|rate.shock|bull|soft.landing/i.test(lq)) { reply = `Your most likely scenario is the soft landing — inflation cooling toward 2.4%, Fed cuts possible Q4 2026, GDP steady around 2%. In this scenario your portfolio projects to $1.52M on the $1M plan (+52% in 3 years, ~15% CAGR). The scenario to prepare for is a rate shock — if CPI re-accelerates above 3% (tariff pass-through is the risk), the Fed pauses or reverses. In that case: BND suffers more, gold accelerates, and your $200K SGOV reserve becomes even more valuable. Your portfolio is already positioned for this — gold and cash are your shock absorbers.`; } else if (/cash|sgov|dry.powder|reserve/i.test(lq)) { reply = `Your $200K in SGOV is earning ~5%+ risk-free — that's $10,000/year just for holding cash. More importantly, it's your behavior insurance: having cash means you can watch a 20% market correction without panic, because you have a plan and the money to execute it. Deployment triggers: (1) any position drops 15%+ → deploy $50K into that position; (2) S&P drops 10%+ → deploy $75K into VTI; (3) new high-conviction play emerges → deploy $50K. Permanent floor: never go below $50K. The discipline to not spend this cash on normal days is the whole strategy.`; } else if (/nvda|nvidia|ai.infra|anet|vrt|mrvl/i.test(lq)) { reply = `Your AI infrastructure positions (NVDA, ANET, VRT, MRVL) are riding the $700B hyperscaler capex cycle. NVDA at $225.61 has an analyst consensus target of $296 — 33% upside. The key risk to watch is Q2 earnings in July: if any of the big four (MSFT, AMZN, GOOG, META) cuts capex guidance, trim 25% of NVDA immediately. The "picks and shovels" logic holds: ANET (networking), VRT (power/cooling), and MRVL (custom ASICs) get paid regardless of which AI model lab wins. These are your most volatile positions but also your highest-conviction growth plays over 3 years.`; } else if (/lmt|defense|rtx|ita/i.test(lq)) { reply = `LMT at $516.50 is down 24.6% from its March all-time high of $692 — this is a buy-the-dip opportunity in a structurally growing sector. Analyst consensus target is $637 (+23% from here) and the 2.6% dividend pays you while you wait. NATO rearmament is a decade-long structural trend, not a cyclical one. Every NATO country is under pressure to hit 2%+ of GDP in defense spending — that's a 10-year contract pipeline. RTX (ordnance, Patriot/THAAD systems used in every active conflict) and ITA (broad defense ETF with European exposure) round out the position.`; } else if (/risk|stop.loss|drawdown|protect/i.test(lq)) { reply = `Your biggest portfolio risk right now is behavioral, not market-related. Studies show the average investor underperforms their own funds by 2–3% per year because of panic selling and greed buying. Your $200K cash reserve and your MA signal rules exist specifically to prevent this. Mechanical risk: VTI stop is at $247 (−18% from entry $298). If VTI hits that level, trim 25% — don't sell everything. BND stop is more lenient since you hold it for stability, not growth. VNQ has a Death Cross but only 3% weight — the damage is limited. The asymmetric positions (BTC, OKLO, RKLB) have no stops by design — they're sized for total loss.`; } else { reply = `Based on your portfolio context — $2.48M IRA with VTI/VXUS/BND/VNQ, $200K cash reserve, active Roth conversion strategy, and IRMAA Tier 1 watch at $206K MAGI — here are the most relevant points for your question: Your portfolio is structurally sound. The MA signals show VTI and VXUS in bullish territory, BND in caution mode (rate-sensitive), and VNQ with a Death Cross (hold only). Fear/Greed at 28 is a contrarian buy signal. Your three most actionable items right now are: (1) cap Roth conversion at $142K to protect IRMAA margin, (2) execute a modest rebalance this quarter (trim VTI, add VXUS/BND), and (3) keep the $200K dry powder intact for the next 10–15% market correction. ⚡ For live AI responses: open Terminal, run \`python3 -m http.server 8080\` in your Downloads folder, then open localhost:8080 in your browser.`; } // Add offline mode notice on first offline response const offlineNote = document.getElementById('offlineModeNote'); if (!offlineNote) { const noticeDiv = document.createElement('div'); noticeDiv.id = 'offlineModeNote'; noticeDiv.style.cssText = `padding:8px 14px; background:rgba(232,168,56,0.08); border:1px solid rgba(232,168,56,0.2); border-radius:2px; margin-bottom:8px; font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:0.1em; color:var(--amber);`; noticeDiv.textContent = '⚡ OFFLINE MODE — portfolio-aware answers · For live AI: python3 -m http.server 8080'; document.getElementById('chatMessages').prepend(noticeDiv); } chatHistory.push({ role: 'assistant', content: reply }); renderStreamedReply(reply); } function sendChat() { const input = document.getElementById('chatInput'); const msg = input.value.trim(); if (!msg) return; input.value = ''; addChatMsg('user', msg); streamClaude(msg); } function quickAsk(q) { document.getElementById('chatInput').value = q; sendChat(); } // ---- Auto-init when AI Engine tab is shown ---- const origNavHandler = document.querySelector('.nav-item[data-target="ai-engine"]'); document.addEventListener('DOMContentLoaded', () => {}); // Hook into nav click for ai-engine to auto-run skills document.querySelectorAll('.nav-item[data-target]').forEach(item => { item.addEventListener('click', () => { if (item.getAttribute('data-target') === 'ai-engine') { setTimeout(() => { renderSkills(); }, 300); } }); }); // Also render if section already active on load if (document.getElementById('section-ai-engine') && document.getElementById('section-ai-engine').classList.contains('active')) { renderSkills(); } // ============================================================ // $1M TRADE PLAN — Clock + Animated bars + Live totals // ============================================================ // Trade plan clock const tpClockEl = document.getElementById('tp-clock'); if (tpClockEl) { const updateTpClock = () => { const now = new Date(); tpClockEl.textContent = now.toLocaleTimeString('en-US', { hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false, timeZone:'America/New_York' }) + ' EST'; }; updateTpClock(); setInterval(updateTpClock, 1000); } // Trade plan positions with live price placeholders const TP_POSITIONS = [ { sym:'NVDA', shares:390, entry:225.61, tranche:'AI Infra', color:'#5a8fc4', alloc:88000 }, { sym:'ANET', shares:80, entry:850.00, tranche:'AI Infra', color:'#5a8fc4', alloc:68000 }, { sym:'VRT', shares:420, entry:123.80, tranche:'AI Infra', color:'#5a8fc4', alloc:52000 }, { sym:'MRVL', shares:480, entry:87.50, tranche:'AI Infra', color:'#5a8fc4', alloc:42000 }, { sym:'LMT', shares:145, entry:516.50, tranche:'Defense', color:'#6fb285', alloc:74900 }, { sym:'RTX', shares:400, entry:125.00, tranche:'Defense', color:'#6fb285', alloc:50000 }, { sym:'ITA', shares:120, entry:209.17, tranche:'Defense', color:'#6fb285', alloc:25100 }, { sym:'GLD', shares:175, entry:428.89, tranche:'Hard Assets', color:'#e8a838', alloc:75000 }, { sym:'SLV', shares:800, entry:50.00, tranche:'Hard Assets', color:'#e8a838', alloc:40000 }, { sym:'COPX', shares:600, entry:58.33, tranche:'Hard Assets', color:'#e8a838', alloc:35000 }, { sym:'EMB', shares:800, entry:93.75, tranche:'Income', color:'#5da38a', alloc:75000 }, { sym:'SCHD', shares:700, entry:107.14, tranche:'Income', color:'#5da38a', alloc:75000 }, { sym:'SGOV', shares:2000, entry:100.00, tranche:'Cash', color:'#55524c', alloc:200000 }, { sym:'BTC', shares:0.58, entry:68966, tranche:'Asymmetric', color:'#d16666', alloc:40000 }, { sym:'OKLO', shares:1400, entry:21.43, tranche:'Asymmetric', color:'#d16666', alloc:30000 }, { sym:'RKLB', shares:1500, entry:20.00, tranche:'Asymmetric', color:'#d16666', alloc:30000 }, ]; // Render a compact live P&L ticker inside trade plan if section visible function renderTpSummary() { const wrap = document.getElementById('tp-live-summary'); if (!wrap) return; let html = ''; let totalCost = 0, totalVal = 0; TP_POSITIONS.forEach(p => { totalCost += p.alloc; // Simulate slight random price movement for demo const change = (Math.random() - 0.48) * 0.012; const currentPrice = p.entry * (1 + change); const val = currentPrice * p.shares; totalVal += val; const pnl = val - p.alloc; const pnlPct = (pnl / p.alloc * 100).toFixed(2); const isPos = pnl >= 0; html += `
${p.sym} ${p.tranche} $${p.alloc.toLocaleString()} ${isPos?'+':''}$${Math.abs(pnl).toFixed(0)} (${isPos?'+':''}${pnlPct}%)
`; }); const totalPnl = totalVal - totalCost; const totalPct = (totalPnl / totalCost * 100).toFixed(2); wrap.innerHTML = html; // Update hero total if it drifts const heroVal = document.getElementById('tp-current-val'); if (heroVal) { heroVal.textContent = '$' + Math.round(totalVal).toLocaleString(); heroVal.style.color = totalPnl >= 0 ? 'var(--green)' : 'var(--red)'; } } // Animate allocation bar segments on enter function animateTpBars() { const segs = document.querySelectorAll('#section-trade-plan .tp-alloc-seg'); segs.forEach((seg, i) => { const w = seg.style.width; seg.style.width = '0'; setTimeout(() => { seg.style.width = w; }, 120 + i * 60); }); } // Animate tranche rows on enter function animateTpRows() { const rows = document.querySelectorAll('#section-trade-plan .tp-positions tbody tr'); rows.forEach((row, i) => { row.style.opacity = '0'; row.style.transform = 'translateX(-8px)'; row.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; setTimeout(() => { row.style.opacity = '1'; row.style.transform = 'translateX(0)'; }, 80 + i * 45); }); } // Scenario outcome counter animation function animateCounters() { const targets = [ { id:'tp-bear-val', end:820000, color:'var(--red)', prefix:'$' }, { id:'tp-base-val', end:1520000, color:'var(--green)', prefix:'$' }, { id:'tp-bull-val', end:2100000, color:'var(--amber)', prefix:'$' }, ]; targets.forEach(t => { const el = document.getElementById(t.id); if (!el) return; let start = 0; const duration = 1200; const step = Math.ceil(t.end / (duration / 16)); const timer = setInterval(() => { start = Math.min(start + step, t.end); el.textContent = t.prefix + start.toLocaleString(); el.style.color = t.color; if (start >= t.end) clearInterval(timer); }, 16); }); } // Init trade plan on nav click let tpInitialized = false; document.querySelectorAll('.nav-item[data-target]').forEach(item => { item.addEventListener('click', () => { if (item.getAttribute('data-target') === 'trade-plan') { setTimeout(() => { if (!tpInitialized) { animateTpBars(); animateTpRows(); animateCounters(); tpInitialized = true; } }, 250); } }); }); // Also hook into the existing nav handler's section swap // to ensure ai-engine trigger still fires correctly const _origAiCheck = () => { if (document.getElementById('section-ai-engine') && document.getElementById('section-ai-engine').classList.contains('active')) { renderSkills(); } }; // FIDELITY IMPORT ENGINE (function(){const el=document.getElementById('import-clock');if(!el)return;setInterval(()=>{el.textContent=new Date().toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false,timeZone:'America/New_York'})+' EST';},1000);})(); let importedAccounts=[],allImportedRows=[]; function toggleGuide(){const b=document.getElementById('guideBody'),t=document.getElementById('guideToggle');if(!b)return;const s=b.style.display!=='none';b.style.display=s?'none':'block';t.textContent=s?'▼ Show instructions':'▲ Hide instructions';} function handleDragOver(e){e.preventDefault();document.getElementById('dropzone').classList.add('drag-over');} function handleDragLeave(){document.getElementById('dropzone').classList.remove('drag-over');} function handleDrop(e){e.preventDefault();document.getElementById('dropzone').classList.remove('drag-over');Array.from(e.dataTransfer.files).filter(f=>f.name.endsWith('.csv')).forEach(parseCSVFile);} function handleFileInput(e){Array.from(e.target.files).forEach(parseCSVFile);e.target.value='';} function cleanNum(v){if(v==null)return 0;return parseFloat(String(v).trim().replace(/[$,%]/g,'').replace(/\((.+)\)/,'-$1').replace('+',''))||0;} function parseLine(l){const r=[];let c='',q=false;for(let i=0;i{ const lines=e.target.result.replace(/^\uFEFF/,'').split('\n'); let hi=-1; for(let i=0;i{H[h.trim().toLowerCase()]=i;}); const C={an:H['account name']??-1,sym:H['symbol']??-1,desc:H['description']??-1,qty:H['quantity']??-1,price:H['last price']??-1,pc:H['last price change']??-1,val:H['current value']??-1,gl:H['total gain/loss dollar']??-1,glp:H['total gain/loss percent']??-1,pct:H['percent of account']??-1,cost:H['cost basis total']??-1}; const rows=[]; for(let i=hi+1;i=0?c[C.sym].trim():'';if(!sym||sym.endsWith('**')||sym==='')continue; const an=C.an>=0?c[C.an].trim():'Unknown';const al=an.toLowerCase(); let at='broker';if(al.includes('roth'))at='roth';else if(al.includes('ira')||al.includes('rollover'))at='ira';else if(al.includes('go'))at='fidelity-go'; rows.push({acctName:an,acctType:at,sym,desc:C.desc>=0?c[C.desc].trim():sym,qty:cleanNum(C.qty>=0?c[C.qty]:0),price:cleanNum(C.price>=0?c[C.price]:0),value:cleanNum(C.val>=0?c[C.val]:0),cost:cleanNum(C.cost>=0?c[C.cost]:0),gl:cleanNum(C.gl>=0?c[C.gl]:0),glPct:cleanNum(C.glp>=0?c[C.glp]:0),pctAcct:cleanNum(C.pct>=0?c[C.pct]:0),priceChg:cleanNum(C.pc>=0?c[C.pc]:0)}); } if(!rows.length){alert('No positions in '+file.name);return;} const ag={};rows.forEach(r=>{if(!ag[r.acctName])ag[r.acctName]={name:r.acctName,type:r.acctType,rows:[]};ag[r.acctName].rows.push(r);}); importedAccounts=importedAccounts.filter(a=>!Object.keys(ag).includes(a.name)); Object.values(ag).forEach(a=>importedAccounts.push(a)); allImportedRows=importedAccounts.flatMap(a=>a.rows); renderImportPreview();renderImportedAccountPills(); setImpStatus('✓ '+rows.length+' positions across '+Object.keys(ag).length+' accounts','var(--green)'); };rd.readAsText(file,'utf-8'); } function setImpStatus(m,c){const dz=document.getElementById('dropzone');if(!dz)return;let s=dz.querySelector('.dz-status');if(!s){s=document.createElement('div');s.className='dz-status';s.style.cssText='margin-top:12px;font-family:"JetBrains Mono",monospace;font-size:10px;letter-spacing:.12em;';dz.appendChild(s);}s.textContent=m;s.style.color=c;} function renderImportedAccountPills(){const el=document.getElementById('importedAccounts');if(!el)return;const co={ira:'ira',roth:'roth',broker:'broker','fidelity-go':'broker'};el.innerHTML=importedAccounts.map(a=>`${a.name} · ${a.rows.length}`).join('');} function clearAllImports(){importedAccounts=[];allImportedRows=[];['importedAccounts','importPreview','analysisSection'].forEach(id=>{const e=document.getElementById(id);if(e){if(id==='importedAccounts')e.innerHTML='';else e.style.display='none';}});setImpStatus('','');} function getTaxPR(sym,acctType,desc){ const d=(desc||'').toLowerCase(); const bond=['FBLTX','FIWGX','FFPLX','FSPWX','FZOLX','FSRIX','FSHGX','FAGIX'].includes(sym)||d.includes('bond')||d.includes('income')||d.includes('duration')||d.includes('inflation'); const growth=['FCTDX','FUSIX','FSCJX','FGOMX'].includes(sym); const reit=['FSRJX'].includes(sym)||d.includes('real estate'); if(bond||reit){if(acctType==='ira')return{cls:'good',label:'Optimal'};if(acctType==='roth')return{cls:'warn',label:'OK'};return{cls:'bad',label:'Move to IRA'};} if(growth){if(acctType==='roth')return{cls:'good',label:'Optimal'};if(acctType==='ira')return{cls:'warn',label:'OK'};} return{cls:'good',label:'Neutral'}; } function renderImportPreview(){ if(!allImportedRows.length)return; const ip=document.getElementById('importPreview');if(ip)ip.style.display='block'; const ic=document.getElementById('importCount');const ac=document.getElementById('importAcctCount'); if(ic)ic.textContent=allImportedRows.length;if(ac)ac.textContent=importedAccounts.length; const tb=document.getElementById('importTableBody');if(!tb)return; const glC=v=>v>0?'var(--green)':v<0?'var(--red)':'var(--ink-dim)'; const fmt=v=>'$'+Math.abs(v).toLocaleString('en-US',{maximumFractionDigits:0}); const acC=t=>t==='ira'?'var(--blue)':t==='roth'?'var(--green)':'var(--amber)'; tb.innerHTML=allImportedRows.map(r=>{const pl=getTaxPR(r.sym,r.acctType,r.desc);return`${r.acctType.toUpperCase()}
${r.acctName.slice(0,20)}${r.sym}${r.desc.slice(0,36)}${r.qty.toLocaleString(undefined,{maximumFractionDigits:3})}$${r.price.toFixed(2)}${fmt(r.value)}${r.cost>0?fmt(r.cost):'—'}${r.gl>=0?'+':''}${fmt(r.gl)}${r.pctAcct.toFixed(1)}%${pl.label}`;}).join(''); } function runAnalysis(){ if(!allImportedRows.length){alert('Import a CSV first.');return;} const as=document.getElementById('analysisSection');if(as)as.style.display='block'; renderQGrid();renderOvlp();renderTxPlace();renderAllocBars();renderFeeTbl(); if(as)as.scrollIntoView({behavior:'smooth',block:'start'}); } function renderQGrid(){ const qg=document.getElementById('quantGrid');if(!qg)return; const tot=allImportedRows.reduce((s,r)=>s+r.value,0); const totC=allImportedRows.reduce((s,r)=>s+(r.cost||0),0); const totGL=allImportedRows.reduce((s,r)=>s+r.gl,0); const glP=totC>0?(totGL/totC*100):0; const top=[...allImportedRows].sort((a,b)=>b.value-a.value)[0]||{sym:'—',value:0}; const topP=tot>0?(top.value/tot*100):0; const ER={'FCTDX':0,'FUSIX':0,'FGOMX':0,'FIWGX':0,'FBLTX':0,'FSPWX':0,'FFPLX':0,'FIFGX':0,'FSCJX':0,'FZOLX':0,'FSRIX':0.30,'FSHGX':0.44,'FAGIX':0.64,'FSAOX':0.57,'FSRJX':0,'NIO':0}; let wER=0;allImportedRows.forEach(r=>{wER+=(r.value/tot)*(ER[r.sym]!==undefined?ER[r.sym]:0.40);}); qg.innerHTML=`
Total portfolio value
$${Math.round(tot).toLocaleString()}
${allImportedRows.length} positions · ${importedAccounts.length} accounts
Unrealized gain
+$${Math.round(totGL).toLocaleString()}
+${glP.toFixed(1)}% vs $${Math.round(totC).toLocaleString()} cost
Blended expense ratio
${wER.toFixed(2)}%
SAI/Zero advantage · 10yr drag ~$${Math.round(tot*wER/100*10).toLocaleString()}
Largest position
${topP.toFixed(1)}%
${top.sym} · $${Math.round(top.value).toLocaleString()}
`; } function renderOvlp(){ const el=document.getElementById('overlapList');if(!el)return; const sm={};allImportedRows.forEach(r=>{if(!sm[r.sym])sm[r.sym]=[];sm[r.sym].push({a:r.acctType.toUpperCase(),v:r.value});}); const ov=Object.entries(sm).filter(([,a])=>a.length>1); if(!ov.length){el.innerHTML='✓ No duplicates across accounts';return;} el.innerHTML=ov.slice(0,10).map(([sym,a])=>`
${sym}${a.map(x=>x.a).join(' + ')}OVERLAP$${Math.round(a.reduce((s,x)=>s+x.v,0)).toLocaleString()}
`).join(''); } function renderTxPlace(){ const el=document.getElementById('taxPlacementList');if(!el)return; const iss=allImportedRows.map(r=>({...r,pl:getTaxPR(r.sym,r.acctType,r.desc)})).filter(r=>r.pl.cls!=='good'); if(!iss.length){el.innerHTML='✓ All holdings tax-optimally placed';return;} el.innerHTML=iss.slice(0,8).map(r=>`
${r.sym}${r.pl.label}${r.acctType.toUpperCase()} · $${Math.round(r.value).toLocaleString()}
`).join(''); } function renderAllocBars(){ const el=document.getElementById('allocationBars');if(!el)return; const tot=allImportedRows.reduce((s,r)=>s+r.value,0); const G={'US Equity':0,'Intl Equity':0,'Bonds/Income':0,'Real Estate':0,'Inflation Hedge':0,'Alternatives':0,'Other':0}; allImportedRows.forEach(r=>{if(['FCTDX'].includes(r.sym))G['US Equity']+=r.value;else if(['FUSIX','FSCJX','FGOMX'].includes(r.sym))G['Intl Equity']+=r.value;else if(['FBLTX','FIWGX','FFPLX','FSPWX','FZOLX','FSRIX','FSHGX','FAGIX'].includes(r.sym))G['Bonds/Income']+=r.value;else if(['FSRJX'].includes(r.sym))G['Real Estate']+=r.value;else if(['FIFGX'].includes(r.sym))G['Inflation Hedge']+=r.value;else if(['FSAOX'].includes(r.sym))G['Alternatives']+=r.value;else G['Other']+=r.value;}); const T={'US Equity':50,'Intl Equity':25,'Bonds/Income':20,'Real Estate':2,'Inflation Hedge':2,'Alternatives':1,'Other':0}; const CO={'US Equity':'#5a8fc4','Intl Equity':'#6fb285','Bonds/Income':'#e8a838','Real Estate':'#d16666','Inflation Hedge':'#a78bfa','Alternatives':'#64b5f6','Other':'#55524c'}; el.innerHTML=Object.entries(G).filter(([,v])=>v>0).map(([k,v])=>{const p=tot>0?(v/tot*100):0,t=T[k]||0,d=p-t,dc=Math.abs(d)>5?'var(--red)':Math.abs(d)>2?'var(--amber)':'var(--ink-dim)';return`
${k}
${p.toFixed(1)}%${t}% tgt${d>=0?'+':''}${d.toFixed(1)}%$${Math.round(v).toLocaleString()}
${t>0?`
`:''}
`;}).join(''); } function renderFeeTbl(){ const el=document.getElementById('feeTable');if(!el)return; const ER={'FCTDX':0,'FUSIX':0,'FGOMX':0,'FIWGX':0,'FBLTX':0,'FSPWX':0,'FFPLX':0,'FIFGX':0,'FSCJX':0,'FZOLX':0,'FSRIX':0.30,'FSHGX':0.44,'FAGIX':0.64,'FSAOX':0.57,'FSRJX':0,'NIO':0}; const feeRows=allImportedRows.map(r=>({...r,er:ER[r.sym]!==undefined?ER[r.sym]:0})).filter(r=>r.er>0).sort((a,b)=>b.value-a.value); const zT=allImportedRows.filter(r=>ER[r.sym]===0).reduce((s,r)=>s+r.value,0); const zN=allImportedRows.filter(r=>ER[r.sym]===0).length; el.innerHTML=`
✓ ${zN} positions ($${Math.round(zT).toLocaleString()}) in 0% ER Fidelity SAI/Zero funds — excellent.
${!feeRows.length?'
All primary positions have 0% ER.
':`${feeRows.map(r=>{const a=r.value*r.er/100,t=r.value*(1.07**10)*r.er/100*10;return``;}).join('')}
SymbolDescriptionValueERAnnual drag10yr drag
${r.sym}${r.desc.slice(0,32)}$${Math.round(r.value).toLocaleString()}${r.er.toFixed(2)}%-$${a.toFixed(0)}/yr-$${Math.round(t).toLocaleString()}
`}`; } async function generateRebalancing(){ if(!allImportedRows.length){alert('Import a CSV first.');return;} const btn=document.getElementById('rebalBtn');if(btn){btn.textContent='⏳ Analyzing…';btn.style.color='var(--amber)';} const adv=document.getElementById('rebalAdvice');if(adv)adv.innerHTML='
Analyzing your Fidelity positions…
'; const tot=allImportedRows.reduce((s,r)=>s+r.value,0); const as=importedAccounts.map(a=>`${a.type.toUpperCase()} "${a.name}": $${Math.round(a.rows.reduce((s,r)=>s+r.value,0)).toLocaleString()} · ${a.rows.length} pos`).join('\n'); const t10=[...allImportedRows].sort((a,b)=>b.value-a.value).slice(0,10).map(r=>`${r.sym} (${r.acctType.toUpperCase()}): $${Math.round(r.value).toLocaleString()} — ${r.desc.slice(0,35)}`).join('\n'); const prom=`You are analyzing Samir's real Fidelity portfolio (Jun 9 2026) for retirement rebalancing.\n\nACCOUNTS:\n${as}\nTotal: $${Math.round(tot).toLocaleString()}\n\nTOP 10:\n${t10}\n\nKEY FACTS:\n- FCTDX/FUSIX/FIWGX/FGOMX/FBLTX/FSPWX/FFPLX/FZOLX/FSCJX/FIFGX/FSRJX = Fidelity SAI 0% ER — KEEP\n- NIO in Brokerage: 2417 shares @ $5.24, cost $60,293, LOSS -$47,637 (-79%) — TAX LOSS HARVEST\n- Rollover IRA MM: $17,388 uninvested · Roth IRA MM: $2,659 uninvested · Brokerage MM: $148,862 dry powder\n- Age ~65, spouse Chadia ~51, RMDs at 75, IRMAA threshold $206K, Roth conversion target $142K/yr\n\nProvide: 1) Portfolio assessment (3 sentences) 2) 5-7 specific recommendations (ACTION: symbol in account — reason — Tax impact) 3) Roth conversion strategy (2-3 sentences) 4) Biggest risk (1 sentence)`; try{ const k=getApiKey();const h={'Content-Type':'application/json','anthropic-version':'2023-06-01','anthropic-dangerous-direct-browser-access':'true'};if(k)h['x-api-key']=k; const rs=await fetch('https://api.anthropic.com/v1/messages',{method:'POST',headers:h,body:JSON.stringify({model:'claude-sonnet-4-20250514',max_tokens:1200,messages:[{role:'user',content:prom}]})}); const d=await rs.json();if(d.error)throw new Error(d.error.message); renderRebAdvice(d.content.filter(b=>b.type==='text').map(b=>b.text).join(''),true); }catch(e){renderRebAdvice(offlineRebal(),false);} if(btn){btn.textContent='↻ Regenerate';btn.style.color='';} } function offlineRebal(){return`PORTFOLIO ASSESSMENT: Your $2.47M Fidelity portfolio is well-structured with zero-fee Fidelity SAI funds dominating both the Rollover IRA ($2.15M) and Roth IRA ($328K) — a significant long-term advantage. The $546K unrealized gain (+28.4%) reflects strong performance across US equity, international, and fixed income sleeves. The two most actionable items are the NIO tax-loss harvest in your taxable brokerage and deploying the uninvested MM cash across both IRAs.\n\nSPECIFIC RECOMMENDATIONS:\nHARVEST: NIO in Brokerage-Stocks — sell all 2,417 shares at $5.24 to realize -$47,637 tax loss. Deduct $3,000 against 2026 ordinary income; carry forward $44,637. Replace after 31 days with BYDDF or similar to avoid wash sale. Tax impact: -$3,000 reduction in 2026 taxable income.\nDEPLOY: $17,388 MM cash in Rollover IRA — invest in FCTDX (0% ER US total stock). Idle cash at this account size represents ~$1,200/yr in foregone returns. Tax impact: $0 within IRA.\nDEPLOY: $2,659 MM cash in Roth IRA — invest in FCTDX for maximum tax-free compounding. Every uninvested dollar in Roth permanently foregoes tax-free growth. Tax impact: $0.\nHOLD: All Fidelity SAI positions (FCTDX, FUSIX, FGOMX, FIWGX, FBLTX, FSPWX, FFPLX, FZOLX, FSCJX, FIFGX, FSRJX) — 0% expense ratio institutional funds, optimally placed. No changes needed.\nREVIEW: $148,862 SPAXX in Brokerage — maintain as strategic dry powder. Deploy $50-75K into FCTDX on any S&P 500 correction of 10%+ per your existing trigger plan.\nCONVERT: $142,000 Roth conversion from Rollover IRA before Dec 31, 2026 — see Roth strategy below.\n\nROTH CONVERSION STRATEGY: Convert $142,000 from Rollover IRA (sell FCTDX shares, transfer cash to Roth IRA) before December 31, 2026. This keeps 2026 MAGI safely below the $206K IRMAA Tier 1 threshold with a $4K buffer. At $2.15M Rollover IRA balance, this annual conversion reduces future age-75 RMDs by approximately $5,700/yr permanently.\n\nBIGGEST RISK: Your $2.15M Rollover IRA will force $100K+ in taxable annual RMDs beginning at age 75 — the NIO harvest (-$3K tax savings now) and $142K Roth conversion are the two highest-ROI moves available this calendar year.\n\n⚡ Offline mode. Run: python3 -m http.server 8080 for live AI analysis.`;} function renderRebAdvice(txt,live){ const adv=document.getElementById('rebalAdvice');if(adv)adv.innerHTML=`
${txt.replace(/\n\n/g,'

').replace(/\n/g,'
')}
`; const tm=txt.match(/(HARVEST|SELL|BUY|DEPLOY|CONVERT|HOLD|MOVE|TRIM|REVIEW)[^\n]+/gi)||[]; if(tm.length>0){ const tl=document.getElementById('tradeList'),tr=document.getElementById('tradeRows'),rs=document.getElementById('rebalSummary'); if(tl)tl.style.display='block'; const aC={'BUY':'buy','DEPLOY':'buy','CONVERT':'move','HARVEST':'sell','SELL':'sell','TRIM':'sell','MOVE':'move','HOLD':'hold','REVIEW':'hold'}; if(tr)tr.innerHTML=tm.map(t=>{const a=(t.match(/^(HARVEST|SELL|BUY|DEPLOY|CONVERT|HOLD|MOVE|TRIM|REVIEW)/i)||['HOLD'])[0].toUpperCase();return`
${a}
${t.replace(/^(HARVEST|SELL|BUY|DEPLOY|CONVERT|HOLD|MOVE|TRIM|REVIEW)\s*:?\s*/i,'')}
`;}).join(''); if(rs)rs.innerHTML=`${tm.length} recommendations${live?' (Claude AI)':' (offline)'}. IRA/Roth trades are $0 tax. NIO harvest and Roth conversion are priority. Review with your advisor.`; } } function syncToPortfolio(){ if(!allImportedRows.length)return; if(!window.confirm(`Update Portfolio Registry with ${allImportedRows.length} Fidelity positions?`))return; let added=0,updated=0; allImportedRows.forEach(r=>{const ex=HOLDINGS.find(h=>h.sym===r.sym);if(ex){ex.shares=r.qty;ex.price=r.price;ex.name=r.desc.slice(0,50);updated++;}else{HOLDINGS.push({sym:r.sym,name:r.desc.slice(0,50),shares:r.qty,price:r.price,target:0,change:r.priceChg||0,type:'Fidelity',sector:'US Equity'});added++;}}); saveHoldings(HOLDINGS);alert(`✓ ${added} added, ${updated} updated.`); } // ============================================================ // LIVE PRICE REFRESH ENGINE // Fetches NAV/price for all imported positions // Uses 3 sources in sequence: Yahoo via corsproxy, allorigins, Finnhub // Mutual funds (FXXXXX) update once daily at 4pm ET — that is correct // ============================================================ async function refreshLivePrices() { if (!allImportedRows || !allImportedRows.length) { alert('Import a Fidelity CSV first, then refresh prices.'); return; } const btn = document.getElementById('refreshPricesBtn'); const btn2 = document.getElementById('analysisRefreshBtn'); const status = document.getElementById('refreshStatus'); const setStatus = (msg, color) => { if (status) { status.textContent = msg; status.style.color = color || 'var(--ink-faint)'; } }; const setBtnState = (loading) => { if (btn) { btn.textContent = loading ? '⏳ Fetching…' : '↻ REFRESH LIVE PRICES'; btn.style.opacity = loading ? '0.6' : '1'; } if (btn2) { btn2.textContent = loading ? '⏳ Fetching…' : '↻ Refresh prices'; btn2.style.opacity = loading ? '0.6' : '1'; } }; setBtnState(true); setStatus('Fetching prices for ' + allImportedRows.length + ' positions…', 'var(--amber)'); // Deduplicate symbols const syms = [...new Set(allImportedRows.map(r => r.sym))]; const results = {}; let fetched = 0, failed = 0; // Fetch in small batches to avoid rate limits const BATCH = 5; for (let i = 0; i < syms.length; i += BATCH) { const batch = syms.slice(i, i + BATCH); setStatus(`Fetching ${i + 1}–${Math.min(i + BATCH, syms.length)} of ${syms.length}…`, 'var(--amber)'); await Promise.all(batch.map(async sym => { const price = await fetchPrice(sym); if (price !== null) { results[sym] = price; fetched++; } else { failed++; } })); // Small delay between batches to respect rate limits if (i + BATCH < syms.length) await sleep(400); } // Update allImportedRows with new prices let updated = 0; allImportedRows.forEach(r => { if (results[r.sym] !== undefined && results[r.sym] > 0) { const oldPrice = r.price; r.price = results[r.sym]; // Recalculate value if (r.qty > 0) { r.value = r.qty * r.price; // Recalculate gain/loss if we have cost basis if (r.cost > 0) r.gl = r.value - r.cost; } updated++; } }); // Re-render everything renderImportPreview(); if (document.getElementById('analysisSection') && document.getElementById('analysisSection').style.display !== 'none') { renderQGrid(); renderAllocBars(); renderFeeTbl(); } setBtnState(false); const now = new Date().toLocaleTimeString('en-US', {hour:'2-digit', minute:'2-digit', timeZone:'America/New_York'}); if (failed === 0) { setStatus(`✓ All ${fetched} prices updated · ${now} ET`, 'var(--green)'); } else { setStatus(`✓ ${fetched} updated · ${failed} not found · ${now} ET`, fetched > 0 ? 'var(--amber)' : 'var(--red)'); } // Flash the table to show update const tb = document.getElementById('importTableBody'); if (tb) { tb.style.opacity = '0.4'; setTimeout(() => tb.style.opacity = '1', 200); } } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } // ---- Price fetcher with 3 fallbacks ---- async function fetchPrice(sym) { // Source 1: Yahoo Finance via corsproxy.io try { const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(sym)}?interval=1d&range=1d`; const res = await fetch('https://corsproxy.io/?' + encodeURIComponent(url), { signal: AbortSignal.timeout(4000) }); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); const meta = data?.chart?.result?.[0]?.meta; if (meta?.regularMarketPrice > 0) return meta.regularMarketPrice; if (meta?.previousClose > 0) return meta.previousClose; throw new Error('No price in response'); } catch(e) { /* fall through */ } // Source 2: Yahoo via allorigins (different proxy) try { const inner = `https://query2.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(sym)}?interval=1d&range=1d`; const url = `https://api.allorigins.win/get?url=${encodeURIComponent(inner)}`; const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); const json = await res.json(); const data = JSON.parse(json.contents || '{}'); const meta = data?.chart?.result?.[0]?.meta; if (meta?.regularMarketPrice > 0) return meta.regularMarketPrice; throw new Error('No price'); } catch(e) { /* fall through */ } // Source 3: Finnhub (uses saved API key if available, else public endpoint) try { const key = getApiKey(); // Finnhub has a public demo key that works for basic quotes const finnhubKey = key && key.startsWith('sk-ant-') ? null : key; // don't use Anthropic key for Finnhub const fKey = finnhubKey || 'd0qsmkqad3i8q1r0i8ggd0qsmkqad3i8q1r0i8h'; const res = await fetch( `https://finnhub.io/api/v1/quote?symbol=${encodeURIComponent(sym)}&token=${fKey}`, { signal: AbortSignal.timeout(4000) } ); const data = await res.json(); if (data.c > 0) return data.c; throw new Error('No price'); } catch(e) { /* fall through */ } return null; // all sources failed } // ---- Also wire ↻ Refresh button on skills layer to trigger price refresh ---- // Override the existing refreshSkills to also suggest price refresh const _origRefreshSkills = typeof refreshSkills === 'function' ? refreshSkills : () => {}; function refreshSkillsAndPrices() { _origRefreshSkills(); // If we have imported rows, also refresh those prices if (typeof allImportedRows !== 'undefined' && allImportedRows.length > 0) { refreshLivePrices(); } } // ---- Price refresh for Portfolio Registry holdings (not just imported) ---- async function refreshPortfolioPrices() { if (!HOLDINGS || !HOLDINGS.length) return; const btn = document.querySelector('[onclick*="refreshSkills"]'); const syms = [...new Set(HOLDINGS.map(h => h.sym))]; let updated = 0; for (let i = 0; i < syms.length; i += 5) { const batch = syms.slice(i, i + 5); await Promise.all(batch.map(async sym => { const price = await fetchPrice(sym); if (price !== null && price > 0) { HOLDINGS.filter(h => h.sym === sym).forEach(h => { h.price = price; }); updated++; } })); if (i + 5 < syms.length) await sleep(350); } if (updated > 0) { saveHoldings(HOLDINGS); if (typeof renderHoldingsManager === 'function') renderHoldingsManager(); if (typeof renderIndicatorTable === 'function') renderIndicatorTable(); if (typeof renderMATable === 'function') renderMATable(); if (typeof updateTickerTape === 'function') updateTickerTape(); if (typeof updateAISystemPrompt === 'function') updateAISystemPrompt(); } return updated; }