diff --git a/README.md b/README.md index 75be026..26adae4 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ A local web app that mirrors the Questrade web portal "Summary" tab so you can r - Currency toggle that surfaces combined and per-currency balances if Questrade returns them. - Total equity card with today's and open P&L badges, cash, market value, and buying power. - Positions table listing symbol, description, account number, intraday/open P&L, quantities, prices, and market value. +- Account performance dialog that pulls execution history plus optional configured transfers to chart total value over time and compute Total P&L/CAGR across preset ranges. - Manual refresh button to force a new fetch from Questrade. - People overlay that converts every account to CAD and totals holdings for each household member. - Automatic handling of access-token refresh and persistence of the newest refresh token. diff --git a/client/src/App.css b/client/src/App.css index c6f9f0b..31fc879 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -463,6 +463,262 @@ textarea { gap: 16px; } +.performance-overlay { + position: fixed; + inset: 0; + background: rgba(17, 24, 39, 0.45); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + z-index: 1000; +} + +.performance-dialog { + background: var(--color-surface); + border-radius: var(--radius-card); + border: 1px solid var(--color-border); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.22); + max-width: 640px; + width: 100%; + max-height: 90vh; + display: flex; + flex-direction: column; + padding: 24px; + overflow-y: auto; +} + +.performance-dialog__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.performance-dialog__heading { + display: flex; + flex-direction: column; + gap: 8px; +} + +.performance-dialog__heading h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: var(--color-text-primary); +} + +.performance-dialog__controls { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--color-text-secondary); +} + +.performance-dialog__controls select { + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); + padding: 4px 10px; + font-size: 13px; + background: var(--color-surface-alt); + color: var(--color-text-primary); +} + +.performance-dialog__controls select:focus-visible { + outline: none; + border-color: var(--color-border-active); +} + +.performance-dialog__close { + border: none; + background: transparent; + color: var(--color-text-secondary); + font-size: 24px; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + border-radius: var(--radius-pill); + transition: color 0.2s ease, background-color 0.2s ease; +} + +.performance-dialog__close:hover, +.performance-dialog__close:focus-visible { + color: var(--color-text-primary); + background: var(--color-surface-alt); + outline: none; +} + +.performance-dialog__body { + display: flex; + flex-direction: column; + gap: 20px; +} + +.performance-dialog__status { + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; + justify-content: center; + min-height: 160px; + text-align: center; + color: var(--color-text-secondary); +} + +.performance-dialog__status--error { + color: var(--color-error); +} + +.performance-dialog__spinner { + width: 28px; + height: 28px; + border-radius: 50%; + border: 3px solid rgba(107, 119, 135, 0.3); + border-top-color: rgba(55, 125, 255, 0.9); + animation: time-pill-spin 1s linear infinite; +} + +.performance-dialog__metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px 24px; +} + +.performance-dialog__metric dt { + font-size: 13px; + color: var(--color-text-secondary); + margin: 0 0 4px; +} + +.performance-dialog__metric dd { + margin: 0; + display: inline-flex; + align-items: baseline; + gap: 8px; + font-variant-numeric: tabular-nums; +} + +.performance-dialog__metric-value { + font-size: 20px; + font-weight: 600; + color: var(--color-text-primary); +} + +.performance-dialog__metric-extra { + font-size: 14px; + color: var(--color-text-secondary); +} + +.performance-dialog__chart { + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + background: var(--color-surface-alt); + padding: 8px 12px; +} + +.performance-dialog__chart-svg { + width: 100%; + height: auto; + display: block; +} + +.performance-chart__surface { + fill: transparent; +} + +.performance-chart__grid-line { + stroke: rgba(136, 149, 167, 0.28); + stroke-width: 0.45; +} + +.performance-chart__grid-label { + font-size: 7px; + fill: var(--color-text-muted); +} + +.performance-chart__path { + fill: none; + stroke: var(--color-accent); + stroke-width: 0.7; + stroke-linecap: round; + stroke-linejoin: round; +} + +.performance-chart__dot { + fill: var(--color-accent); +} + +.performance-dialog__chart-empty { + margin: 0; + text-align: center; + font-size: 13px; + color: var(--color-text-secondary); +} + +.performance-dialog__summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px 16px; +} + +.performance-dialog__summary-label { + display: block; + font-size: 12px; + color: var(--color-text-secondary); + margin-bottom: 2px; +} + +.performance-dialog__summary-value { + font-size: 15px; + font-weight: 500; + color: var(--color-text-primary); + font-variant-numeric: tabular-nums; +} + +.performance-dialog__disclaimer { + margin: 8px 0 0; + font-size: 12px; + color: var(--color-text-muted); +} + +.performance-trigger { + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); + padding: 6px 14px; + background: var(--color-surface-alt); + color: var(--color-text-primary); + font-size: 13px; + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease; +} + +.performance-trigger:hover, +.performance-trigger:focus-visible { + border-color: var(--color-text-primary); + color: var(--color-text-primary); + outline: none; +} + +.performance-trigger:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.performance-trigger__icon { + width: 14px; + height: 14px; + background-repeat: no-repeat; + background-position: center; + background-size: 14px 14px; + background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%3E%3Cpath%20d%3D'M0%200h24v24H0Z'%20fill%3D'none'/%3E%3Cpath%20d%3D'M17.65%206.35A8%208%200%201%200%2019.73%2014h-2.08A6%206%200%201%201%2012%206a5.92%205.92%200%200%201%204.22%201.78L13%2011h7V4Z'%20fill%3D'%236b7787'/%3E%3C/svg%3E"); + animation: time-pill-spin 1s linear infinite; +} + .beneficiaries-list { list-style: none; margin: 0; diff --git a/client/src/App.jsx b/client/src/App.jsx index ca72e01..a08d3ff 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import AccountSelector from './components/AccountSelector'; import SummaryMetrics from './components/SummaryMetrics'; import PositionsTable from './components/PositionsTable'; -import { getSummary, getQqqTemperature } from './api/questrade'; +import { getSummary, getQqqTemperature, getAccountPerformance } from './api/questrade'; import usePersistentState from './hooks/usePersistentState'; import PeopleDialog from './components/PeopleDialog'; import PnlHeatmapDialog from './components/PnlHeatmapDialog'; @@ -13,6 +13,7 @@ import { formatNumber, formatSignedMoney, } from './utils/formatters'; +import AccountPerformanceDialog from './components/AccountPerformanceDialog'; import './App.css'; const DEFAULT_POSITIONS_SORT = { column: 'portfolioShare', direction: 'desc' }; @@ -922,6 +923,12 @@ export default function App() { const [positionsPnlMode, setPositionsPnlMode] = usePersistentState('positionsTablePnlMode', 'currency'); const [showPeople, setShowPeople] = useState(false); const [pnlBreakdownMode, setPnlBreakdownMode] = useState(null); + const [showPerformance, setShowPerformance] = useState(false); + const [performanceStatus, setPerformanceStatus] = useState('idle'); + const [performanceData, setPerformanceData] = useState(null); + const [performanceError, setPerformanceError] = useState(null); + const [performanceRange, setPerformanceRange] = useState('all'); + const performanceAccountRef = useRef(null); const [qqqData, setQqqData] = useState(null); const [qqqLoading, setQqqLoading] = useState(false); const [qqqError, setQqqError] = useState(null); @@ -984,6 +991,15 @@ export default function App() { }) || null ); }, [accounts, selectedAccount]); + + useEffect(() => { + performanceAccountRef.current = selectedAccountInfo?.id || null; + setPerformanceStatus('idle'); + setPerformanceData(null); + setPerformanceError(null); + setShowPerformance(false); + setPerformanceRange('all'); + }, [selectedAccountInfo?.id]); const rawPositions = useMemo(() => data?.positions ?? [], [data?.positions]); const balances = data?.balances || null; const accountBalances = data?.accountBalances ?? EMPTY_OBJECT; @@ -1511,6 +1527,43 @@ export default function App() { setShowPeople(false); }; + const handleRequestPerformance = () => { + if (performanceStatus === 'loading') { + return; + } + if (!selectedAccountInfo?.id || showingAllAccounts) { + return; + } + const accountId = selectedAccountInfo.id; + performanceAccountRef.current = accountId; + setPerformanceStatus('loading'); + setPerformanceError(null); + setPerformanceRange('all'); + getAccountPerformance(accountId) + .then((result) => { + if (performanceAccountRef.current !== accountId) { + return; + } + setPerformanceData(result); + setPerformanceStatus('ready'); + setShowPerformance(true); + }) + .catch((err) => { + if (performanceAccountRef.current !== accountId) { + return; + } + const normalized = err instanceof Error ? err : new Error('Failed to calculate performance.'); + setPerformanceError(normalized); + setPerformanceData(null); + setPerformanceStatus('error'); + setShowPerformance(true); + }); + }; + + const handleClosePerformance = () => { + setShowPerformance(false); + }; + if (loading && !data) { return (
@@ -1561,6 +1614,8 @@ export default function App() { chatUrl={selectedAccountChatUrl} showQqqTemperature={showingAllAccounts} qqqSummary={qqqSummary} + onShowPerformance={showingAllAccounts ? null : handleRequestPerformance} + performanceStatus={performanceStatus} /> )} @@ -1609,6 +1664,16 @@ export default function App() { totalMarketValue={heatmapMarketValue} /> )} + {showPerformance && ( + + )}
); } diff --git a/client/src/api/questrade.js b/client/src/api/questrade.js index ee4696c..24464cf 100644 --- a/client/src/api/questrade.js +++ b/client/src/api/questrade.js @@ -15,6 +15,21 @@ function buildQqqTemperatureUrl() { return url.toString(); } +function buildPerformanceUrl(accountId, options = {}) { + const base = API_BASE_URL.replace(/\/$/, ''); + const url = new URL('/api/account-performance', base); + if (accountId) { + url.searchParams.set('accountId', accountId); + } + if (options.startTime) { + url.searchParams.set('startTime', options.startTime); + } + if (options.endTime) { + url.searchParams.set('endTime', options.endTime); + } + return url.toString(); +} + export async function getSummary(accountId) { const response = await fetch(buildUrl(accountId)); if (!response.ok) { @@ -32,3 +47,15 @@ export async function getQqqTemperature() { } return response.json(); } + +export async function getAccountPerformance(accountId, options = {}) { + if (!accountId || accountId === 'all') { + throw new Error('An individual account identifier is required for performance data.'); + } + const response = await fetch(buildPerformanceUrl(accountId, options)); + if (!response.ok) { + const text = await response.text(); + throw new Error(text || 'Failed to load account performance data'); + } + return response.json(); +} diff --git a/client/src/components/AccountPerformanceDialog.jsx b/client/src/components/AccountPerformanceDialog.jsx new file mode 100644 index 0000000..728ee44 --- /dev/null +++ b/client/src/components/AccountPerformanceDialog.jsx @@ -0,0 +1,270 @@ +import { useEffect, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { PERFORMANCE_RANGES, buildRangeSummary, resolveRangeDefinition } from '../utils/performance'; +import { + formatDate, + formatMoney, + formatSignedMoney, + formatSignedPercent, +} from '../utils/formatters'; + +function PerformanceChart({ data }) { + if (!Array.isArray(data) || data.length < 2) { + return

Not enough data to render a chart.

; + } + + const points = data + .map((entry) => ({ + date: entry.date, + value: Number(entry.value) || 0, + })) + .filter((entry) => Number.isFinite(entry.value)); + + if (points.length < 2) { + return

Not enough data to render a chart.

; + } + + const values = points.map((entry) => entry.value); + const minValue = Math.min(...values); + const maxValue = Math.max(...values); + const width = 128; + const height = 68; + const padding = { top: 6, right: 24, bottom: 12, left: 8 }; + const innerWidth = width - padding.left - padding.right; + const innerHeight = height - padding.top - padding.bottom; + const range = maxValue - minValue; + const domainPadding = range === 0 ? Math.max(1, Math.abs(maxValue) * 0.05) : range * 0.1; + const domainMin = minValue - domainPadding; + const domainMax = maxValue + domainPadding; + const domainRange = domainMax - domainMin || 1; + + const xForIndex = (index) => { + if (points.length === 1) { + return padding.left + innerWidth / 2; + } + return padding.left + (innerWidth * index) / (points.length - 1); + }; + + const yForValue = (value) => { + const ratio = (value - domainMin) / domainRange; + const clamped = Math.max(0, Math.min(1, ratio)); + return padding.top + innerHeight * (1 - clamped); + }; + + const svgPoints = points.map((point, index) => ({ + x: xForIndex(index), + y: yForValue(point.value), + value: point.value, + })); + + const path = svgPoints + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`) + .join(' '); + + const tickCount = 2; + const gridLines = Array.from({ length: tickCount + 1 }, (_, index) => { + const value = domainMin + (domainRange * index) / tickCount; + return { value, y: yForValue(value) }; + }); + + const lastPoint = svgPoints[svgPoints.length - 1]; + + return ( + + + + + + + ); +} + +PerformanceChart.propTypes = { + data: PropTypes.arrayOf( + PropTypes.shape({ + date: PropTypes.string.isRequired, + value: PropTypes.number.isRequired, + }) + ), +}; + +PerformanceChart.defaultProps = { + data: [], +}; + +export default function AccountPerformanceDialog({ + performance, + status, + onClose, + range, + onRangeChange, + error, +}) { + useEffect(() => { + function handleKeyDown(event) { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + } + } + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const handleOverlayClick = (event) => { + if (event.target === event.currentTarget) { + onClose(); + } + }; + + const resolvedRange = resolveRangeDefinition(range); + const summary = useMemo(() => buildRangeSummary(performance, resolvedRange.value), [performance, resolvedRange.value]); + + let bodyContent = null; + + if (status === 'loading') { + bodyContent = ( +
+ Loading performance data… +
+ ); + } else if (status === 'error') { + bodyContent = ( +
+ Unable to load performance data. +

{error?.message || 'Something went wrong while calculating performance.'}

+
+ ); + } else { + const percent = summary.totalReturn !== null ? formatSignedPercent(summary.totalReturn * 100) : '—'; + const cagr = summary.cagr !== null ? formatSignedPercent(summary.cagr * 100) : '—'; + bodyContent = ( + <> +
+
+
Total P&L
+
+ {formatSignedMoney(summary.totalPnl)} + {percent} +
+
+
+
CAGR
+
+ {cagr} +
+
+
+
+ ({ date: entry.date, value: Number(entry.value) || 0 }))} /> +
+
+
+ Start ({formatDate(summary.startDate)}) + {formatMoney(summary.startValue)} +
+
+ End ({formatDate(summary.endDate)}) + {formatMoney(summary.endValue)} +
+
+ Contributions + {formatMoney(summary.contributions)} +
+
+ Withdrawals + {formatMoney(summary.withdrawals)} +
+
+

+ Performance is estimated from trade history and public price data. Dividends, cash transfers, and other adjustments may not be included. +

+ + ); + } + + return ( +
+
+
+
+

Account performance

+
+ + +
+
+ +
+
{bodyContent}
+
+
+ ); +} + +AccountPerformanceDialog.propTypes = { + performance: PropTypes.shape({ + timeline: PropTypes.arrayOf( + PropTypes.shape({ + date: PropTypes.string.isRequired, + value: PropTypes.number.isRequired, + }) + ), + cashFlows: PropTypes.arrayOf( + PropTypes.shape({ + timestamp: PropTypes.string, + amount: PropTypes.number, + }) + ), + totals: PropTypes.shape({ + startDate: PropTypes.string, + endDate: PropTypes.string, + startValue: PropTypes.number, + endValue: PropTypes.number, + totalPnl: PropTypes.number, + totalReturn: PropTypes.number, + cagr: PropTypes.number, + totalContributions: PropTypes.number, + totalWithdrawals: PropTypes.number, + }), + metadata: PropTypes.object, + }), + status: PropTypes.oneOf(['idle', 'loading', 'ready', 'error']).isRequired, + onClose: PropTypes.func.isRequired, + range: PropTypes.string.isRequired, + onRangeChange: PropTypes.func.isRequired, + error: PropTypes.instanceOf(Error), +}; + +AccountPerformanceDialog.defaultProps = { + performance: null, + error: null, +}; diff --git a/client/src/components/SummaryMetrics.jsx b/client/src/components/SummaryMetrics.jsx index b831572..0c80dd3 100644 --- a/client/src/components/SummaryMetrics.jsx +++ b/client/src/components/SummaryMetrics.jsx @@ -205,6 +205,8 @@ export default function SummaryMetrics({ chatUrl, showQqqTemperature, qqqSummary, + onShowPerformance, + performanceStatus, }) { const title = 'Total equity (Combined in CAD)'; const totalEquity = balances?.totalEquity ?? null; @@ -355,7 +357,24 @@ export default function SummaryMetrics({ tone={openTone} onActivate={onShowPnlBreakdown ? () => onShowPnlBreakdown('open') : null} /> - + {typeof onShowPerformance === 'function' ? ( +
+
Total P&L
+
+ +
+
+ ) : ( + + )}
@@ -414,6 +433,8 @@ SummaryMetrics.propTypes = { date: PropTypes.string, message: PropTypes.string, }), + onShowPerformance: PropTypes.func, + performanceStatus: PropTypes.oneOf(['idle', 'loading', 'ready', 'error']), }; SummaryMetrics.defaultProps = { @@ -432,4 +453,6 @@ SummaryMetrics.defaultProps = { chatUrl: null, showQqqTemperature: false, qqqSummary: null, + onShowPerformance: null, + performanceStatus: 'idle', }; diff --git a/client/src/utils/performance.js b/client/src/utils/performance.js new file mode 100644 index 0000000..e20bf96 --- /dev/null +++ b/client/src/utils/performance.js @@ -0,0 +1,276 @@ +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const MS_PER_YEAR = 365.25 * MS_PER_DAY; + +export const PERFORMANCE_RANGES = [ + { value: 'all', label: 'All', days: null }, + { value: '1d', label: 'Last Day', days: 1 }, + { value: '1w', label: 'Last Week', days: 7 }, + { value: '1m', label: 'Last Month', days: 30 }, + { value: '1y', label: 'Last Year', days: 365 }, +]; + +function parseDateKey(key) { + if (!key) { + return null; + } + const normalized = `${key}T00:00:00Z`; + const date = new Date(normalized); + if (Number.isNaN(date.getTime())) { + return null; + } + return date; +} + +function parseTimestamp(value) { + if (!value) { + return null; + } + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) { + return null; + } + return value; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + return date; +} + +function clampStartDate(timeline, desired) { + if (!Array.isArray(timeline) || timeline.length === 0) { + return desired; + } + const first = timeline[0]; + const firstDate = parseDateKey(first.date); + if (!firstDate) { + return desired; + } + if (!desired || desired < firstDate) { + return firstDate; + } + return desired; +} + +function findStartEntry(timeline, startDate) { + if (!Array.isArray(timeline) || timeline.length === 0) { + return null; + } + if (!startDate) { + return timeline[0]; + } + for (let index = 0; index < timeline.length; index += 1) { + const entry = timeline[index]; + const entryDate = parseDateKey(entry.date); + if (!entryDate) { + continue; + } + if (entryDate >= startDate) { + return entry; + } + } + return timeline[0]; +} + +function filterTimeline(timeline, startDate) { + if (!Array.isArray(timeline) || timeline.length === 0) { + return []; + } + if (!startDate) { + return timeline.slice(); + } + return timeline.filter((entry) => { + const entryDate = parseDateKey(entry.date); + if (!entryDate) { + return false; + } + return entryDate >= startDate; + }); +} + +function summarizeCashFlows(cashFlows, startDate, endDate) { + if (!Array.isArray(cashFlows) || cashFlows.length === 0) { + return { + contributions: 0, + withdrawals: 0, + flows: [], + }; + } + const startTime = startDate ? startDate.getTime() : Number.NEGATIVE_INFINITY; + const endTime = endDate ? endDate.getTime() + MS_PER_DAY - 1 : Number.POSITIVE_INFINITY; + let contributions = 0; + let withdrawals = 0; + const flows = []; + + cashFlows.forEach((flow) => { + if (!flow || flow.amount === undefined || flow.amount === null || !flow.timestamp) { + return; + } + const amount = Number(flow.amount); + if (!Number.isFinite(amount) || amount === 0) { + return; + } + const type = flow.type ? String(flow.type).toLowerCase() : null; + if (type === 'execution') { + return; + } + const timestamp = parseTimestamp(flow.timestamp); + if (!timestamp) { + return; + } + const timeValue = timestamp.getTime(); + if (timeValue < startTime || timeValue > endTime) { + return; + } + if (amount > 0) { + withdrawals += amount; + } else { + contributions += -amount; + } + flows.push({ date: timestamp, amount }); + }); + + return { contributions, withdrawals, flows }; +} + +function xnpv(rate, flows) { + const firstDate = flows[0].date; + return flows.reduce((sum, flow) => { + const years = (flow.date.getTime() - firstDate.getTime()) / MS_PER_YEAR; + return sum + flow.amount / (1 + rate) ** years; + }, 0); +} + +function computeXirr(flows) { + if (!Array.isArray(flows) || flows.length < 2) { + return null; + } + const hasPositive = flows.some((flow) => flow.amount > 0); + const hasNegative = flows.some((flow) => flow.amount < 0); + if (!hasPositive || !hasNegative) { + return null; + } + + const sorted = flows + .slice() + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .map((flow) => ({ + date: flow.date, + amount: flow.amount, + })); + + let rate = 0.1; + for (let iteration = 0; iteration < 100; iteration += 1) { + const value = xnpv(rate, sorted); + const derivative = sorted.reduce((sum, flow) => { + const years = (flow.date.getTime() - sorted[0].date.getTime()) / MS_PER_YEAR; + const denominator = (1 + rate) ** (years + 1); + return sum - years * flow.amount / denominator; + }, 0); + + if (Math.abs(derivative) < 1e-10) { + break; + } + + const nextRate = rate - value / derivative; + if (!Number.isFinite(nextRate) || nextRate <= -0.999999) { + break; + } + + if (Math.abs(nextRate - rate) < 1e-7) { + rate = nextRate; + break; + } + + rate = nextRate; + } + + if (!Number.isFinite(rate) || rate <= -0.999999) { + return null; + } + + const residual = xnpv(rate, sorted); + if (Number.isFinite(residual) && Math.abs(residual) < 1e-4) { + return rate; + } + return null; +} + +export function resolveRangeDefinition(value) { + return PERFORMANCE_RANGES.find((range) => range.value === value) || PERFORMANCE_RANGES[0]; +} + +export function buildRangeSummary(performance, rangeValue) { + const range = resolveRangeDefinition(rangeValue); + const timeline = Array.isArray(performance?.timeline) ? performance.timeline : []; + if (!timeline.length) { + return { + range, + timeline: [], + startDate: null, + endDate: null, + startValue: 0, + endValue: 0, + contributions: 0, + withdrawals: 0, + totalPnl: 0, + totalReturn: null, + cagr: null, + }; + } + + const endEntry = timeline[timeline.length - 1]; + const endDate = parseDateKey(endEntry.date); + const earliestDate = parseDateKey(timeline[0].date); + + let desiredStart = earliestDate; + if (range.days && endDate) { + desiredStart = new Date(endDate.getTime() - range.days * MS_PER_DAY); + } + + const startDate = clampStartDate(timeline, desiredStart); + const startEntry = findStartEntry(timeline, startDate); + const effectiveStartDate = parseDateKey(startEntry?.date) || startDate || earliestDate; + const filteredTimeline = filterTimeline(timeline, effectiveStartDate); + + const { contributions, withdrawals, flows } = summarizeCashFlows( + performance.cashFlows, + effectiveStartDate, + endDate + ); + + const startValue = Number(startEntry?.value) || 0; + const endValue = Number(endEntry?.value) || 0; + + const totalPnl = (endValue + withdrawals) - (startValue + contributions); + const invested = startValue + contributions; + const totalReturn = invested > 0 ? totalPnl / invested : null; + + const irrFlows = []; + if (effectiveStartDate && startValue) { + irrFlows.push({ date: effectiveStartDate, amount: -startValue }); + } + flows.forEach((flow) => { + irrFlows.push(flow); + }); + if (endDate && endValue) { + irrFlows.push({ date: endDate, amount: endValue }); + } + + const cagr = irrFlows.length >= 2 ? computeXirr(irrFlows) : null; + + return { + range, + timeline: filteredTimeline, + startDate: effectiveStartDate || null, + endDate: endDate || null, + startValue, + endValue, + contributions, + withdrawals, + totalPnl, + totalReturn, + cagr, + }; +} diff --git a/server/accounts.example.json b/server/accounts.example.json index 1ca977c..438a817 100644 --- a/server/accounts.example.json +++ b/server/accounts.example.json @@ -6,7 +6,14 @@ "showQQQDetails": true, "investmentModel": "A1", "lastRebalance": "2024-01-15", - "default": true + "default": true, + "transfers": [ + { + "symbol": "AAPL", + "quantity": 25, + "timestamp": "2020-06-15" + } + ] }, "53384040": { "name": "Margin", diff --git a/server/src/accountNames.js b/server/src/accountNames.js index e8fd3d0..6a1ea23 100644 --- a/server/src/accountNames.js +++ b/server/src/accountNames.js @@ -256,6 +256,115 @@ const PORTAL_ID_KEYS = [ const CHAT_URL_KEYS = ['chatURL', 'chatUrl']; +const TRANSFER_QUANTITY_KEYS = ['quantity', 'shares', 'units']; + +function normalizeDateTime(value) { + if (value === null || value === undefined) { + return null; + } + if (value instanceof Date) { + const time = value.getTime(); + if (Number.isNaN(time)) { + return null; + } + return new Date(time).toISOString(); + } + if (typeof value === 'number' && Number.isFinite(value)) { + const derived = new Date(value); + if (Number.isNaN(derived.getTime())) { + return null; + } + return derived.toISOString(); + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const parsed = new Date(trimmed); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString(); + } + const dateOnly = normalizeDateOnly(trimmed); + if (dateOnly) { + return dateOnly + 'T00:00:00.000Z'; + } + } + if (typeof value === 'object' && value) { + if (Object.prototype.hasOwnProperty.call(value, 'timestamp')) { + return normalizeDateTime(value.timestamp); + } + if (Object.prototype.hasOwnProperty.call(value, 'date')) { + return normalizeDateTime(value.date); + } + } + return null; +} + +function normalizeTransferEntry(entry) { + if (!entry || typeof entry !== 'object') { + return null; + } + const symbol = entry.symbol !== undefined && entry.symbol !== null ? String(entry.symbol).trim() : ''; + if (!symbol) { + return null; + } + let quantityValue = null; + if (Object.prototype.hasOwnProperty.call(entry, 'quantity')) { + quantityValue = entry.quantity; + } else { + for (const key of TRANSFER_QUANTITY_KEYS) { + if (Object.prototype.hasOwnProperty.call(entry, key)) { + quantityValue = entry[key]; + break; + } + } + } + const quantity = Number(quantityValue); + if (!Number.isFinite(quantity) || quantity === 0) { + return null; + } + const normalized = { symbol, quantity }; + if (Object.prototype.hasOwnProperty.call(entry, 'price')) { + const price = Number(entry.price); + if (Number.isFinite(price)) { + normalized.price = price; + } + } + if (Object.prototype.hasOwnProperty.call(entry, 'currency')) { + const currency = entry.currency === undefined || entry.currency === null ? '' : String(entry.currency).trim(); + if (currency) { + normalized.currency = currency; + } + } + const resolvedTimestamp = normalizeDateTime(entry.timestamp || entry.date); + if (resolvedTimestamp) { + normalized.timestamp = resolvedTimestamp; + } + return normalized; +} + +function applyTransfersSetting(target, key, value) { + const container = ensureAccountSettingsEntry(target, key); + if (!container) { + return; + } + if (!Array.isArray(value)) { + if (value === null || value === undefined) { + delete container.transfers; + } + return; + } + const transfers = value + .map((entry) => normalizeTransferEntry(entry)) + .filter(Boolean); + if (transfers.length) { + container.transfers = transfers; + } else { + delete container.transfers; + } +} + function isLikelyAccountEntryObject(entry) { if (!entry || typeof entry !== 'object') { return false; @@ -379,6 +488,9 @@ function extractEntry( if (Object.prototype.hasOwnProperty.call(entry, 'lastRebalance')) { applyLastRebalanceSetting(settingsTarget, resolvedKey, entry.lastRebalance); } + if (Object.prototype.hasOwnProperty.call(entry, 'transfers')) { + applyTransfersSetting(settingsTarget, resolvedKey, entry.transfers); + } } if (defaultTracker && resolvedKey !== undefined) { diff --git a/server/src/index.js b/server/src/index.js index 9f99e8f..bee21d4 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -4,6 +4,7 @@ const axios = require('axios'); const NodeCache = require('node-cache'); const fs = require('fs'); const path = require('path'); +const yahooFinance = require('yahoo-finance2').default; require('dotenv').config(); const { getAccountNameOverrides, @@ -19,9 +20,21 @@ const { evaluateInvestmentModel } = require('./investmentModel'); const PORT = process.env.PORT || 4000; const ALLOWED_ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173'; +const PERFORMANCE_DEBUG_ENABLED = process.env.PERFORMANCE_DEBUG !== 'false'; const tokenCache = new NodeCache(); const tokenFilePath = path.join(process.cwd(), 'token-store.json'); +function performanceDebug() { + if (!PERFORMANCE_DEBUG_ENABLED) { + return; + } + const args = Array.from(arguments); + if (!args.length) { + return; + } + console.log.apply(console, ['[performance-debug]'].concat(args)); +} + function resolveLoginDisplay(login) { if (!login) { return null; @@ -539,226 +552,2183 @@ async function fetchBalances(login, accountId) { return data || {}; } +async function loadAccountsData(configuredDefaultKey) { + const accountNameOverrides = getAccountNameOverrides(); + const accountPortalOverrides = getAccountPortalOverrides(); + const accountChatOverrides = getAccountChatOverrides(); + const accountSettings = getAccountSettings(); + const accountBeneficiaries = getAccountBeneficiaries(); + const configuredOrdering = getAccountOrdering(); + + const accountCollections = []; + + for (const login of allLogins) { + const fetchedAccounts = await fetchAccounts(login); + const normalized = fetchedAccounts.map(function (account, index) { + const rawNumber = account.number || account.accountNumber || account.id || index; + const number = String(rawNumber); + const compositeId = login.id + ':' + number; + const ownerLabel = resolveLoginDisplay(login); + const normalizedAccount = Object.assign({}, account, { + id: compositeId, + number, + accountNumber: number, + loginId: login.id, + ownerId: login.id, + ownerLabel, + ownerEmail: login.email || null, + loginLabel: ownerLabel, + loginEmail: login.email || null, + }); + const displayName = resolveAccountDisplayName(accountNameOverrides, normalizedAccount, login); + if (displayName) { + normalizedAccount.displayName = displayName; + } + const overridePortalId = resolveAccountPortalId(accountPortalOverrides, normalizedAccount, login); + if (overridePortalId) { + normalizedAccount.portalAccountId = overridePortalId; + } + const overrideChatUrl = resolveAccountChatUrl(accountChatOverrides, normalizedAccount, login); + if (overrideChatUrl) { + normalizedAccount.chatURL = overrideChatUrl; + } else if (normalizedAccount.chatURL === undefined) { + normalizedAccount.chatURL = null; + } + const accountSettingsOverride = resolveAccountOverrideValue(accountSettings, normalizedAccount, login); + if (typeof accountSettingsOverride === 'boolean') { + normalizedAccount.showQQQDetails = accountSettingsOverride; + } else if (accountSettingsOverride && typeof accountSettingsOverride === 'object') { + if (typeof accountSettingsOverride.showQQQDetails === 'boolean') { + normalizedAccount.showQQQDetails = accountSettingsOverride.showQQQDetails; + } + if (typeof accountSettingsOverride.investmentModel === 'string') { + const trimmedModel = accountSettingsOverride.investmentModel.trim(); + if (trimmedModel) { + normalizedAccount.investmentModel = trimmedModel; + } + } + if (typeof accountSettingsOverride.lastRebalance === 'string') { + const trimmedDate = accountSettingsOverride.lastRebalance.trim(); + if (trimmedDate) { + normalizedAccount.investmentModelLastRebalance = trimmedDate; + } + } else if ( + accountSettingsOverride.lastRebalance && + typeof accountSettingsOverride.lastRebalance === 'object' && + typeof accountSettingsOverride.lastRebalance.date === 'string' + ) { + const trimmedDate = accountSettingsOverride.lastRebalance.date.trim(); + if (trimmedDate) { + normalizedAccount.investmentModelLastRebalance = trimmedDate; + } + } + if (Array.isArray(accountSettingsOverride.transfers) && accountSettingsOverride.transfers.length) { + normalizedAccount.performanceTransfers = accountSettingsOverride.transfers.map((transfer) => Object.assign({}, transfer)); + } + } + const defaultBeneficiary = accountBeneficiaries.defaultBeneficiary || null; + if (defaultBeneficiary) { + normalizedAccount.beneficiary = defaultBeneficiary; + } + const resolvedBeneficiary = resolveAccountBeneficiary(accountBeneficiaries, normalizedAccount, login); + if (resolvedBeneficiary) { + normalizedAccount.beneficiary = resolvedBeneficiary; + } + return normalizedAccount; + }); + accountCollections.push({ login, accounts: normalized }); + } -const BALANCE_NUMERIC_FIELDS = [ - 'totalEquity', - 'marketValue', - 'cash', - 'buyingPower', - 'maintenanceExcess', - 'dayPnl', - 'openPnl', - 'totalPnl', - 'totalCost', - 'realizedPnl', - 'unrealizedPnl', -]; + const defaultAccount = findDefaultAccount(accountCollections, configuredDefaultKey); -const BALANCE_FIELD_ALIASES = { - dayPnl: ['dayPnL'], - openPnl: ['openPnL'], - totalPnl: ['totalPnL', 'totalPnLInBase', 'totalReturn'], - realizedPnl: ['realizedPnL'], - unrealizedPnl: ['unrealizedPnL'], -}; + let allAccounts = accountCollections.flatMap(function (entry) { + return entry.accounts; + }); -function createEmptyBalanceAccumulator(currency) { - const base = { currency: currency || null, isRealTime: false, __fieldCounts: Object.create(null) }; - BALANCE_NUMERIC_FIELDS.forEach(function (field) { - base[field] = 0; - base.__fieldCounts[field] = 0; + if (Array.isArray(configuredOrdering) && configuredOrdering.length) { + const orderingMap = new Map(); + configuredOrdering.forEach(function (entry, index) { + const normalized = entry == null ? '' : String(entry).trim(); + if (!normalized) { + return; + } + if (!orderingMap.has(normalized)) { + orderingMap.set(normalized, index); + } + }); + + if (orderingMap.size) { + const DEFAULT_ORDER = Number.MAX_SAFE_INTEGER; + const resolveAccountOrder = function (account) { + if (!account) { + return DEFAULT_ORDER; + } + const candidates = []; + if (account.number) { + candidates.push(String(account.number).trim()); + } + if (account.accountNumber) { + candidates.push(String(account.accountNumber).trim()); + } + if (account.id) { + candidates.push(String(account.id).trim()); + } + for (const candidate of candidates) { + if (!candidate) { + continue; + } + if (orderingMap.has(candidate)) { + return orderingMap.get(candidate); + } + } + return DEFAULT_ORDER; + }; + + allAccounts = allAccounts + .map(function (account, index) { + return { account, index, order: resolveAccountOrder(account) }; + }) + .sort(function (a, b) { + if (a.order !== b.order) { + return a.order - b.order; + } + return a.index - b.index; + }) + .map(function (entry) { + return entry.account; + }); + } + } + + const accountsById = {}; + allAccounts.forEach(function (account) { + accountsById[account.id] = account; }); - return base; + + return { accountCollections, allAccounts, accountsById, defaultAccount }; } -function markBalanceFieldPresent(target, field) { - if (!target.__fieldCounts) { - target.__fieldCounts = Object.create(null); +async function fetchExecutions(login, accountId, options = {}) { + const params = {}; + if (options.startTime) { + params.startTime = options.startTime; } - target.__fieldCounts[field] = (target.__fieldCounts[field] || 0) + 1; + if (options.endTime) { + params.endTime = options.endTime; + } + + const basePath = '/v1/accounts/' + accountId + '/executions'; + let nextPath = basePath; + let firstRequest = true; + const results = []; + let safety = 0; + + while (nextPath && safety < 50) { + safety += 1; + const requestOptions = firstRequest ? { params } : {}; + const data = await questradeRequest(login, nextPath, requestOptions); + firstRequest = false; + if (data && Array.isArray(data.executions)) { + results.push(...data.executions); + } + if (data && data.next) { + nextPath = data.next; + } else if (data && data.nextPage) { + nextPath = data.nextPage; + } else if (data && data.links && data.links.next) { + nextPath = data.links.next; + } else if (data && data.more === true && data.nextRecordsPath) { + nextPath = data.nextRecordsPath; + } else { + nextPath = null; + } + } + + return results; } -function pickNumericValue(source, key) { - if (!source) { +function parseTimestamp(value) { + if (!value) { return null; } - const direct = source[key]; - if (typeof direct === 'number' && Number.isFinite(direct)) { - return direct; + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) { + return null; + } + return new Date(value.getTime()); } - const aliases = BALANCE_FIELD_ALIASES[key] || []; - for (const alias of aliases) { - const value = source[alias]; - if (typeof value === 'number' && Number.isFinite(value)) { - return value; + if (typeof value === 'number' && Number.isFinite(value)) { + const derived = new Date(value); + if (Number.isNaN(derived.getTime())) { + return null; + } + return derived; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const parsed = new Date(trimmed); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + const appended = new Date(trimmed + 'T00:00:00Z'); + if (!Number.isNaN(appended.getTime())) { + return appended; } } return null; } -function accumulateBalance(target, source) { - BALANCE_NUMERIC_FIELDS.forEach(function (field) { - const value = pickNumericValue(source, field); - if (value !== null) { - const current = typeof target[field] === 'number' && Number.isFinite(target[field]) ? target[field] : 0; - target[field] = current + value; - markBalanceFieldPresent(target, field); - } - }); - if (source && typeof source.isRealTime === 'boolean') { - target.isRealTime = target.isRealTime || source.isRealTime; +function toDateKey(value) { + const timestamp = parseTimestamp(value); + if (!timestamp) { + return null; } + return timestamp.toISOString().slice(0, 10); } -async function fetchSymbolsDetails(login, symbolIds) { - if (!symbolIds.length) { - return {}; +function pickNumericCandidate(source, keys) { + if (!source) { + return null; } - - const batches = []; - const BATCH_SIZE = 50; - for (let i = 0; i < symbolIds.length; i += BATCH_SIZE) { - batches.push(symbolIds.slice(i, i + BATCH_SIZE)); + for (const key of keys) { + if (!key) { + continue; + } + if (Object.prototype.hasOwnProperty.call(source, key)) { + const value = Number(source[key]); + if (Number.isFinite(value)) { + return value; + } + } } + return null; +} - const results = {}; - for (const batch of batches) { - const idsParam = batch.join(','); - const data = await questradeRequest(login, '/v1/symbols', { params: { ids: idsParam } }); - (data.symbols || []).forEach(function (symbol) { - results[symbol.symbolId] = symbol; - }); +function resolveExecutionSide(execution) { + const candidates = [execution.side, execution.action, execution.orderSide, execution.type, execution.actionType]; + for (const candidate of candidates) { + if (!candidate || typeof candidate !== 'string') { + continue; + } + const normalized = candidate.trim().toLowerCase(); + if (!normalized) { + continue; + } + if (normalized.includes('buy')) { + return 'buy'; + } + if (normalized.includes('sell')) { + return 'sell'; + } } - return results; + if (execution.isBuy === true) { + return 'buy'; + } + if (execution.isBuy === false) { + return 'sell'; + } + return null; } -function mergeBalances(allBalances) { - const summary = { - combined: {}, - perCurrency: {}, - }; - - allBalances.forEach(function (balanceEntry) { - const combinedBalances = balanceEntry && (balanceEntry.combinedBalances || []); - const perCurrencyBalances = balanceEntry && (balanceEntry.perCurrencyBalances || []); - - combinedBalances.forEach(function (balance) { - const currency = balance && balance.currency; - if (!currency) { - return; - } - if (!summary.combined[currency]) { - summary.combined[currency] = createEmptyBalanceAccumulator(currency); - } - accumulateBalance(summary.combined[currency], balance); - }); - - perCurrencyBalances.forEach(function (balance) { - const currency = balance && balance.currency; - if (!currency) { - return; - } - if (!summary.perCurrency[currency]) { - summary.perCurrency[currency] = createEmptyBalanceAccumulator(currency); - } - accumulateBalance(summary.perCurrency[currency], balance); - }); - }); - - return summary; -} +const EXECUTION_QUANTITY_KEYS = ['quantity', 'qty', 'filledQuantity', 'execQuantity']; +const EXECUTION_PRICE_KEYS = ['price', 'avgPrice', 'pricePerUnit', 'pricePerShare']; +const EXECUTION_FEE_KEYS = ['commission', 'totalCommission', 'commissionAndFees', 'fees']; +const EXECUTION_TIMESTAMP_KEYS = ['transactTime', 'tradeDate', 'transactionTime', 'executionTime', 'fillTime', 'timestamp']; -function summarizeAccountCombinedBalances(balanceEntry) { - const summary = mergeBalances([balanceEntry]); - finalizeBalances(summary); - if (!summary || !summary.combined) { - return null; - } - const combined = summary.combined; - if (!combined || typeof combined !== 'object' || !Object.keys(combined).length) { - return null; +function formatDecimal(value, fractionDigits) { + if (!Number.isFinite(value)) { + return 'n/a'; } - return combined; + const fixed = value.toFixed(fractionDigits); + return fixed.replace(/\.0+$/, '').replace(/(\.\d*?[1-9])0+$/, '$1'); } -function finalizeBalances(summary) { - if (!summary) { - return summary; +function summarizeExecutionsForDebug(executions) { + if (!Array.isArray(executions) || !executions.length) { + return []; } - ['combined', 'perCurrency'].forEach(function (scope) { - const bucket = summary[scope]; - if (!bucket) { - return; - } - Object.values(bucket).forEach(function (entry) { - if (!entry || !entry.__fieldCounts) { - return; + return executions + .map(function (execution, index) { + if (!execution || typeof execution !== 'object') { + return null; } - BALANCE_NUMERIC_FIELDS.forEach(function (field) { - const count = entry.__fieldCounts[field] || 0; - if (count === 0) { - delete entry[field]; + const symbol = execution.symbol ? String(execution.symbol).trim() : '(unknown)'; + const side = resolveExecutionSide(execution) || '(unknown)'; + const quantity = pickNumericCandidate(execution, EXECUTION_QUANTITY_KEYS); + const price = pickNumericCandidate(execution, EXECUTION_PRICE_KEYS); + const timestampCandidate = EXECUTION_TIMESTAMP_KEYS.map(function (key) { + return parseTimestamp(execution[key]); + }).find(Boolean); + const timestamp = timestampCandidate ? timestampCandidate.toISOString() : '(no timestamp)'; + const fees = EXECUTION_FEE_KEYS.reduce(function (total, key) { + const value = pickNumericCandidate(execution, [key]); + if (Number.isFinite(value)) { + return total + value; } - }); - delete entry.__fieldCounts; - }); - }); - return summary; + return total; + }, 0); + const quantityAbs = Number.isFinite(quantity) ? Math.abs(quantity) : null; + const gross = Number.isFinite(quantityAbs) && Number.isFinite(price) ? quantityAbs * price : null; + const sideLabel = side ? side.toUpperCase() : '(unknown)'; + const reference = execution.id || execution.executionId || execution.orderId || execution.orderNumber || null; + const parts = []; + parts.push('#' + (reference || index + 1)); + parts.push(timestamp); + parts.push(symbol); + parts.push(sideLabel); + parts.push('qty=' + formatDecimal(quantityAbs, 4)); + parts.push('price=' + formatDecimal(price, 4)); + parts.push('gross=' + formatDecimal(gross, 2)); + parts.push('fees=' + formatDecimal(fees, 2)); + return parts.join(' | '); + }) + .filter(Boolean); } -function mergePnL(positions) { - return positions.reduce( - function (acc, position) { - acc.dayPnl += position.dayPnl || 0; - acc.openPnl += position.openPnl || 0; - return acc; - }, - { dayPnl: 0, openPnl: 0 } - ); +function resolveExecutionCurrency(execution) { + if (!execution || typeof execution !== 'object') { + return null; + } + const currencyKeys = [ + 'currency', + 'grossCurrency', + 'netCurrency', + 'settlementCurrency', + 'priceCurrency', + 'commissionCurrency', + ]; + for (const key of currencyKeys) { + if (!key || !Object.prototype.hasOwnProperty.call(execution, key)) { + continue; + } + const value = execution[key]; + if (!value || typeof value !== 'string') { + continue; + } + const normalized = value.trim().toUpperCase(); + if (normalized) { + return normalized; + } + } + return null; } -function buildInvestmentModelPositions(positions, accountId) { - if (!Array.isArray(positions) || !accountId) { +function normalizeExecutionEvents(executions) { + if (!Array.isArray(executions)) { return []; } - const normalizedAccountId = String(accountId); - const results = []; + const events = []; - positions.forEach(function (position) { - if (!position || String(position.accountId) !== normalizedAccountId) { + executions.forEach(function (execution) { + if (!execution || typeof execution !== 'object') { return; } - const symbol = position.symbol ? String(position.symbol).trim() : null; + const symbol = execution.symbol ? String(execution.symbol).trim() : null; if (!symbol) { return; } - const marketValue = Number(position.currentMarketValue); - if (!Number.isFinite(marketValue)) { + const side = resolveExecutionSide(execution); + if (!side) { return; } - const entry = { symbol, dollars: marketValue }; - const shares = Number(position.openQuantity); - if (Number.isFinite(shares) && shares !== 0) { - entry.shares = shares; - } - if (Math.abs(entry.dollars) < 0.01 && (!entry.shares || Math.abs(entry.shares) < 0.01)) { + const quantityValue = pickNumericCandidate(execution, EXECUTION_QUANTITY_KEYS); + const priceValue = pickNumericCandidate(execution, EXECUTION_PRICE_KEYS); + if (!Number.isFinite(quantityValue) || quantityValue === 0 || !Number.isFinite(priceValue)) { return; } - results.push(entry); + const quantity = Math.abs(quantityValue); + const price = priceValue; + const timestampCandidate = EXECUTION_TIMESTAMP_KEYS + .map(function (key) { + return parseTimestamp(execution[key]); + }) + .find(Boolean); + const timestamp = timestampCandidate || null; + const fees = EXECUTION_FEE_KEYS.reduce(function (total, key) { + const value = pickNumericCandidate(execution, [key]); + if (Number.isFinite(value)) { + return total + value; + } + return total; + }, 0); + const gross = quantity * price; + const cashFlow = side === 'buy' ? -(gross + fees) : gross - fees; + const quantityChange = side === 'buy' ? quantity : -quantity; + const currency = resolveExecutionCurrency(execution); + + events.push({ + symbol, + quantity: quantityChange, + price, + cashFlow, + timestamp: timestamp || null, + type: 'execution', + currency: currency || null, + metadata: { + side, + rawQuantity: quantity, + fees, + gross, + reference: + execution.id || execution.executionId || execution.orderId || execution.orderNumber || execution.tradeId || null, + sourceTimestamp: timestamp ? timestamp.toISOString() : null, + }, + }); }); - return results; + return events; } -function findAccountCadBalance(accountId, perAccountBalances) { - if (!accountId || !perAccountBalances) { - return null; +function normalizeTransferEvents(transfers) { + if (!Array.isArray(transfers)) { + return []; } - - const balances = perAccountBalances[accountId]; - if (!balances || typeof balances !== 'object') { - return null; + return transfers + .map(function (transfer) { + if (!transfer || typeof transfer !== 'object') { + return null; + } + const symbol = transfer.symbol ? String(transfer.symbol).trim() : null; + if (!symbol) { + return null; + } + const quantity = Number(transfer.quantity); + if (!Number.isFinite(quantity) || quantity === 0) { + return null; + } + const timestamp = transfer.timestamp ? parseTimestamp(transfer.timestamp) : null; + const price = Number(transfer.price); + const normalizedPrice = Number.isFinite(price) ? price : null; + const currency = transfer.currency ? String(transfer.currency).trim() : null; + return { + symbol, + quantity, + price: normalizedPrice, + currency: currency || null, + cashFlow: 0, + timestamp: timestamp || null, + type: 'transfer', + }; + }) + .filter(Boolean); +} + +function summarizePerformanceEvents(events) { + if (!Array.isArray(events) || !events.length) { + return []; + } + return events.map(function (event, index) { + const dateKey = toDateKey(event.timestamp) || '(no date)'; + const symbol = event.symbol || '(no symbol)'; + const quantity = Number.isFinite(event.quantity) ? event.quantity : null; + const price = Number.isFinite(event.price) ? event.price : null; + const cashFlow = Number.isFinite(event.cashFlow) ? event.cashFlow : null; + const currency = event.currency || (event.metadata && event.metadata.currency) || null; + const side = event.metadata && event.metadata.side ? event.metadata.side : null; + const ref = event.metadata && event.metadata.reference ? event.metadata.reference : index + 1; + const parts = []; + parts.push('#' + ref); + parts.push(dateKey); + parts.push(event.type || 'event'); + parts.push(symbol); + if (side) { + parts.push(side.toUpperCase()); + } + parts.push('qty=' + formatDecimal(quantity, 4)); + parts.push('price=' + formatDecimal(price, 4)); + parts.push('cash=' + formatDecimal(cashFlow, 2)); + if (currency) { + parts.push('currency=' + currency); + } + return parts.join(' | '); + }); +} + +function summarizeTimelineForDebug(entries) { + if (!Array.isArray(entries) || !entries.length) { + return []; + } + return entries.map(function (entry) { + const date = entry.date || '(no date)'; + const value = Number.isFinite(entry.totalValue) ? entry.totalValue : Number(entry.value); + let holdingsDescription = 'no holdings'; + if (Array.isArray(entry.holdings) && entry.holdings.length) { + holdingsDescription = entry.holdings + .map(function (holding) { + const quantity = Number.isFinite(holding.quantity) ? holding.quantity : null; + const price = Number.isFinite(holding.price) ? holding.price : null; + const holdingValue = Number.isFinite(holding.value) ? holding.value : null; + const currency = holding.currency || null; + return ( + (holding.symbol || '(symbol)') + + ' qty=' + + formatDecimal(quantity, 4) + + ' @ ' + + formatDecimal(price, 4) + + ' -> ' + + formatDecimal(holdingValue, 2) + + (currency ? ' ' + currency : '') + ); + }) + .join(', '); + } + const currency = entry.currency || null; + return ( + date + + ': total=' + + formatDecimal(value, 2) + + (currency ? ' ' + currency : '') + + ' | ' + + holdingsDescription + ); + }); +} + +function summarizeCashFlowsForDebug(flows) { + if (!Array.isArray(flows) || !flows.length) { + return []; + } + return flows.map(function (flow, index) { + const timestamp = flow.timestamp || flow.date || '(no timestamp)'; + const amount = Number.isFinite(flow.amount) ? flow.amount : null; + const originalAmount = Number.isFinite(flow.originalAmount) ? flow.originalAmount : null; + const baseCurrency = flow.currency || null; + const originalCurrency = flow.originalCurrency || null; + const type = flow.type || 'flow'; + const symbol = flow.symbol || '(no symbol)'; + const status = flow.conversionStatus || null; + const parts = [ + '#' + (index + 1), + timestamp, + type, + symbol, + 'amount=' + formatDecimal(amount, 2) + (baseCurrency ? ' ' + baseCurrency : ''), + ]; + if (originalAmount !== null && (!Number.isFinite(amount) || Math.abs(amount - originalAmount) > 0.0005 || (baseCurrency && originalCurrency && baseCurrency !== originalCurrency))) { + parts.push( + 'original=' + + formatDecimal(originalAmount, 2) + + (originalCurrency ? ' ' + originalCurrency : '') + ); + } + if (status) { + parts.push('status=' + status); + } + return parts.join(' '); + }); +} + +function summarizePositionSnapshotForDebug(snapshot) { + if (!snapshot || typeof snapshot !== 'object') { + return []; + } + return Object.keys(snapshot) + .sort() + .map(function (symbol) { + const entry = snapshot[symbol] || {}; + const quantity = Number.isFinite(entry.quantity) ? entry.quantity : null; + const price = Number.isFinite(entry.price) ? entry.price : null; + const marketValue = Number.isFinite(entry.marketValue) + ? entry.marketValue + : Number.isFinite(quantity) && Number.isFinite(price) + ? quantity * price + : null; + const currency = entry.currency || null; + const parts = [ + symbol, + 'qty=' + formatDecimal(quantity, 4), + 'price=' + formatDecimal(price, 4), + 'value=' + formatDecimal(marketValue, 2), + ]; + if (currency) { + parts.push(currency); + } + return parts.join(' | '); + }); +} + +function summarizeQuantityReconciliationForDebug(netQuantities, snapshot) { + const symbols = new Set(); + if (netQuantities && typeof netQuantities.forEach === 'function') { + netQuantities.forEach(function (_, symbol) { + if (symbol) { + symbols.add(symbol); + } + }); + } + if (snapshot && typeof snapshot === 'object') { + Object.keys(snapshot).forEach(function (symbol) { + if (symbol) { + symbols.add(symbol); + } + }); + } + return Array.from(symbols) + .sort() + .map(function (symbol) { + const hasEventQuantity = + netQuantities && typeof netQuantities.has === 'function' && netQuantities.has(symbol); + const eventQuantity = hasEventQuantity ? netQuantities.get(symbol) : 0; + const snapshotEntry = snapshot && snapshot[symbol] ? snapshot[symbol] : null; + const hasSnapshot = snapshotEntry && typeof snapshotEntry === 'object'; + const snapshotQuantity = hasSnapshot && Number.isFinite(snapshotEntry.quantity) + ? snapshotEntry.quantity + : null; + const delta = Number.isFinite(snapshotQuantity) + ? snapshotQuantity - eventQuantity + : null; + const currency = snapshotEntry && snapshotEntry.currency ? snapshotEntry.currency : null; + const parts = [ + symbol, + 'events=' + formatDecimal(eventQuantity, 4) + (hasEventQuantity ? '' : ' (none)'), + 'snapshot=' + (Number.isFinite(snapshotQuantity) ? formatDecimal(snapshotQuantity, 4) : 'n/a'), + ]; + parts.push('delta=' + (Number.isFinite(delta) ? formatDecimal(delta, 4) : 'n/a')); + if (currency) { + parts.push(currency); + } + return parts.join(' | '); + }); +} + +function summarizeAggregatedTotalsForDebug(totals) { + if (!totals || typeof totals !== 'object') { + return []; + } + const startValue = Number(totals.startValue) || 0; + const endValue = Number(totals.endValue) || 0; + const contributions = Number(totals.totalContributions) || 0; + const withdrawals = Number(totals.totalWithdrawals) || 0; + const investedBase = startValue + contributions; + const endingCapital = endValue + withdrawals; + const totalReturn = Number.isFinite(totals.totalReturn) ? totals.totalReturn : null; + const cagr = Number.isFinite(totals.cagr) ? totals.cagr : null; + const lines = []; + const periodLabel = (totals.startDate || 'n/a') + ' → ' + (totals.endDate || 'n/a'); + lines.push('period=' + periodLabel); + lines.push('startValue=' + formatDecimal(startValue, 2)); + lines.push('endValue=' + formatDecimal(endValue, 2)); + lines.push('contributions=' + formatDecimal(contributions, 2)); + lines.push('withdrawals=' + formatDecimal(withdrawals, 2)); + lines.push('investedCapital=' + formatDecimal(investedBase, 2)); + lines.push('endingCapital=' + formatDecimal(endingCapital, 2)); + lines.push('pnl=' + formatDecimal(Number(totals.totalPnl) || 0, 2)); + lines.push( + 'totalReturn=' + (totalReturn !== null ? formatDecimal(totalReturn * 100, 2) + '%' : 'n/a') + ); + lines.push('cagr=' + (cagr !== null ? formatDecimal(cagr * 100, 2) + '%' : 'n/a')); + const startDate = totals.startDate ? parseTimestamp(totals.startDate + 'T00:00:00Z') : null; + const endDate = totals.endDate ? parseTimestamp(totals.endDate + 'T00:00:00Z') : null; + if (startDate && endDate && endDate >= startDate) { + const durationDays = Math.round((endDate.getTime() - startDate.getTime()) / (24 * 3600 * 1000)); + lines.push('duration=' + durationDays + ' days'); + } + const netCashFlow = withdrawals - contributions; + lines.push('netCashFlow=' + formatDecimal(netCashFlow, 2)); + return lines; +} + +function buildPositionSnapshot(positions) { + const snapshot = {}; + if (!Array.isArray(positions)) { + return snapshot; + } + positions.forEach(function (position) { + if (!position || typeof position !== 'object') { + return; + } + const symbol = position.symbol ? String(position.symbol).trim() : null; + if (!symbol) { + return; + } + const quantity = Number(position.openQuantity); + const price = Number(position.currentPrice); + const marketValue = Number(position.currentMarketValue); + const currency = position.currency ? String(position.currency).trim() : null; + snapshot[symbol] = { + quantity: Number.isFinite(quantity) ? quantity : 0, + price: Number.isFinite(price) ? price : null, + marketValue: Number.isFinite(marketValue) ? marketValue : null, + currency: currency || null, + }; + }); + return snapshot; +} + +function collectCashBalances(balances) { + const amounts = new Map(); + if (!balances || typeof balances !== 'object') { + return amounts; + } + const record = function (entry) { + if (!entry || typeof entry !== 'object') { + return; + } + const currency = entry.currency && typeof entry.currency === 'string' + ? entry.currency.trim().toUpperCase() + : null; + if (!currency) { + return; + } + const cashValue = pickNumericValue(entry, 'cash'); + if (!Number.isFinite(cashValue) || Math.abs(cashValue) < 1e-9) { + return; + } + amounts.set(currency, (amounts.get(currency) || 0) + cashValue); + }; + + if (Array.isArray(balances.perCurrencyBalances) && balances.perCurrencyBalances.length) { + balances.perCurrencyBalances.forEach(record); + } + + if (Array.isArray(balances.combinedBalances) && balances.combinedBalances.length) { + balances.combinedBalances.forEach(function (entry) { + const currency = entry && typeof entry.currency === 'string' ? entry.currency.trim().toUpperCase() : null; + if (!currency || amounts.has(currency)) { + return; + } + record(entry); + }); + } + + return amounts; +} + +function convertCashBalancesToBase(cashBalances, baseCurrency, fxCache, targetDateKey) { + const normalizedBase = baseCurrency && typeof baseCurrency === 'string' + ? baseCurrency.trim().toUpperCase() + : null; + const breakdown = []; + if (!(cashBalances instanceof Map) || cashBalances.size === 0) { + return { total: 0, breakdown, baseCurrency: normalizedBase }; + } + let total = 0; + cashBalances.forEach(function (amount, currency) { + if (!Number.isFinite(amount)) { + return; + } + const normalizedCurrency = currency && typeof currency === 'string' ? currency.trim().toUpperCase() : null; + if (!normalizedCurrency) { + return; + } + let converted = amount; + let status = 'native'; + if (normalizedBase && normalizedCurrency !== normalizedBase) { + const fxEntry = fxCache && fxCache.get(normalizedCurrency); + if (fxEntry && Array.isArray(fxEntry.series) && fxEntry.series.length) { + const maybeConverted = convertValueWithFx(amount, fxEntry.series, targetDateKey); + if (Number.isFinite(maybeConverted)) { + converted = maybeConverted; + status = 'converted'; + } else { + status = 'fx-unresolved'; + converted = 0; + } + } else { + status = 'fx-missing'; + converted = 0; + } + } + if (Number.isFinite(converted)) { + total += converted; + } + breakdown.push({ + currency: normalizedCurrency, + amount, + converted, + status, + }); + }); + return { total, breakdown, baseCurrency: normalizedBase }; +} + +function buildSymbolCurrencyMap(positions, executions, transfers, fallbackCurrency) { + const map = new Map(); + const assign = function (symbol, currency) { + if (!symbol || typeof symbol !== 'string') { + return; + } + const trimmedSymbol = symbol.trim(); + if (!trimmedSymbol) { + return; + } + if (!currency || typeof currency !== 'string') { + return; + } + const normalizedCurrency = currency.trim().toUpperCase(); + if (!normalizedCurrency) { + return; + } + if (!map.has(trimmedSymbol)) { + map.set(trimmedSymbol, normalizedCurrency); + } + }; + + if (Array.isArray(positions)) { + positions.forEach(function (position) { + if (!position || typeof position !== 'object') { + return; + } + assign(position.symbol, position.currency || fallbackCurrency || null); + }); + } + + if (Array.isArray(executions)) { + executions.forEach(function (execution) { + if (!execution || typeof execution !== 'object') { + return; + } + const symbol = execution.symbol || execution.symbolId || null; + const currency = resolveExecutionCurrency(execution) || execution.currency || null; + if (symbol && currency) { + assign(String(symbol), currency); + } + }); + } + + if (Array.isArray(transfers)) { + transfers.forEach(function (transfer) { + if (!transfer || typeof transfer !== 'object') { + return; + } + assign(transfer.symbol, transfer.currency || fallbackCurrency || null); + }); + } + + return map; +} + +function resolveFxPairSymbol(fromCurrency, toCurrency) { + if (!fromCurrency || !toCurrency) { + return null; + } + const from = fromCurrency.trim().toUpperCase(); + const to = toCurrency.trim().toUpperCase(); + if (!from || !to || from === to) { + return null; + } + return from + to + '=X'; +} + +async function fetchFxSeries(pairSymbol, startDate, endDate) { + if (!pairSymbol) { + return []; + } + const period1 = new Date(startDate.getTime() - 24 * 3600 * 1000); + const period2 = new Date(endDate.getTime() + 24 * 3600 * 1000); + try { + const history = await yahooFinance.historical(pairSymbol, { + period1, + period2, + interval: '1d', + }); + if (!Array.isArray(history)) { + return []; + } + const dedup = new Map(); + history.forEach(function (entry) { + if (!entry || !entry.date) { + return; + } + const date = entry.date instanceof Date ? entry.date : new Date(entry.date); + if (Number.isNaN(date.getTime())) { + return; + } + const rateCandidate = Number.isFinite(entry.adjClose) ? entry.adjClose : Number(entry.close); + if (!Number.isFinite(rateCandidate) || rateCandidate <= 0) { + return; + } + const key = date.toISOString().slice(0, 10); + dedup.set(key, rateCandidate); + }); + return Array.from(dedup.entries()) + .map(function ([date, rate]) { + return { date, rate }; + }) + .sort(function (a, b) { + return a.date.localeCompare(b.date); + }); + } catch (error) { + console.warn('Failed to load FX history for pair ' + pairSymbol + ':', error.message); + return []; + } +} + +function resolveFxRateForDate(series, targetDate) { + if (!Array.isArray(series) || !series.length) { + return null; + } + if (!targetDate) { + const fallback = series[series.length - 1]; + return fallback && Number.isFinite(fallback.rate) ? fallback.rate : null; + } + let latestRate = null; + for (let index = 0; index < series.length; index += 1) { + const entry = series[index]; + if (!entry || !entry.date) { + continue; + } + if (!Number.isFinite(entry.rate)) { + continue; + } + if (entry.date <= targetDate) { + latestRate = entry.rate; + continue; + } + if (entry.date > targetDate) { + if (latestRate !== null) { + return latestRate; + } + return entry.rate; + } + } + return latestRate; +} + +function convertSeriesWithFx(history, fxSeries) { + if (!Array.isArray(history) || !history.length) { + return []; + } + if (!Array.isArray(fxSeries) || !fxSeries.length) { + return history + .filter(function (point) { + return point && point.date && Number.isFinite(point.price); + }) + .map(function (point) { + return { date: point.date, price: point.price }; + }); + } + const sortedFx = fxSeries + .filter(function (entry) { + return entry && entry.date && Number.isFinite(entry.rate); + }) + .sort(function (a, b) { + return a.date.localeCompare(b.date); + }); + if (!sortedFx.length) { + return []; + } + const converted = []; + const rateCache = new Map(); + history.forEach(function (point) { + if (!point || !point.date || !Number.isFinite(point.price)) { + return; + } + let rate = rateCache.get(point.date); + if (rate === undefined) { + rate = resolveFxRateForDate(sortedFx, point.date); + rateCache.set(point.date, rate); + } + if (!Number.isFinite(rate)) { + return; + } + converted.push({ date: point.date, price: point.price * rate }); + }); + return converted; +} + +function convertValueWithFx(value, fxSeries, targetDate) { + if (!Number.isFinite(value)) { + return null; + } + if (!Array.isArray(fxSeries) || !fxSeries.length) { + return value; + } + const rate = resolveFxRateForDate(fxSeries, targetDate); + if (!Number.isFinite(rate)) { + return null; + } + return value * rate; +} + +function convertCashFlowToBase(event, baseCurrency, fxCache) { + if (!event || !Number.isFinite(event.cashFlow) || Math.abs(event.cashFlow) < 0.00001) { + return null; + } + const originalAmount = event.cashFlow; + const originalCurrency = event.currency || null; + if (!baseCurrency || !originalCurrency || originalCurrency === baseCurrency) { + return { + amount: originalAmount, + currency: baseCurrency || originalCurrency || null, + originalAmount, + originalCurrency, + status: 'native', + }; + } + const normalizedCurrency = originalCurrency.trim().toUpperCase(); + const fxInfo = fxCache && fxCache.get(normalizedCurrency); + if (!fxInfo || !Array.isArray(fxInfo.series) || !fxInfo.series.length) { + return { + amount: originalAmount, + currency: originalCurrency, + originalAmount, + originalCurrency, + status: 'fx-missing', + }; + } + const dateKey = toDateKey(event.timestamp) || null; + const rate = resolveFxRateForDate(fxInfo.series, dateKey); + if (!Number.isFinite(rate)) { + return { + amount: originalAmount, + currency: originalCurrency, + originalAmount, + originalCurrency, + status: 'fx-rate-missing', + }; + } + return { + amount: originalAmount * rate, + currency: baseCurrency, + originalAmount, + originalCurrency, + status: 'converted', + }; +} + +async function fetchHistoricalPrices(symbol, startDate, endDate) { + const period1 = new Date(startDate.getTime() - 24 * 3600 * 1000); + const period2 = new Date(endDate.getTime() + 24 * 3600 * 1000); + try { + const history = await yahooFinance.historical(symbol, { + period1, + period2, + interval: '1d', + }); + if (!Array.isArray(history)) { + return []; + } + const dedup = new Map(); + history.forEach(function (entry) { + if (!entry || !entry.date) { + return; + } + const date = entry.date instanceof Date ? entry.date : new Date(entry.date); + if (Number.isNaN(date.getTime())) { + return; + } + const price = Number.isFinite(entry.adjClose) ? entry.adjClose : Number(entry.close); + if (!Number.isFinite(price)) { + return; + } + const key = date.toISOString().slice(0, 10); + dedup.set(key, price); + }); + return Array.from(dedup.entries()) + .map(function ([date, price]) { + return { date, price }; + }) + .sort(function (a, b) { + return a.date.localeCompare(b.date); + }); + } catch (error) { + console.warn('Failed to load price history for symbol ' + symbol + ':', error.message); + return []; + } +} + +async function buildPriceSeries( + symbols, + startDate, + endDate, + finalDateKey, + positionSnapshot, + options = {} +) { + const baseCurrency = options.baseCurrency && typeof options.baseCurrency === 'string' + ? options.baseCurrency.trim().toUpperCase() + : null; + const currencyMapInput = options.currencyBySymbol; + const currencyBySymbol = currencyMapInput instanceof Map ? currencyMapInput : new Map(); + if (!(currencyMapInput instanceof Map) && currencyMapInput && typeof currencyMapInput === 'object') { + Object.keys(currencyMapInput).forEach(function (key) { + const value = currencyMapInput[key]; + if (typeof value === 'string' && value.trim()) { + currencyBySymbol.set(key, value.trim().toUpperCase()); + } + }); + } + const extraFxSet = new Set(); + if (Array.isArray(options.extraFxCurrencies)) { + options.extraFxCurrencies.forEach(function (currency) { + if (!currency || typeof currency !== 'string') { + return; + } + const normalized = currency.trim().toUpperCase(); + if (!normalized || normalized === baseCurrency) { + return; + } + extraFxSet.add(normalized); + }); + } + + const seriesMap = new Map(); + const fxCache = new Map(); + const diagnostics = { + baseCurrency: baseCurrency || null, + symbols: [], + fxPairs: [], + }; + + const ensureFxSeries = async function (fromCurrency) { + if (!fromCurrency || !baseCurrency || fromCurrency === baseCurrency) { + return null; + } + const normalizedFrom = fromCurrency.trim().toUpperCase(); + if (!normalizedFrom || normalizedFrom === baseCurrency) { + return null; + } + if (fxCache.has(normalizedFrom)) { + return fxCache.get(normalizedFrom); + } + const pairSymbol = resolveFxPairSymbol(normalizedFrom, baseCurrency); + let series = []; + let status = 'skipped'; + if (pairSymbol) { + series = await fetchFxSeries(pairSymbol, startDate, endDate); + status = series.length ? 'ok' : 'empty'; + } else { + status = 'unavailable'; + } + const entry = { + series, + pairSymbol, + fromCurrency: normalizedFrom, + toCurrency: baseCurrency, + status, + }; + fxCache.set(normalizedFrom, entry); + diagnostics.fxPairs.push({ + fromCurrency: normalizedFrom, + toCurrency: baseCurrency, + pairSymbol, + points: series.length, + status, + }); + return entry; + }; + + for (const symbol of symbols) { + const history = await fetchHistoricalPrices(symbol, startDate, endDate); + const instrumentCurrency = currencyBySymbol.get(symbol) || baseCurrency || null; + const fxInfo = await ensureFxSeries(instrumentCurrency); + const convertedHistory = fxInfo && Array.isArray(fxInfo.series) && fxInfo.series.length + ? convertSeriesWithFx(history, fxInfo.series) + : history + .filter(function (point) { + return point && point.date && Number.isFinite(point.price); + }) + .map(function (point) { + return { date: point.date, price: point.price }; + }); + + const pointsMap = new Map(); + convertedHistory.forEach(function (entry) { + if (!entry || !entry.date) { + return; + } + pointsMap.set(entry.date, entry.price); + }); + + const snapshot = positionSnapshot[symbol]; + let snapshotPrice = null; + let snapshotConversion = null; + if (snapshot && finalDateKey) { + if (Number.isFinite(snapshot.price) && snapshot.price > 0) { + snapshotPrice = snapshot.price; + } else if ( + Number.isFinite(snapshot.marketValue) && + Number.isFinite(snapshot.quantity) && + Math.abs(snapshot.quantity) > 1e-9 && + snapshot.marketValue !== 0 + ) { + snapshotPrice = snapshot.marketValue / snapshot.quantity; + } + if (Number.isFinite(snapshotPrice) && snapshotPrice > 0) { + let converted = snapshotPrice; + if (fxInfo && Array.isArray(fxInfo.series) && fxInfo.series.length) { + const maybeConverted = convertValueWithFx(snapshotPrice, fxInfo.series, finalDateKey); + if (Number.isFinite(maybeConverted) && maybeConverted > 0) { + converted = maybeConverted; + snapshotConversion = 'converted'; + } else { + snapshotConversion = 'fx-fallback'; + } + } else if (fxInfo && (!Array.isArray(fxInfo.series) || !fxInfo.series.length)) { + snapshotConversion = 'fx-missing'; + } else { + snapshotConversion = 'native'; + } + if (Number.isFinite(converted) && converted > 0) { + pointsMap.set(finalDateKey, converted); + } + } + } + + const ordered = Array.from(pointsMap.entries()) + .map(function ([date, price]) { + return { date, price }; + }) + .sort(function (a, b) { + return a.date.localeCompare(b.date); + }); + + seriesMap.set(symbol, ordered); + + diagnostics.symbols.push({ + symbol, + currency: instrumentCurrency || null, + pricePoints: history.length, + convertedPoints: ordered.length, + fxPair: fxInfo ? fxInfo.pairSymbol : null, + fxStatus: fxInfo ? fxInfo.status : baseCurrency ? 'base' : 'unknown', + snapshotApplied: Number.isFinite(snapshotPrice), + snapshotConversion, + }); + } + + if (extraFxSet.size) { + for (const currency of extraFxSet) { + await ensureFxSeries(currency); + } + } + + return { seriesMap, fxCache, diagnostics }; +} + +function computeAccountPerformanceTimeline(events, priceSeries, symbols, finalDateKey, options) { + const debug = options && options.debug; + const baseCurrency = options && typeof options.baseCurrency === 'string' && options.baseCurrency + ? options.baseCurrency + : null; + const changeMaps = new Map(); + const dateSet = new Set(); + + events.forEach(function (event) { + const dateKey = toDateKey(event.timestamp); + if (!dateKey) { + return; + } + dateSet.add(dateKey); + let symbolMap = changeMaps.get(event.symbol); + if (!symbolMap) { + symbolMap = new Map(); + changeMaps.set(event.symbol, symbolMap); + } + symbolMap.set(dateKey, (symbolMap.get(dateKey) || 0) + event.quantity); + }); + + priceSeries.forEach(function (points) { + points.forEach(function (point) { + if (point && point.date) { + dateSet.add(point.date); + } + }); + }); + + if (finalDateKey) { + dateSet.add(finalDateKey); + } + + const sortedDates = Array.from(dateSet).filter(Boolean).sort(function (a, b) { + return a.localeCompare(b); + }); + + const priceCursors = new Map(); + symbols.forEach(function (symbol) { + const points = priceSeries.get(symbol) || []; + priceCursors.set(symbol, { points, index: 0, lastPrice: null }); + }); + + const quantityBySymbol = new Map(); + const timeline = []; + const debugDetails = debug ? [] : null; + + sortedDates.forEach(function (dateKey) { + const holdings = debug ? [] : null; + symbols.forEach(function (symbol) { + const symbolMap = changeMaps.get(symbol); + if (symbolMap && symbolMap.has(dateKey)) { + const next = (quantityBySymbol.get(symbol) || 0) + symbolMap.get(dateKey); + if (Math.abs(next) < 1e-9) { + quantityBySymbol.delete(symbol); + } else { + quantityBySymbol.set(symbol, next); + } + } + }); + + let totalValue = 0; + symbols.forEach(function (symbol) { + const cursor = priceCursors.get(symbol); + if (!cursor) { + return; + } + while (cursor.index < cursor.points.length && cursor.points[cursor.index].date <= dateKey) { + cursor.lastPrice = cursor.points[cursor.index].price; + cursor.index += 1; + } + const quantity = quantityBySymbol.get(symbol) || 0; + if (!quantity) { + return; + } + if (!Number.isFinite(cursor.lastPrice)) { + return; + } + totalValue += quantity * cursor.lastPrice; + if (debug && holdings) { + holdings.push({ + symbol, + quantity, + price: cursor.lastPrice, + value: quantity * cursor.lastPrice, + currency: baseCurrency || null, + }); + } + }); + + const value = Number.isFinite(totalValue) ? totalValue : 0; + const entry = { date: dateKey, value }; + if (baseCurrency) { + entry.currency = baseCurrency; + } + timeline.push(entry); + if (debug && debugDetails) { + debugDetails.push({ + date: dateKey, + totalValue: value, + holdings: holdings || [], + currency: baseCurrency || null, + }); + } + }); + + if (debug && debugDetails) { + return { timeline, debugDetails }; + } + return timeline; +} + +function computeAggregatedMetrics(timeline, cashFlows) { + if (!Array.isArray(timeline) || timeline.length === 0) { + return { + startDate: null, + endDate: null, + startValue: 0, + endValue: 0, + totalContributions: 0, + totalWithdrawals: 0, + totalPnl: 0, + totalReturn: null, + cagr: null, + }; + } + + const startEntry = timeline[0]; + const endEntry = timeline[timeline.length - 1]; + const startValue = Number(startEntry.value) || 0; + const endValue = Number(endEntry.value) || 0; + + let totalContributions = 0; + let totalWithdrawals = 0; + if (Array.isArray(cashFlows)) { + cashFlows.forEach(function (flow) { + const amount = Number(flow.amount); + if (!Number.isFinite(amount) || amount === 0) { + return; + } + if (amount > 0) { + totalWithdrawals += amount; + } else { + totalContributions += -amount; + } + }); + } + + const totalPnl = (endValue + totalWithdrawals) - (startValue + totalContributions); + const investedBase = startValue + totalContributions; + const totalReturn = investedBase > 0 ? totalPnl / investedBase : null; + + const startDate = startEntry.date || null; + const endDate = endEntry.date || null; + const startTime = startDate ? parseTimestamp(startDate + 'T00:00:00Z') : null; + const endTime = endDate ? parseTimestamp(endDate + 'T00:00:00Z') : null; + let cagr = null; + if (startTime && endTime && endTime > startTime && investedBase > 0 && endValue > 0) { + const durationYears = (endTime.getTime() - startTime.getTime()) / (365.25 * 24 * 3600 * 1000); + if (durationYears > 0) { + const endingCapital = endValue + totalWithdrawals; + const startingCapital = investedBase; + if (endingCapital > 0 && startingCapital > 0) { + cagr = Math.pow(endingCapital / startingCapital, 1 / durationYears) - 1; + } + } + } + + return { + startDate, + endDate, + startValue, + endValue, + totalContributions, + totalWithdrawals, + totalPnl, + totalReturn, + cagr, + }; +} + +function ensureEventTimestamps(events, fallbackTimestamp) { + if (!Array.isArray(events)) { + return; + } + events.forEach(function (event) { + if (!event.timestamp) { + event.timestamp = fallbackTimestamp ? new Date(fallbackTimestamp.getTime()) : null; + } + }); +} + +async function generateAccountPerformance({ executions, transfers, positions, balances, account }) { + if (PERFORMANCE_DEBUG_ENABLED) { + const executionSummaries = summarizeExecutionsForDebug(executions); + const count = Array.isArray(executions) ? executions.length : 0; + if (executionSummaries.length) { + performanceDebug( + 'Executions fetched from Questrade (count=' + + count + + '):\n' + + executionSummaries + .map(function (line) { + return ' ' + line; + }) + .join('\n') + ); + } else { + performanceDebug('Executions fetched from Questrade (count=' + count + '): none'); + } + } + + const executionEvents = normalizeExecutionEvents(executions); + if (PERFORMANCE_DEBUG_ENABLED) { + const normalizedSummaries = summarizePerformanceEvents(executionEvents); + if (normalizedSummaries.length) { + performanceDebug( + 'Normalized execution events (count=' + + executionEvents.length + + '):\n' + + normalizedSummaries + .map(function (line) { + return ' ' + line; + }) + .join('\n') + ); + } else { + performanceDebug('Normalized execution events (count=0): none'); + } + } + + const accountCurrency = account && typeof account.currency === 'string' ? account.currency.trim().toUpperCase() : null; + let baseCurrency = accountCurrency || null; + if (!baseCurrency && Array.isArray(positions)) { + const positionWithCurrency = positions.find(function (position) { + return position && typeof position.currency === 'string' && position.currency.trim(); + }); + if (positionWithCurrency && positionWithCurrency.currency) { + baseCurrency = positionWithCurrency.currency.trim().toUpperCase(); + } + } + if (!baseCurrency && Array.isArray(executionEvents)) { + const eventWithCurrency = executionEvents.find(function (event) { + return event && typeof event.currency === 'string' && event.currency.trim(); + }); + if (eventWithCurrency && eventWithCurrency.currency) { + baseCurrency = eventWithCurrency.currency.trim().toUpperCase(); + } + } + if (!baseCurrency) { + baseCurrency = 'CAD'; + } + + const symbolCurrencyMap = buildSymbolCurrencyMap(positions, executions, transfers, baseCurrency); + if (PERFORMANCE_DEBUG_ENABLED) { + const currencyMappings = Array.from(symbolCurrencyMap.entries()).map(function ([symbol, currency]) { + return symbol + ' → ' + currency; + }); + performanceDebug( + 'Performance currency context: base=' + + (baseCurrency || 'n/a') + + ', account=' + + (accountCurrency || 'n/a') + + (currencyMappings.length ? '\n symbol currencies:\n ' + currencyMappings.join('\n ') : '') + ); + } + + const transferEvents = normalizeTransferEvents(transfers); + const positionSnapshot = buildPositionSnapshot(positions); + const cashBalances = collectCashBalances(balances); + if (PERFORMANCE_DEBUG_ENABLED) { + const snapshotCount = Object.keys(positionSnapshot).length; + const snapshotSummaries = summarizePositionSnapshotForDebug(positionSnapshot); + if (snapshotSummaries.length) { + performanceDebug( + 'Position snapshot baseline (count=' + + snapshotCount + + '):\n' + + snapshotSummaries + .map(function (line) { + return ' ' + line; + }) + .join('\n') + ); + } else { + performanceDebug('Position snapshot baseline: none'); + } + if (cashBalances.size) { + performanceDebug( + 'Cash balances by currency:\n' + + Array.from(cashBalances.entries()) + .map(function ([currency, amount]) { + return ' ' + currency + ' ' + formatDecimal(amount, 2); + }) + .join('\n') + ); + } else { + performanceDebug('Cash balances: none'); + } + } + const now = new Date(); + const finalDateKey = now.toISOString().slice(0, 10); + + const events = executionEvents.concat(transferEvents); + if (PERFORMANCE_DEBUG_ENABLED && transferEvents.length) { + const transferSummaries = summarizePerformanceEvents(transferEvents); + performanceDebug( + 'Transfer events included (count=' + + transferEvents.length + + '):\n' + + transferSummaries + .map(function (line) { + return ' ' + line; + }) + .join('\n') + ); + } + + let earliestTimestamp = null; + events.forEach(function (event) { + if (!event.timestamp) { + return; + } + if (!earliestTimestamp || event.timestamp < earliestTimestamp) { + earliestTimestamp = event.timestamp; + } + }); + + if (!earliestTimestamp) { + earliestTimestamp = new Date(now.getTime() - 7 * 24 * 3600 * 1000); + } + + const adjustmentTimestamp = new Date(earliestTimestamp.getTime() - 24 * 3600 * 1000); + + ensureEventTimestamps(events, adjustmentTimestamp); + + const netQuantities = new Map(); + events.forEach(function (event) { + netQuantities.set(event.symbol, (netQuantities.get(event.symbol) || 0) + event.quantity); + }); + if (PERFORMANCE_DEBUG_ENABLED) { + const reconciliationSummaries = summarizeQuantityReconciliationForDebug(netQuantities, positionSnapshot); + if (reconciliationSummaries.length) { + performanceDebug( + 'Net position coverage before adjustments:\n' + + reconciliationSummaries + .map(function (line) { + return ' ' + line; + }) + .join('\n') + ); + } else { + performanceDebug('Net position coverage before adjustments: none'); + } + } + + const adjustmentEvents = []; + + const snapshotSymbols = new Set(Object.keys(positionSnapshot)); + Object.keys(positionSnapshot).forEach(function (symbol) { + const snapshot = positionSnapshot[symbol]; + const targetQuantity = snapshot && Number.isFinite(snapshot.quantity) ? snapshot.quantity : 0; + const current = netQuantities.get(symbol) || 0; + const delta = targetQuantity - current; + if (Math.abs(delta) > 1e-6) { + const adjustmentCurrency = snapshot && snapshot.currency + ? snapshot.currency.trim().toUpperCase() + : baseCurrency; + const adjustment = { + symbol, + quantity: delta, + price: snapshot && Number.isFinite(snapshot.price) ? snapshot.price : null, + cashFlow: 0, + timestamp: new Date(adjustmentTimestamp.getTime()), + type: 'adjustment', + currency: adjustmentCurrency || null, + }; + events.push(adjustment); + adjustmentEvents.push(adjustment); + } + }); + + netQuantities.forEach(function (quantity, symbol) { + if (!symbol || Math.abs(quantity) < 1e-6 || snapshotSymbols.has(symbol)) { + return; + } + const adjustment = { + symbol, + quantity: -quantity, + price: null, + cashFlow: 0, + timestamp: new Date(adjustmentTimestamp.getTime()), + type: 'adjustment', + currency: symbolCurrencyMap.get(symbol) || baseCurrency || null, + }; + events.push(adjustment); + adjustmentEvents.push(adjustment); + }); + + if (PERFORMANCE_DEBUG_ENABLED && adjustmentEvents.length) { + const adjustmentSummaries = summarizePerformanceEvents(adjustmentEvents); + performanceDebug( + 'Position adjustments applied (count=' + + adjustmentEvents.length + + '):\n' + + adjustmentSummaries + .map(function (line) { + return ' ' + line; + }) + .join('\n') + ); + } + + events.sort(function (a, b) { + const timeA = a.timestamp ? a.timestamp.getTime() : 0; + const timeB = b.timestamp ? b.timestamp.getTime() : 0; + if (timeA !== timeB) { + return timeA - timeB; + } + return a.symbol.localeCompare(b.symbol); + }); + + const symbols = new Set(); + events.forEach(function (event) { + if (event.symbol) { + symbols.add(event.symbol); + } + }); + Object.keys(positionSnapshot).forEach(function (symbol) { + if (symbol) { + symbols.add(symbol); + } + }); + + if (!symbols.size) { + return { + timeline: [], + cashFlows: [], + totals: computeAggregatedMetrics([], []), + metadata: { + eventCount: 0, + symbolCount: 0, + generatedAt: now.toISOString(), + }, + }; + } + + const sortedSymbols = Array.from(symbols).sort(); + + const earliestEventTime = events.length ? events[0].timestamp || earliestTimestamp : earliestTimestamp; + const startDate = earliestEventTime ? new Date(earliestEventTime.getTime()) : new Date(now.getTime() - 30 * 24 * 3600 * 1000); + const extraFxCurrencies = []; + cashBalances.forEach(function (amount, currency) { + if (!Number.isFinite(amount)) { + return; + } + if (!currency || typeof currency !== 'string') { + return; + } + const normalized = currency.trim().toUpperCase(); + if (normalized && normalized !== baseCurrency) { + extraFxCurrencies.push(normalized); + } + }); + + const priceSeriesResult = await buildPriceSeries(sortedSymbols, startDate, now, finalDateKey, positionSnapshot, { + baseCurrency, + currencyBySymbol: symbolCurrencyMap, + extraFxCurrencies, + }); + const priceSeries = priceSeriesResult.seriesMap; + const fxCache = priceSeriesResult.fxCache; + if (PERFORMANCE_DEBUG_ENABLED && priceSeriesResult.diagnostics) { + performanceDebug( + 'Price series diagnostics (base ' + + (priceSeriesResult.diagnostics.baseCurrency || 'n/a') + + '):\n' + + priceSeriesResult.diagnostics.symbols + .map(function (entry) { + return ( + ' ' + + entry.symbol + + ' currency=' + + (entry.currency || 'n/a') + + ' points=' + + entry.pricePoints + + ' converted=' + + entry.convertedPoints + + (entry.fxPair ? ' fx=' + entry.fxPair + ' (' + entry.fxStatus + ')' : ' fx=' + entry.fxStatus) + + (entry.snapshotApplied + ? ' snapshot=' + (entry.snapshotConversion || 'applied') + : '') + ); + }) + .join('\n') + + (priceSeriesResult.diagnostics.fxPairs.length + ? '\n fx pairs:\n' + + priceSeriesResult.diagnostics.fxPairs + .map(function (fx) { + return ( + ' ' + + fx.fromCurrency + + '→' + + (fx.toCurrency || 'n/a') + + ' pair=' + + (fx.pairSymbol || 'n/a') + + ' points=' + + fx.points + + ' status=' + + fx.status + ); + }) + .join('\n') + : '') + ); + } + + const cashConversion = convertCashBalancesToBase(cashBalances, baseCurrency, fxCache, finalDateKey); + const cashBaseline = Number.isFinite(cashConversion.total) ? cashConversion.total : 0; + if (PERFORMANCE_DEBUG_ENABLED) { + performanceDebug( + 'Cash baseline (base ' + + (cashConversion.baseCurrency || baseCurrency || 'n/a') + + '): ' + + formatDecimal(cashBaseline, 2) + + (cashConversion.breakdown.length + ? '\n components:\n' + + cashConversion.breakdown + .map(function (entry) { + return ( + ' ' + + entry.currency + + ' ' + + formatDecimal(entry.amount, 2) + + ' → ' + + formatDecimal(entry.converted, 2) + + ' (' + + entry.status + + ')' + ); + }) + .join('\n') + : '') + ); + } + + const timelineResult = computeAccountPerformanceTimeline(events, priceSeries, sortedSymbols, finalDateKey, { + debug: PERFORMANCE_DEBUG_ENABLED, + baseCurrency, + }); + const timeline = Array.isArray(timelineResult) ? timelineResult : timelineResult.timeline; + const debugTimelineEntries = !Array.isArray(timelineResult) && timelineResult && timelineResult.debugDetails + ? timelineResult.debugDetails + : []; + + const cashFlows = events + .filter(function (event) { + return Number.isFinite(event.cashFlow) && Math.abs(event.cashFlow) > 0.00001; + }) + .map(function (event) { + const converted = convertCashFlowToBase(event, baseCurrency, fxCache); + const amount = converted ? converted.amount : event.cashFlow; + const currency = converted ? converted.currency : baseCurrency; + const originalAmount = converted ? converted.originalAmount : event.cashFlow; + const originalCurrency = converted ? converted.originalCurrency || event.currency || null : event.currency || null; + const flow = { + timestamp: event.timestamp ? event.timestamp.toISOString() : null, + amount, + symbol: event.symbol, + type: event.type, + }; + if (currency) { + flow.currency = currency; + } + if (originalAmount !== amount || (originalCurrency && currency && originalCurrency !== currency)) { + flow.originalAmount = originalAmount; + flow.originalCurrency = originalCurrency; + } + if (converted && converted.status && converted.status !== 'converted' && converted.status !== 'native') { + flow.conversionStatus = converted.status; + } + return flow; + }); + + if (Number.isFinite(cashBaseline) && timeline.length) { + const cashByDate = new Map(); + cashFlows.forEach(function (flow) { + const dateKey = toDateKey(flow.timestamp || flow.date); + if (!dateKey) { + return; + } + cashByDate.set(dateKey, (cashByDate.get(dateKey) || 0) + (Number(flow.amount) || 0)); + }); + let runningCash = Number.isFinite(cashBaseline) ? cashBaseline : null; + if (Number.isFinite(runningCash)) { + for (let index = timeline.length - 1; index >= 0; index -= 1) { + const entry = timeline[index]; + entry.cashValue = runningCash; + entry.value = (Number(entry.value) || 0) + runningCash; + if (PERFORMANCE_DEBUG_ENABLED && debugTimelineEntries[index] && Array.isArray(debugTimelineEntries[index].holdings)) { + debugTimelineEntries[index].holdings.push({ + symbol: 'CASH', + quantity: runningCash, + price: 1, + value: runningCash, + currency: baseCurrency || null, + }); + debugTimelineEntries[index].totalValue = entry.value; + } + const dateKey = entry.date || null; + if (dateKey && cashByDate.has(dateKey)) { + runningCash -= cashByDate.get(dateKey); + } + } + } + } + + if (PERFORMANCE_DEBUG_ENABLED) { + const summaryEntries = debugTimelineEntries.length ? debugTimelineEntries : timeline; + const timelineSummaries = summarizeTimelineForDebug(summaryEntries); + if (timelineSummaries.length) { + const startLabel = timeline.length ? timeline[0].date : 'n/a'; + const endLabel = timeline.length ? timeline[timeline.length - 1].date : 'n/a'; + performanceDebug( + 'Account value timeline (' + + timeline.length + + ' entries, ' + + 'range ' + + startLabel + + ' → ' + + endLabel + + ') ' + + (baseCurrency ? '[' + baseCurrency + ']' : '') + + ':\n' + + timelineSummaries + .map(function (line) { + return ' ' + line; + }) + .join('\n') + ); + } else { + performanceDebug('Account value timeline is empty.'); + } + } + + if (PERFORMANCE_DEBUG_ENABLED) { + const cashFlowSummaries = summarizeCashFlowsForDebug(cashFlows); + if (cashFlowSummaries.length) { + performanceDebug( + 'Cash flows considered (' + + cashFlows.length + + '):\n' + + cashFlowSummaries + .map(function (line) { + return ' ' + line; + }) + .join('\n') + ); + } else { + performanceDebug('Cash flows considered: none'); + } + } + + const totals = computeAggregatedMetrics( + timeline, + cashFlows.filter(function (flow) { + return (flow.type || '') !== 'execution'; + }) + ); + if (PERFORMANCE_DEBUG_ENABLED) { + const totalsSummaries = summarizeAggregatedTotalsForDebug(totals); + if (totalsSummaries.length) { + performanceDebug( + 'Aggregated totals (' + + (baseCurrency || 'n/a') + + '):\n' + + totalsSummaries + .map(function (line) { + return ' ' + line; + }) + .join('\n') + ); + } else { + performanceDebug('Aggregated totals (' + (baseCurrency || 'n/a') + '): none'); + } + performanceDebug('Aggregated totals raw data:', totals); + } + + return { + timeline, + cashFlows, + totals, + metadata: { + eventCount: events.length, + symbolCount: sortedSymbols.length, + generatedAt: now.toISOString(), + accountId: account ? account.id : null, + baseCurrency, + accountCurrency: accountCurrency || null, + currencyDiagnostics: { + symbolCurrencyCount: symbolCurrencyMap.size, + }, + cashBaseline: Number.isFinite(cashBaseline) ? cashBaseline : null, + }, + }; +} + + +const BALANCE_NUMERIC_FIELDS = [ + 'totalEquity', + 'marketValue', + 'cash', + 'buyingPower', + 'maintenanceExcess', + 'dayPnl', + 'openPnl', + 'totalPnl', + 'totalCost', + 'realizedPnl', + 'unrealizedPnl', +]; + +const BALANCE_FIELD_ALIASES = { + dayPnl: ['dayPnL'], + openPnl: ['openPnL'], + totalPnl: ['totalPnL', 'totalPnLInBase', 'totalReturn'], + realizedPnl: ['realizedPnL'], + unrealizedPnl: ['unrealizedPnL'], +}; + +function createEmptyBalanceAccumulator(currency) { + const base = { currency: currency || null, isRealTime: false, __fieldCounts: Object.create(null) }; + BALANCE_NUMERIC_FIELDS.forEach(function (field) { + base[field] = 0; + base.__fieldCounts[field] = 0; + }); + return base; +} + +function markBalanceFieldPresent(target, field) { + if (!target.__fieldCounts) { + target.__fieldCounts = Object.create(null); + } + target.__fieldCounts[field] = (target.__fieldCounts[field] || 0) + 1; +} + +function pickNumericValue(source, key) { + if (!source) { + return null; + } + const direct = source[key]; + if (typeof direct === 'number' && Number.isFinite(direct)) { + return direct; + } + const aliases = BALANCE_FIELD_ALIASES[key] || []; + for (const alias of aliases) { + const value = source[alias]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + } + return null; +} + +function accumulateBalance(target, source) { + BALANCE_NUMERIC_FIELDS.forEach(function (field) { + const value = pickNumericValue(source, field); + if (value !== null) { + const current = typeof target[field] === 'number' && Number.isFinite(target[field]) ? target[field] : 0; + target[field] = current + value; + markBalanceFieldPresent(target, field); + } + }); + if (source && typeof source.isRealTime === 'boolean') { + target.isRealTime = target.isRealTime || source.isRealTime; + } +} + +async function fetchSymbolsDetails(login, symbolIds) { + if (!symbolIds.length) { + return {}; + } + + const batches = []; + const BATCH_SIZE = 50; + for (let i = 0; i < symbolIds.length; i += BATCH_SIZE) { + batches.push(symbolIds.slice(i, i + BATCH_SIZE)); + } + + const results = {}; + for (const batch of batches) { + const idsParam = batch.join(','); + const data = await questradeRequest(login, '/v1/symbols', { params: { ids: idsParam } }); + (data.symbols || []).forEach(function (symbol) { + results[symbol.symbolId] = symbol; + }); + } + return results; +} + +function mergeBalances(allBalances) { + const summary = { + combined: {}, + perCurrency: {}, + }; + + allBalances.forEach(function (balanceEntry) { + const combinedBalances = balanceEntry && (balanceEntry.combinedBalances || []); + const perCurrencyBalances = balanceEntry && (balanceEntry.perCurrencyBalances || []); + + combinedBalances.forEach(function (balance) { + const currency = balance && balance.currency; + if (!currency) { + return; + } + if (!summary.combined[currency]) { + summary.combined[currency] = createEmptyBalanceAccumulator(currency); + } + accumulateBalance(summary.combined[currency], balance); + }); + + perCurrencyBalances.forEach(function (balance) { + const currency = balance && balance.currency; + if (!currency) { + return; + } + if (!summary.perCurrency[currency]) { + summary.perCurrency[currency] = createEmptyBalanceAccumulator(currency); + } + accumulateBalance(summary.perCurrency[currency], balance); + }); + }); + + return summary; +} + +function summarizeAccountCombinedBalances(balanceEntry) { + const summary = mergeBalances([balanceEntry]); + finalizeBalances(summary); + if (!summary || !summary.combined) { + return null; + } + const combined = summary.combined; + if (!combined || typeof combined !== 'object' || !Object.keys(combined).length) { + return null; + } + return combined; +} + +function finalizeBalances(summary) { + if (!summary) { + return summary; + } + ['combined', 'perCurrency'].forEach(function (scope) { + const bucket = summary[scope]; + if (!bucket) { + return; + } + Object.values(bucket).forEach(function (entry) { + if (!entry || !entry.__fieldCounts) { + return; + } + BALANCE_NUMERIC_FIELDS.forEach(function (field) { + const count = entry.__fieldCounts[field] || 0; + if (count === 0) { + delete entry[field]; + } + }); + delete entry.__fieldCounts; + }); + }); + return summary; +} + +function mergePnL(positions) { + return positions.reduce( + function (acc, position) { + acc.dayPnl += position.dayPnl || 0; + acc.openPnl += position.openPnl || 0; + return acc; + }, + { dayPnl: 0, openPnl: 0 } + ); +} + +function buildInvestmentModelPositions(positions, accountId) { + if (!Array.isArray(positions) || !accountId) { + return []; + } + + const normalizedAccountId = String(accountId); + const results = []; + + positions.forEach(function (position) { + if (!position || String(position.accountId) !== normalizedAccountId) { + return; + } + const symbol = position.symbol ? String(position.symbol).trim() : null; + if (!symbol) { + return; + } + const marketValue = Number(position.currentMarketValue); + if (!Number.isFinite(marketValue)) { + return; + } + const entry = { symbol, dollars: marketValue }; + const shares = Number(position.openQuantity); + if (Number.isFinite(shares) && shares !== 0) { + entry.shares = shares; + } + if (Math.abs(entry.dollars) < 0.01 && (!entry.shares || Math.abs(entry.shares) < 0.01)) { + return; + } + results.push(entry); + }); + + return results; +} + +function findAccountCadBalance(accountId, perAccountBalances) { + if (!accountId || !perAccountBalances) { + return null; + } + + const balances = perAccountBalances[accountId]; + if (!balances || typeof balances !== 'object') { + return null; } const cadKey = Object.keys(balances).find(function (key) { @@ -890,152 +2860,7 @@ app.get('/api/summary', async function (req, res) { const configuredDefaultKey = getDefaultAccountId(); try { - const accountCollections = []; - const accountNameOverrides = getAccountNameOverrides(); - const accountPortalOverrides = getAccountPortalOverrides(); - const accountChatOverrides = getAccountChatOverrides(); - const configuredOrdering = getAccountOrdering(); - const accountSettings = getAccountSettings(); - const accountBeneficiaries = getAccountBeneficiaries(); - for (const login of allLogins) { - const fetchedAccounts = await fetchAccounts(login); - const normalized = fetchedAccounts.map(function (account, index) { - const rawNumber = account.number || account.accountNumber || account.id || index; - const number = String(rawNumber); - const compositeId = login.id + ':' + number; - const ownerLabel = resolveLoginDisplay(login); - const normalizedAccount = Object.assign({}, account, { - id: compositeId, - number, - accountNumber: number, - loginId: login.id, - ownerId: login.id, - ownerLabel, - ownerEmail: login.email || null, - loginLabel: ownerLabel, - loginEmail: login.email || null, - }); - const displayName = resolveAccountDisplayName(accountNameOverrides, normalizedAccount, login); - if (displayName) { - normalizedAccount.displayName = displayName; - } - const overridePortalId = resolveAccountPortalId(accountPortalOverrides, normalizedAccount, login); - if (overridePortalId) { - normalizedAccount.portalAccountId = overridePortalId; - } - const overrideChatUrl = resolveAccountChatUrl(accountChatOverrides, normalizedAccount, login); - if (overrideChatUrl) { - normalizedAccount.chatURL = overrideChatUrl; - } else if (normalizedAccount.chatURL === undefined) { - normalizedAccount.chatURL = null; - } - const accountSettingsOverride = resolveAccountOverrideValue(accountSettings, normalizedAccount, login); - if (typeof accountSettingsOverride === 'boolean') { - normalizedAccount.showQQQDetails = accountSettingsOverride; - } else if (accountSettingsOverride && typeof accountSettingsOverride === 'object') { - if (typeof accountSettingsOverride.showQQQDetails === 'boolean') { - normalizedAccount.showQQQDetails = accountSettingsOverride.showQQQDetails; - } - if (typeof accountSettingsOverride.investmentModel === 'string') { - const trimmedModel = accountSettingsOverride.investmentModel.trim(); - if (trimmedModel) { - normalizedAccount.investmentModel = trimmedModel; - } - } - if (typeof accountSettingsOverride.lastRebalance === 'string') { - const trimmedDate = accountSettingsOverride.lastRebalance.trim(); - if (trimmedDate) { - normalizedAccount.investmentModelLastRebalance = trimmedDate; - } - } else if ( - accountSettingsOverride.lastRebalance && - typeof accountSettingsOverride.lastRebalance === 'object' && - typeof accountSettingsOverride.lastRebalance.date === 'string' - ) { - const trimmedDate = accountSettingsOverride.lastRebalance.date.trim(); - if (trimmedDate) { - normalizedAccount.investmentModelLastRebalance = trimmedDate; - } - } - } - const defaultBeneficiary = accountBeneficiaries.defaultBeneficiary || null; - if (defaultBeneficiary) { - normalizedAccount.beneficiary = defaultBeneficiary; - } - const resolvedBeneficiary = resolveAccountBeneficiary(accountBeneficiaries, normalizedAccount, login); - if (resolvedBeneficiary) { - normalizedAccount.beneficiary = resolvedBeneficiary; - } - return normalizedAccount; - }); - accountCollections.push({ login, accounts: normalized }); - } - - const defaultAccount = findDefaultAccount(accountCollections, configuredDefaultKey); - - let allAccounts = accountCollections.flatMap(function (entry) { - return entry.accounts; - }); - - if (Array.isArray(configuredOrdering) && configuredOrdering.length) { - const orderingMap = new Map(); - configuredOrdering.forEach(function (entry, index) { - const normalized = entry == null ? '' : String(entry).trim(); - if (!normalized) { - return; - } - if (!orderingMap.has(normalized)) { - orderingMap.set(normalized, index); - } - }); - - if (orderingMap.size) { - const DEFAULT_ORDER = Number.MAX_SAFE_INTEGER; - const resolveAccountOrder = function (account) { - if (!account) { - return DEFAULT_ORDER; - } - const candidates = []; - if (account.number) { - candidates.push(String(account.number).trim()); - } - if (account.accountNumber) { - candidates.push(String(account.accountNumber).trim()); - } - if (account.id) { - candidates.push(String(account.id).trim()); - } - for (const candidate of candidates) { - if (!candidate) { - continue; - } - if (orderingMap.has(candidate)) { - return orderingMap.get(candidate); - } - } - return DEFAULT_ORDER; - }; - - allAccounts = allAccounts - .map(function (account, index) { - return { account, index, order: resolveAccountOrder(account) }; - }) - .sort(function (a, b) { - if (a.order !== b.order) { - return a.order - b.order; - } - return a.index - b.index; - }) - .map(function (entry) { - return entry.account; - }); - } - } - - const accountsById = {}; - allAccounts.forEach(function (account) { - accountsById[account.id] = account; - }); + const { accountCollections, allAccounts, accountsById, defaultAccount } = await loadAccountsData(configuredDefaultKey); let selectedAccounts = allAccounts; let resolvedAccountId = null; @@ -1203,6 +3028,94 @@ app.get('/api/summary', async function (req, res) { } }); +app.get('/api/account-performance', async function (req, res) { + const rawAccountId = typeof req.query.accountId === 'string' ? req.query.accountId.trim() : ''; + if (!rawAccountId) { + return res.status(400).json({ message: 'Query parameter "accountId" is required.' }); + } + if (rawAccountId === 'all') { + return res + .status(400) + .json({ message: 'Performance metrics are only available when viewing a single account.' }); + } + + try { + const configuredDefaultKey = getDefaultAccountId(); + const { accountCollections, accountsById, allAccounts } = await loadAccountsData(configuredDefaultKey); + + let targetAccount = accountsById[rawAccountId] || null; + if (!targetAccount) { + targetAccount = allAccounts.find(function (account) { + return account && (account.number === rawAccountId || account.accountNumber === rawAccountId); + }); + } + + if (!targetAccount) { + return res.status(404).json({ message: 'No matching account found for performance analysis.' }); + } + + const collection = accountCollections.find(function (entry) { + return entry && entry.login && entry.login.id === targetAccount.loginId; + }); + if (!collection || !collection.login) { + return res.status(500).json({ message: 'Unable to resolve login context for the requested account.' }); + } + + const login = collection.login; + const accountNumber = targetAccount.number; + if (!accountNumber) { + return res.status(500).json({ message: 'Account number unavailable for performance query.' }); + } + + const startTimeParam = typeof req.query.startTime === 'string' ? req.query.startTime.trim() : ''; + const endTimeParam = typeof req.query.endTime === 'string' ? req.query.endTime.trim() : ''; + const executionOptions = {}; + if (startTimeParam) { + executionOptions.startTime = startTimeParam; + } else { + executionOptions.startTime = '1970-01-01T00:00:00Z'; + } + if (endTimeParam) { + executionOptions.endTime = endTimeParam; + } + + const [positions, executions, balances] = await Promise.all([ + fetchPositions(login, accountNumber), + fetchExecutions(login, accountNumber, executionOptions), + fetchBalances(login, accountNumber), + ]); + + const transfers = Array.isArray(targetAccount.performanceTransfers) ? targetAccount.performanceTransfers : []; + + const performance = await generateAccountPerformance({ + executions, + transfers, + positions, + balances, + account: targetAccount, + }); + + res.json({ + accountId: targetAccount.id, + accountNumber: targetAccount.number, + accountType: targetAccount.type || null, + currency: targetAccount.currency || null, + generatedAt: new Date().toISOString(), + timeline: performance.timeline, + cashFlows: performance.cashFlows, + totals: performance.totals, + metadata: performance.metadata, + }); + } catch (error) { + if (error.response) { + return res + .status(error.response.status) + .json({ message: 'Questrade API error', details: error.response.data }); + } + res.status(500).json({ message: 'Failed to compute account performance.', details: error.message }); + } +}); + app.get('/health', function (req, res) { res.json({ status: 'ok', timestamp: new Date().toISOString() }); });