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']
+ );
+ });
+});