diff --git a/client/src/App.jsx b/client/src/App.jsx index 7542c98..1ab734f 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1774,6 +1774,10 @@ export default function App() { () => (accountFundingSource && typeof accountFundingSource === 'object' ? accountFundingSource : EMPTY_OBJECT), [accountFundingSource] ); + const symbolBreakdown = useMemo( + () => (Array.isArray(data?.symbolBreakdown) ? data.symbolBreakdown : []), + [data?.symbolBreakdown] + ); const accountBalances = data?.accountBalances ?? EMPTY_OBJECT; const selectedAccountFunding = useMemo(() => { if (selectedAccount === 'all') { @@ -2810,6 +2814,7 @@ export default function App() { baseCurrency={baseCurrency} asOf={asOf} totalMarketValue={heatmapMarketValue} + symbolBreakdown={symbolBreakdown} /> )} diff --git a/client/src/components/PnlHeatmapDialog.jsx b/client/src/components/PnlHeatmapDialog.jsx index 8646d90..2cc984c 100644 --- a/client/src/components/PnlHeatmapDialog.jsx +++ b/client/src/components/PnlHeatmapDialog.jsx @@ -388,6 +388,12 @@ function aggregatePositionsByMergedSymbol(positions) { }); } +function metricSignScore(value) { + if (value > 0) return 0; + if (value === 0) return 1; + return 2; +} + function buildHeatmapNodes(positions, metricKey, styleMode = 'style1') { const sourcePositions = aggregatePositionsByMergedSymbol(positions); @@ -429,12 +435,6 @@ function buildHeatmapNodes(positions, metricKey, styleMode = 'style1') { return []; } - const score = (value) => { - if (value > 0) return 0; - if (value === 0) return 1; - return 2; - }; - if (styleMode === 'style2') { const withMetricWeight = prepared .map((item) => ({ @@ -458,8 +458,8 @@ function buildHeatmapNodes(positions, metricKey, styleMode = 'style1') { const sorted = pool .slice() .sort((a, b) => { - const aScore = score(a.metricValue); - const bScore = score(b.metricValue); + const aScore = metricSignScore(a.metricValue); + const bScore = metricSignScore(b.metricValue); if (aScore !== bScore) { return aScore - bScore; } @@ -495,8 +495,8 @@ function buildHeatmapNodes(positions, metricKey, styleMode = 'style1') { const sorted = normalized .slice() .sort((a, b) => { - const aScore = score(a.metricValue); - const bScore = score(b.metricValue); + const aScore = metricSignScore(a.metricValue); + const bScore = metricSignScore(b.metricValue); if (aScore !== bScore) { return aScore - bScore; } @@ -528,6 +528,206 @@ function buildHeatmapNodes(positions, metricKey, styleMode = 'style1') { }); } +function buildTotalPnlNodes(positions, symbolBreakdown) { + const sourcePositions = aggregatePositionsByMergedSymbol(positions); + const combined = new Map(); + + const ensureEntry = (key, defaults = {}) => { + if (!combined.has(key)) { + combined.set(key, { + key, + symbol: defaults.symbol || key, + description: defaults.description || null, + currency: defaults.currency || null, + marketValue: 0, + cashFlowCad: 0, + incomeCad: 0, + tradeCad: 0, + investedCad: 0, + openPnl: 0, + totalCost: null, + totalCostAvailable: true, + currentPrice: defaults.currentPrice || null, + openQuantity: null, + averageEntryPrice: null, + symbolId: defaults.symbolId || null, + activityCount: 0, + }); + } + const entry = combined.get(key); + if (defaults.symbol && !entry.symbol) { + entry.symbol = defaults.symbol; + } + if (defaults.description && !entry.description) { + entry.description = defaults.description; + } + if (defaults.currency && !entry.currency) { + entry.currency = defaults.currency; + } + if (defaults.currentPrice && entry.currentPrice === null) { + entry.currentPrice = defaults.currentPrice; + } + if (defaults.symbolId && !entry.symbolId) { + entry.symbolId = defaults.symbolId; + } + return entry; + }; + + sourcePositions.forEach((position, index) => { + const normalized = normalizeMergedSymbol(position, `__total_position_${index}`); + const currency = + typeof position.currency === 'string' && position.currency.trim() + ? position.currency.trim().toUpperCase() + : null; + const entry = ensureEntry(normalized.key, { + symbol: normalized.display, + description: position.description || null, + currency, + currentPrice: position.currentPrice || null, + symbolId: position.symbolId || null, + }); + const marketValue = isFiniteNumber(position.normalizedMarketValue) + ? position.normalizedMarketValue + : 0; + if (marketValue !== 0) { + entry.marketValue += marketValue; + } + const openPnl = isFiniteNumber(position.normalizedOpenPnl) ? position.normalizedOpenPnl : 0; + if (openPnl !== 0) { + entry.openPnl += openPnl; + } + if (isFiniteNumber(position.totalCost)) { + entry.totalCost = (entry.totalCost ?? 0) + position.totalCost; + } else if (position.totalCost === null || position.totalCost === undefined) { + entry.totalCostAvailable = false; + entry.totalCost = null; + } + const quantity = isFiniteNumber(position.openQuantity) ? position.openQuantity : null; + if (quantity !== null) { + entry.openQuantity = (entry.openQuantity ?? 0) + quantity; + } + if (entry.averageEntryPrice === null && isFiniteNumber(position.averageEntryPrice)) { + entry.averageEntryPrice = position.averageEntryPrice; + } + }); + + const breakdownEntries = Array.isArray(symbolBreakdown) ? symbolBreakdown : []; + breakdownEntries.forEach((item, index) => { + if (!item || typeof item !== 'object') { + return; + } + const normalized = normalizeMergedSymbol( + { symbol: item.symbol, symbolId: item.symbolId }, + `__total_cashflow_${index}` + ); + const description = + typeof item.description === 'string' && item.description.trim() + ? item.description.trim() + : null; + const entry = ensureEntry(normalized.key, { + symbol: normalized.display, + description, + symbolId: + item.symbolId !== undefined && item.symbolId !== null ? String(item.symbolId) : null, + }); + const netCashFlow = Number(item.netCashFlowCad); + if (Number.isFinite(netCashFlow) && netCashFlow !== 0) { + entry.cashFlowCad += netCashFlow; + } + const incomeCad = Number(item.incomeCad); + if (Number.isFinite(incomeCad) && incomeCad !== 0) { + entry.incomeCad += incomeCad; + } + const tradeCad = Number(item.tradeCad); + if (Number.isFinite(tradeCad) && tradeCad !== 0) { + entry.tradeCad += tradeCad; + } + const investedCad = Number(item.investedCad); + if (Number.isFinite(investedCad) && investedCad > 0) { + entry.investedCad += investedCad; + } + const activityCount = Number(item.activityCount); + if (Number.isFinite(activityCount) && activityCount > 0) { + entry.activityCount += activityCount; + } + if (!entry.description && description) { + entry.description = description; + } + if (!entry.symbolId && item.symbolId !== undefined && item.symbolId !== null) { + entry.symbolId = String(item.symbolId); + } + }); + + const prepared = []; + combined.forEach((entry, key) => { + const hasCashFlow = + Math.abs(entry.cashFlowCad) > 0.0001 || + Math.abs(entry.incomeCad) > 0.0001 || + entry.investedCad > 0.0001; + let metricValue = entry.marketValue + entry.cashFlowCad; + if (!hasCashFlow && entry.openPnl !== 0) { + metricValue = entry.openPnl; + } + if (!Number.isFinite(metricValue) || Math.abs(metricValue) < 0.0001) { + return; + } + const weight = Math.abs(metricValue); + const basis = entry.investedCad > 0.0001 + ? entry.investedCad + : entry.totalCostAvailable && Number.isFinite(entry.totalCost) && Math.abs(entry.totalCost) > 0.0001 + ? Math.abs(entry.totalCost) + : null; + const percentChange = basis ? (metricValue / basis) * 100 : null; + + prepared.push({ + id: `${key}-total`, + symbol: entry.symbol, + description: entry.description || null, + weight, + marketValue: entry.marketValue, + metricValue, + percentChange, + portfolioShare: null, + currency: entry.currency || null, + currentPrice: entry.currentPrice || null, + share: null, + cashFlowCad: entry.cashFlowCad, + incomeCad: entry.incomeCad, + tradeCad: entry.tradeCad, + investedCad: entry.investedCad, + }); + }); + + if (!prepared.length) { + return []; + } + + const sorted = prepared + .slice() + .sort((a, b) => { + const aScore = metricSignScore(a.metricValue); + const bScore = metricSignScore(b.metricValue); + if (aScore !== bScore) { + return aScore - bScore; + } + if (aScore === 0) { + return b.weight - a.weight; + } + if (aScore === 2) { + if (a.weight !== b.weight) { + return a.weight - b.weight; + } + return Math.abs(a.metricValue) - Math.abs(b.metricValue); + } + return b.weight - a.weight; + }); + + return buildTreemapLayout(sorted).map((item) => ({ + ...item, + share: null, + })); +} + const NEUTRAL_COLOR = '#404656'; const POSITIVE_COLOR = '#00ff00'; const NEGATIVE_COLOR = '#ff0000'; @@ -577,6 +777,7 @@ export default function PnlHeatmapDialog({ baseCurrency, asOf, totalMarketValue, + symbolBreakdown = [], }) { const initialMetric = mode === 'open' ? 'open' : 'day'; const [metricMode, setMetricMode] = useState(initialMetric); @@ -584,9 +785,11 @@ export default function PnlHeatmapDialog({ setMetricMode(initialMetric); }, [initialMetric]); - const metricKey = metricMode === 'open' ? 'openPnl' : 'dayPnl'; - const metricLabel = metricMode === 'open' ? 'Open P&L' : "Today's P&L"; - const percentColorThreshold = metricMode === 'open' ? 70 : 5; + const metricKey = + metricMode === 'open' ? 'openPnl' : metricMode === 'day' ? 'dayPnl' : 'totalPnl'; + const metricLabel = + metricMode === 'open' ? 'Open P&L' : metricMode === 'day' ? "Today's P&L" : 'Total P&L'; + const percentColorThreshold = metricMode === 'open' ? 70 : metricMode === 'total' ? 50 : 5; const tileGapPx = 1; const halfTileGapPx = tileGapPx / 2; const epsilon = 0.0001; @@ -594,10 +797,17 @@ export default function PnlHeatmapDialog({ const formatPx = (value) => `${Number.parseFloat(value.toFixed(3))}`; const [styleMode, setStyleMode] = useState('style1'); - const nodes = useMemo( - () => buildHeatmapNodes(positions, metricKey, styleMode), - [positions, metricKey, styleMode] - ); + useEffect(() => { + if (metricMode === 'total' && styleMode !== 'style2') { + setStyleMode('style2'); + } + }, [metricMode, styleMode]); + const nodes = useMemo(() => { + if (metricMode === 'total') { + return buildTotalPnlNodes(positions, symbolBreakdown); + } + return buildHeatmapNodes(positions, metricKey, styleMode); + }, [positions, metricMode, metricKey, styleMode, symbolBreakdown]); const [colorMode, setColorMode] = useState('percent'); const handleTileClick = useCallback((event, symbol) => { if (!symbol) { @@ -704,6 +914,16 @@ export default function PnlHeatmapDialog({ > Open P&L +
@@ -822,6 +1048,27 @@ export default function PnlHeatmapDialog({ node.currency ? ` (${String(node.currency).toUpperCase()})` : '' }` : null; + const hasCashFlow = + metricMode === 'total' && isFiniteNumber(node.cashFlowCad) + ? Math.abs(node.cashFlowCad) >= 0.01 + : false; + const hasIncome = + metricMode === 'total' && isFiniteNumber(node.incomeCad) + ? Math.abs(node.incomeCad) >= 0.01 + : false; + const hasInvested = + metricMode === 'total' && isFiniteNumber(node.investedCad) + ? node.investedCad > 0.01 + : false; + const cashFlowLine = hasCashFlow + ? `Net cash flow: ${formatSignedMoney(node.cashFlowCad)}` + : null; + const incomeLine = hasIncome + ? `Dividends & income: ${formatSignedMoney(node.incomeCad)}` + : null; + const investedLine = hasInvested + ? `Invested capital: ${formatMoney(node.investedCad)}` + : null; const areaFraction = node.width * node.height; const areaRoot = Math.sqrt(areaFraction); const symbolFontSize = clamp(areaRoot * 70, 7, 28); @@ -852,6 +1099,9 @@ export default function PnlHeatmapDialog({ node.description ? `${node.symbol} — ${node.description}` : node.symbol, pnlLine, priceLine, + cashFlowLine, + incomeLine, + investedLine, !isStyleTwo && shareLabel ? `Portfolio share: ${shareLabel}` : null, styleTwoLine, ] @@ -934,6 +1184,18 @@ PnlHeatmapDialog.propTypes = { baseCurrency: PropTypes.string, asOf: PropTypes.string, totalMarketValue: PropTypes.number, + symbolBreakdown: PropTypes.arrayOf( + PropTypes.shape({ + symbol: PropTypes.string, + symbolId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + description: PropTypes.string, + netCashFlowCad: PropTypes.number, + incomeCad: PropTypes.number, + tradeCad: PropTypes.number, + investedCad: PropTypes.number, + activityCount: PropTypes.number, + }) + ), }; PnlHeatmapDialog.defaultProps = { diff --git a/server/src/index.js b/server/src/index.js index d7771a2..224e1db 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -23,6 +23,14 @@ const { normalizeCashFlowsForXirr, computeAnnualizedReturnFromCashFlows, } = require('./xirr'); +const { + normalizeBreakdownSymbol, + resolveActivitySymbolForBreakdown, + classifyActivityForSymbolBreakdown, + accumulateSymbolBreakdown, + finalizeSymbolBreakdown, + isFundingActivity, +} = require('./symbolBreakdown'); const RETURN_BREAKDOWN_PERIODS = [ { key: 'ten_year', months: 120 }, @@ -1101,22 +1109,6 @@ function resolveActivityTimestamp(activity) { return null; } -const FUNDING_TYPE_REGEX = /(deposit|withdraw|transfer|journal)/i; - -function isFundingActivity(activity) { - if (!activity || typeof activity !== 'object') { - return false; - } - const type = typeof activity.type === 'string' ? activity.type : ''; - const action = typeof activity.action === 'string' ? activity.action : ''; - const description = typeof activity.description === 'string' ? activity.description : ''; - return ( - FUNDING_TYPE_REGEX.test(type) || - FUNDING_TYPE_REGEX.test(action) || - FUNDING_TYPE_REGEX.test(description) - ); -} - const EMBEDDED_NUMBER_PATTERN = '\\d+(?:,\\d{3})*(?:\\.\\d+)?'; const EMBEDDED_DECIMAL_PATTERN = '\\d+(?:,\\d{3})*\\.\\d+'; @@ -1589,7 +1581,8 @@ async function computeNetDeposits(login, account, perAccountCombinedBalances, op const paddedStart = earliestFunding ? addDays(floorToMonthStart(earliestFunding), -7) : addDays(now, -365); const crawlStart = clampDate(paddedStart || now, MIN_ACTIVITY_DATE) || MIN_ACTIVITY_DATE; const activities = await fetchActivitiesRange(login, accountNumber, crawlStart, now, accountKey); - const fundingActivities = dedupeActivities(filterFundingActivities(activities)); + const dedupedActivities = dedupeActivities(activities); + const fundingActivities = filterFundingActivities(dedupedActivities); debugTotalPnl(accountKey, 'Funding activities considered', fundingActivities.length); const perCurrencyTotals = new Map(); @@ -1598,6 +1591,8 @@ async function computeNetDeposits(login, account, perAccountCombinedBalances, op const breakdown = []; const cashFlowEntries = []; let missingCashFlowDates = false; + const symbolBreakdownMap = new Map(); + let symbolBreakdownIncomplete = false; for (const activity of fundingActivities) { const details = resolveActivityAmountDetails(activity); @@ -1690,6 +1685,60 @@ async function computeNetDeposits(login, account, perAccountCombinedBalances, op } } + for (const activity of dedupedActivities) { + const category = classifyActivityForSymbolBreakdown(activity); + if (!category) { + continue; + } + const symbolInfo = resolveActivitySymbolForBreakdown(activity); + if (!symbolInfo) { + continue; + } + const details = resolveActivityAmountDetails(activity); + if (!details) { + continue; + } + const { amount, currency, timestamp } = details; + const conversion = await convertAmountToCad(amount, currency, timestamp, accountKey); + const cadAmount = conversion.cadAmount; + const key = symbolInfo.symbol; + if (!symbolBreakdownMap.has(key)) { + symbolBreakdownMap.set(key, { + symbol: symbolInfo.symbol, + symbolId: symbolInfo.symbolId || null, + description: symbolInfo.description || null, + netCashFlowCad: 0, + incomeCad: 0, + tradeCad: 0, + investedCad: 0, + activityCount: 0, + }); + } + const bucket = symbolBreakdownMap.get(key); + bucket.activityCount += 1; + if (!bucket.description && symbolInfo.description) { + bucket.description = symbolInfo.description; + } + if (!bucket.symbolId && symbolInfo.symbolId) { + bucket.symbolId = symbolInfo.symbolId; + } + if (Number.isFinite(cadAmount)) { + bucket.netCashFlowCad += cadAmount; + if (category === 'income') { + bucket.incomeCad += cadAmount; + } else { + bucket.tradeCad += cadAmount; + } + if (cadAmount < 0) { + bucket.investedCad += -cadAmount; + } + } else if (currency !== 'CAD') { + symbolBreakdownIncomplete = true; + } + } + + const symbolBreakdownEntries = finalizeSymbolBreakdown(symbolBreakdownMap); + const perCurrencyObject = {}; for (const [currency, value] of perCurrencyTotals.entries()) { perCurrencyObject[currency] = value; @@ -1843,6 +1892,8 @@ async function computeNetDeposits(login, account, perAccountCombinedBalances, op netDepositsCad: accountAdjustment, } : undefined, + symbolBreakdown: symbolBreakdownEntries.length ? symbolBreakdownEntries : undefined, + symbolBreakdownIncomplete: symbolBreakdownIncomplete || undefined, }; } @@ -2563,6 +2614,9 @@ app.get('/api/summary', async function (req, res) { } const accountFundingSummaries = {}; + const combinedSymbolBreakdownMap = new Map(); + let combinedSymbolBreakdownIncomplete = false; + let combinedSymbolBreakdownArray = []; if (selectedContexts.length === 1) { const context = selectedContexts[0]; try { @@ -2574,6 +2628,15 @@ app.get('/api/summary', async function (req, res) { ); if (fundingSummary) { accountFundingSummaries[context.account.id] = fundingSummary; + if (fundingSummary.symbolBreakdown) { + accumulateSymbolBreakdown( + combinedSymbolBreakdownMap, + fundingSummary.symbolBreakdown + ); + } + if (fundingSummary.symbolBreakdownIncomplete) { + combinedSymbolBreakdownIncomplete = true; + } } } catch (fundingError) { const message = fundingError && fundingError.message ? fundingError.message : String(fundingError); @@ -2613,6 +2676,16 @@ app.get('/api/summary', async function (req, res) { aggregateTotals.netDepositsCount += 1; } + if (fundingSummary.symbolBreakdown) { + accumulateSymbolBreakdown( + combinedSymbolBreakdownMap, + fundingSummary.symbolBreakdown + ); + } + if (fundingSummary.symbolBreakdownIncomplete) { + combinedSymbolBreakdownIncomplete = true; + } + const totalPnlCad = fundingSummary && fundingSummary.totalPnl ? fundingSummary.totalPnl.combinedCad : null; if (Number.isFinite(totalPnlCad)) { @@ -2730,11 +2803,24 @@ app.get('/api/summary', async function (req, res) { } } + const aggregateSymbolBreakdown = finalizeSymbolBreakdown(combinedSymbolBreakdownMap); + if (aggregateSymbolBreakdown.length) { + aggregateEntry.symbolBreakdown = aggregateSymbolBreakdown; + } + if (combinedSymbolBreakdownIncomplete) { + aggregateEntry.symbolBreakdownIncomplete = true; + } + combinedSymbolBreakdownArray = aggregateSymbolBreakdown; + if (Object.keys(aggregateEntry).length > 0) { accountFundingSummaries.all = aggregateEntry; } } + if (combinedSymbolBreakdownArray.length === 0) { + combinedSymbolBreakdownArray = finalizeSymbolBreakdown(combinedSymbolBreakdownMap); + } + Object.values(accountFundingSummaries).forEach((entry) => { if (entry && typeof entry === 'object' && Object.prototype.hasOwnProperty.call(entry, 'cashFlowsCad')) { delete entry.cashFlowsCad; @@ -2781,6 +2867,8 @@ app.get('/api/summary', async function (req, res) { investmentModelEvaluations, accountFunding: accountFundingSummaries, asOf: new Date().toISOString(), + symbolBreakdown: combinedSymbolBreakdownArray, + symbolBreakdownIncomplete: combinedSymbolBreakdownIncomplete || undefined, }); } catch (error) { if (error.response) { diff --git a/server/src/symbolBreakdown.js b/server/src/symbolBreakdown.js new file mode 100644 index 0000000..b74dc4f --- /dev/null +++ b/server/src/symbolBreakdown.js @@ -0,0 +1,185 @@ +'use strict'; + +const SYMBOL_ALIAS_MAP = new Map([ + ['QQQM', 'QQQ'], + ['QQM', 'QQQ'], +]); + +const FUNDING_TYPE_REGEX = /(deposit|withdraw|transfer|journal)/i; + +const DIVIDEND_SYMBOL_OVERRIDES = new Map([ + ['N003056', 'NVDA'], + ['A033916', 'ASML'], + ['.ENB', 'ENB'], + ['A040553', 'GOOG'], + ['C074212', 'CI'], + ['D052167', 'GGLL'], + ['H079292', 'SGOV'], + ['H082968', 'QQQ'], + ['L415517', 'LLY'], + ['M415385', 'MSFT'], + ['PSA', 'PSA'], + ['S022496', 'SPDR'], + ['T002234', 'TSM'], +]); + +const SYMBOL_INCOME_REGEX = /(dividend|distribution|dist|interest|return of capital|capital gain|reinvest)/i; +const SYMBOL_TRADE_REGEX = /(trade|buy|sell|short|cover|exercise|assign|assignment|option)/i; + +function normalizeBreakdownSymbol(symbol) { + if (symbol === undefined || symbol === null) { + return null; + } + const raw = String(symbol).trim(); + if (!raw) { + return null; + } + const upper = raw.toUpperCase(); + if (DIVIDEND_SYMBOL_OVERRIDES.has(upper)) { + return DIVIDEND_SYMBOL_OVERRIDES.get(upper); + } + let normalized = upper; + if (normalized.startsWith('.')) { + normalized = normalized.slice(1); + } + const withoutSuffix = normalized.endsWith('.TO') ? normalized.slice(0, -3) : normalized; + const alias = SYMBOL_ALIAS_MAP.get(withoutSuffix) || SYMBOL_ALIAS_MAP.get(normalized); + return alias || withoutSuffix || normalized; +} + +function resolveActivitySymbolForBreakdown(activity) { + if (!activity || typeof activity !== 'object') { + return null; + } + const primary = normalizeBreakdownSymbol(activity.symbol); + const fallback = normalizeBreakdownSymbol(activity.symbolId); + const symbol = primary || fallback; + if (!symbol) { + return null; + } + const descriptionCandidates = [activity.symbolDescription, activity.description]; + let description = null; + for (const candidate of descriptionCandidates) { + if (typeof candidate === 'string') { + const trimmed = candidate.trim(); + if (trimmed) { + description = trimmed; + break; + } + } + } + const symbolId = + activity.symbolId !== undefined && activity.symbolId !== null + ? String(activity.symbolId) + : null; + return { + symbol, + symbolId, + description, + }; +} + +function classifyActivityForSymbolBreakdown(activity) { + if (!activity || typeof activity !== 'object') { + return null; + } + if (isFundingActivity(activity)) { + return null; + } + const type = typeof activity.type === 'string' ? activity.type.toLowerCase() : ''; + const action = typeof activity.action === 'string' ? activity.action.toLowerCase() : ''; + const description = typeof activity.description === 'string' ? activity.description.toLowerCase() : ''; + const combined = `${type} ${action}`; + if (SYMBOL_INCOME_REGEX.test(combined) || SYMBOL_INCOME_REGEX.test(description)) { + return 'income'; + } + if (SYMBOL_TRADE_REGEX.test(combined) || SYMBOL_TRADE_REGEX.test(description)) { + return 'trade'; + } + return null; +} + +function accumulateSymbolBreakdown(target, entries) { + if (!(target instanceof Map) || !Array.isArray(entries)) { + return; + } + entries.forEach((entry) => { + if (!entry || typeof entry !== 'object') { + return; + } + const symbol = typeof entry.symbol === 'string' ? entry.symbol : null; + if (!symbol) { + return; + } + const key = symbol.toUpperCase(); + if (!target.has(key)) { + target.set(key, { + symbol, + symbolId: entry.symbolId || null, + description: entry.description || null, + netCashFlowCad: 0, + incomeCad: 0, + tradeCad: 0, + investedCad: 0, + activityCount: 0, + }); + } + const bucket = target.get(key); + bucket.netCashFlowCad += Number(entry.netCashFlowCad) || 0; + bucket.incomeCad += Number(entry.incomeCad) || 0; + bucket.tradeCad += Number(entry.tradeCad) || 0; + bucket.investedCad += Number(entry.investedCad) || 0; + bucket.activityCount += Number(entry.activityCount) || 0; + if (!bucket.description && entry.description) { + bucket.description = entry.description; + } + if (!bucket.symbolId && entry.symbolId) { + bucket.symbolId = entry.symbolId; + } + }); +} + +function finalizeSymbolBreakdown(map) { + if (!(map instanceof Map)) { + return []; + } + return Array.from(map.values()) + .filter((entry) => { + const net = Number(entry.netCashFlowCad) || 0; + const income = Number(entry.incomeCad) || 0; + const invested = Number(entry.investedCad) || 0; + return ( + entry.activityCount > 0 && + (Math.abs(net) >= 0.01 || Math.abs(income) >= 0.01 || invested >= 0.01) + ); + }) + .sort((a, b) => Math.abs(b.netCashFlowCad) - Math.abs(a.netCashFlowCad)); +} + +function isFundingActivity(activity) { + if (!activity || typeof activity !== 'object') { + return false; + } + const type = typeof activity.type === 'string' ? activity.type : ''; + const action = typeof activity.action === 'string' ? activity.action : ''; + const description = typeof activity.description === 'string' ? activity.description : ''; + return ( + FUNDING_TYPE_REGEX.test(type) || + FUNDING_TYPE_REGEX.test(action) || + FUNDING_TYPE_REGEX.test(description) + ); +} + +module.exports = { + DIVIDEND_SYMBOL_OVERRIDES, + normalizeBreakdownSymbol, + resolveActivitySymbolForBreakdown, + classifyActivityForSymbolBreakdown, + accumulateSymbolBreakdown, + finalizeSymbolBreakdown, + SYMBOL_ALIAS_MAP, + FUNDING_TYPE_REGEX, + SYMBOL_INCOME_REGEX, + SYMBOL_TRADE_REGEX, + isFundingActivity, +}; diff --git a/server/test/symbolBreakdown.test.js b/server/test/symbolBreakdown.test.js new file mode 100644 index 0000000..7fdd7b3 --- /dev/null +++ b/server/test/symbolBreakdown.test.js @@ -0,0 +1,125 @@ +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + DIVIDEND_SYMBOL_OVERRIDES, + normalizeBreakdownSymbol, + resolveActivitySymbolForBreakdown, + classifyActivityForSymbolBreakdown, + accumulateSymbolBreakdown, + finalizeSymbolBreakdown, +} = require('../src/symbolBreakdown'); + +test('normalizeBreakdownSymbol applies overrides, aliases, and normalization', () => { + assert.equal(normalizeBreakdownSymbol('N003056'), 'NVDA'); + assert.equal(normalizeBreakdownSymbol('.ENB'), 'ENB'); + assert.equal(normalizeBreakdownSymbol('enb.to'), 'ENB'); + assert.equal(normalizeBreakdownSymbol('qqqm'), 'QQQ'); + assert.equal(normalizeBreakdownSymbol(' '), null); + assert.equal(normalizeBreakdownSymbol(null), null); +}); + +test('DIVIDEND_SYMBOL_OVERRIDES captures known Questrade dividend aliases', () => { + const expected = new Map([ + ['N003056', 'NVDA'], + ['A033916', 'ASML'], + ['H082968', 'QQQ'], + ]); + for (const [alias, symbol] of expected.entries()) { + assert.equal(DIVIDEND_SYMBOL_OVERRIDES.get(alias), symbol); + } +}); + +test('resolveActivitySymbolForBreakdown prefers explicit symbol data and trims descriptions', () => { + const activity = { + symbol: 'n003056', + symbolId: 12345, + symbolDescription: ' Nvidia Corp ', + description: ' Dividend ' + }; + const resolved = resolveActivitySymbolForBreakdown(activity); + assert.deepEqual(resolved, { + symbol: 'NVDA', + symbolId: '12345', + description: 'Nvidia Corp', + }); + + const fallback = resolveActivitySymbolForBreakdown({ + symbol: '', + symbolId: 'QQQM', + description: 'Trailing description', + }); + assert.deepEqual(fallback, { + symbol: 'QQQ', + symbolId: 'QQQM', + description: 'Trailing description', + }); +}); + +test('classifyActivityForSymbolBreakdown distinguishes funding, income, and trades', () => { + const income = classifyActivityForSymbolBreakdown({ description: 'Dividend paid' }); + assert.equal(income, 'income'); + + const trade = classifyActivityForSymbolBreakdown({ type: 'Trade', action: 'Buy' }); + assert.equal(trade, 'trade'); + + const funding = classifyActivityForSymbolBreakdown({ type: 'Deposit' }); + assert.equal(funding, null); + + const unknown = classifyActivityForSymbolBreakdown({ description: 'Service fee' }); + assert.equal(unknown, null); +}); + +test('accumulateSymbolBreakdown merges entries and finalize filters insignificant buckets', () => { + const map = new Map(); + accumulateSymbolBreakdown(map, [ + { symbol: 'NVDA', netCashFlowCad: 100, incomeCad: 80, tradeCad: 20, investedCad: 0, activityCount: 1 }, + { symbol: 'nvda', netCashFlowCad: -40, incomeCad: 0, tradeCad: -40, investedCad: 40, activityCount: 1 }, + { symbol: 'QQQM', netCashFlowCad: 0.005, incomeCad: 0, tradeCad: 0, investedCad: 0, activityCount: 1 }, + { symbol: 'TSM', netCashFlowCad: 50, incomeCad: 50, tradeCad: 0, investedCad: 0, activityCount: 1 }, + null, + {}, + ]); + + const results = finalizeSymbolBreakdown(map); + assert.equal(results.length, 2); + assert.deepEqual(results[0], { + symbol: 'NVDA', + symbolId: null, + description: null, + netCashFlowCad: 60, + incomeCad: 80, + tradeCad: -20, + investedCad: 40, + activityCount: 2, + }); + assert.deepEqual(results[1], { + symbol: 'TSM', + symbolId: null, + description: null, + netCashFlowCad: 50, + incomeCad: 50, + tradeCad: 0, + investedCad: 0, + activityCount: 1, + }); + assert.ok(results.every((entry) => entry.symbol !== 'QQQM')); +}); + +describe('finalizeSymbolBreakdown sorting behaviour', () => { + test('sorts by absolute net cash flow descending', () => { + const map = new Map(); + accumulateSymbolBreakdown(map, [ + { symbol: 'A', netCashFlowCad: -10, incomeCad: 0, tradeCad: -10, investedCad: 10, activityCount: 1 }, + { symbol: 'B', netCashFlowCad: 200, incomeCad: 0, tradeCad: 200, investedCad: 0, activityCount: 1 }, + { symbol: 'C', netCashFlowCad: -50, incomeCad: 0, tradeCad: -50, investedCad: 50, activityCount: 1 }, + ]); + const results = finalizeSymbolBreakdown(map); + assert.deepEqual( + results.map((entry) => entry.symbol), + ['B', 'C', 'A'] + ); + }); +});