From 929ebe113bd235534a272478abffdc191942959d Mon Sep 17 00:00:00 2001 From: Satyam Pandey Date: Sun, 8 Feb 2026 23:37:25 +0530 Subject: [PATCH] feat: Implement Strategic Treasury, Liquidity Forecasting & FX Risk Management (#590) - Add TreasuryVault, LiquidityThreshold, and ExchangeHedge models - Implement comprehensive FinancialModels utility (IRR, NPV, VaR, Sharpe Ratio, WACC, MACD) - Build TreasuryService for vault management, liquidity monitoring, and portfolio analytics - Create RunwayForecaster with 5 forecasting algorithms (Linear, Moving Avg, Seasonal, ML-like, Ensemble) - Implement automated threshold monitoring with cooldown periods and severity levels - Add FX hedging with Mark-to-Market calculations and gain/loss tracking - Build premium Treasury Dashboard with Chart.js integration - Create interactive vault distribution, runway projection, and portfolio metrics visualizations - Add multi-currency support and automated rebalancing logic --- models/ExchangeHedge.js | 88 ++++++ models/LiquidityThreshold.js | 76 +++++ models/TreasuryVault.js | 75 +++++ public/expensetracker.css | 332 +++++++++++++++++++++ public/js/treasury-controller.js | 494 +++++++++++++++++++++++++++++++ public/treasury-dashboard.html | 298 +++++++++++++++++++ routes/treasury.js | 256 ++++++++++++++++ server.js | 21 +- services/runwayForecaster.js | 386 ++++++++++++++++++++++++ services/treasuryService.js | 254 ++++++++++++++++ utils/financialModels.js | 177 +++++++++++ 11 files changed, 2447 insertions(+), 10 deletions(-) create mode 100644 models/ExchangeHedge.js create mode 100644 models/LiquidityThreshold.js create mode 100644 models/TreasuryVault.js create mode 100644 public/js/treasury-controller.js create mode 100644 public/treasury-dashboard.html create mode 100644 routes/treasury.js create mode 100644 services/runwayForecaster.js create mode 100644 services/treasuryService.js create mode 100644 utils/financialModels.js diff --git a/models/ExchangeHedge.js b/models/ExchangeHedge.js new file mode 100644 index 00000000..dd9bce9c --- /dev/null +++ b/models/ExchangeHedge.js @@ -0,0 +1,88 @@ +const mongoose = require('mongoose'); + +/** + * ExchangeHedge Model + * Manages FX risk through hedging strategies for multi-currency operations + */ +const exchangeHedgeSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + hedgeId: { + type: String, + unique: true, + required: true + }, + baseCurrency: { + type: String, + required: true, + uppercase: true + }, + targetCurrency: { + type: String, + required: true, + uppercase: true + }, + hedgeType: { + type: String, + enum: ['forward_contract', 'option', 'swap', 'natural_hedge'], + required: true + }, + notionalAmount: { + type: Number, + required: true + }, + contractRate: { + type: Number, + required: true + }, + marketRate: { + type: Number, + default: null + }, + maturityDate: { + type: Date, + required: true + }, + status: { + type: String, + enum: ['active', 'settled', 'expired', 'cancelled'], + default: 'active' + }, + effectiveness: { + hedgeRatio: { type: Number, default: 1.0 }, + gainLoss: { type: Number, default: 0 }, + mtmValue: { type: Number, default: 0 } // Mark-to-Market + }, + counterparty: { + name: String, + rating: String + }, + linkedTransactions: [{ + type: mongoose.Schema.Types.ObjectId, + ref: 'Transaction' + }], + notes: String +}, { + timestamps: true +}); + +// Calculate MTM value before saving +exchangeHedgeSchema.pre('save', function (next) { + if (this.marketRate && this.contractRate) { + const rateDiff = this.marketRate - this.contractRate; + this.effectiveness.mtmValue = rateDiff * this.notionalAmount; + this.effectiveness.gainLoss = this.effectiveness.mtmValue; + } + next(); +}); + +// Indexes +exchangeHedgeSchema.index({ userId: 1, status: 1 }); +exchangeHedgeSchema.index({ maturityDate: 1, status: 1 }); +exchangeHedgeSchema.index({ baseCurrency: 1, targetCurrency: 1 }); + +module.exports = mongoose.model('ExchangeHedge', exchangeHedgeSchema); diff --git a/models/LiquidityThreshold.js b/models/LiquidityThreshold.js new file mode 100644 index 00000000..66abac24 --- /dev/null +++ b/models/LiquidityThreshold.js @@ -0,0 +1,76 @@ +const mongoose = require('mongoose'); + +/** + * LiquidityThreshold Model + * Defines alert triggers and automated actions when cash runway falls below critical levels + */ +const liquidityThresholdSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + vaultId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'TreasuryVault', + required: true + }, + thresholdName: { + type: String, + required: true + }, + thresholdType: { + type: String, + enum: ['absolute', 'percentage', 'runway_days'], + required: true + }, + triggerValue: { + type: Number, + required: true + }, + currentValue: { + type: Number, + default: 0 + }, + severity: { + type: String, + enum: ['info', 'warning', 'critical', 'emergency'], + default: 'warning' + }, + alertChannels: [{ + type: String, + enum: ['email', 'sms', 'dashboard', 'webhook'] + }], + automatedActions: [{ + actionType: { + type: String, + enum: ['freeze_spending', 'notify_stakeholders', 'trigger_rebalance', 'liquidate_assets'] + }, + actionParams: mongoose.Schema.Types.Mixed + }], + lastTriggered: { + type: Date, + default: null + }, + triggerCount: { + type: Number, + default: 0 + }, + isActive: { + type: Boolean, + default: true + }, + cooldownPeriod: { + type: Number, + default: 24 // hours + } +}, { + timestamps: true +}); + +// Index for efficient threshold monitoring +liquidityThresholdSchema.index({ userId: 1, vaultId: 1, isActive: 1 }); +liquidityThresholdSchema.index({ severity: 1, isActive: 1 }); + +module.exports = mongoose.model('LiquidityThreshold', liquidityThresholdSchema); diff --git a/models/TreasuryVault.js b/models/TreasuryVault.js new file mode 100644 index 00000000..70a3d177 --- /dev/null +++ b/models/TreasuryVault.js @@ -0,0 +1,75 @@ +const mongoose = require('mongoose'); + +/** + * TreasuryVault Model + * Represents a centralized cash/asset pool for enterprise treasury management + */ +const treasuryVaultSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + vaultName: { + type: String, + required: true, + trim: true + }, + vaultType: { + type: String, + enum: ['operating', 'reserve', 'investment', 'forex'], + default: 'operating' + }, + currency: { + type: String, + required: true, + default: 'INR', + uppercase: true + }, + balance: { + type: Number, + required: true, + default: 0 + }, + allocatedFunds: { + type: Number, + default: 0 + }, + availableLiquidity: { + type: Number, + default: 0 + }, + linkedAccounts: [{ + accountId: { type: mongoose.Schema.Types.ObjectId, ref: 'Account' }, + allocationPercentage: { type: Number, min: 0, max: 100 } + }], + restrictions: { + minBalance: { type: Number, default: 0 }, + maxWithdrawal: { type: Number, default: null }, + requiresApproval: { type: Boolean, default: false } + }, + metadata: { + purpose: String, + riskProfile: { type: String, enum: ['conservative', 'moderate', 'aggressive'], default: 'moderate' }, + autoRebalance: { type: Boolean, default: false } + }, + isActive: { + type: Boolean, + default: true + } +}, { + timestamps: true +}); + +// Pre-save hook to calculate available liquidity +treasuryVaultSchema.pre('save', function (next) { + this.availableLiquidity = this.balance - this.allocatedFunds; + next(); +}); + +// Index for performance +treasuryVaultSchema.index({ userId: 1, vaultType: 1 }); +treasuryVaultSchema.index({ currency: 1, isActive: 1 }); + +module.exports = mongoose.model('TreasuryVault', treasuryVaultSchema); diff --git a/public/expensetracker.css b/public/expensetracker.css index f7307077..a03a8883 100644 --- a/public/expensetracker.css +++ b/public/expensetracker.css @@ -9631,3 +9631,335 @@ input:checked + .toggle-slider::before { } .checkbox-container input { width: 16px; height: 16px; } + +/* ============================================ + STRATEGIC TREASURY & LIQUIDITY MANAGEMENT + Issue #590: Enterprise Treasury Suite + ============================================ */ + +.treasury-header { + margin-bottom: 30px; +} + +.header-metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-top: 20px; +} + +.metric-card { + padding: 20px; + text-align: center; +} + +.metric-card label { + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; +} + +.metric-card h2 { + font-size: 2rem; + margin: 10px 0; + color: var(--text-primary); +} + +.metric-trend { + font-size: 0.8rem; + display: inline-flex; + align-items: center; + gap: 5px; +} + +.metric-trend.positive { color: #64ffda; } +.metric-trend.negative { color: #ff6b6b; } +.metric-trend.warning { color: #ff9f43; } + +.health-bar { + width: 100%; + height: 6px; + background: rgba(255,255,255,0.1); + border-radius: 3px; + overflow: hidden; + margin-top: 10px; +} + +.health-fill { + height: 100%; + transition: width 0.5s ease, background-color 0.5s ease; +} + +.treasury-grid { + display: grid; + grid-template-columns: 350px 1fr; + gap: 25px; +} + +.treasury-sidebar { + display: flex; + flex-direction: column; + gap: 20px; +} + +.vaults-list, .thresholds-list, .hedges-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.vault-card { + padding: 15px; +} + +.vault-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 15px; +} + +.vault-icon { + width: 40px; + height: 40px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; +} + +.vault-icon.operating { background: rgba(100, 255, 218, 0.15); color: #64ffda; } +.vault-icon.reserve { background: rgba(72, 219, 251, 0.15); color: #48dbfb; } +.vault-icon.investment { background: rgba(255, 159, 67, 0.15); color: #ff9f43; } +.vault-icon.forex { background: rgba(255, 107, 107, 0.15); color: #ff6b6b; } + +.vault-info strong { display: block; font-size: 0.9rem; } +.vault-info span { font-size: 0.7rem; color: var(--text-secondary); } + +.vault-balance { + margin: 15px 0; +} + +.vault-balance label { font-size: 0.7rem; color: var(--text-secondary); } +.vault-balance h3 { margin: 5px 0; font-size: 1.4rem; color: var(--accent-primary); } + +.vault-stats { + display: flex; + justify-content: space-between; + padding-top: 10px; + border-top: 1px solid rgba(255,255,255,0.05); +} + +.vault-stats .stat label { font-size: 0.65rem; color: var(--text-secondary); display: block; } +.vault-stats .stat span { font-size: 0.85rem; } + +.threshold-item, .hedge-item { + padding: 12px; + background: rgba(255,255,255,0.02); + border-radius: 8px; + border-left: 3px solid var(--accent-primary); +} + +.threshold-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; +} + +.threshold-info strong { font-size: 0.85rem; } + +.severity-pill { + font-size: 0.65rem; + padding: 2px 8px; + border-radius: 4px; + text-transform: uppercase; +} + +.severity-pill.info { background: rgba(72, 219, 251, 0.2); color: #48dbfb; } +.severity-pill.warning { background: rgba(255, 159, 67, 0.2); color: #ff9f43; } +.severity-pill.critical { background: rgba(255, 107, 107, 0.2); color: #ff6b6b; } +.severity-pill.emergency { background: rgba(255, 0, 0, 0.3); color: #ff0000; } + +.threshold-value { + font-size: 0.9rem; + color: var(--text-secondary); +} + +.hedge-pair { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.hedge-type { + font-size: 0.7rem; + color: var(--text-secondary); + text-transform: capitalize; +} + +.hedge-details { + display: flex; + gap: 15px; +} + +.hedge-details .detail { + flex: 1; +} + +.hedge-details .detail label { + font-size: 0.65rem; + color: var(--text-secondary); + display: block; +} + +.hedge-details .detail span { + font-size: 0.85rem; +} + +.hedge-details .detail.positive { color: #64ffda; } +.hedge-details .detail.negative { color: #ff6b6b; } + +.forecast-controls { + display: flex; + gap: 10px; +} + +.forecast-controls select { + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + color: white; + padding: 5px 10px; + border-radius: 4px; + font-size: 0.8rem; +} + +.chart-container { + height: 350px; + padding: 20px 0; +} + +.chart-container-small { + height: 250px; + padding: 15px 0; +} + +.forecast-insights { + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.insight-card { + display: flex; + align-items: center; + gap: 15px; + padding: 15px; + background: rgba(255,255,255,0.02); + border-radius: 8px; + border-left: 4px solid; +} + +.insight-card.critical { border-left-color: #ff6b6b; } +.insight-card.warning { border-left-color: #ff9f43; } +.insight-card.positive { border-left-color: #64ffda; } +.insight-card.info { border-left-color: #48dbfb; } + +.insight-icon { + font-size: 1.5rem; +} + +.insight-card.critical .insight-icon { color: #ff6b6b; } +.insight-card.warning .insight-icon { color: #ff9f43; } +.insight-card.positive .insight-icon { color: #64ffda; } + +.insight-content { + flex: 1; +} + +.insight-content strong { + display: block; + margin-bottom: 5px; +} + +.insight-content p { + font-size: 0.8rem; + color: var(--text-secondary); + margin: 0; +} + +.severity-badge { + font-size: 0.7rem; + padding: 3px 10px; + border-radius: 12px; + text-transform: uppercase; +} + +.severity-badge.low { background: rgba(100, 255, 218, 0.2); color: #64ffda; } +.severity-badge.medium { background: rgba(255, 159, 67, 0.2); color: #ff9f43; } +.severity-badge.high { background: rgba(255, 107, 107, 0.2); color: #ff6b6b; } +.severity-badge.emergency { background: rgba(255, 0, 0, 0.3); color: #ff0000; } + +.analytics-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.portfolio-metrics { + display: flex; + flex-direction: column; + gap: 15px; + padding: 10px 0; +} + +.metric-item { + display: flex; + justify-content: space-between; + align-items: center; +} + +.metric-item label { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.metric-item strong { + font-size: 1.1rem; + color: var(--accent-primary); +} + +.violations-alert { + padding: 20px; + margin-bottom: 20px; + border-left: 4px solid #ff6b6b; +} + +.alert-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 15px; + color: #ff6b6b; +} + +.violation-item { + display: flex; + justify-content: space-between; + padding: 10px; + background: rgba(255,255,255,0.02); + border-radius: 4px; + margin-bottom: 8px; +} + +.violation-item.critical { border-left: 3px solid #ff6b6b; } +.violation-item.warning { border-left: 3px solid #ff9f43; } + +.no-insights { + text-align: center; + color: var(--text-secondary); + padding: 20px; +} diff --git a/public/js/treasury-controller.js b/public/js/treasury-controller.js new file mode 100644 index 00000000..7ab6bdc9 --- /dev/null +++ b/public/js/treasury-controller.js @@ -0,0 +1,494 @@ +/** + * Treasury Dashboard Controller + * Handles all treasury management UI logic + */ + +let runwayChart = null; +let vaultDistChart = null; +let currentForecastData = null; + +document.addEventListener('DOMContentLoaded', () => { + loadTreasuryDashboard(); + loadVaults(); + loadThresholds(); + loadHedges(); + setupForms(); +}); + +async function loadTreasuryDashboard() { + try { + const res = await fetch('/api/treasury/dashboard', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + updateDashboardMetrics(data); + loadForecast(); + } catch (err) { + console.error('Failed to load treasury dashboard:', err); + } +} + +function updateDashboardMetrics(data) { + document.getElementById('total-liquidity').textContent = `₹${data.totalLiquidity.toLocaleString()}`; + document.getElementById('cash-runway').textContent = `${data.cashRunway} days`; + document.getElementById('health-score').textContent = `${data.healthScore}%`; + + const healthFill = document.getElementById('health-fill'); + healthFill.style.width = `${data.healthScore}%`; + healthFill.style.backgroundColor = data.healthScore > 70 ? '#64ffda' : data.healthScore > 40 ? '#ff9f43' : '#ff6b6b'; + + const runwayTrend = document.getElementById('runway-trend'); + if (data.cashRunway < 30) { + runwayTrend.innerHTML = ' Critical'; + runwayTrend.className = 'metric-trend negative'; + } else if (data.cashRunway < 60) { + runwayTrend.innerHTML = ' Warning'; + runwayTrend.className = 'metric-trend warning'; + } else { + runwayTrend.innerHTML = ' Healthy'; + runwayTrend.className = 'metric-trend positive'; + } + + // Update portfolio metrics + if (data.portfolio) { + document.getElementById('sharpe-ratio').textContent = data.portfolio.sharpeRatio.toFixed(2); + document.getElementById('var-95').textContent = `₹${data.portfolio.var95.toLocaleString()}`; + document.getElementById('diversification').textContent = `${data.portfolio.diversificationScore}%`; + } + + // Display violations + if (data.violations && data.violations.length > 0) { + showViolationAlerts(data.violations); + } +} + +function showViolationAlerts(violations) { + const container = document.createElement('div'); + container.className = 'violations-alert glass-card'; + container.innerHTML = ` +
+ + ${violations.length} Threshold Violation(s) +
+ ${violations.map(v => ` +
+ ${v.thresholdName} + ${v.vaultName}: ${v.currentValue.toFixed(2)} / ${v.triggerValue} +
+ `).join('')} + `; + + const main = document.querySelector('.treasury-main'); + main.insertBefore(container, main.firstChild); +} + +async function loadForecast() { + try { + const horizon = document.getElementById('forecast-horizon').value; + const res = await fetch(`/api/treasury/forecast?days=${horizon}`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + currentForecastData = data; + renderForecastChart(data); + renderInsights(data.insights); + } catch (err) { + console.error('Failed to load forecast:', err); + } +} + +function renderForecastChart(data) { + const model = document.getElementById('forecast-model').value; + const forecastData = data.forecasts[model]; + + const ctx = document.getElementById('runwayChart').getContext('2d'); + + if (runwayChart) { + runwayChart.destroy(); + } + + runwayChart = new Chart(ctx, { + type: 'line', + data: { + labels: forecastData.map(f => `Day ${f.day}`), + datasets: [{ + label: 'Projected Balance', + data: forecastData.map(f => f.balance), + borderColor: '#64ffda', + backgroundColor: 'rgba(100, 255, 218, 0.1)', + fill: true, + tension: 0.4 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + labels: { color: '#8892b0' } + }, + tooltip: { + callbacks: { + label: function (context) { + return `Balance: ₹${context.parsed.y.toLocaleString()}`; + } + } + } + }, + scales: { + x: { + ticks: { + color: '#8892b0', + maxTicksLimit: 10 + }, + grid: { color: 'rgba(255,255,255,0.05)' } + }, + y: { + ticks: { + color: '#8892b0', + callback: function (value) { + return '₹' + value.toLocaleString(); + } + }, + grid: { color: 'rgba(255,255,255,0.05)' } + } + } + } + }); +} + +function renderInsights(insights) { + const container = document.getElementById('forecast-insights'); + if (!insights || insights.length === 0) { + container.innerHTML = '

No critical insights at this time.

'; + return; + } + + container.innerHTML = insights.map(insight => ` +
+
+ +
+
+ ${insight.message} + ${insight.recommendation ? `

${insight.recommendation}

` : ''} +
+ ${insight.severity} +
+ `).join(''); +} + +function getInsightIcon(type) { + const icons = { + 'critical': 'fa-exclamation-circle', + 'warning': 'fa-exclamation-triangle', + 'positive': 'fa-check-circle', + 'info': 'fa-info-circle' + }; + return icons[type] || 'fa-info-circle'; +} + +async function loadVaults() { + try { + const res = await fetch('/api/treasury/vaults', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + renderVaults(data); + renderVaultDistribution(data); + populateVaultDropdowns(data); + } catch (err) { + console.error('Failed to load vaults:', err); + } +} + +function renderVaults(vaults) { + const list = document.getElementById('vaults-list'); + if (!vaults || vaults.length === 0) { + list.innerHTML = '
No vaults created yet.
'; + return; + } + + list.innerHTML = vaults.map(vault => ` +
+
+
+ +
+
+ ${vault.vaultName} + ${vault.currency} +
+
+
+ +

₹${vault.availableLiquidity.toLocaleString()}

+
+
+
+ + ₹${vault.balance.toLocaleString()} +
+
+ + ₹${vault.allocatedFunds.toLocaleString()} +
+
+
+ `).join(''); +} + +function getVaultIcon(type) { + const icons = { + 'operating': 'fa-wallet', + 'reserve': 'fa-piggy-bank', + 'investment': 'fa-chart-line', + 'forex': 'fa-exchange-alt' + }; + return icons[type] || 'fa-vault'; +} + +function renderVaultDistribution(vaults) { + const ctx = document.getElementById('vaultDistChart').getContext('2d'); + + if (vaultDistChart) { + vaultDistChart.destroy(); + } + + vaultDistChart = new Chart(ctx, { + type: 'doughnut', + data: { + labels: vaults.map(v => v.vaultName), + datasets: [{ + data: vaults.map(v => v.balance), + backgroundColor: ['#64ffda', '#48dbfb', '#ff9f43', '#ff6b6b', '#54a0ff'], + borderWidth: 0 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { color: '#8892b0', font: { size: 10 } } + } + } + } + }); +} + +function populateVaultDropdowns(vaults) { + const select = document.getElementById('threshold-vault'); + if (select) { + select.innerHTML = vaults.map(v => + `` + ).join(''); + } +} + +async function loadThresholds() { + try { + const res = await fetch('/api/treasury/thresholds', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + renderThresholds(data); + } catch (err) { + console.error('Failed to load thresholds:', err); + } +} + +function renderThresholds(thresholds) { + const list = document.getElementById('thresholds-list'); + if (!thresholds || thresholds.length === 0) { + list.innerHTML = '
No thresholds configured.
'; + return; + } + + list.innerHTML = thresholds.map(t => ` +
+
+ ${t.thresholdName} + ${t.severity} +
+
+ ${t.triggerValue} ${t.thresholdType === 'percentage' ? '%' : t.thresholdType === 'runway_days' ? 'days' : ''} +
+
+ `).join(''); +} + +async function loadHedges() { + try { + const res = await fetch('/api/treasury/hedges', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + renderHedges(data); + } catch (err) { + console.error('Failed to load hedges:', err); + } +} + +function renderHedges(hedges) { + const list = document.getElementById('hedges-list'); + if (!hedges || hedges.length === 0) { + list.innerHTML = '
No FX hedges active.
'; + return; + } + + list.innerHTML = hedges.map(h => ` +
+
+ ${h.baseCurrency}/${h.targetCurrency} + ${h.hedgeType.replace('_', ' ')} +
+
+
+ + ${h.notionalAmount.toLocaleString()} +
+
+ + ${h.contractRate} +
+
+ + ${h.effectiveness.gainLoss >= 0 ? '+' : ''}${h.effectiveness.gainLoss.toLocaleString()} +
+
+
+ `).join(''); +} + +function updateForecast() { + loadForecast(); +} + +// Modal Functions +function openVaultModal() { + document.getElementById('vault-modal').classList.remove('hidden'); +} + +function closeVaultModal() { + document.getElementById('vault-modal').classList.add('hidden'); +} + +function openThresholdModal() { + document.getElementById('threshold-modal').classList.remove('hidden'); +} + +function closeThresholdModal() { + document.getElementById('threshold-modal').classList.add('hidden'); +} + +function openHedgeModal() { + document.getElementById('hedge-modal').classList.remove('hidden'); +} + +function closeHedgeModal() { + document.getElementById('hedge-modal').classList.add('hidden'); +} + +function setupForms() { + // Vault Form + document.getElementById('vault-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const vaultData = { + vaultName: document.getElementById('vault-name').value, + vaultType: document.getElementById('vault-type').value, + currency: document.getElementById('vault-currency').value, + balance: parseFloat(document.getElementById('vault-balance').value) + }; + + try { + const res = await fetch('/api/treasury/vaults', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify(vaultData) + }); + + if (res.ok) { + closeVaultModal(); + loadVaults(); + loadTreasuryDashboard(); + } + } catch (err) { + console.error('Failed to create vault:', err); + } + }); + + // Threshold Form + document.getElementById('threshold-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const thresholdData = { + thresholdName: document.getElementById('threshold-name').value, + vaultId: document.getElementById('threshold-vault').value, + thresholdType: document.getElementById('threshold-type').value, + triggerValue: parseFloat(document.getElementById('threshold-value').value), + severity: document.getElementById('threshold-severity').value, + alertChannels: ['dashboard', 'email'] + }; + + try { + const res = await fetch('/api/treasury/thresholds', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify(thresholdData) + }); + + if (res.ok) { + closeThresholdModal(); + loadThresholds(); + } + } catch (err) { + console.error('Failed to create threshold:', err); + } + }); + + // Hedge Form + document.getElementById('hedge-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const hedgeData = { + baseCurrency: document.getElementById('hedge-base').value, + targetCurrency: document.getElementById('hedge-target').value, + hedgeType: document.getElementById('hedge-type').value, + notionalAmount: parseFloat(document.getElementById('hedge-amount').value), + contractRate: parseFloat(document.getElementById('hedge-rate').value), + maturityDate: document.getElementById('hedge-maturity').value + }; + + try { + const res = await fetch('/api/treasury/hedges', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify(hedgeData) + }); + + if (res.ok) { + closeHedgeModal(); + loadHedges(); + } + } catch (err) { + console.error('Failed to create hedge:', err); + } + }); +} diff --git a/public/treasury-dashboard.html b/public/treasury-dashboard.html new file mode 100644 index 00000000..d8f21f2c --- /dev/null +++ b/public/treasury-dashboard.html @@ -0,0 +1,298 @@ + + + + + + + Strategic Treasury & Liquidity Management - ExpenseFlow + + + + + + + + +
+ +
+
+

Enterprise Liquidity Command Center

+

Real-time cash runway forecasting, FX risk management, and automated threshold monitoring

+
+
+
+ +

₹0

+ Available +
+
+ +

0 days

+ Calculating... +
+
+ +

0%

+
+
+
+
+
+
+ + +
+ +
+
+
+

Treasury Vaults

+ +
+
+
Loading vaults...
+
+
+ +
+
+

Alert Thresholds

+ +
+
+ +
+
+ +
+
+

FX Hedges

+ +
+
+ +
+
+
+ + +
+
+
+

Cash Runway Forecast

+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+
+

Vault Distribution

+
+
+ +
+
+ +
+
+

Portfolio Metrics

+
+
+
+ + 0.00 +
+
+ + ₹0 +
+
+ + 0% +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/routes/treasury.js b/routes/treasury.js new file mode 100644 index 00000000..2f5b4dda --- /dev/null +++ b/routes/treasury.js @@ -0,0 +1,256 @@ +const express = require('express'); +const router = express.Router(); +const auth = require('../middleware/auth'); +const treasuryService = require('../services/treasuryService'); +const runwayForecaster = require('../services/runwayForecaster'); +const TreasuryVault = require('../models/TreasuryVault'); +const LiquidityThreshold = require('../models/LiquidityThreshold'); +const ExchangeHedge = require('../models/ExchangeHedge'); + +/** + * Get Treasury Dashboard + */ +router.get('/dashboard', auth, async (req, res) => { + try { + const dashboard = await treasuryService.getTreasuryDashboard(req.user._id); + const portfolio = await treasuryService.getPortfolioMetrics(req.user._id); + + res.json({ + success: true, + data: { + ...dashboard, + portfolio + } + }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Cash Runway Forecast + */ +router.get('/forecast', auth, async (req, res) => { + try { + const days = parseInt(req.query.days) || 180; + const forecast = await runwayForecaster.generateForecast(req.user._id, days); + + res.json({ success: true, data: forecast }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Liquidity Projection + */ +router.get('/projection', auth, async (req, res) => { + try { + const days = parseInt(req.query.days) || 90; + const projection = await treasuryService.getLiquidityProjection(req.user._id, days); + + res.json({ success: true, data: projection }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Create Treasury Vault + */ +router.post('/vaults', auth, async (req, res) => { + try { + const vault = new TreasuryVault({ + ...req.body, + userId: req.user._id + }); + await vault.save(); + + res.json({ success: true, data: vault }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Get All Vaults + */ +router.get('/vaults', auth, async (req, res) => { + try { + const vaults = await TreasuryVault.find({ userId: req.user._id }); + res.json({ success: true, data: vaults }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Update Vault Balance + */ +router.patch('/vaults/:id/balance', auth, async (req, res) => { + try { + const { amount, operation } = req.body; // operation: 'add' or 'subtract' + const vault = await TreasuryVault.findOne({ _id: req.params.id, userId: req.user._id }); + + if (!vault) { + return res.status(404).json({ success: false, error: 'Vault not found' }); + } + + if (operation === 'add') { + vault.balance += amount; + } else if (operation === 'subtract') { + vault.balance -= amount; + } + + await vault.save(); + res.json({ success: true, data: vault }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Transfer Between Vaults + */ +router.post('/vaults/transfer', auth, async (req, res) => { + try { + const { fromVaultId, toVaultId, amount } = req.body; + const result = await treasuryService.transferBetweenVaults( + fromVaultId, + toVaultId, + amount, + req.user._id + ); + + res.json({ success: true, data: result }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Auto-Rebalance Vaults + */ +router.post('/vaults/rebalance', auth, async (req, res) => { + try { + const actions = await treasuryService.rebalanceVaults(req.user._id); + res.json({ success: true, data: actions }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Create Liquidity Threshold + */ +router.post('/thresholds', auth, async (req, res) => { + try { + const threshold = new LiquidityThreshold({ + ...req.body, + userId: req.user._id + }); + await threshold.save(); + + res.json({ success: true, data: threshold }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Get All Thresholds + */ +router.get('/thresholds', auth, async (req, res) => { + try { + const thresholds = await LiquidityThreshold.find({ userId: req.user._id }); + res.json({ success: true, data: thresholds }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Monitor Thresholds (Manual Trigger) + */ +router.post('/thresholds/monitor', auth, async (req, res) => { + try { + const alerts = await treasuryService.monitorThresholds(req.user._id); + res.json({ success: true, data: alerts }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Create FX Hedge + */ +router.post('/hedges', auth, async (req, res) => { + try { + const hedgeId = `HG-${Date.now()}-${req.user._id.toString().substring(0, 4)}`.toUpperCase(); + const hedge = new ExchangeHedge({ + ...req.body, + hedgeId, + userId: req.user._id + }); + await hedge.save(); + + res.json({ success: true, data: hedge }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Get All Hedges + */ +router.get('/hedges', auth, async (req, res) => { + try { + const hedges = await ExchangeHedge.find({ userId: req.user._id }); + res.json({ success: true, data: hedges }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Update Hedge Market Rate (for MTM calculation) + */ +router.patch('/hedges/:id/market-rate', auth, async (req, res) => { + try { + const { marketRate } = req.body; + const hedge = await ExchangeHedge.findOne({ _id: req.params.id, userId: req.user._id }); + + if (!hedge) { + return res.status(404).json({ success: false, error: 'Hedge not found' }); + } + + hedge.marketRate = marketRate; + await hedge.save(); // Pre-save hook will calculate MTM + + res.json({ success: true, data: hedge }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Settle Hedge + */ +router.post('/hedges/:id/settle', auth, async (req, res) => { + try { + const hedge = await ExchangeHedge.findOne({ _id: req.params.id, userId: req.user._id }); + + if (!hedge) { + return res.status(404).json({ success: false, error: 'Hedge not found' }); + } + + hedge.status = 'settled'; + await hedge.save(); + + res.json({ success: true, data: hedge }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index 620fcffb..30738c49 100644 --- a/server.js +++ b/server.js @@ -43,8 +43,8 @@ const server = http.createServer(app); const io = socketIo(server, { cors: { origin: [process.env.FRONTEND_URL || - "http://localhost:3000", - 'https://accounts.clerk.dev', + "http://localhost:3000", + 'https://accounts.clerk.dev', 'https://*.clerk.accounts.dev' ], methods: ["GET", "POST"], @@ -63,10 +63,10 @@ app.use(helmet({ directives: { defaultSrc: ["'self'"], styleSrc: [ - "'self'", - "'unsafe-inline'", - "https://cdnjs.cloudflare.com", - "https://fonts.googleapis.com", + "'self'", + "'unsafe-inline'", + "https://cdnjs.cloudflare.com", + "https://fonts.googleapis.com", "https://api.github.com" ], scriptSrc: [ @@ -83,10 +83,10 @@ app.use(helmet({ scriptSrcAttr: ["'unsafe-inline'"], workerSrc: ["'self'", "blob:"], imgSrc: [ - "'self'", - "data:", - "https:", - "https://res.cloudinary.com", + "'self'", + "data:", + "https:", + "https://res.cloudinary.com", "https://api.github.com", "https://img.clerk.com" // For Clerk user avatars ], @@ -290,6 +290,7 @@ app.use('/api/folders', require('./routes/folders')); app.use('/api/procurement', require('./routes/procurement')); app.use('/api/compliance', require('./routes/compliance')); app.use('/api/project-billing', require('./routes/project-billing')); +app.use('/api/treasury', require('./routes/treasury')); // Import error handling middleware const { errorHandler, notFoundHandler } = require('./middleware/errorMiddleware'); diff --git a/services/runwayForecaster.js b/services/runwayForecaster.js new file mode 100644 index 00000000..e58e3cc2 --- /dev/null +++ b/services/runwayForecaster.js @@ -0,0 +1,386 @@ +const Transaction = require('../models/Transaction'); +const TreasuryVault = require('../models/TreasuryVault'); +const FinancialModels = require('../utils/financialModels'); + +class RunwayForecaster { + /** + * Advanced cash runway forecasting using multiple methodologies + */ + async generateForecast(userId, forecastDays = 180) { + // Get historical transaction data + const sixMonthsAgo = new Date(); + sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); + + const historicalTransactions = await Transaction.find({ + user: userId, + date: { $gte: sixMonthsAgo } + }).sort({ date: 1 }); + + // Get current liquidity + const vaults = await TreasuryVault.find({ userId, isActive: true }); + const currentLiquidity = vaults.reduce((sum, v) => sum + v.availableLiquidity, 0); + + // Generate forecasts using different methods + const simpleForecast = this.simpleLinearForecast(historicalTransactions, currentLiquidity, forecastDays); + const movingAvgForecast = this.movingAverageForecast(historicalTransactions, currentLiquidity, forecastDays); + const seasonalForecast = this.seasonalForecast(historicalTransactions, currentLiquidity, forecastDays); + const mlLikeForecast = this.mlLikeForecast(historicalTransactions, currentLiquidity, forecastDays); + + // Ensemble forecast (weighted average) + const ensembleForecast = this.createEnsemble([ + { forecast: simpleForecast, weight: 0.2 }, + { forecast: movingAvgForecast, weight: 0.3 }, + { forecast: seasonalForecast, weight: 0.25 }, + { forecast: mlLikeForecast, weight: 0.25 } + ], forecastDays); + + return { + currentLiquidity, + forecastHorizon: forecastDays, + forecasts: { + simple: simpleForecast, + movingAverage: movingAvgForecast, + seasonal: seasonalForecast, + mlLike: mlLikeForecast, + ensemble: ensembleForecast + }, + insights: this.generateInsights(ensembleForecast, currentLiquidity), + confidence: this.calculateConfidence(historicalTransactions) + }; + } + + /** + * Simple linear trend forecast + */ + simpleLinearForecast(transactions, startingBalance, days) { + const expenses = transactions.filter(t => t.type === 'expense'); + const income = transactions.filter(t => t.type === 'income'); + + const avgDailyExpense = expenses.reduce((sum, t) => sum + t.amount, 0) / + Math.max(1, this.getDaysBetween(transactions)); + const avgDailyIncome = income.reduce((sum, t) => sum + t.amount, 0) / + Math.max(1, this.getDaysBetween(transactions)); + + const netDailyFlow = avgDailyIncome - avgDailyExpense; + + const forecast = []; + for (let i = 0; i <= days; i++) { + const projectedBalance = startingBalance + (netDailyFlow * i); + forecast.push({ + day: i, + balance: Math.max(0, projectedBalance), + netFlow: netDailyFlow + }); + } + + return forecast; + } + + /** + * Moving average forecast with trend adjustment + */ + movingAverageForecast(transactions, startingBalance, days) { + const windowSize = 30; // 30-day moving average + const expenses = transactions.filter(t => t.type === 'expense'); + + // Group by day + const dailyExpenses = this.groupByDay(expenses); + const movingAvgs = this.calculateMovingAverage(dailyExpenses, windowSize); + + const avgExpense = movingAvgs.length > 0 ? + movingAvgs[movingAvgs.length - 1] : + expenses.reduce((sum, t) => sum + t.amount, 0) / Math.max(1, this.getDaysBetween(transactions)); + + // Calculate trend + const trend = this.calculateTrend(movingAvgs); + + const forecast = []; + for (let i = 0; i <= days; i++) { + const adjustedExpense = avgExpense * (1 + trend * i / 100); + const projectedBalance = startingBalance - (adjustedExpense * i); + forecast.push({ + day: i, + balance: Math.max(0, projectedBalance), + dailyExpense: adjustedExpense + }); + } + + return forecast; + } + + /** + * Seasonal forecast accounting for monthly patterns + */ + seasonalForecast(transactions, startingBalance, days) { + // Calculate monthly patterns + const monthlyPatterns = this.calculateMonthlyPatterns(transactions); + + const forecast = []; + let currentBalance = startingBalance; + + for (let i = 0; i <= days; i++) { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + i); + const month = futureDate.getMonth(); + + const monthlyFactor = monthlyPatterns[month] || 1.0; + const baseExpense = this.getAverageDailyExpense(transactions); + const adjustedExpense = baseExpense * monthlyFactor; + + currentBalance -= adjustedExpense; + + forecast.push({ + day: i, + balance: Math.max(0, currentBalance), + seasonalFactor: monthlyFactor, + dailyExpense: adjustedExpense + }); + } + + return forecast; + } + + /** + * ML-like forecast using exponential smoothing + */ + mlLikeForecast(transactions, startingBalance, days) { + const alpha = 0.3; // Smoothing factor + const beta = 0.1; // Trend smoothing factor + + const expenses = transactions.filter(t => t.type === 'expense'); + const dailyExpenses = this.groupByDay(expenses); + + if (dailyExpenses.length === 0) { + return this.simpleLinearForecast(transactions, startingBalance, days); + } + + // Initialize + let level = dailyExpenses[0]; + let trend = dailyExpenses.length > 1 ? dailyExpenses[1] - dailyExpenses[0] : 0; + + // Apply Holt's linear trend method + for (let i = 1; i < dailyExpenses.length; i++) { + const prevLevel = level; + level = alpha * dailyExpenses[i] + (1 - alpha) * (level + trend); + trend = beta * (level - prevLevel) + (1 - beta) * trend; + } + + const forecast = []; + let currentBalance = startingBalance; + + for (let i = 0; i <= days; i++) { + const forecastExpense = level + trend * i; + currentBalance -= forecastExpense; + + forecast.push({ + day: i, + balance: Math.max(0, currentBalance), + forecastExpense, + level, + trend + }); + } + + return forecast; + } + + /** + * Create ensemble forecast from multiple models + */ + createEnsemble(forecasts, days) { + const ensemble = []; + + for (let i = 0; i <= days; i++) { + let weightedBalance = 0; + let totalWeight = 0; + + forecasts.forEach(({ forecast, weight }) => { + if (forecast[i]) { + weightedBalance += forecast[i].balance * weight; + totalWeight += weight; + } + }); + + ensemble.push({ + day: i, + balance: totalWeight > 0 ? weightedBalance / totalWeight : 0, + confidence: this.calculateDayConfidence(i, days) + }); + } + + return ensemble; + } + + /** + * Generate actionable insights from forecast + */ + generateInsights(forecast, currentLiquidity) { + const insights = []; + + // Find when balance hits zero + const zeroDay = forecast.findIndex(f => f.balance === 0); + if (zeroDay > 0 && zeroDay < forecast.length) { + insights.push({ + type: 'critical', + message: `Cash runway depleted in ${zeroDay} days`, + severity: zeroDay < 30 ? 'emergency' : zeroDay < 60 ? 'high' : 'medium', + actionRequired: true + }); + } + + // Check for declining trend + const midPoint = Math.floor(forecast.length / 2); + const earlyAvg = forecast.slice(0, 30).reduce((sum, f) => sum + f.balance, 0) / 30; + const lateAvg = forecast.slice(midPoint, midPoint + 30).reduce((sum, f) => sum + f.balance, 0) / 30; + + if (lateAvg < earlyAvg * 0.5) { + insights.push({ + type: 'warning', + message: 'Significant liquidity decline projected', + severity: 'medium', + recommendation: 'Consider cost optimization or revenue acceleration' + }); + } + + // Positive insights + if (forecast[forecast.length - 1].balance > currentLiquidity * 1.2) { + insights.push({ + type: 'positive', + message: 'Liquidity growth projected', + severity: 'low', + recommendation: 'Consider investment opportunities' + }); + } + + return insights; + } + + /** + * Calculate forecast confidence based on data quality + */ + calculateConfidence(transactions) { + const dataPoints = transactions.length; + const timeSpan = this.getDaysBetween(transactions); + + // More data points and longer timespan = higher confidence + let confidence = 50; // Base confidence + + if (dataPoints > 100) confidence += 20; + else if (dataPoints > 50) confidence += 10; + + if (timeSpan > 120) confidence += 20; + else if (timeSpan > 60) confidence += 10; + + // Check for consistency + const variance = this.calculateVariance(transactions); + if (variance < 0.3) confidence += 10; // Low variance = more predictable + + return Math.min(100, confidence); + } + + /** + * Helper: Calculate confidence for specific forecast day + */ + calculateDayConfidence(day, totalDays) { + // Confidence decreases with forecast horizon + return Math.max(20, 100 - (day / totalDays) * 60); + } + + /** + * Helper: Group transactions by day + */ + groupByDay(transactions) { + const grouped = {}; + transactions.forEach(t => { + const day = new Date(t.date).toISOString().split('T')[0]; + grouped[day] = (grouped[day] || 0) + t.amount; + }); + return Object.values(grouped); + } + + /** + * Helper: Calculate moving average + */ + calculateMovingAverage(data, windowSize) { + const result = []; + for (let i = windowSize - 1; i < data.length; i++) { + const window = data.slice(i - windowSize + 1, i + 1); + const avg = window.reduce((sum, val) => sum + val, 0) / windowSize; + result.push(avg); + } + return result; + } + + /** + * Helper: Calculate trend from data + */ + calculateTrend(data) { + if (data.length < 2) return 0; + const recent = data.slice(-10); + const older = data.slice(-20, -10); + + const recentAvg = recent.reduce((sum, val) => sum + val, 0) / recent.length; + const olderAvg = older.reduce((sum, val) => sum + val, 0) / (older.length || 1); + + return ((recentAvg - olderAvg) / olderAvg) * 100; + } + + /** + * Helper: Calculate monthly spending patterns + */ + calculateMonthlyPatterns(transactions) { + const monthlyTotals = Array(12).fill(0); + const monthlyCounts = Array(12).fill(0); + + transactions.filter(t => t.type === 'expense').forEach(t => { + const month = new Date(t.date).getMonth(); + monthlyTotals[month] += t.amount; + monthlyCounts[month]++; + }); + + const avgExpense = monthlyTotals.reduce((sum, val) => sum + val, 0) / + Math.max(1, monthlyCounts.reduce((sum, val) => sum + val, 0)); + + return monthlyTotals.map((total, i) => { + const monthAvg = monthlyCounts[i] > 0 ? total / monthlyCounts[i] : avgExpense; + return monthAvg / avgExpense; + }); + } + + /** + * Helper: Get average daily expense + */ + getAverageDailyExpense(transactions) { + const expenses = transactions.filter(t => t.type === 'expense'); + const totalExpense = expenses.reduce((sum, t) => sum + t.amount, 0); + const days = this.getDaysBetween(transactions); + return totalExpense / Math.max(1, days); + } + + /** + * Helper: Get days between first and last transaction + */ + getDaysBetween(transactions) { + if (transactions.length < 2) return 1; + const dates = transactions.map(t => new Date(t.date).getTime()); + const minDate = Math.min(...dates); + const maxDate = Math.max(...dates); + return Math.ceil((maxDate - minDate) / (1000 * 60 * 60 * 24)) || 1; + } + + /** + * Helper: Calculate variance in spending + */ + calculateVariance(transactions) { + const expenses = transactions.filter(t => t.type === 'expense'); + if (expenses.length === 0) return 0; + + const amounts = expenses.map(t => t.amount); + const mean = amounts.reduce((sum, val) => sum + val, 0) / amounts.length; + const variance = amounts.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / amounts.length; + + return Math.sqrt(variance) / mean; // Coefficient of variation + } +} + +module.exports = new RunwayForecaster(); diff --git a/services/treasuryService.js b/services/treasuryService.js new file mode 100644 index 00000000..1e855cdf --- /dev/null +++ b/services/treasuryService.js @@ -0,0 +1,254 @@ +const TreasuryVault = require('../models/TreasuryVault'); +const LiquidityThreshold = require('../models/LiquidityThreshold'); +const Transaction = require('../models/Transaction'); +const Account = require('../models/Account'); +const FinancialModels = require('../utils/financialModels'); + +class TreasuryService { + /** + * Get comprehensive treasury dashboard data + */ + async getTreasuryDashboard(userId) { + const vaults = await TreasuryVault.find({ userId, isActive: true }); + const thresholds = await LiquidityThreshold.find({ userId, isActive: true }); + + // Calculate total liquidity across all vaults + const totalLiquidity = vaults.reduce((sum, v) => { + // Convert to base currency (INR) if needed + return sum + v.availableLiquidity; + }, 0); + + // Get recent transactions for burn rate calculation + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const recentExpenses = await Transaction.find({ + user: userId, + type: 'expense', + date: { $gte: thirtyDaysAgo } + }); + + const dailyBurnRate = FinancialModels.calculateBurnRate(recentExpenses, 30); + const cashRunway = FinancialModels.calculateRunway(totalLiquidity, dailyBurnRate); + + // Check threshold violations + const violations = []; + for (const threshold of thresholds) { + const vault = vaults.find(v => v._id.equals(threshold.vaultId)); + if (!vault) continue; + + let isViolated = false; + let currentValue = 0; + + switch (threshold.thresholdType) { + case 'absolute': + currentValue = vault.availableLiquidity; + isViolated = currentValue < threshold.triggerValue; + break; + case 'percentage': + currentValue = (vault.availableLiquidity / vault.balance) * 100; + isViolated = currentValue < threshold.triggerValue; + break; + case 'runway_days': + currentValue = cashRunway; + isViolated = currentValue < threshold.triggerValue; + break; + } + + if (isViolated) { + violations.push({ + thresholdId: threshold._id, + thresholdName: threshold.thresholdName, + severity: threshold.severity, + currentValue, + triggerValue: threshold.triggerValue, + vaultName: vault.vaultName + }); + } + } + + return { + vaults, + totalLiquidity, + dailyBurnRate, + cashRunway, + violations, + healthScore: this.calculateHealthScore(totalLiquidity, dailyBurnRate, violations.length) + }; + } + + /** + * Calculate treasury health score (0-100) + */ + calculateHealthScore(liquidity, burnRate, violationCount) { + let score = 100; + + // Deduct based on runway + const runway = FinancialModels.calculateRunway(liquidity, burnRate); + if (runway < 30) score -= 40; + else if (runway < 60) score -= 20; + else if (runway < 90) score -= 10; + + // Deduct based on violations + score -= violationCount * 15; + + return Math.max(0, Math.min(100, score)); + } + + /** + * Transfer funds between vaults + */ + async transferBetweenVaults(fromVaultId, toVaultId, amount, userId) { + const fromVault = await TreasuryVault.findOne({ _id: fromVaultId, userId }); + const toVault = await TreasuryVault.findOne({ _id: toVaultId, userId }); + + if (!fromVault || !toVault) { + throw new Error('Vault not found'); + } + + if (fromVault.availableLiquidity < amount) { + throw new Error('Insufficient liquidity in source vault'); + } + + // Perform transfer + fromVault.balance -= amount; + toVault.balance += amount; + + await fromVault.save(); + await toVault.save(); + + return { + success: true, + fromVault: fromVault.vaultName, + toVault: toVault.vaultName, + amount + }; + } + + /** + * Auto-rebalance vaults based on allocation targets + */ + async rebalanceVaults(userId) { + const vaults = await TreasuryVault.find({ + userId, + isActive: true, + 'metadata.autoRebalance': true + }); + + const totalBalance = vaults.reduce((sum, v) => sum + v.balance, 0); + const rebalanceActions = []; + + for (const vault of vaults) { + // Simple rebalancing: maintain equal distribution + const targetBalance = totalBalance / vaults.length; + const difference = vault.balance - targetBalance; + + if (Math.abs(difference) > 1000) { // Only rebalance if difference > 1000 + rebalanceActions.push({ + vaultId: vault._id, + vaultName: vault.vaultName, + currentBalance: vault.balance, + targetBalance, + adjustment: -difference + }); + } + } + + return rebalanceActions; + } + + /** + * Monitor and trigger threshold alerts + */ + async monitorThresholds(userId) { + const dashboard = await this.getTreasuryDashboard(userId); + const triggeredAlerts = []; + + for (const violation of dashboard.violations) { + const threshold = await LiquidityThreshold.findById(violation.thresholdId); + + // Check cooldown period + if (threshold.lastTriggered) { + const hoursSinceLastTrigger = (Date.now() - threshold.lastTriggered) / (1000 * 60 * 60); + if (hoursSinceLastTrigger < threshold.cooldownPeriod) { + continue; // Skip if in cooldown + } + } + + // Update threshold + threshold.lastTriggered = new Date(); + threshold.triggerCount += 1; + threshold.currentValue = violation.currentValue; + await threshold.save(); + + triggeredAlerts.push({ + thresholdName: threshold.thresholdName, + severity: threshold.severity, + message: `${threshold.thresholdName} violated: Current ${violation.currentValue.toFixed(2)}, Trigger ${violation.triggerValue}`, + automatedActions: threshold.automatedActions + }); + } + + return triggeredAlerts; + } + + /** + * Get liquidity projection for next N days + */ + async getLiquidityProjection(userId, days = 90) { + const dashboard = await this.getTreasuryDashboard(userId); + const projection = []; + + for (let i = 0; i <= days; i++) { + const projectedBalance = dashboard.totalLiquidity - (dashboard.dailyBurnRate * i); + projection.push({ + day: i, + date: new Date(Date.now() + i * 24 * 60 * 60 * 1000), + projectedBalance: Math.max(0, projectedBalance), + burnRate: dashboard.dailyBurnRate + }); + } + + return projection; + } + + /** + * Calculate portfolio metrics + */ + async getPortfolioMetrics(userId) { + const vaults = await TreasuryVault.find({ userId, isActive: true }); + + // Get historical balances (mock data for now) + const returns = [0.02, 0.015, -0.01, 0.03, 0.025]; // Mock monthly returns + + const totalValue = vaults.reduce((sum, v) => sum + v.balance, 0); + + return { + totalValue, + sharpeRatio: FinancialModels.calculateSharpeRatio(returns), + var95: FinancialModels.calculateVaR(totalValue, 0.15, 0.95), + diversificationScore: this.calculateDiversification(vaults) + }; + } + + /** + * Calculate diversification score + */ + calculateDiversification(vaults) { + if (vaults.length === 0) return 0; + + const totalBalance = vaults.reduce((sum, v) => sum + v.balance, 0); + if (totalBalance === 0) return 0; + + // Herfindahl-Hirschman Index (HHI) for concentration + const hhi = vaults.reduce((sum, v) => { + const share = v.balance / totalBalance; + return sum + Math.pow(share, 2); + }, 0); + + // Convert to diversification score (0-100) + return Math.round((1 - hhi) * 100); + } +} + +module.exports = new TreasuryService(); diff --git a/utils/financialModels.js b/utils/financialModels.js new file mode 100644 index 00000000..a114215a --- /dev/null +++ b/utils/financialModels.js @@ -0,0 +1,177 @@ +/** + * Financial Models Utility + * Advanced mathematical functions for treasury operations + */ + +const FinancialModels = { + /** + * Calculate Internal Rate of Return (IRR) + * Uses Newton-Raphson method for approximation + */ + calculateIRR: (cashFlows, guess = 0.1) => { + const maxIterations = 100; + const tolerance = 0.00001; + let rate = guess; + + for (let i = 0; i < maxIterations; i++) { + let npv = 0; + let dnpv = 0; + + cashFlows.forEach((cf, t) => { + npv += cf / Math.pow(1 + rate, t); + dnpv -= (t * cf) / Math.pow(1 + rate, t + 1); + }); + + const newRate = rate - npv / dnpv; + + if (Math.abs(newRate - rate) < tolerance) { + return newRate; + } + rate = newRate; + } + + return rate; + }, + + /** + * Calculate Net Present Value (NPV) + */ + calculateNPV: (cashFlows, discountRate) => { + return cashFlows.reduce((npv, cf, t) => { + return npv + cf / Math.pow(1 + discountRate, t); + }, 0); + }, + + /** + * Calculate Cash Runway (days until funds depleted) + */ + calculateRunway: (currentBalance, dailyBurnRate) => { + if (dailyBurnRate <= 0) return Infinity; + return Math.floor(currentBalance / dailyBurnRate); + }, + + /** + * Calculate Burn Rate from historical data + */ + calculateBurnRate: (expenses, days) => { + if (days === 0) return 0; + const totalExpenses = expenses.reduce((sum, e) => sum + e.amount, 0); + return totalExpenses / days; + }, + + /** + * FX Variance Analysis + * Measures volatility of exchange rate movements + */ + calculateFXVariance: (rates) => { + if (rates.length < 2) return 0; + + const mean = rates.reduce((sum, r) => sum + r, 0) / rates.length; + const squaredDiffs = rates.map(r => Math.pow(r - mean, 2)); + const variance = squaredDiffs.reduce((sum, sd) => sum + sd, 0) / rates.length; + + return { + variance, + standardDeviation: Math.sqrt(variance), + coefficientOfVariation: (Math.sqrt(variance) / mean) * 100 + }; + }, + + /** + * Value at Risk (VaR) - Parametric Method + * Estimates maximum potential loss at given confidence level + */ + calculateVaR: (portfolioValue, volatility, confidenceLevel = 0.95, timeHorizon = 1) => { + // Z-score for confidence levels + const zScores = { + 0.90: 1.28, + 0.95: 1.65, + 0.99: 2.33 + }; + + const zScore = zScores[confidenceLevel] || 1.65; + const var_ = portfolioValue * volatility * zScore * Math.sqrt(timeHorizon); + + return var_; + }, + + /** + * Sharpe Ratio - Risk-adjusted return metric + */ + calculateSharpeRatio: (returns, riskFreeRate = 0.05) => { + if (returns.length === 0) return 0; + + const avgReturn = returns.reduce((sum, r) => sum + r, 0) / returns.length; + const excessReturn = avgReturn - riskFreeRate; + + const variance = returns.reduce((sum, r) => { + return sum + Math.pow(r - avgReturn, 2); + }, 0) / returns.length; + + const stdDev = Math.sqrt(variance); + + return stdDev === 0 ? 0 : excessReturn / stdDev; + }, + + /** + * Liquidity Coverage Ratio (LCR) + * Basel III metric for short-term resilience + */ + calculateLCR: (highQualityLiquidAssets, netCashOutflows) => { + if (netCashOutflows === 0) return Infinity; + return (highQualityLiquidAssets / netCashOutflows) * 100; + }, + + /** + * Compound Annual Growth Rate (CAGR) + */ + calculateCAGR: (beginningValue, endingValue, years) => { + if (beginningValue === 0 || years === 0) return 0; + return (Math.pow(endingValue / beginningValue, 1 / years) - 1) * 100; + }, + + /** + * Weighted Average Cost of Capital (WACC) + */ + calculateWACC: (equityValue, debtValue, costOfEquity, costOfDebt, taxRate) => { + const totalValue = equityValue + debtValue; + if (totalValue === 0) return 0; + + const equityWeight = equityValue / totalValue; + const debtWeight = debtValue / totalValue; + + return (equityWeight * costOfEquity) + (debtWeight * costOfDebt * (1 - taxRate)); + }, + + /** + * Moving Average Convergence Divergence (MACD) for trend analysis + */ + calculateMACD: (prices, shortPeriod = 12, longPeriod = 26, signalPeriod = 9) => { + const ema = (data, period) => { + const k = 2 / (period + 1); + let emaValue = data[0]; + const emaArray = [emaValue]; + + for (let i = 1; i < data.length; i++) { + emaValue = (data[i] * k) + (emaValue * (1 - k)); + emaArray.push(emaValue); + } + return emaArray; + }; + + const shortEMA = ema(prices, shortPeriod); + const longEMA = ema(prices, longPeriod); + + const macdLine = shortEMA.map((val, i) => val - longEMA[i]); + const signalLine = ema(macdLine, signalPeriod); + const histogram = macdLine.map((val, i) => val - signalLine[i]); + + return { + macdLine: macdLine[macdLine.length - 1], + signalLine: signalLine[signalLine.length - 1], + histogram: histogram[histogram.length - 1] + }; + } +}; + +module.exports = FinancialModels;