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
137 changes: 133 additions & 4 deletions server/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -5540,6 +5668,7 @@ module.exports = {
summarizeAccountBalances,
getAllLogins,
getLoginById,
__setPriceHistoryFetcherForTests: setPriceHistoryFetcherForTests,
};


Expand Down
185 changes: 154 additions & 31 deletions server/test/totalPnlSeries.test.js
Original file line number Diff line number Diff line change
@@ -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',
};
Expand Down Expand Up @@ -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);
}
});