Skip to content

Commit 13b85f2

Browse files
committed
Enhance portfolio metrics with cash flow and baseline data
Added support for displaying net contributions, deposits, withdrawals, and activity count in the portfolio metrics UI. Improved total return calculation in AlpacaAPI to use cash flow data when available, with fallback to portfolio history baseline. Refactored portfolio data conversion to better handle base values and percent calculations, and added timestamp to PortfolioDataPoint.
1 parent 1a92090 commit 13b85f2

File tree

3 files changed

+351
-44
lines changed

3 files changed

+351
-44
lines changed

src/components/PerformanceChart.tsx

Lines changed: 160 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,56 @@ const formatValue = (value: number | undefined, isMobile: boolean = false): stri
5757
return value.toLocaleString();
5858
};
5959

60+
const formatSignedCurrency = (value: number | undefined, isMobile: boolean = false): string => {
61+
if (value === undefined || value === null || Number.isNaN(value)) {
62+
return isMobile ? '$0' : '$0.00';
63+
}
64+
65+
const sign = value > 0 ? '+' : value < 0 ? '-' : '';
66+
const absolute = Math.abs(value);
67+
const formatted = isMobile
68+
? formatValue(absolute, true)
69+
: absolute.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
70+
71+
return `${sign}$${formatted}`;
72+
};
73+
74+
const formatUnsignedCurrency = (value: number | undefined, isMobile: boolean = false): string => {
75+
if (value === undefined || value === null || Number.isNaN(value)) {
76+
return isMobile ? '$0' : '$0.00';
77+
}
78+
79+
const absolute = Math.abs(value);
80+
const formatted = isMobile
81+
? formatValue(absolute, true)
82+
: absolute.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
83+
84+
return `$${formatted}`;
85+
};
86+
87+
const formatLabelForPeriod = (date: Date, period: TimePeriod): string => {
88+
switch (period) {
89+
case '1D':
90+
return date.toLocaleTimeString('en-US', {
91+
hour: '2-digit',
92+
minute: '2-digit',
93+
hour12: false,
94+
});
95+
case '1W':
96+
return date.toLocaleDateString('en-US', { weekday: 'short' });
97+
case '1M':
98+
case '3M':
99+
case 'YTD':
100+
case '1Y':
101+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
102+
case '5Y':
103+
case 'All':
104+
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
105+
default:
106+
return date.toLocaleDateString();
107+
}
108+
};
109+
60110
// Remove the old hardcoded function - we'll create a dynamic one in the component
61111

