diff --git a/server/src/index.js b/server/src/index.js index 2d6d828..2a75188 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -85,6 +85,8 @@ function ensureYahooFinanceClient() { const QUOTE_CACHE_TTL_SECONDS = 60; const quoteCache = new NodeCache({ stdTTL: QUOTE_CACHE_TTL_SECONDS, checkperiod: 120 }); +let customPriceHistoryFetcher = null; + const BENCHMARK_CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour const benchmarkReturnCache = new Map(); const interestRateCache = new Map(); @@ -3395,6 +3397,95 @@ async function computeDailyNetDeposits(activityContext, account, accountKey) { const LEDGER_QUANTITY_EPSILON = 1e-8; +function extractActivityPriceHint(activity) { + if (!activity || typeof activity !== 'object') { + return null; + } + + const directPrice = Number(activity.price); + if (Number.isFinite(directPrice) && directPrice > 0) { + return Math.abs(directPrice); + } + + const quantity = Number(activity.quantity); + if (!Number.isFinite(quantity) || Math.abs(quantity) < LEDGER_QUANTITY_EPSILON) { + return null; + } + + const grossAmount = Number(activity.grossAmount); + if (Number.isFinite(grossAmount) && Math.abs(grossAmount) >= CASH_FLOW_EPSILON / 10) { + const derived = Math.abs(grossAmount) / Math.abs(quantity); + if (Number.isFinite(derived) && derived > 0) { + return derived; + } + } + + const netAmount = Number(activity.netAmount); + if (Number.isFinite(netAmount) && Math.abs(netAmount) >= CASH_FLOW_EPSILON / 10) { + const derived = Math.abs(netAmount) / Math.abs(quantity); + if (Number.isFinite(derived) && derived > 0) { + return derived; + } + } + + const amountInfo = resolveActivityAmount(activity); + if ( + amountInfo && + Number.isFinite(amountInfo.amount) && + Math.abs(amountInfo.amount) >= CASH_FLOW_EPSILON / 10 + ) { + const derived = Math.abs(amountInfo.amount) / Math.abs(quantity); + if (Number.isFinite(derived) && derived > 0) { + return derived; + } + } + + return null; +} + +function buildPriceSeriesFromHints(hints, dateKeys) { + if (!Array.isArray(hints) || !Array.isArray(dateKeys) || dateKeys.length === 0) { + return new Map(); + } + + const entriesByDate = new Map(); + hints.forEach((hint) => { + if (!hint) { + return; + } + const price = Number(hint.price); + if (!Number.isFinite(price) || price <= 0) { + return; + } + let timestamp = hint.timestamp; + if (!(timestamp instanceof Date) || Number.isNaN(timestamp.getTime())) { + const parsed = typeof hint.dateKey === 'string' ? parseDateOnlyString(hint.dateKey) : null; + timestamp = parsed instanceof Date && !Number.isNaN(parsed.getTime()) ? parsed : null; + } + if (!(timestamp instanceof Date) || Number.isNaN(timestamp.getTime())) { + return; + } + const normalized = new Date( + Date.UTC(timestamp.getUTCFullYear(), timestamp.getUTCMonth(), timestamp.getUTCDate()) + ); + const dateKey = formatDateOnly(normalized); + if (!dateKey) { + return; + } + const existing = entriesByDate.get(dateKey); + if (!existing || normalized > existing.date) { + entriesByDate.set(dateKey, { date: normalized, price }); + } + }); + + if (!entriesByDate.size) { + return new Map(); + } + + const normalizedHistory = Array.from(entriesByDate.values()).sort((a, b) => a.date - b.date); + return buildDailyPriceSeries(normalizedHistory, dateKeys); +} + function adjustHolding(holdings, symbol, delta) { if (!symbol || !Number.isFinite(delta)) { return; @@ -3549,6 +3640,7 @@ async function computeTotalPnlSeries(login, account, perAccountCombinedBalances, const processedActivities = []; const symbolIds = new Set(); const symbolMeta = new Map(); + const priceHintsBySymbol = new Map(); const rawActivities = Array.isArray(activityContext.activities) ? activityContext.activities : []; rawActivities.forEach((activity) => { @@ -3575,6 +3667,14 @@ async function computeTotalPnlSeries(login, account, perAccountCombinedBalances, return; } + if (!priceHintsBySymbol.has(symbol)) { + priceHintsBySymbol.set(symbol, []); + } + const priceHint = extractActivityPriceHint(activity); + if (Number.isFinite(priceHint) && priceHint > 0) { + priceHintsBySymbol.get(symbol).push({ price: priceHint, timestamp, dateKey }); + } + if (!symbolMeta.has(symbol)) { symbolMeta.set(symbol, { symbolId: Number.isFinite(symbolId) && symbolId > 0 ? symbolId : null, @@ -3631,16 +3731,36 @@ async function computeTotalPnlSeries(login, account, perAccountCombinedBalances, } if (!history) { try { - history = await fetchSymbolPriceHistory(symbol, startKey, endKey); + const useCustomFetcher = typeof customPriceHistoryFetcher === 'function'; + const fetcher = useCustomFetcher ? customPriceHistoryFetcher : fetchSymbolPriceHistory; + history = await fetcher(symbol, startKey, endKey); } catch (priceError) { history = null; } - if (history && cacheKey) { + const shouldCache = !customPriceHistoryFetcher && cacheKey; + if (history && shouldCache) { setCachedPriceHistory(cacheKey, history); } } - if (Array.isArray(history)) { - priceSeriesMap.set(symbol, buildDailyPriceSeries(history, dateKeys)); + let series = null; + if (Array.isArray(history) && history.length > 0) { + series = buildDailyPriceSeries(history, dateKeys); + } + const hints = priceHintsBySymbol.get(symbol); + const hintSeries = + Array.isArray(hints) && hints.length > 0 ? buildPriceSeriesFromHints(hints, dateKeys) : null; + + if (series && series.size > 0) { + if (hintSeries && hintSeries.size > 0) { + hintSeries.forEach((price, dateKey) => { + if (!Number.isFinite(series.get(dateKey))) { + series.set(dateKey, price); + } + }); + } + priceSeriesMap.set(symbol, series); + } else if (hintSeries && hintSeries.size > 0) { + priceSeriesMap.set(symbol, hintSeries); } else { priceSeriesMap.set(symbol, new Map()); missingPriceSymbols.add(symbol); @@ -5522,6 +5642,14 @@ function getLoginById(loginId) { return loginsById[loginId] || null; } +function setPriceHistoryFetcherForTests(fetcher) { + if (typeof fetcher === 'function') { + customPriceHistoryFetcher = fetcher; + } else { + customPriceHistoryFetcher = null; + } +} + module.exports = { app, computeTotalPnlSeries, @@ -5540,6 +5668,7 @@ module.exports = { summarizeAccountBalances, getAllLogins, getLoginById, + __setPriceHistoryFetcherForTests: setPriceHistoryFetcherForTests, }; diff --git a/server/test/totalPnlSeries.test.js b/server/test/totalPnlSeries.test.js index a89e82a..99c9586 100644 --- a/server/test/totalPnlSeries.test.js +++ b/server/test/totalPnlSeries.test.js @@ -1,11 +1,39 @@ const test = require('node:test'); const assert = require('node:assert/strict'); +const axios = require('axios'); + +process.env.FRED_API_KEY = process.env.FRED_API_KEY || 'dummy-test-key'; + +const MOCK_FX_OBSERVATIONS = [ + { date: '2025-01-02', value: '1.34' }, + { date: '2025-01-03', value: '1.34' }, + { date: '2025-01-10', value: '1.34' }, + { date: '2025-01-15', value: '1.34' }, + { date: '2025-01-16', value: '1.34' }, + { date: '2025-05-18', value: '1.33' }, + { date: '2025-05-19', value: '1.33' }, + { date: '2025-05-20', value: '1.33' }, + { date: '2025-05-21', value: '1.33' }, +]; + +function mockFxRequests() { + return test.mock.method(axios, 'get', async (url) => { + if (typeof url === 'string' && url.includes('stlouisfed')) { + return { data: { observations: MOCK_FX_OBSERVATIONS } }; + } + return { data: {} }; + }); +} const { computeTotalPnlSeries, + __setPriceHistoryFetcherForTests, } = require('../src/index.js'); test('computeTotalPnlSeries handles cash-only activities', async () => { + const axiosMock = mockFxRequests(); + + try { const account = { id: 'TEST-ACCOUNT', }; @@ -71,35 +99,130 @@ test('computeTotalPnlSeries handles cash-only activities', async () => { }, }; - const result = await computeTotalPnlSeries( - { id: 'login-1' }, - account, - balances, - { activityContext } - ); - - assert.ok(result, 'Expected series result'); - assert.equal(result.accountId, account.id); - assert.ok(Array.isArray(result.points) && result.points.length > 0, 'Expected daily points'); - - const firstPoint = result.points[0]; - assert.equal(firstPoint.date, '2025-01-02'); - assert.ok(Math.abs(firstPoint.cumulativeNetDepositsCad - 1000) < 1e-6); - assert.ok(Math.abs(firstPoint.totalPnlCad - 0) < 1e-6); - - const lastPoint = result.points[result.points.length - 1]; - assert.equal(lastPoint.date, '2025-01-16'); - assert.ok(Math.abs(lastPoint.cumulativeNetDepositsCad - 975) < 1e-6); - assert.ok(Math.abs(lastPoint.totalPnlCad - 75) < 1e-6); - assert.ok(Math.abs(lastPoint.equityCad - 1050) < 1e-6); - - const profitPoint = result.points.find((point) => point.date === '2025-01-10'); - assert.ok(profitPoint, 'Expected profit date entry'); - assert.ok(Math.abs(profitPoint.totalPnlCad - 75) < 1e-6); - - assert.ok(Math.abs(result.summary.totalPnlCad - 75) < 1e-6); - assert.ok(Math.abs(result.summary.totalEquityCad - 1050) < 1e-6); - assert.ok(Math.abs(result.summary.netDepositsCad - 975) < 1e-6); - - assert.ok(!result.issues, 'Expected no issues for cash-only scenario'); + const result = await computeTotalPnlSeries( + { id: 'login-1' }, + account, + balances, + { activityContext } + ); + + assert.ok(result, 'Expected series result'); + assert.equal(result.accountId, account.id); + assert.ok(Array.isArray(result.points) && result.points.length > 0, 'Expected daily points'); + + const firstPoint = result.points[0]; + assert.equal(firstPoint.date, '2025-01-02'); + assert.ok(Math.abs(firstPoint.cumulativeNetDepositsCad - 1000) < 1e-6); + assert.ok(Math.abs(firstPoint.totalPnlCad - 0) < 1e-6); + + const lastPoint = result.points[result.points.length - 1]; + assert.equal(lastPoint.date, '2025-01-16'); + assert.ok(Math.abs(lastPoint.cumulativeNetDepositsCad - 975) < 1e-6); + assert.ok(Math.abs(lastPoint.totalPnlCad - 75) < 1e-6); + assert.ok(Math.abs(lastPoint.equityCad - 1050) < 1e-6); + + const profitPoint = result.points.find((point) => point.date === '2025-01-10'); + assert.ok(profitPoint, 'Expected profit date entry'); + assert.ok(Math.abs(profitPoint.totalPnlCad - 75) < 1e-6); + + assert.ok(Math.abs(result.summary.totalPnlCad - 75) < 1e-6); + assert.ok(Math.abs(result.summary.totalEquityCad - 1050) < 1e-6); + assert.ok(Math.abs(result.summary.netDepositsCad - 975) < 1e-6); + + assert.ok(!result.issues, 'Expected no issues for cash-only scenario'); + } finally { + axiosMock.mock.restore(); + } +}); + +test('computeTotalPnlSeries uses activity price hints when history is unavailable', async () => { + __setPriceHistoryFetcherForTests(() => []); + const axiosMock = mockFxRequests(); + + try { + const account = { + id: 'TEST-HINTS', + number: 'TEST-HINTS', + }; + + const now = new Date('2025-05-21T00:00:00Z'); + + const activityContext = { + accountId: account.id, + accountKey: account.id, + accountNumber: account.id, + earliestFunding: new Date('2025-05-18T00:00:00Z'), + crawlStart: new Date('2025-05-18T00:00:00Z'), + now, + nowIsoString: now.toISOString(), + activities: [ + { + tradeDate: '2025-05-18T09:00:00.000000-04:00', + transactionDate: '2025-05-18T09:00:00.000000-04:00', + settlementDate: '2025-05-18T09:00:00.000000-04:00', + type: 'Deposits', + action: 'DEP', + currency: 'CAD', + netAmount: 4000, + grossAmount: 4000, + symbol: '', + symbolId: 0, + }, + { + tradeDate: '2025-05-19T13:30:00.000000-04:00', + transactionDate: '2025-05-19T13:30:00.000000-04:00', + settlementDate: '2025-05-21T00:00:00.000000-04:00', + type: 'Trades', + action: 'BUY', + currency: 'CAD', + netAmount: -4000, + grossAmount: -4000, + quantity: 100, + price: 40, + symbol: 'PRIVATECO', + symbolId: 987654, + }, + ], + fingerprint: 'hints-fingerprint', + }; + + const balances = { + [account.id]: { + combined: { + CAD: { + totalEquity: 4050, + }, + }, + }, + }; + + const result = await computeTotalPnlSeries( + { id: 'login-hints' }, + account, + balances, + { activityContext } + ); + + assert.ok(result, 'Expected series result'); + assert.equal(result.accountId, account.id); + assert.ok(Array.isArray(result.points) && result.points.length > 0, 'Expected daily points'); + + const buyPoint = result.points.find((point) => point.date === '2025-05-19'); + assert.ok(buyPoint, 'Expected entry for trade date'); + assert.ok( + Math.abs(buyPoint.totalPnlCad) < 1e-4, + 'Activity price hint should keep P&L near zero after the trade' + ); + + assert.ok(result.summary); + assert.ok( + Math.abs(result.summary.totalPnlCad - 50) < 1e-3, + 'Expected summary total P&L to reflect balance-derived gain' + ); + + assert.ok(!result.missingPriceSymbols, 'Expected price hints to avoid missing symbol flag'); + } finally { + axiosMock.mock.restore(); + __setPriceHistoryFetcherForTests(null); + } });