From ae0a818d7197e0977a8ec89332b52529aa61d544 Mon Sep 17 00:00:00 2001
From: aishagbadamosi <125849753+aishagbadamosi@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:48:20 +0100
Subject: [PATCH] Add integration tests for the portfolio analytics flow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds two integration test suites covering the full dashboard pipeline — account balances → CoinGecko USD estimates → Recharts allocation/performance/risk views → CSV export — 67 tests, all passing.
- tests/integration/portfolioAnalyticsCalculations.test.ts (52 tests) covers every portfolio calculation: USD pricing, unpriced assets, allocation, diversification, concentration, counterparty risk, volatility, Sharpe ratio, P&L, correlation, rebalancing, historical reconstruction, summaries, and CSV row generation.
- tests/integration/portfolioAnalyticsFlow.test.tsx (15 tests) drives the real PortfolioValue component through two-, one-, five-, unpriced-, empty- and disconnected-account portfolios, plus loading, price-API failure, refresh recovery, and Horizon history failure.
Only the seams are stubbed — the store, the Horizon server, and Recharts' ResponsiveContainer — so all pricing, analytics, chart and export code under test is production code.
closes #251
---
src/components/dashboard/PortfolioValue.tsx | 2 +-
.../portfolioAnalyticsCalculations.test.ts | 893 ++++++++++++++++++
.../portfolioAnalyticsFlow.test.tsx | 731 ++++++++++++++
3 files changed, 1625 insertions(+), 1 deletion(-)
create mode 100644 tests/integration/portfolioAnalyticsCalculations.test.ts
create mode 100644 tests/integration/portfolioAnalyticsFlow.test.tsx
diff --git a/src/components/dashboard/PortfolioValue.tsx b/src/components/dashboard/PortfolioValue.tsx
index 2b0f6286..967d5b2c 100644
--- a/src/components/dashboard/PortfolioValue.tsx
+++ b/src/components/dashboard/PortfolioValue.tsx
@@ -572,7 +572,7 @@ function AllocationView({ analytics }) {
{item.code}
- ${item.valueUsd.toFixed(2)}
+ {item.valueUsd !== null ? `$${item.valueUsd.toFixed(2)}` : '—'}
diff --git a/tests/integration/portfolioAnalyticsCalculations.test.ts b/tests/integration/portfolioAnalyticsCalculations.test.ts
new file mode 100644
index 00000000..363e7efa
--- /dev/null
+++ b/tests/integration/portfolioAnalyticsCalculations.test.ts
@@ -0,0 +1,893 @@
+/**
+ * Portfolio analytics — calculation pipeline integration tests
+ *
+ * Exercises the real modules that back the Portfolio Analytics flow
+ * (src/lib/priceFeed.ts + src/lib/portfolioAnalytics.ts) end to end:
+ * account balances -> USD estimates -> allocation -> risk -> P&L -> CSV rows.
+ *
+ * The companion suite (portfolioAnalyticsFlow.test.tsx) covers the rendered
+ * component; this suite covers every exported calculation.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import {
+ calculatePortfolioValue,
+ clearPriceCache,
+ fetchPrices,
+} from '../../src/lib/priceFeed';
+import {
+ assessCounterpartyRisk,
+ assessPortfolioRisk,
+ calculate24hPortfolioChange,
+ calculateAssetAllocation,
+ calculateAssetPnL,
+ calculateConcentrationRiskScore,
+ calculateCorrelation,
+ calculateDiversificationScore,
+ calculatePerformanceMetrics,
+ calculateRebalancingActions,
+ calculateSharpeRatio,
+ calculateTotalPnL,
+ calculateVolatility,
+ fetchHistoricalPerformance,
+ generatePortfolioSummary,
+ identifyConcentrationRisks,
+} from '../../src/lib/portfolioAnalytics';
+import { buildCsv } from '../../src/lib/chartUtils';
+
+const ACCOUNT_ID = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN';
+const USDC_ISSUER = 'GD7XE3T5Z6J5K6FJH3Z2K4L2M4N6P8Q0R2S4T6V8W0X';
+const UNKNOWN_ISSUER = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';
+
+const HOUR_MS = 60 * 60 * 1000;
+const DAY_MS = 24 * HOUR_MS;
+
+// fetchHistoricalPerformance measures its window from the real clock, so the
+// fixtures are anchored to it rather than to a fixed epoch.
+const NOW = Date.now();
+const at = (msAgo: number) => new Date(NOW - msAgo).toISOString();
+
+/** Horizon balance rows as returned by account.balances. */
+function buildBalances() {
+ return [
+ { asset_type: 'native', balance: '1000.0000000' },
+ {
+ asset_type: 'credit_alphanum4',
+ asset_code: 'USDC',
+ asset_issuer: USDC_ISSUER,
+ balance: '400.0000000',
+ },
+ ];
+}
+
+/** Items as produced by calculatePortfolioValue for XLM + USDC. */
+function buildPricedItems() {
+ return [
+ { code: 'XLM', valueUsd: 250, amount: 1000, priceUsd: 0.25, change24h: 10, issuer: null },
+ {
+ code: 'USDC',
+ valueUsd: 400,
+ amount: 400,
+ priceUsd: 1,
+ change24h: -5,
+ issuer: USDC_ISSUER,
+ },
+ ];
+}
+
+function overrideCoinGecko(prices: Record
) {
+ server.use(
+ http.get('https://api.coingecko.com/api/v3/simple/price', () => HttpResponse.json(prices))
+ );
+}
+
+async function pricedPortfolio() {
+ const prices = await fetchPrices(['XLM', 'USDC']);
+ return calculatePortfolioValue(buildBalances(), prices);
+}
+
+beforeEach(async () => {
+ await clearPriceCache();
+});
+
+afterEach(async () => {
+ await clearPriceCache();
+});
+
+// ─── USD estimation ───────────────────────────────────────────────────────────
+
+describe('USD estimation: account balances -> priced portfolio items', () => {
+ it('values every balance from CoinGecko prices and sums the total', async () => {
+ overrideCoinGecko({
+ stellar: { usd: 0.25, usd_24h_change: 10 },
+ 'usd-coin': { usd: 1, usd_24h_change: -5 },
+ });
+
+ const { totalUsd, items } = await pricedPortfolio();
+
+ expect(totalUsd).toBe(650);
+ expect(items).toHaveLength(2);
+ expect(items[0]).toEqual({
+ code: 'XLM',
+ amount: 1000,
+ issuer: null,
+ priceUsd: 0.25,
+ valueUsd: 250,
+ change24h: 10,
+ });
+ expect(items[1]).toEqual({
+ code: 'USDC',
+ amount: 400,
+ issuer: USDC_ISSUER,
+ priceUsd: 1,
+ valueUsd: 400,
+ change24h: -5,
+ });
+ });
+
+ it('marks balances as unpriced when the price map has no entry', () => {
+ const portfolio = calculatePortfolioValue([{ asset_type: 'native', balance: '1000.0000000' }], {});
+
+ expect(portfolio.totalUsd).toBe(0);
+ expect(portfolio.items[0]).toMatchObject({
+ code: 'XLM',
+ priceUsd: null,
+ valueUsd: null,
+ change24h: null,
+ });
+ });
+
+ it('returns null when balances or prices are missing entirely', () => {
+ expect(calculatePortfolioValue(null, {})).toBeNull();
+ expect(calculatePortfolioValue(buildBalances(), null)).toBeNull();
+ });
+
+ it('treats an unparseable balance string as a zero amount', () => {
+ const portfolio = calculatePortfolioValue(
+ [{ asset_type: 'native', balance: 'not-a-number' }],
+ { XLM: { usd: 2, usd_24h_change: 0 } }
+ );
+
+ expect(portfolio.items[0].amount).toBe(0);
+ expect(portfolio.totalUsd).toBe(0);
+ });
+
+ it('degrades to unpriced items when the price API fails', async () => {
+ server.use(
+ http.get('https://api.coingecko.com/api/v3/simple/price', () =>
+ HttpResponse.json({ error: 'rate limited' }, { status: 429 })
+ )
+ );
+
+ const prices = await fetchPrices(['XLM', 'USDC']);
+ expect(prices).toEqual({});
+
+ const portfolio = calculatePortfolioValue(buildBalances(), prices);
+ expect(portfolio.totalUsd).toBe(0);
+ expect(portfolio.items.every((item) => item.valueUsd === null)).toBe(true);
+ });
+
+ it('skips asset codes that have no CoinGecko mapping', async () => {
+ overrideCoinGecko({ stellar: { usd: 0.25, usd_24h_change: 10 } });
+
+ const prices = await fetchPrices(['XLM', 'SCAMTOKEN']);
+ expect(Object.keys(prices)).toEqual(['XLM']);
+ });
+});
+
+// ─── Performance ──────────────────────────────────────────────────────────────
+
+describe('performance metrics', () => {
+ it('derives change and changePercent from current and previous values', () => {
+ expect(calculatePerformanceMetrics(650, 500)).toEqual({
+ change: 150,
+ changePercent: 30,
+ isPositive: true,
+ });
+ expect(calculatePerformanceMetrics(400, 500)).toEqual({
+ change: -100,
+ changePercent: -20,
+ isPositive: false,
+ });
+ });
+
+ it('returns a neutral result for missing or zero baselines', () => {
+ const neutral = { change: 0, changePercent: 0, isPositive: false };
+ expect(calculatePerformanceMetrics(0, 500)).toEqual(neutral);
+ expect(calculatePerformanceMetrics(650, 0)).toEqual(neutral);
+ expect(calculatePerformanceMetrics(null, undefined)).toEqual(neutral);
+ });
+
+ it('reconstructs the 24h portfolio change from per-asset moves', async () => {
+ overrideCoinGecko({
+ stellar: { usd: 0.25, usd_24h_change: 10 },
+ 'usd-coin': { usd: 1, usd_24h_change: -5 },
+ });
+
+ const { items } = await pricedPortfolio();
+ const change = calculate24hPortfolioChange(items);
+
+ // Previous values: 250 / 1.10 = 227.2727… + 400 / 0.95 = 421.0526…
+ expect(change.change).toBeCloseTo(1.6746411, 6);
+ expect(change.changePercent).toBeCloseTo(0.25830258, 6);
+ expect(change.isPositive).toBe(true);
+ });
+
+ it('ignores assets that are missing a price or a 24h change', () => {
+ const change = calculate24hPortfolioChange([
+ { code: 'XLM', valueUsd: 250, change24h: 10 },
+ { code: 'MYSTERY', valueUsd: null, change24h: null },
+ { code: 'SHX', valueUsd: 100, change24h: undefined },
+ ]);
+
+ expect(change.changePercent).toBeCloseTo(10, 6);
+ });
+});
+
+// ─── Allocation, diversification, concentration ───────────────────────────────
+
+describe('asset allocation and concentration', () => {
+ it('computes percentages that sum to 100 and sorts by weight', () => {
+ const allocation = calculateAssetAllocation(buildPricedItems());
+
+ expect(allocation.map((entry) => entry.code)).toEqual(['USDC', 'XLM']);
+ expect(allocation[0].allocation).toBeCloseTo(61.538462, 6);
+ expect(allocation[1].allocation).toBeCloseTo(38.461538, 6);
+ expect(allocation.reduce((sum, entry) => sum + entry.allocation, 0)).toBeCloseTo(100, 6);
+ });
+
+ it('returns an empty allocation for an empty or missing portfolio', () => {
+ expect(calculateAssetAllocation([])).toEqual([]);
+ expect(calculateAssetAllocation(null)).toEqual([]);
+ });
+
+ it('returns zero allocations rather than dividing by zero when nothing is priced', () => {
+ const allocation = calculateAssetAllocation([{ code: 'MYSTERY', valueUsd: null, amount: 10 }]);
+
+ expect(allocation).toEqual([{ code: 'MYSTERY', valueUsd: null, amount: 10, allocation: 0 }]);
+ });
+
+ it('scores a 50/50 split as maximally diversified', () => {
+ expect(
+ calculateDiversificationScore([
+ { code: 'A', allocation: 50 },
+ { code: 'B', allocation: 50 },
+ ])
+ ).toBe(100);
+ });
+
+ it('scores a single-asset portfolio as zero diversification', () => {
+ expect(calculateDiversificationScore([])).toBe(0);
+ expect(calculateDiversificationScore([{ code: 'XLM', allocation: 100 }])).toBe(0);
+ });
+
+ it('flags every allocation above the 25% concentration threshold', () => {
+ const risks = identifyConcentrationRisks(calculateAssetAllocation(buildPricedItems()));
+
+ // 61.5% and 38.5% both clear 25%.
+ expect(risks).toHaveLength(2);
+ expect(risks[0]).toMatchObject({
+ asset: 'USDC',
+ code: 'USDC',
+ valueUsd: 400,
+ riskLevel: 'high',
+ });
+ expect(risks[0].allocation).toBeCloseTo(61.538462, 6);
+ expect(risks[0].percentage).toBeCloseTo(61.538462, 6);
+ expect(risks[0].message).toBe('USDC represents 61.5% of the portfolio');
+ expect(risks[1]).toMatchObject({ code: 'XLM', riskLevel: 'medium' });
+ });
+
+ it('rates 25-50% weight as medium concentration', () => {
+ const risks = identifyConcentrationRisks([{ code: 'A', allocation: 30, valueUsd: 30 }]);
+ expect(risks[0].riskLevel).toBe('medium');
+ });
+
+ it('keeps assets at or below the 25% threshold out of the risk list', () => {
+ const risks = identifyConcentrationRisks([
+ { code: 'A', allocation: 25 },
+ { code: 'B', allocation: 20 },
+ ]);
+ expect(risks).toEqual([]);
+ });
+
+ it('scores concentration higher for a single dominant asset', () => {
+ // One asset only: HHI cannot exceed the theoretical minimum, so only the
+ // largest-position term contributes.
+ expect(calculateConcentrationRiskScore([{ allocation: 100 }])).toBe(65);
+ // Same dominant weight spread over two slots also lifts the HHI term.
+ expect(calculateConcentrationRiskScore([{ allocation: 100 }, { allocation: 0 }])).toBe(83);
+ expect(
+ calculateConcentrationRiskScore([{ allocation: 50 }, { allocation: 50 }])
+ ).toBe(28);
+ expect(
+ calculateConcentrationRiskScore([
+ { allocation: 20 },
+ { allocation: 20 },
+ { allocation: 20 },
+ { allocation: 20 },
+ { allocation: 20 },
+ ])
+ ).toBe(8);
+ expect(calculateConcentrationRiskScore([])).toBe(0);
+ });
+});
+
+// ─── Counterparty risk ────────────────────────────────────────────────────────
+
+describe('issuer counterparty exposure', () => {
+ it('returns a zeroed report for an empty portfolio', () => {
+ const risk = assessCounterpartyRisk([]);
+
+ expect(risk.score).toBe(0);
+ expect(risk.level).toBe('low');
+ expect(risk.issuerCount).toBe(0);
+ expect(risk.largestIssuerExposure).toBe(0);
+ expect(risk.issuerExposures).toEqual([]);
+ expect(risk.factors).toEqual([]);
+ });
+
+ it('ignores native XLM when measuring issuer exposure', () => {
+ const risk = assessCounterpartyRisk([{ code: 'XLM', valueUsd: 1000, amount: 1000, issuer: null }]);
+
+ expect(risk.issuerCount).toBe(0);
+ expect(risk.issuedExposure).toBe(0);
+ expect(risk.factors.map((factor) => factor.name)).toEqual(['Protocol-only exposure']);
+ expect(risk.level).toBe('low');
+ });
+
+ it('groups assets by issuer and ranks exposure by percentage', () => {
+ const risk = assessCounterpartyRisk([
+ { code: 'XLM', valueUsd: 250, amount: 1000, issuer: null },
+ { code: 'USDC', valueUsd: 400, amount: 400, issuer: USDC_ISSUER },
+ { code: 'DAI', valueUsd: 100, amount: 100, issuer: USDC_ISSUER },
+ { code: 'SHX', valueUsd: 250, amount: 250, issuer: UNKNOWN_ISSUER },
+ ]);
+
+ expect(risk.issuerCount).toBe(2);
+ expect(risk.issuedExposure).toBeCloseTo(75, 6);
+ expect(risk.issuerExposures[0].issuer).toBe(USDC_ISSUER);
+ expect(risk.issuerExposures[0].assets).toEqual(['USDC', 'DAI']);
+ expect(risk.issuerExposures[0].percentage).toBeCloseTo(50, 6);
+ expect(risk.issuerExposures[1].percentage).toBeCloseTo(25, 6);
+ expect(risk.largestIssuerExposure).toBeCloseTo(50, 6);
+ expect(risk.factors.map((factor) => factor.name)).toEqual(['High issuer exposure']);
+ expect(risk.recommendations).toHaveLength(1);
+ });
+
+ it('escalates a sole issuer with heavy exposure to high risk', () => {
+ const risk = assessCounterpartyRisk([
+ { code: 'USDC', valueUsd: 800, amount: 800, issuer: USDC_ISSUER },
+ ]);
+
+ // 100% issued/largest exposure: 100 * 0.25 + 100 * 0.55 = 80, +10 for a
+ // sole issuer above 20% exposure, +8 for fewer than three issuers above 50%.
+ expect(risk.score).toBe(98);
+ expect(risk.level).toBe('high');
+ expect(risk.recommendations.join(' ')).toMatch(/largest issuer/i);
+ });
+
+ it('reports unpriced trustlines as a hidden counterparty exposure', () => {
+ const risk = assessCounterpartyRisk([
+ { code: 'XLM', valueUsd: 500, amount: 2000, issuer: null },
+ { code: 'MYSTERY', valueUsd: null, amount: 25, issuer: UNKNOWN_ISSUER },
+ ]);
+
+ expect(risk.unpricedAssets).toBe(1);
+ expect(risk.unpricedAmount).toBe(25);
+ expect(risk.unpricedExposure).toBeCloseTo(50, 6);
+ expect(risk.factors.map((factor) => factor.name)).toContain('Unpriced trustlines');
+ expect(risk.recommendations.join(' ')).toMatch(/unpriced trustlines/i);
+ });
+});
+
+// ─── Volatility, Sharpe, overall risk ─────────────────────────────────────────
+
+describe('volatility, Sharpe ratio and risk assessment', () => {
+ const series = [700, 600, 650];
+
+ it('computes the standard deviation of period returns', () => {
+ const volatility = calculateVolatility(series.map((value) => ({ value })));
+
+ expect(volatility).toBeCloseTo(11.309524, 5);
+ expect(calculateVolatility([{ value: 700 }])).toBe(0);
+ expect(calculateVolatility([])).toBe(0);
+ });
+
+ it('scales volatility by sqrt(365) only when annualisation is requested', () => {
+ const observed = calculateVolatility(series.map((value) => ({ value })));
+ const annualised = calculateVolatility(series.map((value) => ({ value })), {
+ annualized: true,
+ });
+
+ expect(annualised).toBeCloseTo(observed * Math.sqrt(365), 3);
+ expect(annualised).toBeGreaterThan(observed);
+ });
+
+ it('skips periods whose baseline value is zero', () => {
+ const volatility = calculateVolatility([{ value: 0 }, { value: 100 }, { value: 100 }]);
+ expect(volatility).toBe(0);
+ });
+
+ it('computes excess return over volatility', () => {
+ expect(calculateSharpeRatio(20, 10)).toBe(1.6);
+ expect(calculateSharpeRatio(20, 10, 5)).toBe(1.5);
+ expect(calculateSharpeRatio(20, 0)).toBe(0);
+ });
+
+ it('escalates to high risk for a concentrated, volatile, single-issuer book', () => {
+ const risk = assessPortfolioRisk({
+ volatility: 18,
+ diversificationScore: 5,
+ concentrationRisks: [{ code: 'XLM', riskLevel: 'high', allocation: 90 }],
+ concentrationRiskScore: 90,
+ counterpartyRisk: { score: 70, factors: [], recommendations: [] },
+ unpricedAssetCount: 0,
+ assetCount: 2,
+ });
+
+ expect(risk.score).toBe(75);
+ expect(risk.level).toBe('high');
+ expect(risk.components).toEqual({
+ volatility: 100,
+ concentration: 90,
+ diversification: 95,
+ counterparty: 70,
+ valuation: 0,
+ });
+ expect(risk.factors.map((factor) => factor.name)).toEqual(
+ expect.arrayContaining(['High volatility', 'Poor diversification', 'High concentration'])
+ );
+ expect(risk.recommendations.join(' ')).toMatch(/Rebalance positions above 50%/);
+ });
+
+ it('floors a high-impact factor at a medium risk score', () => {
+ const risk = assessPortfolioRisk({
+ volatility: 1,
+ diversificationScore: 0,
+ concentrationRisks: [],
+ concentrationRiskScore: 0,
+ counterpartyRisk: { score: 0, factors: [], recommendations: [] },
+ unpricedAssetCount: 0,
+ assetCount: 1,
+ });
+
+ expect(risk.score).toBe(45);
+ expect(risk.level).toBe('medium');
+ });
+
+ it('stays low risk for a balanced book with no unpriced assets', () => {
+ const risk = assessPortfolioRisk({
+ volatility: 1,
+ diversificationScore: 90,
+ concentrationRisks: [],
+ concentrationRiskScore: 10,
+ counterpartyRisk: { score: 0, factors: [], recommendations: [] },
+ unpricedAssetCount: 0,
+ assetCount: 5,
+ });
+
+ expect(risk.level).toBe('low');
+ expect(risk.factors.map((factor) => factor.name)).toEqual([
+ 'Low volatility',
+ 'Good diversification',
+ ]);
+ expect(risk.recommendations).toEqual([
+ 'Maintain periodic reviews as prices, issuers, and allocations change.',
+ ]);
+ });
+
+ it('weights unpriced holdings into the valuation component', () => {
+ const risk = assessPortfolioRisk({
+ volatility: 0,
+ diversificationScore: 100,
+ concentrationRisks: [],
+ concentrationRiskScore: 0,
+ counterpartyRisk: { score: 0, factors: [], recommendations: [] },
+ unpricedAssetCount: 1,
+ assetCount: 4,
+ });
+
+ expect(risk.components.valuation).toBe(25);
+ expect(risk.recommendations.join(' ')).toMatch(/without market data/i);
+ });
+
+ it('merges counterparty factors and de-duplicates recommendations', () => {
+ const risk = assessPortfolioRisk({
+ volatility: 6,
+ diversificationScore: 80,
+ concentrationRisks: [],
+ concentrationRiskScore: 0,
+ counterpartyRisk: {
+ score: 40,
+ factors: [
+ {
+ name: 'High issuer exposure',
+ factor: 'High issuer exposure',
+ impact: 'high',
+ description: 'One issuer backs 80.0% of priced portfolio value.',
+ },
+ ],
+ recommendations: [
+ 'Add assets backed by independent issuers.',
+ 'Add assets backed by independent issuers.',
+ ],
+ },
+ unpricedAssetCount: 0,
+ assetCount: 3,
+ });
+
+ expect(risk.factors.map((factor) => factor.name)).toContain('High issuer exposure');
+ expect(
+ risk.recommendations.filter((r) => r === 'Add assets backed by independent issuers.')
+ ).toHaveLength(1);
+ });
+});
+
+// ─── P&L ──────────────────────────────────────────────────────────────────────
+
+describe('profit and loss', () => {
+ it('compares current value against the supplied cost basis', () => {
+ const pnl = calculateAssetPnL(
+ [
+ { code: 'XLM', valueUsd: 250, amount: 1000, priceUsd: 0.25 },
+ { code: 'SHX', valueUsd: 150, amount: 100, priceUsd: 1.5 },
+ ],
+ { XLM: 0.2 }
+ );
+
+ expect(pnl[0]).toMatchObject({
+ avgCost: 0.2,
+ totalCost: 200,
+ unrealizedPnL: 50,
+ unrealizedPnLPercent: 25,
+ isProfitable: true,
+ });
+ // SHX has no cost-basis entry, so the current price becomes the basis.
+ expect(pnl[1]).toMatchObject({
+ avgCost: 1.5,
+ totalCost: 150,
+ unrealizedPnL: 0,
+ unrealizedPnLPercent: 0,
+ isProfitable: true,
+ });
+ });
+
+ it('reports a losing position as unprofitable', () => {
+ const pnl = calculateAssetPnL(
+ [{ code: 'ETH', valueUsd: 80, amount: 1, priceUsd: 80 }],
+ { ETH: 100 }
+ );
+
+ expect(pnl[0].unrealizedPnL).toBe(-20);
+ expect(pnl[0].unrealizedPnLPercent).toBe(-20);
+ expect(pnl[0].isProfitable).toBe(false);
+ });
+
+ it('aggregates per-asset P&L into a portfolio total', () => {
+ const total = calculateTotalPnL([
+ { totalCost: 200, valueUsd: 250 },
+ { totalCost: 150, valueUsd: 100 },
+ ]);
+
+ expect(total).toEqual({
+ totalCost: 350,
+ totalValue: 350,
+ totalPnL: 0,
+ totalPnLPercent: 0,
+ isProfitable: true,
+ });
+ });
+
+ it('handles a zero cost basis without dividing by zero', () => {
+ expect(calculateTotalPnL([{ totalCost: 0, valueUsd: 0 }])).toMatchObject({
+ totalPnLPercent: 0,
+ isProfitable: true,
+ });
+ });
+});
+
+// ─── Correlation and rebalancing ───────────────────────────────────────────────
+
+describe('correlation', () => {
+ it('returns 1 for perfectly correlated series and -1 for inverted ones', () => {
+ expect(calculateCorrelation([1, 2, 3], [2, 4, 6])).toBeCloseTo(1, 10);
+ expect(calculateCorrelation([1, 2, 3], [6, 4, 2])).toBeCloseTo(-1, 10);
+ });
+
+ it('returns 0 for mismatched, too-short or flat series', () => {
+ expect(calculateCorrelation([1, 2, 3], [1, 2])).toBe(0);
+ expect(calculateCorrelation([1], [1])).toBe(0);
+ expect(calculateCorrelation(null, [1, 2])).toBe(0);
+ expect(calculateCorrelation([2, 2, 2], [1, 5, 9])).toBe(0);
+ });
+});
+
+describe('rebalancing recommendations', () => {
+ it('buys under-allocated and sells over-allocated assets beyond the 5% band', () => {
+ const actions = calculateRebalancingActions(
+ [
+ { code: 'XLM', allocation: 70, valueUsd: 700 },
+ { code: 'USDC', allocation: 30, valueUsd: 300 },
+ ],
+ { XLM: 40, USDC: 60 },
+ 1000
+ );
+
+ expect(actions).toHaveLength(2);
+ expect(actions[0]).toMatchObject({
+ asset: 'XLM',
+ action: 'sell',
+ amountUsd: 300,
+ priority: 'high',
+ });
+ expect(actions[1]).toMatchObject({
+ asset: 'USDC',
+ action: 'buy',
+ amountUsd: 300,
+ priority: 'high',
+ });
+ });
+
+ it('ignores drift inside the 5% threshold', () => {
+ const actions = calculateRebalancingActions(
+ [
+ { code: 'XLM', allocation: 42, valueUsd: 420 },
+ { code: 'USDC', allocation: 58, valueUsd: 580 },
+ ],
+ { XLM: 40, USDC: 60 },
+ 1000
+ );
+
+ expect(actions).toEqual([]);
+ });
+
+ it('marks 5-10% deviations as medium priority and sorts by absolute drift', () => {
+ const actions = calculateRebalancingActions(
+ [
+ { code: 'A', allocation: 47, valueUsd: 470 },
+ { code: 'B', allocation: 46, valueUsd: 460 },
+ { code: 'C', allocation: 8, valueUsd: 8 },
+ ],
+ { A: 40, B: 40, C: 40 },
+ 1000
+ );
+
+ expect(actions.map((action) => action.asset)).toEqual(['C', 'A', 'B']);
+ expect(actions[0]).toMatchObject({ priority: 'high' });
+ expect(actions.slice(1).every((action) => action.priority === 'medium')).toBe(true);
+ });
+});
+
+// ─── Historical reconstruction ────────────────────────────────────────────────
+
+function buildHorizonStub(pages: any[][], { failOnCall = false } = {}) {
+ let pageIndex = 0;
+ return {
+ effects() {
+ const builder: any = {
+ forAccount: () => builder,
+ order: () => builder,
+ limit: () => builder,
+ call: async () => {
+ if (failOnCall) throw new Error('Horizon 503');
+ const records = pages[pageIndex++] ?? [];
+ return { records, next: async () => ({ records: pages[pageIndex++] ?? [] }) };
+ },
+ };
+ return builder;
+ },
+ };
+}
+
+describe('historical performance reconstruction', () => {
+ const currentBalances = { XLM: 1000, USDC: 400 };
+
+ it('reverses desc-ordered Horizon effects into a chronological balance timeline', async () => {
+ const serverStub = buildHorizonStub([
+ [
+ // newest first, as Horizon returns them
+ {
+ type: 'account_credited',
+ asset_type: 'credit_alphanum4',
+ asset_code: 'USDC',
+ amount: '100.0000000',
+ created_at: at(2 * HOUR_MS),
+ },
+ {
+ type: 'account_debited',
+ asset_type: 'native',
+ amount: '200.0000000',
+ created_at: at(3 * HOUR_MS),
+ },
+ ],
+ ]);
+
+ const history = await fetchHistoricalPerformance(serverStub, ACCOUNT_ID, currentBalances, 30);
+
+ expect(history).toHaveLength(3);
+ expect(history.map((point) => point.balances)).toEqual([
+ { XLM: 1200, USDC: 300 },
+ { XLM: 1000, USDC: 300 },
+ { XLM: 1000, USDC: 400 },
+ ]);
+ expect(history.map((point) => point.timestamp)).toEqual([
+ new Date(at(3 * HOUR_MS)).getTime(),
+ new Date(at(2 * HOUR_MS)).getTime(),
+ history[2].timestamp,
+ ]);
+ expect(history[0].date).toBe(new Date(at(3 * HOUR_MS)).toISOString().split('T')[0]);
+ });
+
+ it('returns only the current snapshot when there are no effects', async () => {
+ const history = await fetchHistoricalPerformance(
+ buildHorizonStub([[]]),
+ ACCOUNT_ID,
+ currentBalances,
+ 30
+ );
+
+ expect(history).toHaveLength(1);
+ expect(history[0].balances).toEqual(currentBalances);
+ });
+
+ it('stops at the requested window and ignores older effects', async () => {
+ const serverStub = buildHorizonStub([
+ [
+ {
+ type: 'account_credited',
+ asset_type: 'native',
+ amount: '50.0000000',
+ created_at: at(2 * HOUR_MS),
+ },
+ {
+ type: 'account_credited',
+ asset_type: 'native',
+ amount: '9999.0000000',
+ created_at: at(40 * DAY_MS),
+ },
+ ],
+ ]);
+
+ const history = await fetchHistoricalPerformance(serverStub, ACCOUNT_ID, currentBalances, 30);
+
+ expect(history).toHaveLength(2);
+ expect(history[0].balances).toEqual({ XLM: 950, USDC: 400 });
+ });
+
+ it('paginates backwards until the window is exhausted', async () => {
+ const serverStub = buildHorizonStub([
+ [
+ {
+ type: 'account_credited',
+ asset_type: 'native',
+ amount: '10.0000000',
+ created_at: at(1 * HOUR_MS),
+ },
+ ],
+ [
+ {
+ type: 'account_debited',
+ asset_type: 'native',
+ amount: '30.0000000',
+ created_at: at(2 * HOUR_MS),
+ },
+ ],
+ ]);
+
+ const history = await fetchHistoricalPerformance(serverStub, ACCOUNT_ID, currentBalances, 30);
+
+ expect(history.map((point) => point.balances.XLM)).toEqual([1020, 990, 1000]);
+ });
+
+ it('degrades to the current snapshot when Horizon fails', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ const history = await fetchHistoricalPerformance(
+ buildHorizonStub([], { failOnCall: true }),
+ ACCOUNT_ID,
+ currentBalances,
+ 30
+ );
+
+ expect(history).toHaveLength(1);
+ expect(history[0].balances).toEqual(currentBalances);
+ expect(warn).toHaveBeenCalled();
+ warn.mockRestore();
+ });
+});
+
+// ─── End-to-end summary ───────────────────────────────────────────────────────
+
+describe('generatePortfolioSummary', () => {
+ it('ties balances, prices, allocation and risk into one report', () => {
+ const summary = generatePortfolioSummary(buildPricedItems(), [
+ { value: 600 },
+ { value: 550 },
+ { value: 650 },
+ ]);
+
+ expect(summary.totalValue).toBe(650);
+ expect(summary.assetCount).toBe(2);
+ expect(summary.allocation.map((entry) => entry.code)).toEqual(['USDC', 'XLM']);
+ expect(summary.diversificationScore).toBeCloseTo(94.674556, 5);
+ expect(summary.concentrationRiskScore).toBe(38);
+ expect(summary.concentrationRisks).toHaveLength(2);
+ expect(summary.counterpartyRisk.issuerCount).toBe(1);
+ expect(summary.counterpartyRisk.largestIssuerExposure).toBeCloseTo(61.538462, 6);
+ expect(summary.change24h).toBeCloseTo(summary.performance24h.changePercent, 10);
+ expect(summary.volatility).toBeCloseTo(13.257, 2);
+ expect(summary.riskAssessment.level).toBe('medium');
+ expect(summary.riskAssessment.score).toBe(55);
+ expect(summary.riskAssessment.components).toEqual({
+ volatility: 88,
+ concentration: 38,
+ diversification: 5,
+ counterparty: 67,
+ valuation: 0,
+ });
+ expect(summary.topAssets).toEqual(summary.allocation);
+ expect(summary.lastUpdated).toMatch(/^\d{4}-\d{2}-\d{2}T/);
+ });
+
+ it('reports an empty portfolio without throwing', () => {
+ const summary = generatePortfolioSummary([], []);
+
+ expect(summary.totalValue).toBe(0);
+ expect(summary.assetCount).toBe(0);
+ expect(summary.allocation).toEqual([]);
+ expect(summary.concentrationRisks).toEqual([]);
+ expect(summary.volatility).toBe(0);
+ expect(summary.riskAssessment.level).toBe('medium');
+ expect(summary.lastUpdated).toMatch(/^\d{4}-\d{2}-\d{2}T/);
+ });
+
+ it('counts unpriced holdings in the valuation risk component', () => {
+ const summary = generatePortfolioSummary(
+ [
+ { code: 'XLM', valueUsd: 250, amount: 1000, priceUsd: 0.25, change24h: 10, issuer: null },
+ {
+ code: 'MYSTERY',
+ valueUsd: null,
+ amount: 5,
+ priceUsd: null,
+ change24h: null,
+ issuer: UNKNOWN_ISSUER,
+ },
+ ],
+ []
+ );
+
+ expect(summary.riskAssessment.components.valuation).toBe(50);
+ expect(summary.riskAssessment.recommendations.join(' ')).toMatch(/without market data/i);
+ });
+});
+
+// ─── CSV export of the analytics payload ──────────────────────────────────────
+
+describe('CSV export of the analytics payload', () => {
+ it('renders the allocation table as spreadsheet-ready rows', () => {
+ const allocation = calculateAssetAllocation(buildPricedItems());
+
+ const csv = buildCsv(
+ allocation.map((entry) => ({
+ Asset: entry.code,
+ Balance: entry.amount,
+ 'Value (USD)': entry.valueUsd.toFixed(2),
+ 'Allocation (%)': entry.allocation.toFixed(2),
+ }))
+ );
+
+ const lines = csv.split('\n');
+ expect(lines[0]).toBe('Asset,Balance,Value (USD),Allocation (%)');
+ expect(lines[1]).toBe('USDC,400,400.00,61.54');
+ expect(lines[2]).toBe('XLM,1000,250.00,38.46');
+ expect(lines).toHaveLength(3);
+ });
+
+ it('produces an empty string for an empty export', () => {
+ expect(buildCsv([])).toBe('');
+ });
+});
diff --git a/tests/integration/portfolioAnalyticsFlow.test.tsx b/tests/integration/portfolioAnalyticsFlow.test.tsx
new file mode 100644
index 00000000..aefac5b0
--- /dev/null
+++ b/tests/integration/portfolioAnalyticsFlow.test.tsx
@@ -0,0 +1,731 @@
+/**
+ * Portfolio analytics — end-to-end flow integration tests
+ *
+ * Drives the real feature pipeline a user exercises in the dashboard:
+ *
+ * account balances -> CoinGecko price fetch -> USD estimates
+ * -> Recharts allocation/performance/risk views -> CSV export
+ *
+ * Only the seams are stubbed: the Zustand store (so state is controllable and
+ * observable), the Horizon server (so effect history is deterministic) and
+ * Recharts' ResponsiveContainer (jsdom cannot measure layout, so the real chart
+ * components are handed a fixed 500x300 viewport instead).
+ *
+ * Every calculation, chart and export helper underneath is the production one.
+ */
+
+import React from 'react';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { render, fireEvent, cleanup, waitFor, act, within } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import '@testing-library/jest-dom';
+import { server } from '../mocks/server';
+import { clearPriceCache } from '../../src/lib/priceFeed';
+import { fetchHistoricalPerformance } from '../../src/lib/portfolioAnalytics';
+import { exportHistoricalBalances } from '../../src/utils/export';
+
+// ── Store seam ────────────────────────────────────────────────────────────────
+// PortfolioValue subscribes to the whole store, so the mock is a real external
+// store: components read a live snapshot and the test can assert on it.
+
+vi.mock('../../src/lib/store', async () => {
+ const { useSyncExternalStore } = await import('react');
+
+ const defaults = {
+ accountData: null,
+ connectedAddress: null,
+ network: 'testnet',
+ prices: {},
+ pricesLoading: false,
+ pricesError: null,
+ };
+
+ const listeners = new Set<() => void>();
+ let current: Record;
+
+ function setState(partial: Record) {
+ // A new object identity is required: useSyncExternalStore bails out on an
+ // unchanged snapshot reference.
+ current = { ...current, ...partial };
+ listeners.forEach((listener) => listener());
+ }
+
+ current = {
+ ...defaults,
+ setPrices: (prices: any) => setState({ prices, pricesError: null }),
+ setPricesLoading: (pricesLoading: boolean) => setState({ pricesLoading }),
+ setPricesError: (pricesError: string | null) => setState({ pricesError }),
+ };
+
+ const subscribe = (listener: () => void) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ };
+ const getSnapshot = () => current;
+
+ const useStore: any = () => useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+ useStore.setState = setState;
+ useStore.getState = getSnapshot;
+ useStore.reset = () => setState({ ...defaults });
+
+ return { useStore };
+});
+
+// ── Horizon seam ──────────────────────────────────────────────────────────────
+
+let horizonServer: any = null;
+
+vi.mock('../../src/lib/stellar', () => ({
+ getServer: () => horizonServer,
+}));
+
+// ── Recharts seam ─────────────────────────────────────────────────────────────
+// Two jsdom problems, both fixed without touching the chart components:
+// 1. a 0x0 layout box makes ResponsiveContainer render nothing, so the real
+// chart children are handed an explicit viewport instead;
+// 2. animates its sectors in over 1.9s, so the entry animation is
+// switched off. Recharts resolves graphical-item props from
+// `type.defaultProps` (generateCategoricalChart.getFormatItems), so the
+// override has to land on the real class rather than on a wrapper — a
+// wrapper without Pie's defaults silently loses cx/cy/angle/radius and
+// renders no sectors at all.
+
+vi.mock('recharts', async () => {
+ const actual = await vi.importActual('recharts');
+ const { Children, cloneElement } = await import('react');
+
+ actual.Pie.defaultProps = { ...actual.Pie.defaultProps, isAnimationActive: false };
+
+ return {
+ ...actual,
+ ResponsiveContainer: ({ children }: any) =>
+ cloneElement(Children.only(children), { width: 500, height: 300 }),
+ };
+});
+
+import { useStore } from '../../src/lib/store';
+import PortfolioValue from '../../src/components/dashboard/PortfolioValue';
+
+const store = useStore as unknown as {
+ setState: (partial: Record) => void;
+ getState: () => Record;
+ reset: () => void;
+};
+
+const ACCOUNT_ID = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN';
+const USDC_ISSUER = 'GD7XE3T5Z6J5K6FJH3Z2K4L2M4N6P8Q0R2S4T6V8W0X';
+const HOUR_MS = 60 * 60 * 1000;
+
+// fetchHistoricalPerformance measures its 30-day window from the real clock, so
+// the effect fixtures are anchored to it rather than to a fixed epoch.
+const NOW = Date.now();
+const at = (msAgo: number) => new Date(NOW - msAgo).toISOString();
+const HISTORY_DAY = new Date(at(3 * HOUR_MS)).toISOString().slice(0, 10);
+
+// ── Fixtures ──────────────────────────────────────────────────────────────────
+
+function buildAccount(balances: any[]) {
+ return { id: ACCOUNT_ID, account_id: ACCOUNT_ID, sequence: '1', balances };
+}
+
+function nativeBalance(balance: string) {
+ return { asset_type: 'native', balance };
+}
+
+function issuedBalance(assetCode: string, balance: string, issuer = USDC_ISSUER) {
+ return { asset_type: 'credit_alphanum4', asset_code: assetCode, asset_issuer: issuer, balance };
+}
+
+const XLM_AND_USDC_BALANCES = [nativeBalance('1000.0000000'), issuedBalance('USDC', '400.0000000')];
+
+/** Desc-ordered Horizon effect pages used to reconstruct balance history. */
+const EFFECT_PAGES = [
+ [
+ {
+ type: 'account_credited',
+ asset_type: 'credit_alphanum4',
+ asset_code: 'USDC',
+ amount: '100.0000000',
+ created_at: at(2 * HOUR_MS),
+ },
+ {
+ type: 'account_debited',
+ asset_type: 'native',
+ amount: '200.0000000',
+ created_at: at(3 * HOUR_MS),
+ },
+ ],
+];
+
+function buildHorizon(pages: any[][], { fail = false } = {}) {
+ let index = 0;
+ return {
+ effects() {
+ const builder: any = {
+ forAccount: () => builder,
+ order: () => builder,
+ limit: () => builder,
+ call: async () => {
+ if (fail) throw new Error('Horizon 503 Service Unavailable');
+ const records = pages[index++] ?? [];
+ return { records, next: async () => ({ records: pages[index++] ?? [] }) };
+ },
+ };
+ return builder;
+ },
+ };
+}
+
+const requestedPriceUrls: string[] = [];
+
+function mockPrices(prices: Record) {
+ server.use(
+ http.get('https://api.coingecko.com/api/v3/simple/price', ({ request }) => {
+ requestedPriceUrls.push(request.url);
+ return HttpResponse.json(prices);
+ })
+ );
+}
+
+function mockPriceFailure() {
+ server.use(
+ http.get('https://api.coingecko.com/api/v3/simple/price', ({ request }) => {
+ requestedPriceUrls.push(request.url);
+ return HttpResponse.json({ error: 'rate limited' }, { status: 429 });
+ })
+ );
+}
+
+/** Holds the price response open so the in-flight state can be observed. */
+function gatePrices(prices: Record) {
+ let release: () => void = () => {};
+ const opened = new Promise((resolve) => {
+ release = resolve;
+ });
+ server.use(
+ http.get('https://api.coingecko.com/api/v3/simple/price', async ({ request }) => {
+ requestedPriceUrls.push(request.url);
+ await opened;
+ return HttpResponse.json(prices);
+ })
+ );
+ return { release };
+}
+
+// ── Download capture ──────────────────────────────────────────────────────────
+
+let capturedBlobs: Blob[] = [];
+let capturedFilenames: string[] = [];
+
+beforeEach(() => {
+ capturedBlobs = [];
+ capturedFilenames = [];
+ requestedPriceUrls.length = 0;
+
+ store.reset();
+ store.setState({
+ connectedAddress: ACCOUNT_ID,
+ network: 'testnet',
+ accountData: buildAccount(XLM_AND_USDC_BALANCES),
+ });
+ horizonServer = buildHorizon(EFFECT_PAGES);
+
+ const originalCreateElement = document.createElement.bind(document);
+ vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
+ const element = originalCreateElement(tag);
+ if (tag === 'a') {
+ (element as HTMLAnchorElement).click = () => {
+ capturedFilenames.push((element as HTMLAnchorElement).download);
+ };
+ }
+ return element;
+ });
+
+ (globalThis.URL as any).createObjectURL = vi.fn((blob: Blob) => {
+ capturedBlobs.push(blob);
+ return `blob:portfolio-${capturedBlobs.length}`;
+ });
+ (globalThis.URL as any).revokeObjectURL = vi.fn();
+});
+
+afterEach(async () => {
+ cleanup();
+ vi.restoreAllMocks();
+ store.reset();
+ await clearPriceCache();
+});
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+let container: HTMLElement = document.body;
+
+/**
+ * Recharts appends a `recharts_measurement_span` to document.body and leaves it
+ * there for the lifetime of the file, so asset codes leak into body-level
+ * queries. Every assertion is therefore scoped to the rendered tree.
+ */
+function ui() {
+ return within(container);
+}
+
+async function renderFlow() {
+ await act(async () => {
+ container = render().container;
+ });
+}
+
+/** Wait until the price pipeline has settled with the expected asset codes. */
+async function waitForPricing(expectedCodes: string[]) {
+ await waitFor(() => {
+ expect(store.getState().pricesLoading).toBe(false);
+ expect(Object.keys(store.getState().prices).sort()).toEqual([...expectedCodes].sort());
+ });
+ // Let the derived analytics useMemo commit before asserting on the DOM.
+ await act(async () => {});
+}
+
+function openTab(label: string) {
+ fireEvent.click(ui().getByRole('button', { name: new RegExp(`^${label}$`, 'i') }));
+}
+
+async function openTabWhenReady(label: string, expectedCodes = ['USDC', 'XLM']) {
+ await waitForPricing(expectedCodes);
+ await act(async () => {
+ openTab(label);
+ });
+}
+
+/** The row element of the holdings table for a given asset code. */
+function holdingsRow(code: string) {
+ return ui().getByText(code).closest('div')!;
+}
+
+const pieSectors = () => container.querySelectorAll('.recharts-pie-sector');
+const barRectangles = () => container.querySelectorAll('.recharts-bar-rectangle');
+const lineCurves = () => container.querySelectorAll('.recharts-line-curve');
+
+/** The chart root that contains `selector` — views render several at once. */
+function chart(selector: string) {
+ return Array.from(container.querySelectorAll('.recharts-wrapper')).find((node) =>
+ node.querySelector(selector)
+ ) as HTMLElement;
+}
+
+const axisTickValues = (root: HTMLElement, axis: 'xAxis' | 'yAxis') =>
+ Array.from(
+ root.querySelectorAll(`.recharts-${axis} .recharts-cartesian-axis-tick-value`)
+ ).map((node) => node.textContent);
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 1. Full flow: assets -> USD estimates -> allocation chart -> CSV export
+// ═══════════════════════════════════════════════════════════════════════════
+
+describe('portfolio analytics flow: balances -> USD estimates -> charts -> CSV', () => {
+ it('prices every account asset and reports accurate USD estimates', async () => {
+ mockPrices({
+ stellar: { usd: 0.25, usd_24h_change: 10 },
+ 'usd-coin': { usd: 1, usd_24h_change: -5 },
+ });
+
+ await renderFlow();
+ await waitFor(() => expect(ui().getByText('$650.00')).toBeInTheDocument());
+
+ // Total: 1000 XLM @ $0.25 = $250 + 400 USDC @ $1.00 = $400
+ expect(ui().getByText('Total Value')).toBeInTheDocument();
+ expect(ui().getByText('$650.00')).toBeInTheDocument();
+
+ // 24h change is rebuilt from per-asset moves: $650 vs $648.33 = +0.26%
+ expect(ui().getByText('+0.26%')).toBeInTheDocument();
+ expect(ui().getByText('Gain')).toBeInTheDocument();
+
+ // 2-asset 61.5/38.5 split is close to perfectly diversified
+ expect(ui().getByText('95/100')).toBeInTheDocument();
+ expect(ui().getByText('Well diversified')).toBeInTheDocument();
+
+ expect(holdingsRow('XLM')).toHaveTextContent('1,000');
+ expect(holdingsRow('XLM')).toHaveTextContent('$0.2500');
+ expect(holdingsRow('XLM')).toHaveTextContent('$250.00');
+ expect(holdingsRow('XLM')).toHaveTextContent('10.00%');
+
+ expect(holdingsRow('USDC')).toHaveTextContent('400');
+ expect(holdingsRow('USDC')).toHaveTextContent('$1.0000');
+ expect(holdingsRow('USDC')).toHaveTextContent('$400.00');
+ expect(holdingsRow('USDC')).toHaveTextContent('-5.00%');
+ });
+
+ it('requests prices only for the asset codes the account actually holds', async () => {
+ mockPrices({
+ stellar: { usd: 0.25, usd_24h_change: 10 },
+ 'usd-coin': { usd: 1, usd_24h_change: -5 },
+ });
+
+ await renderFlow();
+ await waitForPricing(['USDC', 'XLM']);
+
+ expect(requestedPriceUrls).toHaveLength(1);
+ const url = new URL(requestedPriceUrls[0]);
+ expect(url.pathname).toBe('/api/v3/simple/price');
+ // Only Stellar-mapped assets are requested, de-duplicated and sorted.
+ expect(url.searchParams.get('ids')).toBe('stellar,usd-coin');
+ expect(url.searchParams.get('vs_currencies')).toBe('usd');
+ expect(url.searchParams.get('include_24hr_change')).toBe('true');
+
+ expect(store.getState().prices).toEqual({
+ XLM: { usd: 0.25, usd_24h_change: 10 },
+ USDC: { usd: 1, usd_24h_change: -5 },
+ });
+ });
+
+ it('renders the allocation pie and per-asset weights for a two-asset book', async () => {
+ mockPrices({
+ stellar: { usd: 0.25, usd_24h_change: 10 },
+ 'usd-coin': { usd: 1, usd_24h_change: -5 },
+ });
+
+ await renderFlow();
+ await openTabWhenReady('Allocation');
+
+ expect(ui().getByText('Asset Allocation')).toBeInTheDocument();
+ expect(container.querySelector('.recharts-pie')).toBeInTheDocument();
+ expect(pieSectors()).toHaveLength(2);
+
+ // 400/650 = 61.5% and 250/650 = 38.5%, shown in the legend and again in
+ // the concentration panel because both clear the 25% threshold.
+ expect(ui().getAllByText('61.5%')).toHaveLength(2);
+ expect(ui().getAllByText('38.5%')).toHaveLength(2);
+ expect(ui().getByText('$400.00')).toBeInTheDocument();
+ expect(ui().getByText('$250.00')).toBeInTheDocument();
+
+ expect(ui().getByText('Concentration Risks')).toBeInTheDocument();
+ expect(ui().getByText('USDC represents 61.5% of the portfolio')).toBeInTheDocument();
+ expect(ui().getByText('XLM represents 38.5% of the portfolio')).toBeInTheDocument();
+ });
+
+ it('renders the 30-day performance line and per-asset 24h bars from Horizon history', async () => {
+ mockPrices({
+ stellar: { usd: 0.25, usd_24h_change: 10 },
+ 'usd-coin': { usd: 1, usd_24h_change: -5 },
+ });
+
+ await renderFlow();
+ await openTabWhenReady('Performance');
+
+ // Reconstructed balances: (1200/300) -> (1000/300) -> (1000/400)
+ // Priced at $0.25/$1.00: 600 -> 550 -> 650, dated on today and today.
+ expect(lineCurves().length).toBeGreaterThan(0);
+ expect(axisTickValues(chart('.recharts-line-curve'), 'xAxis')).toContain(HISTORY_DAY);
+
+ // The 24h bar chart is laid out vertically, so the asset codes are its
+ // category axis, ordered by change: XLM (+10) above USDC (-5).
+ expect(ui().getByText('24h Asset Performance')).toBeInTheDocument();
+ expect(barRectangles()).toHaveLength(2);
+ expect(axisTickValues(chart('.recharts-bar-rectangle'), 'yAxis')).toEqual(['XLM', 'USDC']);
+ });
+
+ it('surfaces the risk view metrics derived from the reconstructed history', async () => {
+ mockPrices({
+ stellar: { usd: 0.25, usd_24h_change: 10 },
+ 'usd-coin': { usd: 1, usd_24h_change: -5 },
+ });
+
+ await renderFlow();
+ await openTabWhenReady('Risk');
+
+ // Std-dev of the reconstructed series (600 -> 550 -> 650) is ~13.26%
+ expect(ui().getByText('13.26%')).toBeInTheDocument();
+ expect(ui().getByText('High volatility')).toBeInTheDocument();
+ expect(ui().getByText('Good diversification')).toBeInTheDocument();
+ expect(ui().getByText('38/100')).toBeInTheDocument(); // concentration
+ expect(ui().getByText('67/100')).toBeInTheDocument(); // counterparty
+ expect(ui().getByText('High issuer exposure')).toBeInTheDocument();
+
+ // USDC is the only issued asset, so counterparty exposure is 61.5%
+ expect(ui().getByText('Counterparty')).toBeInTheDocument();
+ expect(ui().getByText('1 issuer')).toBeInTheDocument();
+ expect(ui().getByText('61.5%')).toBeInTheDocument();
+
+ expect(ui().getByText('Recommendations')).toBeInTheDocument();
+ expect(ui().getByText(/Reduce exposure to the largest issuer/i)).toBeInTheDocument();
+ });
+
+ it('exports the flow portfolio snapshot as a CSV download', async () => {
+ mockPrices({
+ stellar: { usd: 0.25, usd_24h_change: 10 },
+ 'usd-coin': { usd: 1, usd_24h_change: -5 },
+ });
+
+ await renderFlow();
+ await waitForPricing(['USDC', 'XLM']);
+
+ // Export the same reconstructed timeline the performance chart consumes.
+ const history = await fetchHistoricalPerformance(
+ buildHorizon(EFFECT_PAGES),
+ ACCOUNT_ID,
+ { XLM: 1000, USDC: 400 },
+ 30
+ );
+
+ await act(async () => {
+ exportHistoricalBalances(history, 'portfolio-export');
+ });
+
+ expect(capturedFilenames).toEqual(['portfolio-export.csv']);
+ expect(capturedBlobs).toHaveLength(1);
+ expect(capturedBlobs[0].type).toBe('text/csv');
+
+ const lines = (await capturedBlobs[0].text()).split('\r\n');
+ expect(lines[0]).toBe('Timestamp,Balance,Asset');
+ // 3 snapshots x 2 assets = 6 data rows
+ expect(lines).toHaveLength(7);
+ expect(lines).toContain(`${at(3 * HOUR_MS)},1200,XLM`);
+ expect(lines).toContain(`${at(3 * HOUR_MS)},300,USDC`);
+ expect(lines).toContain(`${at(2 * HOUR_MS)},1000,XLM`);
+ expect(lines).toContain(`${at(2 * HOUR_MS)},300,USDC`);
+ });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 2. Portfolio compositions
+// ═══════════════════════════════════════════════════════════════════════════
+
+describe('portfolio analytics flow: alternative compositions', () => {
+ it('renders a single-sector pie and a 100% weight for a one-asset portfolio', async () => {
+ store.setState({ accountData: buildAccount([nativeBalance('1000.0000000')]) });
+ horizonServer = buildHorizon([[]]);
+ mockPrices({ stellar: { usd: 0.25, usd_24h_change: 10 } });
+
+ await renderFlow();
+ await waitForPricing(['XLM']);
+
+ // $250.00 shows up twice on the overview: the Total Value stat and the
+ // single holdings row.
+ expect(ui().getAllByText('$250.00')).toHaveLength(2);
+ expect(holdingsRow('XLM')).toHaveTextContent('$250.00');
+ expect(ui().getByText('0/100')).toBeInTheDocument();
+ expect(ui().getByText('Concentrated')).toBeInTheDocument();
+ expect(ui().getByText('+10.00%')).toBeInTheDocument();
+
+ await act(async () => {
+ openTab('Allocation');
+ });
+
+ expect(pieSectors()).toHaveLength(1);
+ expect(ui().getAllByText('100.0%')).toHaveLength(2); // legend + concentration
+ expect(ui().getByText('XLM represents 100.0% of the portfolio')).toBeInTheDocument();
+ });
+
+ it('renders a five-slice pie with BTC- and ETH-dominated weights', async () => {
+ store.setState({
+ accountData: buildAccount([
+ nativeBalance('1000.0000000'),
+ issuedBalance('USDC', '1000.0000000'),
+ issuedBalance('BTC', '1.0000000'),
+ issuedBalance('ETH', '10.0000000'),
+ issuedBalance('SHX', '1000.0000000'),
+ ]),
+ });
+ horizonServer = buildHorizon([[]]);
+ mockPrices({
+ stellar: { usd: 0.25, usd_24h_change: 1 },
+ 'usd-coin': { usd: 1, usd_24h_change: 0 },
+ bitcoin: { usd: 30000, usd_24h_change: -2 },
+ ethereum: { usd: 1500, usd_24h_change: 3 },
+ 'stronghold-token': { usd: 0.1, usd_24h_change: -1 },
+ });
+
+ await renderFlow();
+ // 250 + 1000 + 30000 + 15000 + 100 = 46350
+ await waitFor(() => expect(ui().getByText('$46,350.00')).toBeInTheDocument());
+
+ await act(async () => {
+ openTab('Allocation');
+ });
+
+ expect(pieSectors()).toHaveLength(5);
+ // The legend renders ungrouped currency via toFixed, the header stat uses
+ // toLocaleString.
+ expect(ui().getAllByText('64.7%')).toHaveLength(2);
+ expect(ui().getAllByText('32.4%')).toHaveLength(2);
+ expect(ui().getByText('2.2%')).toBeInTheDocument();
+ expect(ui().getByText('$30000.00')).toBeInTheDocument();
+ expect(ui().getByText('$15000.00')).toBeInTheDocument();
+ // Only BTC and ETH clear the 25% concentration threshold.
+ expect(ui().getByText('BTC represents 64.7% of the portfolio')).toBeInTheDocument();
+ expect(ui().getByText('ETH represents 32.4% of the portfolio')).toBeInTheDocument();
+ expect(ui().queryByText('USDC represents 2.2% of the portfolio')).toBeNull();
+ });
+
+ it('excludes unpriced assets from the totals but keeps them listed', async () => {
+ store.setState({
+ accountData: buildAccount([...XLM_AND_USDC_BALANCES, issuedBalance('SCAM', '5000.0000000')]),
+ });
+ horizonServer = buildHorizon([[]]);
+ mockPrices({
+ stellar: { usd: 0.25, usd_24h_change: 10 },
+ 'usd-coin': { usd: 1, usd_24h_change: -5 },
+ });
+
+ await renderFlow();
+ await waitFor(() => expect(ui().getByText('$650.00')).toBeInTheDocument());
+
+ const scamRow = holdingsRow('SCAM');
+ expect(scamRow).toHaveTextContent('5,000');
+ expect(scamRow).toHaveTextContent('—'); // no price, no value, no 24h change
+ expect(scamRow).not.toHaveTextContent('$');
+
+ await act(async () => {
+ openTab('Risk');
+ });
+ expect(ui().getByText('Unpriced trustlines')).toBeInTheDocument();
+ expect(
+ ui().getByText('1 issued asset lack pricing data and may hide counterparty risk.')
+ ).toBeInTheDocument();
+ });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 3. Edge cases
+// ═══════════════════════════════════════════════════════════════════════════
+
+describe('portfolio analytics flow: empty and disconnected states', () => {
+ it('shows an empty state instead of charts when the account holds no assets', async () => {
+ store.setState({ accountData: buildAccount([]) });
+ horizonServer = buildHorizon([[]]);
+
+ await renderFlow();
+
+ // The overview is the only view that can render without any priced items.
+ expect(ui().getByText('No data available')).toBeInTheDocument();
+ expect(ui().queryByText('Asset Holdings')).toBeNull();
+ expect(container.querySelector('.recharts-pie')).toBeNull();
+ expect(barRectangles()).toHaveLength(0);
+
+ // The analytics tabs stay empty rather than rendering a zero-value chart.
+ await act(async () => {
+ openTab('Allocation');
+ });
+ expect(ui().queryByText('No data available')).toBeNull();
+ expect(container.querySelector('.recharts-pie')).toBeNull();
+
+ // Nothing to price, so the refresh control is inert and no request is made.
+ expect(ui().getByRole('button', { name: /refresh prices/i })).toBeDisabled();
+ expect(requestedPriceUrls).toHaveLength(0);
+ expect(store.getState().prices).toEqual({});
+ });
+
+ it('prompts to connect when no account is loaded', async () => {
+ store.setState({ accountData: null, connectedAddress: null });
+
+ await renderFlow();
+
+ expect(ui().getByText('No account connected')).toBeInTheDocument();
+ expect(ui().queryByRole('button', { name: /^allocation$/i })).toBeNull();
+ });
+
+ it('keeps the loading spinner until the price request settles', async () => {
+ const { release } = gatePrices({
+ stellar: { usd: 0.25, usd_24h_change: 10 },
+ 'usd-coin': { usd: 1, usd_24h_change: -5 },
+ });
+
+ await renderFlow();
+
+ // The price request is still in flight: every analytics view is withheld.
+ expect(store.getState().pricesLoading).toBe(true);
+ expect(container.querySelector('.spinner')).not.toBeNull();
+ expect(ui().queryByText('Total Value')).toBeNull();
+
+ await act(async () => {
+ release();
+ });
+
+ await waitForPricing(['USDC', 'XLM']);
+
+ expect(container.querySelector('.spinner')).toBeNull();
+ expect(ui().getByText('$650.00')).toBeInTheDocument();
+ });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 4. Failure handling
+// ═══════════════════════════════════════════════════════════════════════════
+
+describe('portfolio analytics flow: failure handling', () => {
+ it('degrades to unpriced holdings when the price API fails', async () => {
+ mockPriceFailure();
+
+ await renderFlow();
+ await waitFor(() => {
+ expect(store.getState().pricesLoading).toBe(false);
+ expect(store.getState().prices).toEqual({});
+ });
+ await act(async () => {});
+
+ // Renders without throwing; every valuation cell falls back to an em dash.
+ expect(ui().getByText('Total Value')).toBeInTheDocument();
+ expect(ui().getByText('$0.00')).toBeInTheDocument();
+ expect(holdingsRow('XLM')).toHaveTextContent('—');
+ expect(holdingsRow('XLM')).toHaveTextContent('1,000');
+ expect(holdingsRow('USDC')).toHaveTextContent('—');
+ expect(holdingsRow('USDC')).toHaveTextContent('400');
+
+ // No weights can be derived, so allocation degrades to 0.0% with em-dash
+ // valuations instead of crashing on a null valueUsd.
+ await act(async () => {
+ openTab('Allocation');
+ });
+ expect(ui().getByText('Asset Allocation')).toBeInTheDocument();
+ expect(ui().getAllByText('0.0%')).toHaveLength(2);
+ expect(ui().getAllByText('—')).toHaveLength(2);
+ expect(ui().queryByText('Concentration Risks')).toBeNull();
+ });
+
+ it('recovers a full portfolio when prices become available on refresh', async () => {
+ mockPriceFailure();
+
+ await renderFlow();
+ await waitFor(() => {
+ expect(store.getState().pricesLoading).toBe(false);
+ expect(store.getState().prices).toEqual({});
+ });
+
+ mockPrices({
+ stellar: { usd: 0.25, usd_24h_change: 10 },
+ 'usd-coin': { usd: 1, usd_24h_change: -5 },
+ });
+
+ await act(async () => {
+ fireEvent.click(ui().getByRole('button', { name: /refresh prices/i }));
+ });
+
+ await waitFor(() => expect(ui().getByText('$650.00')).toBeInTheDocument());
+ expect(ui().getByText('Asset Holdings')).toBeInTheDocument();
+ expect(requestedPriceUrls).toHaveLength(2);
+ });
+
+ it('keeps rendering every view when Horizon history reconstruction fails', async () => {
+ mockPrices({
+ stellar: { usd: 0.25, usd_24h_change: 10 },
+ 'usd-coin': { usd: 1, usd_24h_change: -5 },
+ });
+ horizonServer = buildHorizon([], { fail: true });
+
+ await renderFlow();
+ await waitFor(() => expect(ui().getByText('$650.00')).toBeInTheDocument());
+
+ // Falls back to the current snapshot: total value is unaffected, volatility 0.
+ await act(async () => {
+ openTab('Risk');
+ });
+ expect(ui().getByText('0.00%')).toBeInTheDocument();
+ expect(ui().getByText('Low volatility')).toBeInTheDocument();
+
+ await act(async () => {
+ openTab('Performance');
+ });
+ expect(ui().getByText('Portfolio Value (30 Days)')).toBeInTheDocument();
+ expect(barRectangles()).toHaveLength(2);
+ });
+});