From c827a10489d776be389471e010ae707ed7cfb67c Mon Sep 17 00:00:00 2001 From: Daniel Bigham Date: Sat, 11 Oct 2025 16:52:16 -0400 Subject: [PATCH 1/2] Fix USD symbol currency detection in total P&L --- server/src/index.js | 37 ++++++++- server/test/totalPnlSeries.test.js | 124 +++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 4 deletions(-) diff --git a/server/src/index.js b/server/src/index.js index 2d6d828..fdda67f 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -1923,6 +1923,28 @@ function normalizeCurrency(code) { return code.trim().toUpperCase(); } +const CAD_SYMBOL_SUFFIXES = ['.TO', '.TSX', '.TSXV', '.NE', '.NEO', '.CN', '.CA', '.V']; + +function inferSymbolCurrency(symbol, currentCurrency) { + if (typeof symbol !== 'string') { + return null; + } + const trimmed = symbol.trim().toUpperCase(); + if (!trimmed) { + return null; + } + + if (CAD_SYMBOL_SUFFIXES.some((suffix) => trimmed.endsWith(suffix))) { + return 'CAD'; + } + + if (/^[A-Z]{1,5}$/.test(trimmed)) { + return 'USD'; + } + + return null; +} + const usdCadRateCache = new Map(); async function fetchLatestUsdToCadRate() { @@ -3603,12 +3625,19 @@ async function computeTotalPnlSeries(login, account, perAccountCombinedBalances, } for (const [symbol, meta] of symbolMeta.entries()) { - if (!meta.currency && meta.symbolId && symbolDetails && symbolDetails[meta.symbolId]) { - const detailCurrency = normalizeCurrency(symbolDetails[meta.symbolId].currency); - if (detailCurrency) { - meta.currency = detailCurrency; + const detail = meta.symbolId && symbolDetails ? symbolDetails[meta.symbolId] : null; + const detailCurrency = detail ? normalizeCurrency(detail.currency) : null; + if (detailCurrency) { + meta.currency = detailCurrency; + } + + if (!meta.currency || (meta.currency === 'CAD' && detailCurrency !== 'CAD')) { + const inferredCurrency = inferSymbolCurrency(symbol, meta.currency); + if (inferredCurrency) { + meta.currency = inferredCurrency; } } + if (!meta.currency) { meta.currency = 'CAD'; } diff --git a/server/test/totalPnlSeries.test.js b/server/test/totalPnlSeries.test.js index a89e82a..ce9a49a 100644 --- a/server/test/totalPnlSeries.test.js +++ b/server/test/totalPnlSeries.test.js @@ -1,5 +1,11 @@ const test = require('node:test'); const assert = require('node:assert/strict'); +const axios = require('axios'); +const yahooFinance = require('yahoo-finance2').default; + +if (!process.env.FRED_API_KEY) { + process.env.FRED_API_KEY = 'TEST_KEY'; +} const { computeTotalPnlSeries, @@ -103,3 +109,121 @@ test('computeTotalPnlSeries handles cash-only activities', async () => { assert.ok(!result.issues, 'Expected no issues for cash-only scenario'); }); + +test('computeTotalPnlSeries resolves USD securities when activities report CAD currency', async (t) => { + const originalFredKey = process.env.FRED_API_KEY; + process.env.FRED_API_KEY = 'TEST_KEY'; + + t.mock.method(axios, 'get', async (url) => { + if (typeof url === 'string' && url.startsWith('https://api.stlouisfed.org/fred/series/observations')) { + return { + data: { + observations: [ + { date: '2025-09-01', value: '1.35' }, + { date: '2025-09-02', value: '1.35' }, + ], + }, + }; + } + if (typeof url === 'string' && url.startsWith('https://login.questrade.com/oauth2/token')) { + return { + data: { + access_token: 'test-access', + api_server: 'https://mock.api/', + expires_in: 1800, + refresh_token: 'test-refresh', + }, + }; + } + throw new Error(`Unexpected axios.get url: ${url}`); + }); + + t.mock.method(axios, 'request', async (config) => { + if (config && typeof config.url === 'string' && config.url.startsWith('https://mock.api/v1/symbols')) { + const ids = config.params && config.params.ids ? String(config.params.ids) : ''; + const entries = ids.split(',').filter(Boolean).map((id) => ({ symbolId: Number(id), currency: 'USD' })); + return { data: { symbols: entries }, headers: {} }; + } + throw new Error(`Unexpected axios.request url: ${config && config.url}`); + }); + + t.mock.method(yahooFinance, 'historical', async () => { + return [ + { date: new Date('2025-09-01T00:00:00Z'), adjClose: 100 }, + ]; + }); + + t.after(() => { + process.env.FRED_API_KEY = originalFredKey; + t.mock.restoreAll(); + }); + + const account = { id: 'USD-TEST' }; + const now = new Date('2025-09-02T00:00:00Z'); + + const activityContext = { + accountId: account.id, + accountKey: account.id, + accountNumber: account.id, + earliestFunding: new Date('2025-09-01T00:00:00Z'), + crawlStart: new Date('2025-09-01T00:00:00Z'), + now, + nowIsoString: now.toISOString(), + activities: [ + { + tradeDate: '2025-09-01T00:00:00.000000-04:00', + transactionDate: '2025-09-01T00:00:00.000000-04:00', + settlementDate: '2025-09-01T00:00:00.000000-04:00', + type: 'Deposits', + action: 'CON', + currency: 'CAD', + netAmount: 1350, + grossAmount: 1350, + symbol: '', + symbolId: 0, + }, + { + tradeDate: '2025-09-01T00:00:00.000000-04:00', + transactionDate: '2025-09-01T00:00:00.000000-04:00', + settlementDate: '2025-09-01T00:00:00.000000-04:00', + type: 'Trades', + action: 'Buy', + currency: 'CAD', + netAmount: -1350, + grossAmount: -1350, + quantity: 10, + price: 135, + symbol: 'QQQM', + symbolId: 32621374, + }, + ], + fingerprint: 'usd-test-fingerprint', + }; + + const balances = { + [account.id]: { + combined: { + CAD: { + totalEquity: 1350, + }, + }, + }, + }; + + const result = await computeTotalPnlSeries( + { id: 'login-1', refreshToken: 'test-refresh' }, + account, + balances, + { activityContext, applyAccountCagrStartDate: false } + ); + + assert.ok(result, 'Expected series result'); + assert.ok(Array.isArray(result.points) && result.points.length > 0, 'Expected daily points'); + assert.ok(Math.abs(result.summary.totalPnlCad || 0) < 1e-6, 'Expected zero P&L when FX adjusted'); + assert.ok(Math.abs(result.summary.netDepositsCad - 1350) < 1e-6, 'Expected deposits to equal 1350 CAD'); + assert.ok(Math.abs(result.summary.totalEquityCad - 1350) < 1e-6, 'Expected equity to equal 1350 CAD'); + + const firstPoint = result.points[0]; + assert.equal(firstPoint.date, '2025-09-01'); + assert.ok(Math.abs(firstPoint.totalPnlCad || 0) < 1e-6, 'Expected no loss on first day'); +}); From df640bddd72d5263aaa4ed00c3576f03411b5ed9 Mon Sep 17 00:00:00 2001 From: Daniel Bigham Date: Sat, 11 Oct 2025 17:05:11 -0400 Subject: [PATCH 2/2] Normalize Yahoo symbols for class share tickers --- server/src/index.js | 37 ++++++++- server/test/totalPnlSeries.test.js | 117 +++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/server/src/index.js b/server/src/index.js index fdda67f..d092711 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -1924,6 +1924,11 @@ function normalizeCurrency(code) { } const CAD_SYMBOL_SUFFIXES = ['.TO', '.TSX', '.TSXV', '.NE', '.NEO', '.CN', '.CA', '.V']; +const YAHOO_SYMBOL_OVERRIDES = { + 'BRK.B': 'BRK-B', + 'BRK.A': 'BRK-A', + 'BF.B': 'BF-B', +}; function inferSymbolCurrency(symbol, currentCurrency) { if (typeof symbol !== 'string') { @@ -1945,6 +1950,35 @@ function inferSymbolCurrency(symbol, currentCurrency) { return null; } +function resolveYahooPriceHistorySymbol(symbol) { + if (typeof symbol !== 'string') { + return null; + } + const trimmed = symbol.trim(); + if (!trimmed) { + return null; + } + + const upper = trimmed.toUpperCase(); + if (YAHOO_SYMBOL_OVERRIDES[upper]) { + return YAHOO_SYMBOL_OVERRIDES[upper]; + } + + if (CAD_SYMBOL_SUFFIXES.some((suffix) => upper.endsWith(suffix))) { + return upper; + } + + if (/^[A-Z]{1,5}-[A-Z]{1,3}$/.test(upper)) { + return upper; + } + + if (/^[A-Z]{1,5}\.[A-Z]{1,3}$/.test(upper)) { + return upper.replace('.', '-'); + } + + return upper; +} + const usdCadRateCache = new Map(); async function fetchLatestUsdToCadRate() { @@ -3250,7 +3284,8 @@ async function fetchSymbolPriceHistory(symbol, startDateKey, endDateKey) { const exclusiveEnd = addDays(endDate, 1) || new Date(endDate.getTime() + DAY_IN_MS); const finance = ensureYahooFinanceClient(); - const history = await finance.historical(symbol, { + const yahooSymbol = resolveYahooPriceHistorySymbol(symbol) || symbol; + const history = await finance.historical(yahooSymbol, { period1: startDate, period2: exclusiveEnd, interval: '1d', diff --git a/server/test/totalPnlSeries.test.js b/server/test/totalPnlSeries.test.js index ce9a49a..216bcb7 100644 --- a/server/test/totalPnlSeries.test.js +++ b/server/test/totalPnlSeries.test.js @@ -227,3 +227,120 @@ test('computeTotalPnlSeries resolves USD securities when activities report CAD c assert.equal(firstPoint.date, '2025-09-01'); assert.ok(Math.abs(firstPoint.totalPnlCad || 0) < 1e-6, 'Expected no loss on first day'); }); + +test('computeTotalPnlSeries normalizes Yahoo symbols for class share tickers', async (t) => { + const originalFredKey = process.env.FRED_API_KEY; + process.env.FRED_API_KEY = 'TEST_KEY'; + + const seenSymbols = []; + + t.mock.method(axios, 'get', async (url) => { + if (typeof url === 'string' && url.startsWith('https://api.stlouisfed.org/fred/series/observations')) { + return { + data: { + observations: [ + { date: '2025-09-01', value: '1.35' }, + { date: '2025-09-02', value: '1.35' }, + ], + }, + }; + } + if (typeof url === 'string' && url.startsWith('https://login.questrade.com/oauth2/token')) { + return { + data: { + access_token: 'test-access', + api_server: 'https://mock.api/', + expires_in: 1800, + refresh_token: 'test-refresh', + }, + }; + } + throw new Error(`Unexpected axios.get url: ${url}`); + }); + + t.mock.method(axios, 'request', async (config) => { + if (config && typeof config.url === 'string' && config.url.startsWith('https://mock.api/v1/symbols')) { + const ids = config.params && config.params.ids ? String(config.params.ids) : ''; + const entries = ids.split(',').filter(Boolean).map((id) => ({ symbolId: Number(id), currency: 'USD' })); + return { data: { symbols: entries }, headers: {} }; + } + throw new Error(`Unexpected axios.request url: ${config && config.url}`); + }); + + t.mock.method(yahooFinance, 'historical', async (symbol) => { + seenSymbols.push(symbol); + return [ + { date: new Date('2025-09-01T00:00:00Z'), adjClose: 400 }, + ]; + }); + + t.after(() => { + process.env.FRED_API_KEY = originalFredKey; + t.mock.restoreAll(); + }); + + const account = { id: 'CLASS-SHARE-TEST' }; + const now = new Date('2025-09-02T00:00:00Z'); + + const activityContext = { + accountId: account.id, + accountKey: account.id, + accountNumber: account.id, + earliestFunding: new Date('2025-09-01T00:00:00Z'), + crawlStart: new Date('2025-09-01T00:00:00Z'), + now, + nowIsoString: now.toISOString(), + activities: [ + { + tradeDate: '2025-09-01T00:00:00.000000-04:00', + transactionDate: '2025-09-01T00:00:00.000000-04:00', + settlementDate: '2025-09-01T00:00:00.000000-04:00', + type: 'Deposits', + action: 'CON', + currency: 'CAD', + netAmount: 540, + grossAmount: 540, + symbol: '', + symbolId: 0, + }, + { + tradeDate: '2025-09-01T00:00:00.000000-04:00', + transactionDate: '2025-09-01T00:00:00.000000-04:00', + settlementDate: '2025-09-01T00:00:00.000000-04:00', + type: 'Trades', + action: 'Buy', + currency: 'CAD', + netAmount: -540, + grossAmount: -540, + quantity: 1, + price: 540, + symbol: 'BRK.B', + symbolId: 987654, + }, + ], + fingerprint: 'class-share-test', + }; + + const balances = { + [account.id]: { + combined: { + CAD: { + totalEquity: 540, + }, + }, + }, + }; + + const result = await computeTotalPnlSeries( + { id: 'login-1', refreshToken: 'test-refresh' }, + account, + balances, + { activityContext, applyAccountCagrStartDate: false } + ); + + assert.ok(result, 'Expected series result'); + assert.deepEqual(seenSymbols, ['BRK-B']); + assert.ok(Math.abs(result.summary.totalPnlCad || 0) < 1e-6, 'Expected zero P&L when FX adjusted'); + assert.ok(Math.abs(result.summary.totalEquityCad - 540) < 1e-6, 'Expected equity to equal 540 CAD'); + assert.ok(Math.abs(result.summary.netDepositsCad - 540) < 1e-6, 'Expected deposits to equal 540 CAD'); +});