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 (
+
+ );
+}
+
+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() });
});