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