62112
const PerformanceChart = React.memo(({ selectedStock: propSelectedStock, selectedStockDescription, onClearSelection }: PerformanceChartProps) => {
@@ -384,7 +434,69 @@ const PerformanceChart = React.memo(({ selectedStock: propSelectedStock, selecte
384434
return [];
385435
}, [selectedStock, stockData, selectedPeriod, portfolioData]);
386436

387-
const currentData = useMemo(() => getCurrentData(), [getCurrentData]);
437+
const baseData = useMemo(() => getCurrentData(), [getCurrentData]);
438+
439+
const currentData = useMemo(() => {
440+
if (!baseData || baseData.length === 0) {
441+
return baseData;
442+
}
443+
444+
if (selectedStock || !metrics?.accountValue) {
445+
return baseData;
446+
}
447+
448+
const currentValue = Number(metrics.accountValue);
449+
if (!Number.isFinite(currentValue) || currentValue <= 0) {
450+
return baseData;
451+
}
452+
453+
const referencePoint = baseData.find(point => Number(point?.value ?? 0) > 0) || baseData[0];
454+
if (!referencePoint) {
455+
return baseData;
456+
}
457+
458+
const baselineCandidate = Number(referencePoint.value) - Number(referencePoint.pnl ?? 0);
459+
const fallbackBaseline = Number(referencePoint.value) || Number.EPSILON;
460+
const baseline = Number.isFinite(baselineCandidate) && baselineCandidate !== 0
461+
? baselineCandidate
462+
: fallbackBaseline;
463+
464+
const pnl = currentValue - baseline;
465+
const pnlPercent = baseline !== 0 ? (pnl / baseline) * 100 : 0;
466+
467+
const now = new Date();
468+
const timeLabel = formatLabelForPeriod(now, selectedPeriod);
469+
const latestTimestamp = Math.floor(now.getTime() / 1000);
470+
471+
const lastPoint = baseData[baseData.length - 1];
472+
const timeAlreadyUsed = lastPoint?.time === timeLabel || Number(lastPoint?.timestamp ?? 0) >= latestTimestamp;
473+
474+
const replacePeriods = selectedPeriod === '3M'
475+
|| selectedPeriod === '1Y'
476+
|| selectedPeriod === '5Y'
477+
|| selectedPeriod === 'All'
478+
|| (selectedPeriod === 'YTD' && baseData.length > 31);
479+
480+
const shouldReplaceLast = timeAlreadyUsed || replacePeriods;
481+
482+
const dataCopy = [...baseData];
483+
484+
const newPoint: PortfolioDataPoint = {
485+
time: timeLabel,
486+
value: currentValue,
487+
pnl,
488+
pnlPercent,
489+
timestamp: latestTimestamp
490+
};
491+
492+
if (shouldReplaceLast && dataCopy.length > 0) {
493+
dataCopy[dataCopy.length - 1] = newPoint;
494+
} else {
495+
dataCopy.push(newPoint);
496+
}
497+
498+
return dataCopy;
499+
}, [baseData, metrics, selectedStock, selectedPeriod]);
388500

389501
// Custom tick formatter for X-axis based on period
390502
const formatXAxisTick = useCallback((value: string) => {
@@ -405,6 +517,22 @@ const PerformanceChart = React.memo(({ selectedStock: propSelectedStock, selecte
405517
(firstValue.value > 0 ? ((totalReturn / firstValue.value) * 100).toFixed(2) : '0.00');
406518
const isPositive = totalReturn >= 0;
407519

520+
const cashFlowSummary = metrics?.cashFlows;
521+
const hasCashFlowSummary = Boolean(cashFlowSummary);
522+
const netContributions = hasCashFlowSummary ? Number(cashFlowSummary?.netContributions ?? 0) : null;
523+
const totalDeposits = hasCashFlowSummary ? Number(cashFlowSummary?.totalDeposits ?? 0) : null;
524+
const totalWithdrawals = hasCashFlowSummary ? Number(cashFlowSummary?.totalWithdrawals ?? 0) : null;
525+
const activityCount = hasCashFlowSummary ? Number(cashFlowSummary?.activityCount ?? 0) : null;
526+
const baselineValue = typeof metrics?.baselineValue === 'number' && metrics.baselineValue > 0
527+
? metrics.baselineValue
528+
: null;
529+
const totalReturnSourceLabel = metrics?.totalReturnSource === 'cash_flows'
530+
? 'cash flow activity'
531+
: metrics?.totalReturnSource === 'history_base'
532+
? 'portfolio history baseline'
533+
: null;
534+
const portfolioMetricsGridClass = `grid grid-cols-2 ${hasCashFlowSummary ? 'sm:grid-cols-4' : 'sm:grid-cols-3'} gap-3 sm:gap-4 pt-4 border-t`;
535+
408536
// Debug log for 1D period
409537
if (selectedStock && selectedPeriod === '1D' && currentData.length > 0) {
410538
console.log(`[PerformanceChart] ${selectedStock} 1D data:`, {
@@ -676,7 +804,7 @@ const PerformanceChart = React.memo(({ selectedStock: propSelectedStock, selecte
676804
{!selectedStock ? (
677805
// Portfolio metrics
678806
<div className="space-y-4">
679-
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 sm:gap-4 pt-4 border-t">
807+
<div className={portfolioMetricsGridClass}>
680808
<div className="col-span-2 sm:col-span-1">
681809
<p className="text-xs text-muted-foreground">Portfolio Value</p>
682810
<p className="text-sm sm:text-base font-semibold">
@@ -725,7 +853,37 @@ const PerformanceChart = React.memo(({ selectedStock: propSelectedStock, selecte
725853
'Loading...'
726854
)}
727855
</p>
856+
{(totalReturnSourceLabel || baselineValue) && (
857+
<p className="text-[11px] text-muted-foreground">
858+
{totalReturnSourceLabel ? `Source: ${totalReturnSourceLabel}` : ''}
859+
{baselineValue ? `${totalReturnSourceLabel ? ' · ' : ''}Baseline ${formatUnsignedCurrency(baselineValue)}` : ''}
860+
</p>
861+
)}
728862
</div>
863+
{hasCashFlowSummary && (
864+
<div>
865+
<p className="text-xs text-muted-foreground">Net Contributions</p>
866+
<div className="space-y-1">
867+
<p className="text-sm sm:text-base font-semibold text-muted-foreground">
868+
<span className="hidden sm:inline">{formatSignedCurrency(netContributions ?? 0)}</span>
869+
<span className="sm:hidden">{formatSignedCurrency(netContributions ?? 0, true)}</span>
870+
</p>
871+
<p className="text-[11px] text-muted-foreground">
872+
<span className="hidden sm:inline">
873+
Deposits {formatUnsignedCurrency(totalDeposits ?? 0)} · Withdrawals {formatUnsignedCurrency(totalWithdrawals ?? 0)}
874+
</span>
875+
<span className="sm:hidden">
876+
Deposits {formatUnsignedCurrency(totalDeposits ?? 0, true)} · Withdrawals {formatUnsignedCurrency(totalWithdrawals ?? 0, true)}
877+
</span>
878+
</p>
879+
{activityCount !== null && activityCount > 0 && (
880+
<p className="text-[11px] text-muted-foreground">
881+
{`Based on ${activityCount} activity${activityCount === 1 ? '' : ' entries'}`}
882+
</p>
883+
)}
884+
</div>
885+
</div>
886+
)}
729887
</div>
730888
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 pt-4 border-t">
731889
<div>

0 commit comments

Comments
 (0)