-
-
Transaction History
+
+
Performance
+
+
-
-
-
-
+
+
+
+
+
-
- ${renderTransactionHistory()}
+
+
+
+
+
+
+
+
+
Failed to load performance data
+
+
+
+
+
+
+
Holdings
+
+
+ ${renderInvestmentHoldings(investment)}
+
+
+
+
+
+
`;
}
+
function renderTransactionHistory() {
const transactions = state.investmentTransactions || [];
@@ -2993,5 +3109,186 @@ async function loadInvestmentTransactions(investmentId) {
}
}
+
+/**
+ * Load performance chart data and render
+ */
+async function loadPerformanceChart(investmentId, period = '1Y') {
+ const chartContainer = document.getElementById('performanceChart');
+ const loadingEl = document.getElementById('chartLoading');
+ const errorEl = document.getElementById('chartError');
+
+ if (!chartContainer) return;
+
+ // Show loading
+ if (loadingEl) loadingEl.classList.remove('hidden');
+ if (errorEl) errorEl.classList.add('hidden');
+
+ // Update period button active state
+ document.querySelectorAll('.period-btn').forEach(btn => {
+ btn.classList.remove('bg-indigo-600', 'text-white');
+ btn.classList.add('border', 'border-gray-300', 'text-gray-600');
+ });
+ const activeBtn = document.getElementById(`period-${period}`);
+ if (activeBtn) {
+ activeBtn.classList.add('bg-indigo-600', 'text-white');
+ activeBtn.classList.remove('border', 'border-gray-300', 'text-gray-600');
+ }
+
+ try {
+ const response = await api.get(`/investments/${investmentId}/performance?period=${period}`);
+
+ if (loadingEl) loadingEl.classList.add('hidden');
+
+ if (!response?.success || !response.data) {
+ throw new Error(response?.error?.message || 'Failed to load performance data');
+ }
+
+ renderPerformanceChart(response.data);
+
+ } catch (error) {
+ console.error('Error loading performance chart:', error);
+ if (loadingEl) loadingEl.classList.add('hidden');
+ if (errorEl) {
+ errorEl.classList.remove('hidden');
+ const errorMsg = document.getElementById('chartErrorMessage');
+ if (errorMsg) {
+ errorMsg.textContent = error.message || 'Failed to load performance data';
+ }
+ }
+ }
+}
+
+/**
+ * Render Chart.js performance chart
+ */
+function renderPerformanceChart(data) {
+ const chartCanvas = document.getElementById('performanceChart');
+ if (!chartCanvas) return;
+
+ // Destroy existing chart instance
+ if (performanceChartInstance) {
+ performanceChartInstance.destroy();
+ }
+
+ const ctx = chartCanvas.getContext('2d');
+
+ performanceChartInstance = new Chart(ctx, {
+ type: 'line',
+ data: {
+ labels: data.dates.map(date => {
+ const d = new Date(date);
+ return d.toLocaleDateString('en-IN', { month: 'short', day: 'numeric' });
+ }),
+ datasets: [
+ {
+ label: 'Portfolio',
+ data: data.values,
+ borderColor: '#4F46E5', // Indigo 600
+ backgroundColor: 'rgba(79, 70, 229, 0.1)',
+ borderWidth: 2,
+ pointRadius: 0,
+ pointHoverRadius: 4,
+ tension: 0.3,
+ fill: true
+ },
+ {
+ label: data.benchmark_name,
+ data: data.benchmark_values,
+ borderColor: '#6B7280', // Gray 500
+ backgroundColor: 'transparent',
+ borderWidth: 2,
+ borderDash: [5, 5],
+ pointRadius: 0,
+ pointHoverRadius: 4,
+ tension: 0.3,
+ fill: false
+ }
+ ]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: {
+ mode: 'index',
+ intersect: false
+ },
+ plugins: {
+ legend: {
+ display: true,
+ position: 'top',
+ align: 'end',
+ labels: {
+ usePointStyle: true,
+ pointStyle: 'circle',
+ padding: 15,
+ font: {
+ size: 12,
+ family: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
+ }
+ }
+ },
+ tooltip: {
+ backgroundColor: 'rgba(255, 255, 255, 0.95)',
+ titleColor: '#111827',
+ bodyColor: '#374151',
+ borderColor: '#E5E7EB',
+ borderWidth: 1,
+ padding: 12,
+ displayColors: true,
+ callbacks: {
+ label: function(context) {
+ let label = context.dataset.label || '';
+ if (label) {
+ label += ': ';
+ }
+ label += context.parsed.y.toFixed(2);
+ return label;
+ }
+ }
+ }
+ },
+ scales: {
+ x: {
+ grid: {
+ display: false
+ },
+ ticks: {
+ font: {
+ size: 11
+ },
+ maxRotation: 0,
+ autoSkipPadding: 20
+ }
+ },
+ y: {
+ beginAtZero: false,
+ grid: {
+ color: '#F3F4F6'
+ },
+ ticks: {
+ font: {
+ size: 11
+ },
+ callback: function(value) {
+ return value.toFixed(0);
+ }
+ },
+ title: {
+ display: true,
+ text: 'Normalized to 100',
+ font: {
+ size: 12,
+ weight: '500'
+ },
+ color: '#6B7280'
+ }
+ }
+ }
+ }
+ });
+}
+
+
// Initialize
initApp();
diff --git a/scripts/seed-final.sql b/scripts/seed-final.sql
new file mode 100644
index 0000000..61fe143
--- /dev/null
+++ b/scripts/seed-final.sql
@@ -0,0 +1,118 @@
+-- ============================================
+-- PRODUCTION-READY SEED SCRIPT
+-- ⚠️ WARNING: FOR LOCAL/DEV ENVIRONMENTS ONLY
+-- ============================================
+
+
+-- Step 1: Create account (using test email)
+INSERT OR IGNORE INTO accounts (
+ id, zerodha_user_id, name, email, broker_type,
+ is_primary, is_active, created_at, updated_at
+) VALUES (
+ 1, 'AB1234', 'Test Trading Account', 'test@example.com', 'zerodha',
+ 1, 1, datetime('now'), datetime('now')
+);
+
+
+-- Step 2: Create broker account
+INSERT OR IGNORE INTO broker_accounts (
+ user_id, broker_type, account_name, broker_user_id,
+ is_connected, is_active, connection_status, created_at, updated_at
+) VALUES (
+ 1, 'zerodha', 'My Trading Account', 'AB1234',
+ 1, 1, 'connected', datetime('now'), datetime('now')
+);
+
+
+-- Step 3: Create baskets
+INSERT OR IGNORE INTO baskets (
+ user_id, account_id, name, description, theme, category,
+ is_active, is_public, is_template, min_investment, risk_level,
+ benchmark_symbol, created_at, updated_at
+) VALUES
+(1, 1, 'Tech Leaders', 'Top technology stocks from India', 'Technology', 'custom', 1, 0, 0, 25000, 'moderate', 'NIFTY IT', datetime('now', '-180 days'), datetime('now')),
+(1, 1, 'Banking Giants', 'Leading banking and financial services', 'Banking', 'custom', 1, 0, 0, 30000, 'low', 'NIFTY BANK', datetime('now', '-240 days'), datetime('now')),
+(1, 1, 'Pharma Power', 'Pharmaceutical and healthcare stocks', 'Healthcare', 'custom', 1, 0, 0, 20000, 'high', 'NIFTY PHARMA', datetime('now', '-120 days'), datetime('now'));
+
+
+-- Step 4: Create basket stocks
+INSERT OR IGNORE INTO basket_stocks (basket_id, trading_symbol, exchange, weight_percentage, company_name, sector, created_at) VALUES
+(1, 'TCS', 'NSE', 25.0, 'Tata Consultancy Services', 'IT', datetime('now')),
+(1, 'INFY', 'NSE', 25.0, 'Infosys Limited', 'IT', datetime('now')),
+(1, 'WIPRO', 'NSE', 25.0, 'Wipro Limited', 'IT', datetime('now')),
+(1, 'HCLTECH', 'NSE', 25.0, 'HCL Technologies', 'IT', datetime('now')),
+(2, 'HDFCBANK', 'NSE', 30.0, 'HDFC Bank', 'Banking', datetime('now')),
+(2, 'ICICIBANK', 'NSE', 30.0, 'ICICI Bank', 'Banking', datetime('now')),
+(2, 'AXISBANK', 'NSE', 20.0, 'Axis Bank', 'Banking', datetime('now')),
+(2, 'KOTAKBANK', 'NSE', 20.0, 'Kotak Mahindra Bank', 'Banking', datetime('now')),
+(3, 'SUNPHARMA', 'NSE', 30.0, 'Sun Pharma', 'Pharma', datetime('now')),
+(3, 'DRREDDY', 'NSE', 30.0, 'Dr Reddys Labs', 'Pharma', datetime('now')),
+(3, 'CIPLA', 'NSE', 20.0, 'Cipla', 'Pharma', datetime('now')),
+(3, 'DIVISLAB', 'NSE', 20.0, 'Divis Laboratories', 'Pharma', datetime('now'));
+
+
+-- Step 5: Create investments
+INSERT OR IGNORE INTO investments (
+ user_id, account_id, basket_id, broker_account_id,
+ invested_amount, current_value, units, invested_at,
+ last_synced_at, status
+) VALUES
+(1, 1, 1, 1, 50000, 55000, 1, date('now', '-180 days'), datetime('now'), 'ACTIVE'),
+(1, 1, 2, 1, 75000, 78500, 1, date('now', '-240 days'), datetime('now'), 'ACTIVE'),
+(1, 1, 3, 1, 40000, 44000, 1, date('now', '-120 days'), datetime('now'), 'ACTIVE');
+
+
+-- Step 6: Create investment holdings
+INSERT OR IGNORE INTO investment_holdings (investment_id, trading_symbol, exchange, quantity, average_price, current_price, target_weight, actual_weight, pnl, pnl_percentage, last_updated) VALUES
+(1, 'TCS', 'NSE', 3, 4166.67, 4350.00, 25.0, 25.0, 550, 4.4, datetime('now')),
+(1, 'INFY', 'NSE', 8, 1562.50, 1625.00, 25.0, 25.0, 500, 4.0, datetime('now')),
+(1, 'WIPRO', 'NSE', 25, 500.00, 530.00, 25.0, 25.0, 750, 6.0, datetime('now')),
+(1, 'HCLTECH', 'NSE', 9, 1388.89, 1450.00, 25.0, 25.0, 550, 4.4, datetime('now')),
+(2, 'HDFCBANK', 'NSE', 14, 1607.14, 1650.00, 30.0, 30.0, 600, 2.7, datetime('now')),
+(2, 'ICICIBANK', 'NSE', 22, 1022.73, 1050.00, 30.0, 30.0, 600, 2.7, datetime('now')),
+(2, 'AXISBANK', 'NSE', 15, 1000.00, 1020.00, 20.0, 20.0, 300, 2.0, datetime('now')),
+(2, 'KOTAKBANK', 'NSE', 9, 1666.67, 1700.00, 20.0, 20.0, 300, 2.0, datetime('now')),
+(3, 'SUNPHARMA', 'NSE', 8, 1500.00, 1575.00, 30.0, 30.0, 600, 5.0, datetime('now')),
+(3, 'DRREDDY', 'NSE', 2, 6000.00, 6300.00, 30.0, 30.0, 600, 5.0, datetime('now')),
+(3, 'CIPLA', 'NSE', 7, 1142.86, 1200.00, 20.0, 20.0, 400, 5.0, datetime('now')),
+(3, 'DIVISLAB', 'NSE', 2, 4000.00, 4200.00, 20.0, 20.0, 400, 5.0, datetime('now'));
+
+
+-- Step 7: Populate investment_history
+-- ⚠️ SAFETY: Only delete test investment data (IDs: 1, 2, 3)
+DELETE FROM investment_history WHERE investment_id IN (1, 2, 3);
+
+INSERT INTO investment_history (investment_id, recorded_date, invested_amount, current_value, day_change, day_change_percentage, total_pnl, total_pnl_percentage, created_at)
+SELECT i.id, date(i.invested_at, '+' || n.day || ' days'), i.invested_amount,
+ CAST(i.invested_amount * (1.0 + (n.day * 0.0003) + ((ABS(RANDOM()) % 200 - 100) / 10000.0)) AS REAL),
+ 0, 0, 0, 0, datetime('now')
+FROM investments i
+CROSS JOIN (WITH RECURSIVE days(day) AS (SELECT 0 UNION ALL SELECT day + 1 FROM days WHERE day < 365) SELECT day FROM days) n
+WHERE i.id IN (1, 2, 3) AND date(i.invested_at, '+' || n.day || ' days') <= date('now');
+
+UPDATE investment_history SET
+ total_pnl = current_value - invested_amount,
+ total_pnl_percentage = ((current_value - invested_amount) / invested_amount) * 100
+WHERE investment_id IN (1, 2, 3);
+
+
+-- Step 8: Add benchmark historical data
+INSERT OR IGNORE INTO benchmark_data (symbol, recorded_date, close_price, created_at)
+SELECT 'NIFTY 50', date('now', '-' || n.day || ' days'),
+ CAST(24500 - (n.day * 10) + ((ABS(RANDOM()) % 500 - 250)) AS REAL), datetime('now')
+FROM (WITH RECURSIVE days(day) AS (SELECT 1 UNION ALL SELECT day + 1 FROM days WHERE day < 365) SELECT day FROM days) n;
+
+INSERT OR IGNORE INTO benchmark_data (symbol, recorded_date, close_price, created_at)
+SELECT 'NIFTY IT', date('now', '-' || n.day || ' days'),
+ CAST(38000 - (n.day * 15) + ((ABS(RANDOM()) % 400 - 200)) AS REAL), datetime('now')
+FROM (WITH RECURSIVE days(day) AS (SELECT 1 UNION ALL SELECT day + 1 FROM days WHERE day < 365) SELECT day FROM days) n;
+
+INSERT OR IGNORE INTO benchmark_data (symbol, recorded_date, close_price, created_at)
+SELECT 'NIFTY BANK', date('now', '-' || n.day || ' days'),
+ CAST(52000 - (n.day * 20) + ((ABS(RANDOM()) % 600 - 300)) AS REAL), datetime('now')
+FROM (WITH RECURSIVE days(day) AS (SELECT 1 UNION ALL SELECT day + 1 FROM days WHERE day < 365) SELECT day FROM days) n;
+
+INSERT OR IGNORE INTO benchmark_data (symbol, recorded_date, close_price, created_at)
+SELECT 'NIFTY PHARMA', date('now', '-' || n.day || ' days'),
+ CAST(21000 - (n.day * 8) + ((ABS(RANDOM()) % 300 - 150)) AS REAL), datetime('now')
+FROM (WITH RECURSIVE days(day) AS (SELECT 1 UNION ALL SELECT day + 1 FROM days WHERE day < 365) SELECT day FROM days) n;
diff --git a/src/index.tsx b/src/index.tsx
index ff43f0b..64b88c3 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -139,6 +139,7 @@ app.get('/', async (c) => {
.card-hover { transition: transform 0.2s, box-shadow 0.2s; }
.card-hover:hover { transform: translateY(-4px); box-shadow: 0 12px 24px rgba(0,0,0,0.15); }
+
diff --git a/src/routes/investments.ts b/src/routes/investments.ts
index be73ae7..5ed9cbf 100644
--- a/src/routes/investments.ts
+++ b/src/routes/investments.ts
@@ -16,9 +16,10 @@ import type {
BuyBasketRequest,
SellBasketRequest,
RebalancePreview,
- KiteOrder
+ KiteOrder,
+ PerformanceData, InvestmentHistory, BenchmarkData
} from '../types';
-import { successResponse, errorResponse, decrypt, calculatePercentageChange } from '../lib/utils';
+import { successResponse, errorResponse, decrypt, calculatePercentageChange, normalizeToBase100, getISTDateString } from '../lib/utils';
import { KiteClient } from '../lib/kite';
import { AngelOneClient, AngelOneOrder } from '../lib/angelone';
@@ -1635,4 +1636,190 @@ investments.get('/:id/rebalance-history', async (c) => {
}
});
+/**
+ * GET /api/investments/:id/performance
+ * Get historical performance data for investment with benchmark comparison
+ *
+ * Query Parameters:
+ * - period: '1M' | '3M' | '6M' | '1Y' | 'ALL' (default: '1Y')
+ * - benchmark: Benchmark symbol (default: from basket.benchmark_symbol)
+ */
+investments.get('/:id/performance', async (c) => {
+ try {
+ const session = c.get('session');
+ if (!session) {
+ return c.json(errorResponse('UNAUTHORIZED', 'Authentication required'), 401);
+ }
+
+ const investmentId = parseInt(c.req.param('id'));
+ const period = c.req.query('period') || '1Y';
+ const customBenchmark = c.req.query('benchmark');
+
+ // Validate investment exists and belongs to user
+ const investment = await c.env.DB.prepare(`
+ SELECT
+ i.id,
+ i.user_id,
+ i.basket_id,
+ i.invested_at,
+ b.benchmark_symbol,
+ b.name as basket_name
+ FROM investments i
+ JOIN baskets b ON b.id = i.basket_id
+ WHERE i.id = ? AND i.user_id = ?
+ `).bind(investmentId, session.user_id).first<{
+ id: number;
+ user_id: number;
+ basket_id: number;
+ invested_at: string;
+ benchmark_symbol: string;
+ basket_name: string;
+ }>();
+
+ if (!investment) {
+ return c.json(errorResponse('NOT_FOUND', 'Investment not found'), 404);
+ }
+
+ // Calculate date range based on period
+ const endDate = new Date();
+ let startDate = new Date(endDate); // Clone endDate, not invested_at
+
+ switch (period) {
+ case '1M':
+ startDate.setMonth(startDate.getMonth() - 1);
+ break;
+ case '3M':
+ startDate.setMonth(startDate.getMonth() - 3);
+ break;
+ case '6M':
+ startDate.setMonth(startDate.getMonth() - 6);
+ break;
+ case '1Y':
+ startDate.setFullYear(startDate.getFullYear() - 1);
+ break;
+ case 'ALL':
+ startDate = new Date(investment.invested_at);
+ break;
+ default:
+ startDate.setFullYear(startDate.getFullYear() - 1);
+
+}
+
+ // Ensure startDate is not before investment date
+const investmentStartDate = new Date(investment.invested_at);
+if (startDate < investmentStartDate) {
+ startDate = investmentStartDate;
+}
+
+ const startDateStr = getISTDateString(startDate);
+ const endDateStr = getISTDateString(endDate);
+
+ // Fetch investment history
+ const investmentHistory = await c.env.DB.prepare(`
+ SELECT
+ recorded_date,
+ current_value,
+ total_pnl,
+ total_pnl_percentage
+ FROM investment_history
+ WHERE investment_id = ?
+ AND recorded_date >= ?
+ AND recorded_date <= ?
+ ORDER BY recorded_date ASC
+ `).bind(investmentId, startDateStr, endDateStr).all
();
+
+ if (!investmentHistory.results || investmentHistory.results.length === 0) {
+ return c.json(errorResponse('NO_DATA', 'No historical data available for this investment. Data is recorded daily.'), 404);
+ }
+
+ // Determine benchmark symbol
+ const benchmarkSymbol = customBenchmark || investment.benchmark_symbol || 'NIFTY 50';
+
+// Fetch benchmark data
+const benchmarkHistory = await c.env.DB.prepare(`
+ SELECT
+ recorded_date,
+ close_price
+ FROM benchmark_data
+ WHERE symbol = ?
+ AND recorded_date >= ?
+ AND recorded_date <= ?
+ ORDER BY recorded_date ASC
+`).bind(benchmarkSymbol, startDateStr, endDateStr).all();
+
+
+
+// Create maps for quick lookup
+const investmentMap = new Map(
+ investmentHistory.results.map(row => [row.recorded_date, row.current_value])
+);
+const benchmarkMap = new Map(
+ benchmarkHistory.results?.map(row => [row.recorded_date, row.close_price]) || []
+);
+
+
+// Prepare dates array - use investment dates as primary
+const dates = investmentHistory.results.map(row => row.recorded_date);
+
+// Fill data arrays (forward-fill missing dates)
+const investmentValues: number[] = [];
+const benchmarkValues: number[] = [];
+let lastInvestmentValue = investmentHistory.results[0].current_value;
+if (!benchmarkHistory.results || benchmarkHistory.results.length === 0) {
+ return c.json(
+ errorResponse(
+ 'NO_BENCHMARK_DATA',
+ `No benchmark data available for ${benchmarkSymbol}. The portfolio chart will still display.`
+ ),
+ 404
+ );
+}
+
+let lastBenchmarkValue = benchmarkHistory.results[0].close_price;
+
+dates.forEach((date, index) => {
+ // Investment value
+ if (investmentMap.has(date)) {
+ lastInvestmentValue = investmentMap.get(date)!;
+ }
+ investmentValues.push(lastInvestmentValue);
+
+ // Benchmark value
+ if (benchmarkMap.has(date)) {
+ lastBenchmarkValue = benchmarkMap.get(date)!;
+ }
+ benchmarkValues.push(lastBenchmarkValue);
+
+ // Debug first few and last few
+ if (index < 3 || index > dates.length - 3) {
+ console.log(`[DEBUG] Date ${date}: investment=${lastInvestmentValue}, benchmark=${lastBenchmarkValue}`);
+ }
+});
+
+
+
+
+ // Normalize both series to base 100
+ const normalizedInvestment = normalizeToBase100(investmentValues);
+ const normalizedBenchmark = normalizeToBase100(benchmarkValues);
+
+ // Prepare response
+ const performanceData: PerformanceData = {
+ dates,
+ values: normalizedInvestment,
+ benchmark_values: normalizedBenchmark,
+ benchmark_name: benchmarkSymbol
+ };
+
+ return c.json(successResponse(performanceData));
+
+ } catch (error) {
+ console.error('Error fetching performance data:', error);
+ return c.json(
+ errorResponse('INTERNAL_ERROR', 'Failed to fetch performance data'),
+ 500
+ );
+ }
+});
+
export default investments;