Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 69 additions & 5 deletions server/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1923,6 +1923,62 @@ function normalizeCurrency(code) {
return code.trim().toUpperCase();
}

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

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() {
Expand Down Expand Up @@ -3228,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',
Expand Down Expand Up @@ -3603,12 +3660,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';
}
Expand Down
241 changes: 241 additions & 0 deletions server/test/totalPnlSeries.test.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -103,3 +109,238 @@ 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');
});

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