diff --git a/client/src/App.jsx b/client/src/App.jsx index ca72e01..4382c1b 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import AccountSelector from './components/AccountSelector'; -import SummaryMetrics from './components/SummaryMetrics'; +import SummaryMetrics, { resolveScopedAmount } from './components/SummaryMetrics'; import PositionsTable from './components/PositionsTable'; import { getSummary, getQqqTemperature } from './api/questrade'; import usePersistentState from './hooks/usePersistentState'; @@ -223,7 +223,10 @@ function buildClipboardSummary({ lines.push(`Total amount: ${formatMoney(totalAmount)}`); lines.push(`Today's P&L: ${formatSignedMoney(pnl?.dayPnl)}`); lines.push(`Open P&L: ${formatSignedMoney(pnl?.openPnl)}`); - lines.push(`Total P&L: ${formatSignedMoney(pnl?.totalPnl)}`); + const totalPnlAmount = resolveScopedAmount(pnl?.totalPnlBreakdown, currencyOption) ?? pnl?.totalPnl ?? null; + lines.push(`Total P&L: ${formatSignedMoney(totalPnlAmount)}`); + const netDepositAmount = resolveScopedAmount(pnl?.netDeposits, currencyOption); + lines.push(`Net deposits: ${formatSignedMoney(netDepositAmount ?? null)}`); lines.push(`Total equity: ${formatMoney(balances?.totalEquity)}`); lines.push(`Market value: ${formatMoney(balances?.marketValue)}`); lines.push(`Cash: ${formatMoney(balances?.cash)}`); @@ -431,7 +434,14 @@ function resolveDisplayTotalEquity(balances) { return null; } -const ZERO_PNL = Object.freeze({ dayPnl: 0, openPnl: 0, totalPnl: 0 }); +const ZERO_PNL = Object.freeze({ + dayPnl: 0, + openPnl: 0, + totalPnl: 0, + totalPnlBreakdown: null, + totalEquityBreakdown: null, + netDeposits: null, +}); function isFiniteNumber(value) { return typeof value === 'number' && Number.isFinite(value); @@ -1234,27 +1244,44 @@ export default function App() { return positionPnlSummaries.perCurrency[activeCurrency.currency] || ZERO_PNL; }, [activeCurrency, positionPnlSummaries, currencyRates, baseCurrency]); + const rawServerPnl = data?.pnl || null; + const activePnl = useMemo(() => { if (!activeCurrency) { - return ZERO_PNL; + return rawServerPnl ? Object.assign({}, ZERO_PNL, rawServerPnl) : ZERO_PNL; } + const balanceEntry = balancePnlSummaries[activeCurrency.scope]?.[activeCurrency.currency] || null; const totalFromBalance = balanceEntry ? balanceEntry.totalPnl : null; const hasBalanceTotal = isFiniteNumber(totalFromBalance); + const base = rawServerPnl ? Object.assign({}, ZERO_PNL, rawServerPnl) : { ...ZERO_PNL }; + if (!balanceEntry) { - return { - dayPnl: fallbackPnl.dayPnl, - openPnl: fallbackPnl.openPnl, - totalPnl: null, - }; + base.dayPnl = fallbackPnl.dayPnl; + base.openPnl = fallbackPnl.openPnl; + base.totalPnl = hasBalanceTotal ? totalFromBalance : null; + if (base.totalPnl === null && isFiniteNumber(fallbackPnl.totalPnl)) { + base.totalPnl = fallbackPnl.totalPnl; + } + return base; } - return { - dayPnl: balanceEntry.dayPnl ?? fallbackPnl.dayPnl, - openPnl: balanceEntry.openPnl ?? fallbackPnl.openPnl, - totalPnl: hasBalanceTotal ? totalFromBalance : null, - }; - }, [activeCurrency, balancePnlSummaries, fallbackPnl]); + + const resolvedDay = balanceEntry.dayPnl ?? fallbackPnl.dayPnl; + const resolvedOpen = balanceEntry.openPnl ?? fallbackPnl.openPnl; + base.dayPnl = isFiniteNumber(resolvedDay) ? resolvedDay : fallbackPnl.dayPnl; + base.openPnl = isFiniteNumber(resolvedOpen) ? resolvedOpen : fallbackPnl.openPnl; + + if (hasBalanceTotal) { + base.totalPnl = totalFromBalance; + } else if (isFiniteNumber(fallbackPnl.totalPnl)) { + base.totalPnl = fallbackPnl.totalPnl; + } else { + base.totalPnl = null; + } + + return base; + }, [activeCurrency, balancePnlSummaries, fallbackPnl, rawServerPnl]); const heatmapMarketValue = useMemo(() => { if (activeBalances && typeof activeBalances === 'object') { diff --git a/client/src/components/SummaryMetrics.jsx b/client/src/components/SummaryMetrics.jsx index b831572..c682d04 100644 --- a/client/src/components/SummaryMetrics.jsx +++ b/client/src/components/SummaryMetrics.jsx @@ -9,6 +9,50 @@ import { formatSignedPercent, } from '../utils/formatters'; +export function resolveScopedAmount(bucket, currencyOption) { + if (!bucket || typeof bucket !== 'object' || !currencyOption) { + return null; + } + + const scope = currencyOption.scope || 'combined'; + const currency = typeof currencyOption.currency === 'string' ? currencyOption.currency.toUpperCase() : null; + if (!currency) { + return null; + } + + const sourceBucket = + scope === 'perCurrency' + ? bucket.perCurrency || bucket.currency || bucket + : bucket.combined || bucket.currency || bucket.perCurrency || bucket; + + if (!sourceBucket || typeof sourceBucket !== 'object') { + return null; + } + + if (Object.prototype.hasOwnProperty.call(sourceBucket, currency)) { + const value = sourceBucket[currency]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (value === 0) { + return 0; + } + } + + for (const [key, value] of Object.entries(sourceBucket)) { + if (typeof key === 'string' && key.toUpperCase() === currency) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (value === 0) { + return 0; + } + } + } + + return null; +} + function MetricRow({ label, value, extra, tone, className, onActivate }) { const rowClass = className ? `equity-card__metric-row ${className}` : 'equity-card__metric-row'; const interactive = typeof onActivate === 'function'; @@ -212,13 +256,18 @@ export default function SummaryMetrics({ const cash = balances?.cash ?? null; const buyingPower = balances?.buyingPower ?? null; + const scopedTotalPnl = resolveScopedAmount(pnl?.totalPnlBreakdown, currencyOption); + const totalPnlValue = scopedTotalPnl ?? pnl?.totalPnl ?? null; + const todayTone = classifyPnL(pnl?.dayPnl); const openTone = classifyPnL(pnl?.openPnl); - const totalTone = classifyPnL(pnl?.totalPnl); + const totalTone = classifyPnL(totalPnlValue); const formattedToday = formatSignedMoney(pnl?.dayPnl ?? null); const formattedOpen = formatSignedMoney(pnl?.openPnl ?? null); - const formattedTotal = formatSignedMoney(pnl?.totalPnl ?? null); + const formattedTotal = formatSignedMoney(totalPnlValue ?? null); + const netDepositAmount = resolveScopedAmount(pnl?.netDeposits, currencyOption); + const formattedNetDeposits = formatSignedMoney(netDepositAmount ?? null); const safeTotalEquity = Number.isFinite(totalEquity) ? totalEquity : null; @@ -356,6 +405,7 @@ export default function SummaryMetrics({ onActivate={onShowPnlBreakdown ? () => onShowPnlBreakdown('open') : null} /> +
@@ -395,6 +445,19 @@ SummaryMetrics.propTypes = { dayPnl: PropTypes.number, openPnl: PropTypes.number, totalPnl: PropTypes.number, + totalPnlBreakdown: PropTypes.shape({ + combined: PropTypes.objectOf(PropTypes.number), + perCurrency: PropTypes.objectOf(PropTypes.number), + }), + totalEquityBreakdown: PropTypes.shape({ + combined: PropTypes.objectOf(PropTypes.number), + perCurrency: PropTypes.objectOf(PropTypes.number), + }), + netDeposits: PropTypes.shape({ + combined: PropTypes.objectOf(PropTypes.number), + perCurrency: PropTypes.objectOf(PropTypes.number), + counts: PropTypes.objectOf(PropTypes.number), + }), }).isRequired, asOf: PropTypes.string, onRefresh: PropTypes.func, diff --git a/server/src/index.js b/server/src/index.js index 9f99e8f..87916d0 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -38,10 +38,16 @@ const requestQueue = []; let isProcessingQueue = false; let nextAvailableTime = Date.now(); +const ENABLE_QUESTRADE_API_DEBUG = process.env.QUESTRADE_API_DEBUG !== 'false'; + function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function isFiniteNumber(value) { + return typeof value === 'number' && Number.isFinite(value); +} + function updateRateLimitFromHeaders(headers) { if (!headers) { return; @@ -457,86 +463,1994 @@ async function refreshAccessToken(login) { }; tokenCache.set(getTokenCacheKey(login.id), tokenContext, cacheTtl); - if (tokenData.refresh_token && tokenData.refresh_token !== login.refreshToken) { - updateLoginRefreshToken(login, tokenData.refresh_token); + if (tokenData.refresh_token && tokenData.refresh_token !== login.refreshToken) { + updateLoginRefreshToken(login, tokenData.refresh_token); + } + + return tokenContext; +} + +async function getTokenContext(login) { + const cacheKey = getTokenCacheKey(login.id); + const cached = tokenCache.get(cacheKey); + if (cached) { + return cached; + } + return refreshAccessToken(login); +} + +async function questradeRequest(login, pathSegment, options = {}) { + if (!login) { + throw new Error('Questrade login context is required for API requests'); + } + const { method = 'GET', params, data, headers = {} } = options; + const tokenContext = await getTokenContext(login); + const url = new URL(pathSegment, tokenContext.apiServer).toString(); + + const baseConfig = { + method, + url, + params, + data, + headers: Object.assign( + { + Authorization: 'Bearer ' + tokenContext.accessToken, + }, + headers + ), + }; + + try { + if (ENABLE_QUESTRADE_API_DEBUG) { + const serializedParams = params ? JSON.stringify(params) : ''; + console.log('[Questrade][request]', method.toUpperCase(), url, serializedParams); + } + const response = await enqueueRequest(() => axios(baseConfig)); + if (ENABLE_QUESTRADE_API_DEBUG) { + console.log('[Questrade][response]', method.toUpperCase(), url, 'status', response.status); + } + return response.data; + } catch (error) { + if (error.response && error.response.status === 401) { + tokenCache.del(getTokenCacheKey(login.id)); + const freshContext = await refreshAccessToken(login); + const retryConfig = { + method, + url: new URL(pathSegment, freshContext.apiServer).toString(), + params, + data, + headers: Object.assign( + { + Authorization: 'Bearer ' + freshContext.accessToken, + }, + headers + ), + }; + if (ENABLE_QUESTRADE_API_DEBUG) { + const serializedParams = params ? JSON.stringify(params) : ''; + console.log('[Questrade][retry]', method.toUpperCase(), retryConfig.url, serializedParams); + } + const retryResponse = await enqueueRequest(() => axios(retryConfig)); + if (ENABLE_QUESTRADE_API_DEBUG) { + console.log('[Questrade][response]', method.toUpperCase(), retryConfig.url, 'status', retryResponse.status); + } + return retryResponse.data; + } + console.error( + 'Questrade API error for login ' + resolveLoginDisplay(login) + ':', + error.response ? error.response.data : error.message + ); + throw error; + } +} + +async function fetchAccounts(login) { + const data = await questradeRequest(login, '/v1/accounts'); + return data.accounts || []; +} + +async function fetchPositions(login, accountId) { + const data = await questradeRequest(login, '/v1/accounts/' + accountId + '/positions'); + return data.positions || []; +} + +async function fetchBalances(login, accountId) { + const data = await questradeRequest(login, '/v1/accounts/' + accountId + '/balances'); + return data || {}; +} + +const NET_DEPOSIT_ACTIVITY_WINDOW_DAYS = 30; +const NET_DEPOSIT_ACTIVITY_START_PADDING_DAYS = 7; +const NET_DEPOSIT_ACTIVITY_EPOCH = new Date(Date.UTC(2000, 0, 1)); +const NET_DEPOSIT_CACHE_TTL_MS = 15 * 60 * 1000; +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const NET_DEPOSIT_SEED_LOOKBACK_DAYS = 120; +const NET_DEPOSIT_SEED_LOOKAHEAD_DAYS = 180; +const NET_DEPOSIT_FALLBACK_EXPANSION_DAYS = 365; +const ACCOUNT_ACTIVITY_SEED_CACHE_TTL_MS = 6 * 60 * 60 * 1000; +const ACCOUNT_ACTIVITY_START_CACHE_TTL_MS = 6 * 60 * 60 * 1000; +const FX_RATE_CACHE_TTL_MS = 6 * 60 * 60 * 1000; +const FX_CANDLE_LOOKBACK_DAYS = 3; +const FX_CANDLE_LOOKAHEAD_DAYS = 3; +const BANK_OF_CANADA_BASE_URL = 'https://www.bankofcanada.ca/valet/observations/'; +const BANK_OF_CANADA_TIMEOUT_MS = 5000; +const BANK_OF_CANADA_LOOKBACK_DAYS = 7; + +const fxSymbolCache = new Map(); +const fxRateCache = new Map(); +const bankOfCanadaRateCache = new Map(); + +const ACCOUNT_PRIMARY_START_DATE_KEYS = [ + 'openedDate', + 'openDate', + 'createdDate', + 'creationDate', + 'createdOn', + 'fundedDate', + 'activationDate', + 'effectiveDate', + 'startDate', + 'approvedDate', + 'enrolledDate', + 'clientSince', +]; + +const ACCOUNT_SECONDARY_START_DATE_KEYS = ['statusDate']; + +function parseDateValue(value) { + if (!value) { + return null; + } + if (value instanceof Date) { + const time = value.getTime(); + return Number.isNaN(time) ? null : new Date(time); + } + if (typeof value === 'number' && Number.isFinite(value)) { + const derived = new Date(value); + return Number.isNaN(derived.getTime()) ? null : derived; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const parsed = new Date(trimmed); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + return null; +} + +function resolveTimestampFromObject(entity, preferredKeys = []) { + if (!entity || typeof entity !== 'object') { + return null; + } + + for (const key of preferredKeys) { + if (!(key in entity)) { + continue; + } + const parsed = parseDateValue(entity[key]); + if (parsed) { + return parsed; + } + } + + for (const key of Object.keys(entity)) { + if (preferredKeys.includes(key)) { + continue; + } + if (!/date|time/i.test(key)) { + continue; + } + const parsed = parseDateValue(entity[key]); + if (parsed) { + return parsed; + } + } + + return null; +} + +function inferAccountStartDate(account) { + if (!account || typeof account !== 'object') { + return null; + } + + const candidates = []; + + const collectCandidates = function (keys) { + keys.forEach(function (key) { + const parsed = parseDateValue(account[key]); + if (parsed) { + candidates.push(parsed); + } + }); + }; + + collectCandidates(ACCOUNT_PRIMARY_START_DATE_KEYS); + + if (!candidates.length) { + collectCandidates(ACCOUNT_SECONDARY_START_DATE_KEYS); + } + + if (!candidates.length) { + Object.keys(account).forEach(function (key) { + if (!/date/i.test(key)) { + return; + } + if (ACCOUNT_PRIMARY_START_DATE_KEYS.includes(key) || ACCOUNT_SECONDARY_START_DATE_KEYS.includes(key)) { + return; + } + const parsed = parseDateValue(account[key]); + if (parsed) { + candidates.push(parsed); + } + }); + } + + if (!candidates.length) { + return null; + } + + const filtered = candidates + .map(function (date) { + return Number.isNaN(date.getTime()) ? null : date; + }) + .filter(Boolean) + .filter(function (date) { + return date.getTime() >= NET_DEPOSIT_ACTIVITY_EPOCH.getTime() - 365 * 24 * 60 * 60 * 1000; + }) + .sort(function (a, b) { + return a.getTime() - b.getTime(); + }); + + if (!filtered.length) { + return null; + } + + return filtered[0]; +} + +function resolveActivityHistoryStart(candidate) { + const parsed = parseDateValue(candidate); + const baselineMs = NET_DEPOSIT_ACTIVITY_EPOCH.getTime(); + if (!parsed) { + return new Date(baselineMs); + } + const now = Date.now(); + const paddingMs = Math.max(0, NET_DEPOSIT_ACTIVITY_START_PADDING_DAYS) * 24 * 60 * 60 * 1000; + const clampedMs = Math.min(parsed.getTime(), now); + const adjustedMs = Math.max(baselineMs, clampedMs - paddingMs); + return new Date(adjustedMs); +} + +const netDepositCache = new Map(); +const accountActivitySeedCache = new Map(); +const accountActivityStartCache = new Map(); + +function getTimedCacheEntry(cache, key, ttlMs) { + if (!cache.has(key)) { + return null; + } + const entry = cache.get(key); + if (!entry) { + return null; + } + if (ttlMs > 0 && Date.now() - entry.timestamp > ttlMs) { + cache.delete(key); + return null; + } + return entry.value; +} + +function setTimedCacheEntry(cache, key, value) { + cache.set(key, { value, timestamp: Date.now() }); +} + +function getCachedAccountActivitySeed(key) { + return getTimedCacheEntry(accountActivitySeedCache, key, ACCOUNT_ACTIVITY_SEED_CACHE_TTL_MS); +} + +function setCachedAccountActivitySeed(key, value) { + setTimedCacheEntry(accountActivitySeedCache, key, value); +} + +function getCachedAccountActivityStart(key) { + return getTimedCacheEntry(accountActivityStartCache, key, ACCOUNT_ACTIVITY_START_CACHE_TTL_MS); +} + +function setCachedAccountActivityStart(key, value) { + setTimedCacheEntry(accountActivityStartCache, key, value); +} + +function getFxSymbolCacheKey(sourceCurrency, targetCurrency) { + return `${sourceCurrency || 'UNKNOWN'}:${targetCurrency || 'UNKNOWN'}`; +} + +function setCachedFxSymbol(sourceCurrency, targetCurrency, value) { + const key = getFxSymbolCacheKey(sourceCurrency, targetCurrency); + fxSymbolCache.set(key, value || null); +} + +function getCachedFxSymbol(sourceCurrency, targetCurrency) { + const key = getFxSymbolCacheKey(sourceCurrency, targetCurrency); + return fxSymbolCache.has(key) ? fxSymbolCache.get(key) : null; +} + +function getFxRateCacheKey(symbolId, dateKey) { + return `${symbolId || 'unknown'}:${dateKey || 'unknown'}`; +} + +function getCachedFxRate(symbolId, dateKey) { + const key = getFxRateCacheKey(symbolId, dateKey); + const entry = fxRateCache.get(key); + if (!entry) { + return null; + } + if (Date.now() - entry.timestamp > FX_RATE_CACHE_TTL_MS) { + fxRateCache.delete(key); + return null; + } + return entry.value; +} + +function setCachedFxRate(symbolId, dateKey, value) { + const key = getFxRateCacheKey(symbolId, dateKey); + fxRateCache.set(key, { timestamp: Date.now(), value }); +} + +function getBankOfCanadaCacheKey(seriesId, dateKey) { + return `${seriesId || 'unknown'}:${dateKey || 'unknown'}`; +} + +function getCachedBankOfCanadaRate(seriesId, dateKey) { + const key = getBankOfCanadaCacheKey(seriesId, dateKey); + const entry = bankOfCanadaRateCache.get(key); + if (!entry) { + return null; + } + if (Date.now() - entry.timestamp > FX_RATE_CACHE_TTL_MS) { + bankOfCanadaRateCache.delete(key); + return null; + } + return entry.value; +} + +function setCachedBankOfCanadaRate(seriesId, dateKey, value) { + const key = getBankOfCanadaCacheKey(seriesId, dateKey); + bankOfCanadaRateCache.set(key, { timestamp: Date.now(), value }); +} + +function getNetDepositCacheKey(loginId, accountNumber) { + return `${loginId || 'unknown'}:${accountNumber || 'unknown'}`; +} + +function normalizeActivityLabel(value) { + if (value === null || value === undefined) { + return ''; + } + return String(value) + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); +} + +function resolveActivityCurrency(activity) { + if (!activity || typeof activity !== 'object') { + return null; + } + const candidates = [activity.currency, activity.currencyPrimary, activity.currencySecondary]; + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim()) { + return candidate.trim().toUpperCase(); + } + } + return null; +} + +function parseNumericValue(value) { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + if (typeof value === 'string') { + const cleaned = value.trim(); + if (!cleaned) { + return null; + } + const normalized = cleaned.replace(/[,\s]/g, ''); + if (!normalized) { + return null; + } + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function resolveNumericField(source, fields) { + if (!source || typeof source !== 'object') { + return null; + } + for (const field of fields) { + if (!Object.prototype.hasOwnProperty.call(source, field)) { + continue; + } + const value = parseNumericValue(source[field]); + if (value === null || value === undefined) { + continue; + } + return { field, value }; + } + return null; +} + +const NET_DEPOSIT_AMOUNT_FALLBACK_FIELDS = [ + 'grossAmount', + 'grossAmountCad', + 'grossAmountCdn', + 'grossAmountUsd', + 'grossAmountBase', + 'settlementAmount', + 'settlementAmountCad', + 'settlementAmountCdn', + 'settlementAmountUsd', + 'amount', + 'amountCad', + 'amountCdn', + 'amountUsd', + 'tradeValue', + 'tradeAmount', + 'totalAmount', + 'principal', + 'proceeds', + 'value', + 'bookValue', + 'bookValueCad', + 'bookValueUsd', + 'marketValue', + 'marketValueCad', + 'marketValueUsd', + 'consideration', + 'cashAmount', + 'cash', + 'debit', + 'credit', +]; + +const NET_DEPOSIT_QUANTITY_FIELDS = ['quantity', 'qty', 'units', 'shares', 'shareQuantity']; +const NET_DEPOSIT_PRICE_FIELDS = ['price', 'tradePrice', 'avgPrice', 'averagePrice', 'fillPrice', 'cost', 'costPerShare', 'bookPrice']; + +function resolveActivityAmountForNetDeposits(activity) { + if (!activity || typeof activity !== 'object') { + return { amount: 0, source: null }; + } + + const direct = parseNumericValue(activity.netAmount); + if (isFiniteNumber(direct) && Math.abs(direct) > 1e-9) { + return { amount: direct, source: 'netAmount' }; + } + + const fallback = resolveNumericField(activity, NET_DEPOSIT_AMOUNT_FALLBACK_FIELDS); + if (fallback && isFiniteNumber(fallback.value) && Math.abs(fallback.value) > 1e-9) { + return { amount: fallback.value, source: fallback.field }; + } + + const quantityInfo = resolveNumericField(activity, NET_DEPOSIT_QUANTITY_FIELDS); + const priceInfo = resolveNumericField(activity, NET_DEPOSIT_PRICE_FIELDS); + if ( + quantityInfo && + priceInfo && + isFiniteNumber(quantityInfo.value) && + isFiniteNumber(priceInfo.value) + ) { + const estimated = quantityInfo.value * priceInfo.value; + if (isFiniteNumber(estimated) && Math.abs(estimated) > 1e-9) { + return { + amount: estimated, + source: 'quantity*price', + quantity: quantityInfo.value, + quantitySource: quantityInfo.field, + price: priceInfo.value, + priceSource: priceInfo.field, + }; + } + } + + const quantityOnly = quantityInfo && isFiniteNumber(quantityInfo.value) ? quantityInfo : null; + const priceOnly = priceInfo && isFiniteNumber(priceInfo.value) ? priceInfo : null; + + return { + amount: 0, + source: null, + quantity: quantityOnly ? quantityOnly.value : null, + quantitySource: quantityOnly ? quantityOnly.field : null, + price: priceOnly ? priceOnly.value : null, + priceSource: priceOnly ? priceOnly.field : null, + }; +} + +function resolveActivityTimestamp(activity) { + const preferredKeys = [ + 'transactionDateTime', + 'transactionDatetime', + 'transactionTime', + 'tradeDate', + 'transactionDate', + 'settlementDate', + 'activityDate', + 'processedTime', + 'processedAt', + 'executionTime', + 'createdDate', + 'createdTime', + 'date', + ]; + return resolveTimestampFromObject(activity, preferredKeys); +} + +function resolveOrderTimestamp(order) { + const preferredKeys = [ + 'creationTime', + 'creationDate', + 'createdTime', + 'createdAt', + 'updateTime', + 'updateDate', + ]; + return resolveTimestampFromObject(order, preferredKeys); +} + +function resolveExecutionTimestamp(execution) { + const preferredKeys = ['timestamp', 'executionTime', 'tradeDate', 'transactionDate']; + return resolveTimestampFromObject(execution, preferredKeys); +} + +function getUtcStartOfDay(date) { + if (!(date instanceof Date) || Number.isNaN(date.valueOf())) { + return null; + } + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +function getUtcEndOfDay(date) { + const start = getUtcStartOfDay(date); + if (!start) { + return null; + } + return new Date(start.getTime() + MS_PER_DAY - 1); +} + +function formatFxDateKey(date) { + const start = getUtcStartOfDay(date); + if (!start) { + return null; + } + return start.toISOString().slice(0, 10); +} + +function buildFxDateCandidates(baseDate, lookbackDays) { + const candidates = []; + const base = baseDate instanceof Date && !Number.isNaN(baseDate.valueOf()) ? baseDate : new Date(); + const unique = new Set(); + const totalDays = Math.max(lookbackDays || 0, 0); + + for (let offset = 0; offset <= totalDays; offset += 1) { + const candidate = new Date(base.getTime() - offset * MS_PER_DAY); + const dateKey = formatFxDateKey(candidate); + if (!dateKey || unique.has(dateKey)) { + continue; + } + unique.add(dateKey); + candidates.push({ date: candidate, dateKey }); + } + + return candidates; +} + +function inferFxOrientation(text, sourceCurrency, targetCurrency) { + if (typeof text !== 'string' || !text.trim()) { + return null; + } + const normalized = text.toUpperCase(); + const condensed = normalized.replace(/[^A-Z]/g, ''); + const sourceIndex = condensed.indexOf(sourceCurrency); + const targetIndex = condensed.indexOf(targetCurrency); + if (sourceIndex === -1 || targetIndex === -1) { + return null; + } + if (sourceIndex === targetIndex) { + return null; + } + return sourceIndex < targetIndex ? 'direct' : 'inverse'; +} + +function buildFxSymbolSearchQueries(sourceCurrency, targetCurrency) { + const normalizedSource = normalizeCurrencyCode(sourceCurrency); + const normalizedTarget = normalizeCurrencyCode(targetCurrency); + if (!normalizedSource || !normalizedTarget) { + return []; + } + const basePatterns = [ + `${normalizedSource}.${normalizedTarget}`, + `${normalizedSource}${normalizedTarget}`, + `${normalizedSource}/${normalizedTarget}`, + `${normalizedSource}-${normalizedTarget}`, + `${normalizedTarget}.${normalizedSource}`, + `${normalizedTarget}${normalizedSource}`, + `${normalizedTarget}/${normalizedSource}`, + `${normalizedTarget}-${normalizedSource}`, + ]; + const suffixes = ['', '.FX', '.TO']; + const queries = new Set(); + basePatterns.forEach((pattern) => { + suffixes.forEach((suffix) => { + const candidate = (pattern + suffix).replace(/\s+/g, ''); + if (candidate) { + queries.add(candidate); + } + }); + }); + return Array.from(queries); +} + +function findFxSymbolMatch(symbols, sourceCurrency, targetCurrency, orientationHint = null) { + if (!Array.isArray(symbols)) { + return null; + } + const normalizedSource = normalizeCurrencyCode(sourceCurrency); + const normalizedTarget = normalizeCurrencyCode(targetCurrency); + if (!normalizedSource || !normalizedTarget) { + return null; + } + + const matches = []; + symbols.forEach((symbol) => { + if (!symbol || typeof symbol !== 'object') { + return; + } + const symbolId = symbol.symbolId ?? symbol.id ?? null; + if (symbolId === null || symbolId === undefined) { + return; + } + const symbolText = typeof symbol.symbol === 'string' ? symbol.symbol.toUpperCase() : ''; + const description = typeof symbol.description === 'string' ? symbol.description.toUpperCase() : ''; + const symbolDescription = + typeof symbol.symbolDescription === 'string' ? symbol.symbolDescription.toUpperCase() : ''; + const symbolName = typeof symbol.symbolName === 'string' ? symbol.symbolName.toUpperCase() : ''; + const combined = [symbolText, description, symbolDescription, symbolName].filter(Boolean).join(' '); + if (!combined.includes(normalizedSource) || !combined.includes(normalizedTarget)) { + return; + } + const securityType = typeof symbol.securityType === 'string' ? symbol.securityType.toUpperCase() : ''; + const orientation = + inferFxOrientation(symbolText, normalizedSource, normalizedTarget) || + inferFxOrientation(description, normalizedSource, normalizedTarget) || + inferFxOrientation(symbolDescription, normalizedSource, normalizedTarget) || + inferFxOrientation(symbolName, normalizedSource, normalizedTarget) || + orientationHint || + null; + const rank = + (orientation ? 2 : 0) + + (securityType && /CURRENCY|FOREX|FX/.test(securityType) ? 1 : 0) + + (symbolText && symbolText.includes('.') ? 0.1 : 0); + matches.push({ + symbolId, + symbol: symbol.symbol || null, + orientation, + securityType, + rank, + }); + }); + + if (!matches.length) { + return null; + } + + matches.sort((a, b) => b.rank - a.rank); + const chosen = matches[0]; + if (!chosen.orientation && orientationHint) { + chosen.orientation = orientationHint; + } + if (!chosen.orientation) { + chosen.orientation = 'direct'; + } + return chosen; +} + +function buildBankOfCanadaSeriesInfo(sourceCurrency, targetCurrency) { + const normalizedSource = normalizeCurrencyCode(sourceCurrency); + const normalizedTarget = normalizeCurrencyCode(targetCurrency); + if (!normalizedSource || !normalizedTarget) { + return null; + } + if (normalizedSource === normalizedTarget) { + return null; + } + if (normalizedSource === 'CAD') { + return { seriesId: `FXCAD${normalizedTarget}`, invert: false }; + } + if (normalizedTarget === 'CAD') { + return { seriesId: `FX${normalizedSource}CAD`, invert: false }; + } + return null; +} + +async function fetchBankOfCanadaSeriesObservation(seriesId, dateKey) { + if (!seriesId || !dateKey) { + return null; + } + const cached = getCachedBankOfCanadaRate(seriesId, dateKey); + if (cached) { + return cached; + } + + const url = `${BANK_OF_CANADA_BASE_URL}${encodeURIComponent(seriesId)}?start_date=${dateKey}&end_date=${dateKey}`; + + try { + const response = await axios.get(url, { timeout: BANK_OF_CANADA_TIMEOUT_MS }); + const payload = response && response.data ? response.data : response; + const observations = Array.isArray(payload && payload.observations) + ? payload.observations + : []; + for (let index = observations.length - 1; index >= 0; index -= 1) { + const observation = observations[index]; + if (!observation || typeof observation !== 'object') { + continue; + } + const container = observation[seriesId]; + const value = + container && typeof container === 'object' + ? parseNumericValue(container.v ?? container.value ?? container.val) + : parseNumericValue(container); + if (!isFiniteNumber(value) || value <= 0) { + continue; + } + const observationDateKey = + typeof observation.d === 'string' && observation.d ? observation.d : dateKey; + const meta = { rate: value, dateKey: observationDateKey }; + setCachedBankOfCanadaRate(seriesId, dateKey, meta); + if (observationDateKey && observationDateKey !== dateKey) { + setCachedBankOfCanadaRate(seriesId, observationDateKey, meta); + } + return meta; + } + } catch (error) { + if (ENABLE_QUESTRADE_API_DEBUG) { + console.warn( + '[FX][BankOfCanada] Failed to fetch series', + seriesId, + 'for', + dateKey + ':', + error && error.message ? error.message : error + ); + } + } + + return null; +} + +async function resolveExternalHistoricalFxRateMeta(sourceCurrency, targetCurrency, timestamp, visited) { + const normalizedSource = normalizeCurrencyCode(sourceCurrency); + const normalizedTarget = normalizeCurrencyCode(targetCurrency); + if (!normalizedSource || !normalizedTarget) { + return null; + } + + const visitKey = `${normalizedSource}->${normalizedTarget}`; + const tracker = visited instanceof Set ? visited : new Set(); + if (tracker.has(visitKey)) { + return null; + } + tracker.add(visitKey); + + if (normalizedSource === normalizedTarget) { + return { + rate: 1, + source: 'identity', + dateKey: formatFxDateKey(timestamp instanceof Date ? timestamp : new Date()), + symbolInfo: { + symbolId: null, + symbol: null, + orientation: 'identity', + provider: 'bank-of-canada', + sourceCurrency: normalizedSource, + targetCurrency: normalizedTarget, + }, + }; + } + + const baseDate = timestamp instanceof Date && !Number.isNaN(timestamp.valueOf()) ? timestamp : new Date(); + const candidates = buildFxDateCandidates(baseDate, BANK_OF_CANADA_LOOKBACK_DAYS); + const seriesInfo = buildBankOfCanadaSeriesInfo(normalizedSource, normalizedTarget); + + if (seriesInfo) { + for (const candidate of candidates) { + const observation = await fetchBankOfCanadaSeriesObservation(seriesInfo.seriesId, candidate.dateKey); + if (!observation || !isFiniteNumber(observation.rate) || observation.rate <= 0) { + continue; + } + const effectiveRate = seriesInfo.invert ? 1 / observation.rate : observation.rate; + if (!isFiniteNumber(effectiveRate) || effectiveRate <= 0) { + continue; + } + const meta = { + rate: effectiveRate, + source: 'bank-of-canada', + dateKey: observation.dateKey || candidate.dateKey, + symbolInfo: { + symbolId: null, + symbol: seriesInfo.seriesId, + orientation: seriesInfo.invert ? 'inverse' : 'direct', + provider: 'bank-of-canada', + sourceCurrency: normalizedSource, + targetCurrency: normalizedTarget, + }, + }; + if (ENABLE_QUESTRADE_API_DEBUG) { + console.log('[FX][BankOfCanada] Resolved rate', meta); + } + return meta; + } + } + + if (normalizedSource !== 'CAD' && normalizedTarget !== 'CAD') { + const toCad = await resolveExternalHistoricalFxRateMeta(normalizedSource, 'CAD', baseDate, tracker); + const fromCad = await resolveExternalHistoricalFxRateMeta('CAD', normalizedTarget, baseDate, tracker); + const toCadRate = toCad && isFiniteNumber(toCad.rate) && toCad.rate > 0 ? toCad.rate : null; + const fromCadRate = fromCad && isFiniteNumber(fromCad.rate) && fromCad.rate > 0 ? fromCad.rate : null; + if (toCadRate && fromCadRate) { + const meta = { + rate: toCadRate * fromCadRate, + source: 'bank-of-canada:derived', + dateKey: fromCad && fromCad.dateKey ? fromCad.dateKey : toCad ? toCad.dateKey : null, + symbolInfo: { + symbolId: null, + symbol: 'BANK-OF-CANADA-VIA-CAD', + orientation: 'derived', + provider: 'bank-of-canada', + sourceCurrency: normalizedSource, + targetCurrency: normalizedTarget, + }, + }; + if (ENABLE_QUESTRADE_API_DEBUG) { + console.log('[FX][BankOfCanada] Derived cross rate', meta, { + via: { toCad, fromCad }, + }); + } + return meta; + } + } + + return null; +} + +const NET_DEPOSIT_INFLOW_KEYWORDS = [ + 'deposit', + 'transferin', + 'transfercashin', + 'cashtransferin', + 'journalcashin', + 'jrnlcashin', + 'journalin', + 'cashin', + 'incomingtransfer', + 'fundsreceived', + 'billpayment', + 'dripcashin', + 'paymentreceived', +]; + +const NET_DEPOSIT_OUTFLOW_KEYWORDS = [ + 'withdraw', + 'withdrawal', + 'transferout', + 'transfercashout', + 'cashtransferout', + 'journalcashout', + 'jrnlcashout', + 'journalout', + 'cashout', + 'outgoingtransfer', + 'fundssent', + 'paymentmade', +]; + +function classifyNetDepositActivity(activity) { + const labels = [normalizeActivityLabel(activity && activity.type), normalizeActivityLabel(activity && activity.action)]; + const description = normalizeActivityLabel(activity && activity.description); + if (description) { + labels.push(description); + } + + const hasKeyword = (keywords) => { + return keywords.some((keyword) => labels.some((label) => label && label.includes(keyword))); + }; + + if (hasKeyword(NET_DEPOSIT_INFLOW_KEYWORDS)) { + return 'inflow'; + } + if (hasKeyword(NET_DEPOSIT_OUTFLOW_KEYWORDS)) { + return 'outflow'; + } + + const baseLabel = labels.find((label) => label); + if (!baseLabel) { + return null; + } + + if (baseLabel.includes('transfer') || baseLabel.includes('journal')) { + const netAmount = Number(activity && activity.netAmount); + if (!Number.isFinite(netAmount) || netAmount === 0) { + return null; + } + return netAmount > 0 ? 'inflow' : 'outflow'; + } + + return null; +} + +function isPotentialNetDepositActivity(activity) { + if (!activity || typeof activity !== 'object') { + return false; + } + + const classification = classifyNetDepositActivity(activity); + if (classification) { + return true; + } + + const labels = [normalizeActivityLabel(activity.type), normalizeActivityLabel(activity.action)]; + const description = normalizeActivityLabel(activity.description); + if (description) { + labels.push(description); + } + + const keywords = NET_DEPOSIT_INFLOW_KEYWORDS.concat(NET_DEPOSIT_OUTFLOW_KEYWORDS); + return keywords.some(function (keyword) { + return labels.some(function (label) { + return label && label.includes(keyword); + }); + }); +} + +function buildActivityWindows(startDate, endDate, windowDays) { + const windows = []; + if (!(startDate instanceof Date) || Number.isNaN(startDate.valueOf())) { + return windows; + } + if (!(endDate instanceof Date) || Number.isNaN(endDate.valueOf())) { + return windows; + } + + const windowMs = Math.max(windowDays, 1) * 24 * 60 * 60 * 1000; + let cursorMs = startDate.getTime(); + const limit = endDate.getTime(); + + while (cursorMs <= limit) { + const remaining = limit - cursorMs; + let windowEndMs = cursorMs + windowMs; + let isFinalWindow = false; + + if (remaining <= windowMs) { + windowEndMs = limit; + isFinalWindow = true; + } else { + windowEndMs = Math.min(limit, windowEndMs - 1); + } + + if (windowEndMs < cursorMs) { + windowEndMs = cursorMs; + } + + windows.push({ start: new Date(cursorMs), end: new Date(windowEndMs) }); + + if (isFinalWindow) { + break; + } + + cursorMs = windowEndMs + 1; + } + + return windows; +} + +function formatActivityTimestamp(date) { + if (!(date instanceof Date) || Number.isNaN(date.valueOf())) { + return null; + } + return date.toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +function isArgumentLengthError(error) { + return Boolean(error && error.response && error.response.data && error.response.data.code === 1003); +} + +async function fetchEarliestTimestampFromPagedEndpoint( + login, + initialPath, + params, + itemsKey, + timestampResolver, + options = {} +) { + if (!login || !initialPath || !itemsKey || typeof timestampResolver !== 'function') { + return null; + } + + let nextPath = initialPath; + let currentParams = params || null; + let earliest = null; + const maxPages = Math.max(options.maxPages || 20, 1); + let pagesFetched = 0; + + while (nextPath && pagesFetched < maxPages) { + const trimmedPath = String(nextPath).trim(); + if (!trimmedPath) { + break; + } + + const requestOptions = currentParams ? { params: currentParams } : {}; + let data; + try { + data = await questradeRequest(login, trimmedPath, requestOptions); + } catch (error) { + throw error; + } + + pagesFetched += 1; + currentParams = null; + + const items = Array.isArray(data && data[itemsKey]) ? data[itemsKey] : []; + for (const item of items) { + const timestamp = timestampResolver(item); + if (!(timestamp instanceof Date) || Number.isNaN(timestamp.valueOf())) { + continue; + } + if (!earliest || timestamp.getTime() < earliest.getTime()) { + earliest = timestamp; + } + } + + if (data && typeof data.next === 'string' && data.next.trim()) { + nextPath = data.next.trim(); + } else { + break; + } + } + + return earliest; +} + +async function fetchActivitiesWindow(login, accountId, startDate, endDate) { + const params = {}; + const startTime = formatActivityTimestamp(startDate); + const endTime = formatActivityTimestamp(endDate); + if (startTime) { + params.startTime = startTime; + } + if (endTime) { + params.endTime = endTime; + } + try { + const data = await questradeRequest(login, '/v1/accounts/' + accountId + '/activities', { params }); + return Array.isArray(data.activities) ? data.activities : []; + } catch (error) { + if (isArgumentLengthError(error) && startDate instanceof Date && endDate instanceof Date) { + const startMs = startDate.getTime(); + const endMs = endDate.getTime(); + if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs - startMs > 24 * 60 * 60 * 1000) { + const midpointMs = Math.floor((startMs + endMs) / 2); + if (midpointMs > startMs && midpointMs < endMs) { + const midpoint = new Date(midpointMs); + const firstBatch = await fetchActivitiesWindow(login, accountId, startDate, midpoint); + const secondStart = new Date(midpoint.getTime() + 1); + const secondBatch = await fetchActivitiesWindow(login, accountId, secondStart, endDate); + return firstBatch.concat(secondBatch); + } + } + } + throw error; + } +} + +async function fetchEarliestOrderCreationTime(login, accountId) { + const params = { + startTime: formatActivityTimestamp(NET_DEPOSIT_ACTIVITY_EPOCH), + endTime: formatActivityTimestamp(new Date()), + stateFilter: 'All', + }; + return fetchEarliestTimestampFromPagedEndpoint( + login, + '/v1/accounts/' + accountId + '/orders', + params, + 'orders', + resolveOrderTimestamp, + { maxPages: 25 } + ); +} + +async function fetchEarliestExecutionTimestamp(login, accountId) { + const params = { + startTime: formatActivityTimestamp(NET_DEPOSIT_ACTIVITY_EPOCH), + endTime: formatActivityTimestamp(new Date()), + }; + return fetchEarliestTimestampFromPagedEndpoint( + login, + '/v1/accounts/' + accountId + '/executions', + params, + 'executions', + resolveExecutionTimestamp, + { maxPages: 25 } + ); +} + +async function fetchAccountActivitySeed(login, accountId) { + if (!login || !accountId) { + return null; + } + + const cacheKey = getNetDepositCacheKey(login.id, accountId); + const cached = getCachedAccountActivitySeed(cacheKey); + if (cached !== null && cached !== undefined) { + if (cached === false) { + return null; + } + const cachedDate = new Date(cached); + return Number.isNaN(cachedDate.valueOf()) ? null : cachedDate; + } + + let earliest = null; + + try { + const orderSeed = await fetchEarliestOrderCreationTime(login, accountId); + if (orderSeed && (!earliest || orderSeed.getTime() < earliest.getTime())) { + earliest = orderSeed; + } + } catch (error) { + console.warn( + 'Failed to fetch orders for account ' + accountId + ' (' + resolveLoginDisplay(login) + ') when seeding activities:', + error && error.message ? error.message : error + ); + } + + try { + const executionSeed = await fetchEarliestExecutionTimestamp(login, accountId); + if (executionSeed && (!earliest || executionSeed.getTime() < earliest.getTime())) { + earliest = executionSeed; + } + } catch (error) { + console.warn( + 'Failed to fetch executions for account ' + accountId + ' (' + resolveLoginDisplay(login) + ') when seeding activities:', + error && error.message ? error.message : error + ); + } + + if (earliest) { + setCachedAccountActivitySeed(cacheKey, earliest.getTime()); + return earliest; + } + + setCachedAccountActivitySeed(cacheKey, false); + return null; +} + +async function summarizeActivityWindow(login, accountId, startMs, endMs, cache) { + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) { + return { hasFunding: false, earliestTimestamp: null }; + } + + const key = startMs + ':' + endMs; + if (cache && cache.has(key)) { + return cache.get(key); + } + + const startDate = new Date(startMs); + const endDate = new Date(endMs); + const activities = await fetchActivitiesWindow(login, accountId, startDate, endDate); + let hasFunding = false; + let earliestTimestamp = null; + + for (const activity of activities) { + if (!isPotentialNetDepositActivity(activity)) { + continue; + } + hasFunding = true; + const timestamp = resolveActivityTimestamp(activity); + if (!(timestamp instanceof Date) || Number.isNaN(timestamp.valueOf())) { + continue; + } + const ms = timestamp.getTime(); + if (!Number.isFinite(ms)) { + continue; + } + if (earliestTimestamp === null || ms < earliestTimestamp) { + earliestTimestamp = ms; + } + } + + const summary = { hasFunding, earliestTimestamp }; + if (cache) { + cache.set(key, summary); + } + return summary; +} + +async function searchActivityRangeForFunding(login, accountId, rangeStartMs, rangeEndMs, cache) { + if (!Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs) || rangeEndMs < rangeStartMs) { + return null; + } + + const startDate = new Date(rangeStartMs); + const endDate = new Date(rangeEndMs); + const windows = buildActivityWindows(startDate, endDate, NET_DEPOSIT_ACTIVITY_WINDOW_DAYS); + + for (const window of windows) { + const windowStartMs = window.start.getTime(); + const windowEndMs = window.end.getTime(); + const summary = await summarizeActivityWindow(login, accountId, windowStartMs, windowEndMs, cache); + if (summary.hasFunding && summary.earliestTimestamp !== null && summary.earliestTimestamp !== undefined) { + return summary.earliestTimestamp; + } + } + + return null; +} + +async function resolveAccountActivityHistoryStart(login, accountId, options = {}) { + if (!login || !accountId) { + return resolveActivityHistoryStart(null); + } + + const cacheKey = getNetDepositCacheKey(login.id, accountId); + const cachedStart = getCachedAccountActivityStart(cacheKey); + if (cachedStart !== null && cachedStart !== undefined) { + const cachedDate = new Date(cachedStart); + if (!Number.isNaN(cachedDate.valueOf())) { + return cachedDate; + } + } + + const baselineMs = NET_DEPOSIT_ACTIVITY_EPOCH.getTime(); + const nowMs = Date.now(); + const directCandidate = parseDateValue(options.startDate); + const directMs = directCandidate ? directCandidate.getTime() : null; + const windowCache = new Map(); + + const seedDates = []; + if (Number.isFinite(directMs) && directMs >= baselineMs && directMs <= nowMs) { + seedDates.push(directMs); + } + + const seed = await fetchAccountActivitySeed(login, accountId); + if (seed && seed.getTime() >= baselineMs && seed.getTime() <= nowMs) { + seedDates.push(seed.getTime()); + } + + const uniqueSeeds = Array.from(new Set(seedDates)).sort(function (a, b) { + return a - b; + }); + + const lookbackMs = Math.max(NET_DEPOSIT_SEED_LOOKBACK_DAYS, NET_DEPOSIT_ACTIVITY_WINDOW_DAYS) * MS_PER_DAY; + const lookaheadMs = Math.max(NET_DEPOSIT_SEED_LOOKAHEAD_DAYS, NET_DEPOSIT_ACTIVITY_WINDOW_DAYS) * MS_PER_DAY; + let earliestTimestampMs = null; + + for (const seedMs of uniqueSeeds) { + const rangeStart = Math.max(baselineMs, seedMs - lookbackMs); + const rangeEnd = Math.min(nowMs, seedMs + lookaheadMs); + const found = await searchActivityRangeForFunding(login, accountId, rangeStart, rangeEnd, windowCache); + if (found !== null && found !== undefined) { + if (earliestTimestampMs === null || found < earliestTimestampMs) { + earliestTimestampMs = found; + } + } + if (earliestTimestampMs !== null && earliestTimestampMs <= rangeStart) { + break; + } + } + + if (earliestTimestampMs === null) { + let fallbackEndMs; + if (uniqueSeeds.length) { + fallbackEndMs = Math.min(uniqueSeeds[0], nowMs); + } else if (Number.isFinite(directMs)) { + fallbackEndMs = Math.min(directMs, nowMs); + } else { + fallbackEndMs = nowMs; + } + + if (!Number.isFinite(fallbackEndMs) || fallbackEndMs < baselineMs) { + fallbackEndMs = nowMs; + } + + let fallbackStartMs = Math.max(baselineMs, fallbackEndMs - NET_DEPOSIT_FALLBACK_EXPANSION_DAYS * MS_PER_DAY); + + while (fallbackEndMs > baselineMs) { + const found = await searchActivityRangeForFunding(login, accountId, fallbackStartMs, fallbackEndMs, windowCache); + if (found !== null && found !== undefined) { + earliestTimestampMs = found; + break; + } + if (fallbackStartMs <= baselineMs) { + break; + } + fallbackEndMs = fallbackStartMs - 1; + fallbackStartMs = Math.max(baselineMs, fallbackEndMs - NET_DEPOSIT_FALLBACK_EXPANSION_DAYS * MS_PER_DAY); + } + } + + if (earliestTimestampMs === null && uniqueSeeds.length === 0 && !Number.isFinite(directMs)) { + const found = await searchActivityRangeForFunding(login, accountId, baselineMs, nowMs, windowCache); + if (found !== null && found !== undefined) { + earliestTimestampMs = found; + } + } + + let resolvedStart = null; + if (earliestTimestampMs !== null && earliestTimestampMs !== undefined) { + resolvedStart = resolveActivityHistoryStart(new Date(earliestTimestampMs)); + } else if (directCandidate) { + resolvedStart = resolveActivityHistoryStart(directCandidate); + } else if (uniqueSeeds.length) { + resolvedStart = resolveActivityHistoryStart(new Date(uniqueSeeds[0])); + } else { + resolvedStart = resolveActivityHistoryStart(null); + } + + if (resolvedStart) { + setCachedAccountActivityStart(cacheKey, resolvedStart.getTime()); + } + + if (ENABLE_QUESTRADE_API_DEBUG && resolvedStart) { + const seedSummaries = uniqueSeeds.map(function (ms) { + return new Date(ms).toISOString(); + }); + console.log( + '[Questrade][activitiesSeed]', + accountId, + 'seeds', + seedSummaries, + 'start', + resolvedStart.toISOString(), + earliestTimestampMs ? 'firstActivity' : 'firstActivityMissing', + earliestTimestampMs ? new Date(earliestTimestampMs).toISOString() : 'none' + ); + } + + return resolvedStart; +} + +async function fetchAllAccountActivities(login, accountId, options = {}) { + if (!login || !accountId) { + return []; + } + + const now = new Date(); + const start = await resolveAccountActivityHistoryStart(login, accountId, options); + const windows = buildActivityWindows(start, now, NET_DEPOSIT_ACTIVITY_WINDOW_DAYS); + const activities = []; + + if (ENABLE_QUESTRADE_API_DEBUG) { + console.log( + '[Questrade][activitiesWindowPlan]', + accountId, + 'start', + start.toISOString(), + 'end', + now.toISOString(), + 'windows', + windows.length + ); + } + + for (const window of windows) { + try { + const batch = await fetchActivitiesWindow(login, accountId, window.start, window.end); + if (batch.length) { + activities.push(...batch); + } + } catch (error) { + console.warn( + 'Failed to fetch activities for account ' + accountId + ' (' + resolveLoginDisplay(login) + '):', + error.message || error + ); + throw error; + } + } + + return activities; +} + +function accumulateNetDeposits(activities) { + const totals = new Map(); + const counts = new Map(); + const entries = []; + const debugEntries = ENABLE_QUESTRADE_API_DEBUG ? [] : null; + const skippedEntries = ENABLE_QUESTRADE_API_DEBUG ? [] : null; + + activities.forEach((activity) => { + const currency = resolveActivityCurrency(activity); + if (!currency) { + return; + } + + const classification = classifyNetDepositActivity(activity); + if (!classification) { + return; + } + + const amountInfo = resolveActivityAmountForNetDeposits(activity); + let amount = amountInfo ? amountInfo.amount : 0; + if (!isFiniteNumber(amount) || Math.abs(amount) <= 1e-9) { + if (skippedEntries) { + skippedEntries.push({ + timestamp: resolveActivityTimestamp(activity), + currency: currency.toUpperCase(), + classification, + type: activity && activity.type ? String(activity.type) : null, + action: activity && activity.action ? String(activity.action) : null, + description: activity && activity.description ? String(activity.description) : null, + rawNetAmount: parseNumericValue(activity && activity.netAmount) || 0, + amountSource: amountInfo ? amountInfo.source || null : null, + quantity: amountInfo && isFiniteNumber(amountInfo.quantity) ? amountInfo.quantity : null, + quantitySource: amountInfo ? amountInfo.quantitySource || null : null, + price: amountInfo && isFiniteNumber(amountInfo.price) ? amountInfo.price : null, + priceSource: amountInfo ? amountInfo.priceSource || null : null, + }); + } + return; + } + + if (classification === 'inflow' && amount < 0) { + amount = -amount; + } else if (classification === 'outflow' && amount > 0) { + amount = -amount; + } + + const normalizedCurrency = currency.toUpperCase(); + const currentTotal = totals.get(normalizedCurrency) || 0; + totals.set(normalizedCurrency, currentTotal + amount); + counts.set(normalizedCurrency, (counts.get(normalizedCurrency) || 0) + 1); + + const timestamp = resolveActivityTimestamp(activity); + const timestampMs = + timestamp instanceof Date && !Number.isNaN(timestamp.valueOf()) ? timestamp.getTime() : null; + entries.push({ + timestamp: timestampMs, + currency: normalizedCurrency, + amount, + classification, + amountSource: amountInfo ? amountInfo.source || null : null, + }); + + if (debugEntries) { + debugEntries.push({ + timestamp: resolveActivityTimestamp(activity), + currency: normalizedCurrency, + amount, + rawNetAmount: parseNumericValue(activity && activity.netAmount) || 0, + classification, + type: activity && activity.type ? String(activity.type) : null, + action: activity && activity.action ? String(activity.action) : null, + description: activity && activity.description ? String(activity.description) : null, + amountSource: amountInfo ? amountInfo.source || null : null, + estimatedAmount: amountInfo && amountInfo.source !== 'netAmount' ? amountInfo.amount : null, + quantity: amountInfo && isFiniteNumber(amountInfo.quantity) ? amountInfo.quantity : null, + quantitySource: amountInfo ? amountInfo.quantitySource || null : null, + price: amountInfo && isFiniteNumber(amountInfo.price) ? amountInfo.price : null, + priceSource: amountInfo ? amountInfo.priceSource || null : null, + }); + } + }); + + return { totals, counts, entries, details: debugEntries, skipped: skippedEntries }; +} + +async function resolveFxSymbolInfo(login, sourceCurrency, targetCurrency) { + const normalizedSource = normalizeCurrencyCode(sourceCurrency); + const normalizedTarget = normalizeCurrencyCode(targetCurrency); + if (!normalizedSource || !normalizedTarget) { + return null; + } + if (normalizedSource === normalizedTarget) { + return { + symbolId: null, + symbol: null, + orientation: 'identity', + sourceCurrency: normalizedSource, + targetCurrency: normalizedTarget, + }; + } + + const cached = getCachedFxSymbol(normalizedSource, normalizedTarget); + if (cached !== null && cached !== undefined) { + return cached; + } + + const queries = buildFxSymbolSearchQueries(normalizedSource, normalizedTarget); + for (const query of queries) { + const params = { prefix: query }; + let data; + try { + data = await questradeRequest(login, '/v1/symbols/search', { params }); + } catch (error) { + continue; + } + const symbols = Array.isArray(data && data.symbols) ? data.symbols : []; + const orientationHint = inferFxOrientation(query, normalizedSource, normalizedTarget); + const match = findFxSymbolMatch(symbols, normalizedSource, normalizedTarget, orientationHint); + if (match) { + const resolved = Object.assign({}, match, { + sourceCurrency: normalizedSource, + targetCurrency: normalizedTarget, + }); + setCachedFxSymbol(normalizedSource, normalizedTarget, resolved); + return resolved; + } + } + + setCachedFxSymbol(normalizedSource, normalizedTarget, null); + return null; +} + +async function fetchFxCandles(login, symbolId, startDate, endDate) { + if (!login || symbolId === null || symbolId === undefined) { + return []; + } + if (!(startDate instanceof Date) || Number.isNaN(startDate.valueOf())) { + return []; + } + if (!(endDate instanceof Date) || Number.isNaN(endDate.valueOf())) { + return []; + } + + const params = { interval: 'OneDay' }; + const startTime = formatActivityTimestamp(startDate); + const endTime = formatActivityTimestamp(endDate); + if (startTime) { + params.startTime = startTime; + } + if (endTime) { + params.endTime = endTime; + } + + try { + const data = await questradeRequest(login, '/v1/markets/candles/' + symbolId, { params }); + return Array.isArray(data && data.candles) ? data.candles : []; + } catch (error) { + if (ENABLE_QUESTRADE_API_DEBUG) { + console.warn( + 'Failed to fetch FX candles for symbol ' + symbolId + ':', + error && error.message ? error.message : error + ); + } + return []; + } +} + +function resolveCandleTimestamp(candle) { + const preferred = ['start', 'startTime', 'time', 'date', 'timestamp', 'begin']; + return resolveTimestampFromObject(candle, preferred); +} + +function extractCandleClose(candle) { + if (!candle || typeof candle !== 'object') { + return null; + } + const fields = ['close', 'closePrice', 'last', 'value', 'price']; + for (const field of fields) { + if (!Object.prototype.hasOwnProperty.call(candle, field)) { + continue; + } + const value = parseNumericValue(candle[field]); + if (isFiniteNumber(value) && value > 0) { + return value; + } + } + return null; +} + +function selectFxCandle(candles, targetDate) { + if (!Array.isArray(candles) || !candles.length) { + return null; + } + const targetStart = getUtcStartOfDay(targetDate); + const targetEnd = getUtcEndOfDay(targetDate); + if (!targetStart || !targetEnd) { + return candles[0] || null; + } + const startMs = targetStart.getTime(); + const endMs = targetEnd.getTime(); + let exact = null; + let exactMs = null; + let before = null; + let beforeMs = null; + let after = null; + let afterMs = null; + + candles.forEach((candle) => { + const timestamp = resolveCandleTimestamp(candle); + if (!(timestamp instanceof Date) || Number.isNaN(timestamp.valueOf())) { + return; + } + const ms = timestamp.getTime(); + if (ms >= startMs && ms <= endMs) { + if (exact === null || ms < exactMs) { + exact = candle; + exactMs = ms; + } + return; + } + if (ms < startMs) { + if (before === null || ms > beforeMs) { + before = candle; + beforeMs = ms; + } + return; + } + if (ms > endMs) { + if (after === null || ms < afterMs) { + after = candle; + afterMs = ms; + } + } + }); + + return exact || before || after || candles[0] || null; +} + +async function resolveHistoricalFxRate(login, sourceCurrency, targetCurrency, timestamp, options = {}) { + const normalizedSource = normalizeCurrencyCode(sourceCurrency); + const normalizedTarget = normalizeCurrencyCode(targetCurrency); + const includeMetadata = Boolean(options && options.returnMetadata); + + if (!normalizedSource || !normalizedTarget) { + return includeMetadata ? { rate: null, source: 'invalid-currency', dateKey: null, symbolInfo: null } : null; + } + + if (normalizedSource === normalizedTarget) { + if (includeMetadata) { + return { + rate: 1, + source: 'identity', + dateKey: formatFxDateKey(timestamp instanceof Date ? timestamp : new Date()), + symbolInfo: { + symbolId: null, + symbol: null, + orientation: 'identity', + sourceCurrency: normalizedSource, + targetCurrency: normalizedTarget, + }, + }; + } + return 1; + } + + const symbolInfo = await resolveFxSymbolInfo(login, normalizedSource, normalizedTarget); + let resolvedSymbolInfo = symbolInfo || null; + + if (symbolInfo) { + const candidateDates = []; + if (timestamp instanceof Date && !Number.isNaN(timestamp.valueOf())) { + candidateDates.push({ date: timestamp, tag: 'historical' }); + } + const now = new Date(); + const nowTag = candidateDates.length ? 'fallback-latest' : 'historical'; + candidateDates.push({ date: now, tag: nowTag }); + + for (const candidate of candidateDates) { + const dayKey = formatFxDateKey(candidate.date); + if (!dayKey) { + continue; + } + const cached = getCachedFxRate(symbolInfo.symbolId, dayKey); + if (cached !== null && cached !== undefined) { + if (includeMetadata) { + return { + rate: cached, + source: candidate.tag, + dateKey: dayKey, + symbolInfo, + }; + } + return cached; + } + const dayStart = getUtcStartOfDay(candidate.date); + const dayEnd = getUtcEndOfDay(candidate.date); + if (!dayStart || !dayEnd) { + continue; + } + const rangeStart = new Date(dayStart.getTime() - FX_CANDLE_LOOKBACK_DAYS * MS_PER_DAY); + const rangeEnd = new Date(dayEnd.getTime() + FX_CANDLE_LOOKAHEAD_DAYS * MS_PER_DAY); + const candles = await fetchFxCandles(login, symbolInfo.symbolId, rangeStart, rangeEnd); + const candle = selectFxCandle(candles, candidate.date); + const close = extractCandleClose(candle); + if (!isFiniteNumber(close) || close <= 0) { + continue; + } + let rate = close; + if (symbolInfo.orientation === 'inverse') { + rate = 1 / close; + } + if (!isFiniteNumber(rate) || rate <= 0) { + continue; + } + setCachedFxRate(symbolInfo.symbolId, dayKey, rate); + if (includeMetadata) { + return { + rate, + source: candidate.tag, + dateKey: dayKey, + symbolInfo, + }; + } + return rate; + } + } + + const fallbackMeta = await resolveExternalHistoricalFxRateMeta(normalizedSource, normalizedTarget, timestamp); + if (fallbackMeta && isFiniteNumber(fallbackMeta.rate) && fallbackMeta.rate > 0) { + if (fallbackMeta.dateKey) { + const cacheId = fallbackMeta.symbolInfo && fallbackMeta.symbolInfo.symbol + ? `external:${fallbackMeta.symbolInfo.symbol}` + : `external:${normalizedSource}:${normalizedTarget}`; + setCachedFxRate(cacheId, fallbackMeta.dateKey, fallbackMeta.rate); + } + if (ENABLE_QUESTRADE_API_DEBUG) { + console.log('[FX][Fallback] Using external FX rate', { + source: fallbackMeta.source, + rate: fallbackMeta.rate, + dateKey: fallbackMeta.dateKey || null, + pair: `${normalizedSource}/${normalizedTarget}`, + }); + } + if (includeMetadata) { + return fallbackMeta; + } + return fallbackMeta.rate; + } + + if (ENABLE_QUESTRADE_API_DEBUG) { + console.warn('[FX][Fallback] Failed to resolve external FX rate for pair', `${normalizedSource}/${normalizedTarget}`); + } + + if (includeMetadata) { + const failureSource = symbolInfo ? 'unavailable' : 'missing-symbol'; + return { rate: null, source: failureSource, dateKey: null, symbolInfo: resolvedSymbolInfo }; + } + return null; +} + +async function augmentNetDepositSummaryWithHistoricalConversions(login, summary, options = {}) { + if (!summary || typeof summary !== 'object') { + return summary; + } + + const baseCurrency = normalizeCurrencyCode(options.baseCurrency, 'CAD') || 'CAD'; + const entries = Array.isArray(summary.entries) ? summary.entries : []; + const convertedTotals = new Map(); + const fxDetails = ENABLE_QUESTRADE_API_DEBUG ? [] : null; + const fxSkipped = ENABLE_QUESTRADE_API_DEBUG ? [] : null; + const localRateCache = new Map(); + + for (const entry of entries) { + const amount = entry && entry.amount; + const currency = normalizeCurrencyCode(entry && entry.currency); + if (!isFiniteNumber(amount) || Math.abs(amount) <= 1e-9 || !currency) { + continue; + } + + if (currency === baseCurrency) { + convertedTotals.set(baseCurrency, (convertedTotals.get(baseCurrency) || 0) + amount); + if (fxDetails) { + fxDetails.push({ + timestamp: Number.isFinite(entry.timestamp) ? new Date(entry.timestamp).toISOString() : null, + sourceCurrency: currency, + targetCurrency: baseCurrency, + amount, + rate: 1, + convertedAmount: amount, + rateSource: 'identity', + dateKey: entry.timestamp ? formatFxDateKey(new Date(entry.timestamp)) : null, + symbol: null, + symbolId: null, + orientation: 'identity', + }); + } + continue; + } + + const timestamp = Number.isFinite(entry.timestamp) ? new Date(entry.timestamp) : null; + const dateKey = timestamp ? formatFxDateKey(timestamp) : null; + const dateCacheKey = dateKey ? `${currency}:${baseCurrency}:${dateKey}` : null; + const latestCacheKey = `${currency}:${baseCurrency}:latest`; + + let meta = null; + if (dateCacheKey && localRateCache.has(dateCacheKey)) { + meta = localRateCache.get(dateCacheKey); + } else if (localRateCache.has(latestCacheKey)) { + meta = localRateCache.get(latestCacheKey); + } + + if (!meta) { + meta = await resolveHistoricalFxRate(login, currency, baseCurrency, timestamp, { returnMetadata: true }); + const targetCacheKey = dateCacheKey || latestCacheKey; + localRateCache.set(targetCacheKey, meta); + if (dateCacheKey && meta && meta.source === 'fallback-latest') { + localRateCache.set(latestCacheKey, meta); + } + } + + const effectiveRate = meta && isFiniteNumber(meta.rate) && meta.rate > 0 ? meta.rate : null; + if (!effectiveRate) { + if (fxSkipped) { + fxSkipped.push({ + timestamp: timestamp ? timestamp.toISOString() : null, + sourceCurrency: currency, + targetCurrency: baseCurrency, + amount, + reason: meta && meta.source ? meta.source : 'missing-rate', + }); + } + continue; + } + + convertedTotals.set(baseCurrency, (convertedTotals.get(baseCurrency) || 0) + amount * effectiveRate); + + if (fxDetails) { + fxDetails.push({ + timestamp: timestamp ? timestamp.toISOString() : null, + sourceCurrency: currency, + targetCurrency: baseCurrency, + amount, + rate: effectiveRate, + convertedAmount: amount * effectiveRate, + rateSource: meta.source || 'historical', + dateKey: meta.dateKey || dateKey, + symbol: meta.symbolInfo ? meta.symbolInfo.symbol : null, + symbolId: meta.symbolInfo ? meta.symbolInfo.symbolId : null, + orientation: meta.symbolInfo ? meta.symbolInfo.orientation : null, + }); + } } - return tokenContext; -} + if (!convertedTotals.has(baseCurrency)) { + convertedTotals.set(baseCurrency, 0); + } -async function getTokenContext(login) { - const cacheKey = getTokenCacheKey(login.id); - const cached = tokenCache.get(cacheKey); - if (cached) { - return cached; + summary.historicalConvertedTotals = convertedTotals; + if (fxDetails) { + summary.fxDetails = fxDetails; } - return refreshAccessToken(login); + if (fxSkipped) { + summary.fxSkipped = fxSkipped; + } + return summary; } -async function questradeRequest(login, pathSegment, options = {}) { - if (!login) { - throw new Error('Questrade login context is required for API requests'); +function getCachedNetDeposits(key) { + const entry = netDepositCache.get(key); + if (!entry) { + return null; } - const { method = 'GET', params, data, headers = {} } = options; - const tokenContext = await getTokenContext(login); - const url = new URL(pathSegment, tokenContext.apiServer).toString(); - - const baseConfig = { - method, - url, - params, - data, - headers: Object.assign( - { - Authorization: 'Bearer ' + tokenContext.accessToken, - }, - headers - ), - }; - - try { - const response = await enqueueRequest(() => axios(baseConfig)); - return response.data; - } catch (error) { - if (error.response && error.response.status === 401) { - tokenCache.del(getTokenCacheKey(login.id)); - const freshContext = await refreshAccessToken(login); - const retryConfig = { - method, - url: new URL(pathSegment, freshContext.apiServer).toString(), - params, - data, - headers: Object.assign( - { - Authorization: 'Bearer ' + freshContext.accessToken, - }, - headers - ), - }; - const retryResponse = await enqueueRequest(() => axios(retryConfig)); - return retryResponse.data; - } - console.error( - 'Questrade API error for login ' + resolveLoginDisplay(login) + ':', - error.response ? error.response.data : error.message - ); - throw error; + if (Date.now() - entry.timestamp > NET_DEPOSIT_CACHE_TTL_MS) { + netDepositCache.delete(key); + return null; } + return entry.value; } -async function fetchAccounts(login) { - const data = await questradeRequest(login, '/v1/accounts'); - return data.accounts || []; +function setCachedNetDeposits(key, value) { + netDepositCache.set(key, { timestamp: Date.now(), value }); } -async function fetchPositions(login, accountId) { - const data = await questradeRequest(login, '/v1/accounts/' + accountId + '/positions'); - return data.positions || []; +function convertMapToObject(map) { + const result = {}; + if (!(map instanceof Map)) { + return result; + } + map.forEach((value, key) => { + if (typeof key !== 'string') { + return; + } + const normalizedKey = key.toUpperCase(); + if (!isFiniteNumber(value) && value !== 0) { + return; + } + result[normalizedKey] = value; + }); + return result; } -async function fetchBalances(login, accountId) { - const data = await questradeRequest(login, '/v1/accounts/' + accountId + '/balances'); - return data || {}; +async function fetchAccountNetDeposits(login, accountId, options = {}) { + const cacheKey = getNetDepositCacheKey(login && login.id, accountId); + const cached = getCachedNetDeposits(cacheKey); + if (cached) { + return cached; + } + + const activities = await fetchAllAccountActivities(login, accountId, options); + const summary = accumulateNetDeposits(activities); + const baseCurrency = normalizeCurrencyCode(options.baseCurrency, 'CAD') || 'CAD'; + await augmentNetDepositSummaryWithHistoricalConversions(login, summary, { baseCurrency }); + if (ENABLE_QUESTRADE_API_DEBUG && summary && Array.isArray(summary.details) && summary.details.length) { + const preview = summary.details.slice(0, 20).map((entry) => { + return { + timestamp: entry.timestamp ? new Date(entry.timestamp).toISOString() : null, + currency: entry.currency, + amount: entry.amount, + classification: entry.classification, + type: entry.type, + action: entry.action, + description: entry.description, + amountSource: entry.amountSource || null, + rawNetAmount: entry.rawNetAmount || 0, + estimatedAmount: entry.estimatedAmount || null, + quantity: entry.quantity || null, + quantitySource: entry.quantitySource || null, + price: entry.price || null, + priceSource: entry.priceSource || null, + }; + }); + console.log('[Questrade][netDepositEntries]', accountId, preview); + if (summary.details.length > preview.length) { + console.log('[Questrade][netDepositEntries]', accountId, '…', summary.details.length - preview.length, 'more entries'); + } + } + if (ENABLE_QUESTRADE_API_DEBUG && summary && Array.isArray(summary.skipped) && summary.skipped.length) { + const skippedPreview = summary.skipped.slice(0, 20).map((entry) => { + return { + timestamp: entry.timestamp ? new Date(entry.timestamp).toISOString() : null, + currency: entry.currency, + classification: entry.classification, + type: entry.type, + action: entry.action, + description: entry.description, + rawNetAmount: entry.rawNetAmount || 0, + amountSource: entry.amountSource || null, + quantity: entry.quantity || null, + quantitySource: entry.quantitySource || null, + price: entry.price || null, + priceSource: entry.priceSource || null, + }; + }); + console.log('[Questrade][netDepositEntriesSkipped]', accountId, skippedPreview); + if (summary.skipped.length > skippedPreview.length) { + console.log('[Questrade][netDepositEntriesSkipped]', accountId, '…', summary.skipped.length - skippedPreview.length, 'more entries'); + } + } + if (ENABLE_QUESTRADE_API_DEBUG && summary && Array.isArray(summary.fxDetails) && summary.fxDetails.length) { + const fxPreview = summary.fxDetails.slice(0, 20).map((entry) => ({ + timestamp: entry.timestamp, + sourceCurrency: entry.sourceCurrency, + targetCurrency: entry.targetCurrency, + amount: entry.amount, + rate: entry.rate, + convertedAmount: entry.convertedAmount, + rateSource: entry.rateSource, + dateKey: entry.dateKey || null, + symbol: entry.symbol || null, + symbolId: entry.symbolId || null, + orientation: entry.orientation || null, + })); + console.log('[Questrade][netDepositFx]', accountId, fxPreview); + if (summary.fxDetails.length > fxPreview.length) { + console.log('[Questrade][netDepositFx]', accountId, '…', summary.fxDetails.length - fxPreview.length, 'more entries'); + } + } + if (ENABLE_QUESTRADE_API_DEBUG && summary && Array.isArray(summary.fxSkipped) && summary.fxSkipped.length) { + const skippedFxPreview = summary.fxSkipped.slice(0, 20).map((entry) => ({ + timestamp: entry.timestamp, + sourceCurrency: entry.sourceCurrency, + targetCurrency: entry.targetCurrency, + amount: entry.amount, + reason: entry.reason, + })); + console.log('[Questrade][netDepositFxSkipped]', accountId, skippedFxPreview); + if (summary.fxSkipped.length > skippedFxPreview.length) { + console.log('[Questrade][netDepositFxSkipped]', accountId, '…', summary.fxSkipped.length - skippedFxPreview.length, 'more entries'); + } + } + setCachedNetDeposits(cacheKey, summary); + return summary; } @@ -706,8 +2620,318 @@ function finalizeBalances(summary) { return summary; } -function mergePnL(positions) { - return positions.reduce( +function normalizeCurrencyCode(currency, fallback = null) { + if (typeof currency !== 'string') { + return fallback; + } + const trimmed = currency.trim(); + if (!trimmed) { + return fallback; + } + return trimmed.toUpperCase(); +} + +function findBalanceEntryKey(bucket, currency) { + if (!bucket || !currency) { + return null; + } + const normalized = normalizeCurrencyCode(currency); + if (!normalized) { + return null; + } + if (Object.prototype.hasOwnProperty.call(bucket, normalized)) { + return normalized; + } + for (const key of Object.keys(bucket)) { + if (typeof key === 'string' && key.toUpperCase() === normalized) { + return key; + } + } + return null; +} + +function getBalanceEntry(bucket, currency) { + const key = findBalanceEntryKey(bucket, currency); + if (!key) { + return null; + } + return bucket[key] || null; +} + +function deriveExchangeRate(perEntry, combinedEntry, baseCombinedEntry) { + if (!perEntry && !combinedEntry) { + return null; + } + + const directFields = ['exchangeRate', 'fxRate', 'conversionRate', 'rate']; + for (const field of directFields) { + const candidate = (perEntry && perEntry[field]) ?? (combinedEntry && combinedEntry[field]) ?? null; + if (isFiniteNumber(candidate) && candidate > 0) { + return candidate; + } + } + + if (baseCombinedEntry && combinedEntry) { + const baseFields = ['totalEquity', 'marketValue', 'cash', 'buyingPower']; + for (const field of baseFields) { + const baseValue = baseCombinedEntry[field]; + const currencyValue = combinedEntry[field]; + if (!isFiniteNumber(baseValue) || !isFiniteNumber(currencyValue)) { + continue; + } + if (Math.abs(currencyValue) <= 1e-9) { + continue; + } + const ratio = baseValue / currencyValue; + if (isFiniteNumber(ratio) && Math.abs(ratio) > 1e-9) { + return Math.abs(ratio); + } + } + } + + const ratioSources = [ + ['totalEquity', 'totalEquity'], + ['marketValue', 'marketValue'], + ]; + + for (const [perField, combinedField] of ratioSources) { + const perValue = perEntry ? perEntry[perField] : null; + const combinedValue = combinedEntry ? combinedEntry[combinedField] : null; + if (!isFiniteNumber(perValue) || !isFiniteNumber(combinedValue)) { + continue; + } + if (Math.abs(perValue) <= 1e-9 || Math.abs(combinedValue) <= 1e-9) { + continue; + } + const ratio = combinedValue / perValue; + if (isFiniteNumber(ratio) && Math.abs(ratio) > 1e-9) { + return Math.abs(ratio); + } + } + + return null; +} + +function buildCurrencyRateMapFromSummary(summary, baseCurrency = 'CAD') { + const normalizedBase = normalizeCurrencyCode(baseCurrency, 'CAD') || 'CAD'; + const rates = new Map(); + rates.set(normalizedBase, 1); + + if (!summary || typeof summary !== 'object') { + return rates; + } + + const combined = summary.combined || {}; + const perCurrency = summary.perCurrency || {}; + const baseCombinedEntry = getBalanceEntry(combined, normalizedBase); + const allKeys = new Set([...Object.keys(combined || {}), ...Object.keys(perCurrency || {})]); + + allKeys.forEach((key) => { + if (!key) { + return; + } + const normalizedKey = normalizeCurrencyCode(key, normalizedBase); + if (!normalizedKey || rates.has(normalizedKey)) { + return; + } + const perEntry = getBalanceEntry(perCurrency, normalizedKey); + const combinedEntry = getBalanceEntry(combined, normalizedKey); + const derived = deriveExchangeRate(perEntry, combinedEntry, baseCombinedEntry); + if (derived && derived > 0) { + rates.set(normalizedKey, derived); + return; + } + if (normalizedKey === normalizedBase) { + rates.set(normalizedKey, 1); + } + }); + + return rates; +} + +function convertAmountToCurrency(value, sourceCurrency, targetCurrency, currencyRates, baseCurrency = 'CAD') { + if (!isFiniteNumber(value)) { + return 0; + } + const normalizedBase = normalizeCurrencyCode(baseCurrency, 'CAD') || 'CAD'; + const normalizedSource = normalizeCurrencyCode(sourceCurrency, normalizedBase) || normalizedBase; + const normalizedTarget = normalizeCurrencyCode(targetCurrency, normalizedBase) || normalizedBase; + + const sourceRate = currencyRates && currencyRates.get(normalizedSource); + let baseValue = null; + if (isFiniteNumber(sourceRate) && sourceRate > 0) { + baseValue = value * sourceRate; + } else if (normalizedSource === normalizedBase) { + baseValue = value; + } + + if (baseValue === null) { + return 0; + } + + if (normalizedTarget === normalizedBase) { + return baseValue; + } + + const targetRate = currencyRates && currencyRates.get(normalizedTarget); + if (isFiniteNumber(targetRate) && targetRate > 0) { + return baseValue / targetRate; + } + + return baseValue; +} + +function applyTotalPnlToBalanceSummary(summary, totals) { + if (!summary || typeof summary !== 'object' || !totals) { + return; + } + + if (totals.perCurrency && summary.perCurrency) { + Object.entries(totals.perCurrency).forEach(([currency, value]) => { + if (!isFiniteNumber(value) && value !== 0) { + return; + } + const key = findBalanceEntryKey(summary.perCurrency, currency); + if (!key || !summary.perCurrency[key] || typeof summary.perCurrency[key] !== 'object') { + return; + } + summary.perCurrency[key].totalPnl = value; + }); + } + + if (totals.combined && summary.combined) { + Object.entries(totals.combined).forEach(([currency, value]) => { + if (!isFiniteNumber(value) && value !== 0) { + return; + } + const key = findBalanceEntryKey(summary.combined, currency); + if (!key || !summary.combined[key] || typeof summary.combined[key] !== 'object') { + return; + } + summary.combined[key].totalPnl = value; + }); + } +} + +function computeAccountTotalPnlSummary(balanceSummary, netDeposits, options = {}) { + if (!balanceSummary || typeof balanceSummary !== 'object' || !netDeposits) { + return null; + } + const totalsMap = netDeposits.totals instanceof Map ? netDeposits.totals : null; + const countsMap = netDeposits.counts instanceof Map ? netDeposits.counts : null; + if (!totalsMap || totalsMap.size === 0) { + return null; + } + const baseCurrency = options.baseCurrency || 'CAD'; + const normalizedBaseCurrency = normalizeCurrencyCode(baseCurrency, 'CAD') || 'CAD'; + const perCurrencyResult = {}; + const combinedResult = {}; + const netDepositPerCurrency = {}; + const netDepositCounts = convertMapToObject(countsMap); + const combinedNetDeposits = {}; + const perCurrencyEquity = {}; + const combinedEquity = {}; + let hasValue = false; + let hasNetDepositData = false; + const historicalCombinedMap = + netDeposits && netDeposits.historicalConvertedTotals instanceof Map + ? netDeposits.historicalConvertedTotals + : null; + + totalsMap.forEach((amount, currency) => { + const normalized = normalizeCurrencyCode(currency); + if (!normalized) { + return; + } + if (!isFiniteNumber(amount) && amount !== 0) { + return; + } + netDepositPerCurrency[normalized] = amount; + hasNetDepositData = true; + }); + + if (balanceSummary.perCurrency && countsMap) { + Object.entries(balanceSummary.perCurrency).forEach(([key, entry]) => { + const normalized = normalizeCurrencyCode(key); + if (!normalized || !countsMap.has(normalized)) { + return; + } + const equity = entry && entry.totalEquity; + if (!isFiniteNumber(equity)) { + return; + } + const deposit = totalsMap.get(normalized) || 0; + const totalPnl = equity - deposit; + perCurrencyResult[normalized] = totalPnl; + perCurrencyEquity[normalized] = equity; + hasValue = true; + }); + } + + if (balanceSummary.combined) { + let rates = null; + Object.entries(balanceSummary.combined).forEach(([key, entry]) => { + const normalized = normalizeCurrencyCode(key); + if (!normalized) { + return; + } + const equity = entry && entry.totalEquity; + if (!isFiniteNumber(equity)) { + return; + } + let convertedDeposits = null; + if ( + normalized === normalizedBaseCurrency && + historicalCombinedMap && + historicalCombinedMap.has(normalizedBaseCurrency) + ) { + const candidate = historicalCombinedMap.get(normalizedBaseCurrency); + if (isFiniteNumber(candidate)) { + convertedDeposits = candidate; + } + } + if (!isFiniteNumber(convertedDeposits)) { + if (!rates) { + rates = buildCurrencyRateMapFromSummary(balanceSummary, baseCurrency); + } + let fallbackDeposits = 0; + totalsMap.forEach((amount, sourceCurrency) => { + fallbackDeposits += convertAmountToCurrency(amount, sourceCurrency, normalized, rates, baseCurrency); + }); + convertedDeposits = fallbackDeposits; + } + combinedResult[normalized] = equity - convertedDeposits; + combinedEquity[normalized] = equity; + if (hasNetDepositData) { + combinedNetDeposits[normalized] = convertedDeposits; + } + hasValue = true; + }); + } + + if (!hasValue && !hasNetDepositData) { + return null; + } + + const result = { + perCurrency: perCurrencyResult, + combined: combinedResult, + netDeposits: { + perCurrency: netDepositPerCurrency, + combined: combinedNetDeposits, + counts: netDepositCounts, + }, + equity: { + perCurrency: perCurrencyEquity, + combined: combinedEquity, + }, + }; + + return result; +} + +function mergePnL(positions, totalPnlValue = null) { + const summary = positions.reduce( function (acc, position) { acc.dayPnl += position.dayPnl || 0; acc.openPnl += position.openPnl || 0; @@ -715,6 +2939,12 @@ function mergePnL(positions) { }, { dayPnl: 0, openPnl: 0 } ); + if (isFiniteNumber(totalPnlValue) || totalPnlValue === 0) { + summary.totalPnl = totalPnlValue; + } else { + summary.totalPnl = null; + } + return summary; } function buildInvestmentModelPositions(positions, accountId) { @@ -915,6 +3145,11 @@ app.get('/api/summary', async function (req, res) { loginLabel: ownerLabel, loginEmail: login.email || null, }); + const inferredStartDate = inferAccountStartDate(account); + if (inferredStartDate) { + normalizedAccount.activityHistoryStart = inferredStartDate.toISOString(); + normalizedAccount.activityHistoryStartMs = inferredStartDate.getTime(); + } const displayName = resolveAccountDisplayName(accountNameOverrides, normalizedAccount, login); if (displayName) { normalizedAccount.displayName = displayName; @@ -1081,10 +3316,15 @@ app.get('/api/summary', async function (req, res) { }) ); const perAccountCombinedBalances = {}; + const perAccountBalanceSummaries = {}; selectedContexts.forEach(function (context, index) { - const combined = summarizeAccountCombinedBalances(balancesResults[index]); - if (combined) { - perAccountCombinedBalances[context.account.id] = combined; + const accountBalancesSummary = mergeBalances([balancesResults[index]]); + finalizeBalances(accountBalancesSummary); + if (accountBalancesSummary && accountBalancesSummary.combined) { + perAccountCombinedBalances[context.account.id] = accountBalancesSummary.combined; + } + if (accountBalancesSummary) { + perAccountBalanceSummaries[context.account.id] = accountBalancesSummary; } }); const flattenedPositions = positionsResults @@ -1127,10 +3367,79 @@ app.get('/api/summary', async function (req, res) { }); const decoratedPositions = decoratePositions(flattenedPositions, symbolsMap, accountsMap); - const pnl = mergePnL(flattenedPositions); const balancesSummary = mergeBalances(balancesResults); finalizeBalances(balancesSummary); + let computedTotalPnl = null; + if (selectedContexts.length === 1) { + const context = selectedContexts[0]; + const accountSummary = perAccountBalanceSummaries[context.account.id] || null; + if (accountSummary && context.account && context.account.number) { + try { + const netDepositStart = + context.account.activityHistoryStartMs || context.account.activityHistoryStart || null; + const baseCurrency = 'CAD'; + const netDeposits = await fetchAccountNetDeposits(context.login, context.account.number, { + startDate: netDepositStart, + baseCurrency, + }); + const totals = computeAccountTotalPnlSummary(accountSummary, netDeposits, { baseCurrency }); + if (totals) { + applyTotalPnlToBalanceSummary(accountSummary, totals); + applyTotalPnlToBalanceSummary(balancesSummary, totals); + computedTotalPnl = totals; + if (ENABLE_QUESTRADE_API_DEBUG) { + console.log('[Questrade][totalPnl]', context.account.number, { + perCurrency: totals.perCurrency, + combined: totals.combined, + netDeposits: totals.netDeposits, + equity: totals.equity, + }); + } + } + } catch (totalError) { + console.warn( + 'Failed to compute total P&L for account ' + context.account.id + ':', + totalError && totalError.message ? totalError.message : totalError + ); + } + } + } + + let totalPnlOverride = null; + if (computedTotalPnl && computedTotalPnl.combined) { + const cadKey = findBalanceEntryKey(balancesSummary.combined, 'CAD'); + if (cadKey) { + const cadEntry = balancesSummary.combined[cadKey]; + if (cadEntry && isFiniteNumber(cadEntry.totalPnl)) { + totalPnlOverride = cadEntry.totalPnl; + } + } + if (totalPnlOverride === null) { + for (const value of Object.values(computedTotalPnl.combined)) { + if (isFiniteNumber(value)) { + totalPnlOverride = value; + break; + } + } + } + } + + const pnl = mergePnL(flattenedPositions, totalPnlOverride); + if (computedTotalPnl) { + if (computedTotalPnl.perCurrency || computedTotalPnl.combined) { + pnl.totalPnlBreakdown = { + perCurrency: computedTotalPnl.perCurrency || {}, + combined: computedTotalPnl.combined || {}, + }; + } + if (computedTotalPnl.netDeposits) { + pnl.netDeposits = computedTotalPnl.netDeposits; + } + if (computedTotalPnl.equity) { + pnl.totalEquityBreakdown = computedTotalPnl.equity; + } + } const defaultAccountId = defaultAccount ? defaultAccount.id : null; const investmentModelEvaluations = {};