diff --git a/packages/shared/src/pulse/service.test.ts b/packages/shared/src/pulse/service.test.ts index 8b51eaf4..631201bc 100644 --- a/packages/shared/src/pulse/service.test.ts +++ b/packages/shared/src/pulse/service.test.ts @@ -194,12 +194,15 @@ async function makeService( } const PORTFOLIO: PortfolioSnapshot = { + // Single-currency book: an unconverted `marketValue` is only comparable with + // `totalAssets` when the holding currency and the base currency are known. + baseCurrency: 'USD', totalAssets: 10_000, accounts: [], holdings: [ - { symbol: 'AAPL.US', name: 'Apple', marketValueBase: 1_500, marketValue: 1_500 }, - { symbol: 'MSFT.US', name: 'Microsoft', marketValue: 2_500 }, - { symbol: 'GOOGL.US', name: 'Alphabet', marketValueBase: 4_000 }, + { symbol: 'AAPL.US', name: 'Apple', currency: 'USD', marketValueBase: 1_500, marketValue: 1_500 }, + { symbol: 'MSFT.US', name: 'Microsoft', currency: 'USD', marketValue: 2_500 }, + { symbol: 'GOOGL.US', name: 'Alphabet', currency: 'USD', marketValueBase: 4_000 }, ], fetchedAt: NOW_MS, } @@ -280,6 +283,55 @@ describe('portfolioExposurePercent', () => { expect(portfolioExposurePercent('AAPL.US')).toBeUndefined() expect(portfolioExposurePercent('AAPL.US', { ...PORTFOLIO, totalAssets: undefined })).toBeUndefined() }) + + it('does not divide an unconverted marketValue by a base-currency total', () => { + // 780,000 HKD is NOT 780% of a 100,000 USD book — without a converted + // marketValueBase the exposure is simply unknown. + const crossCurrency: PortfolioSnapshot = { + baseCurrency: 'USD', + totalAssets: 100_000, + accounts: [], + holdings: [ + { symbol: '0700.HK', name: 'Tencent', currency: 'HKD', marketValue: 780_000 }, + ], + fetchedAt: NOW_MS, + } + expect(portfolioExposurePercent('0700.HK', crossCurrency)).toBeUndefined() + }) + + it('uses marketValue when the holding currency matches the base currency', () => { + const sameCurrency: PortfolioSnapshot = { + baseCurrency: 'HKD', + totalAssets: 1_000_000, + accounts: [], + holdings: [ + { symbol: '0700.HK', name: 'Tencent', currency: 'HKD', marketValue: 780_000 }, + ], + fetchedAt: NOW_MS, + } + expect(portfolioExposurePercent('0700.HK', sameCurrency)).toBe(78) + }) + + it('leaves exposure undefined when either currency is unknown', () => { + // With the holding currency (or the base currency) missing, the unit of the + // denominator is unverifiable, so the raw market value cannot stand in — + // same rule as evaluatePositionWeight in the alerts evaluator. + const noHoldingCurrency: PortfolioSnapshot = { + baseCurrency: 'USD', + totalAssets: 100_000, + accounts: [], + holdings: [{ symbol: '0700.HK', name: 'Tencent', marketValue: 780_000 }], + fetchedAt: NOW_MS, + } + const noBaseCurrency: PortfolioSnapshot = { + totalAssets: 100_000, + accounts: [], + holdings: [{ symbol: '0700.HK', name: 'Tencent', currency: 'HKD', marketValue: 780_000 }], + fetchedAt: NOW_MS, + } + expect(portfolioExposurePercent('0700.HK', noHoldingCurrency)).toBeUndefined() + expect(portfolioExposurePercent('0700.HK', noBaseCurrency)).toBeUndefined() + }) }) describe('computePersonalImpact', () => { diff --git a/packages/shared/src/pulse/service.ts b/packages/shared/src/pulse/service.ts index efbe2cbe..4879d187 100644 --- a/packages/shared/src/pulse/service.ts +++ b/packages/shared/src/pulse/service.ts @@ -1,5 +1,6 @@ import type { CapabilityRegistry, + Holding, MarketStatus, MarketTemperature, PortfolioSnapshot, @@ -303,6 +304,31 @@ export function computePersonalImpact( return { scope: 'watchlist', items } } +/** + * Market value of `holding` that is safe to divide by a base-currency total. + * + * `marketValueBase` is the vendor-converted base-currency value and is always + * safe. `marketValue` is denominated in `holding.currency` (see core + * `Holding`), so it may only stand in when the two currencies are known to + * match — otherwise dividing it by `PortfolioSnapshot.totalAssets` would scale + * the exposure by the FX rate. This mirrors `evaluatePositionWeight` in the + * alerts evaluator. + */ +function baseCurrencyValue(holding: Holding, baseCurrency?: string): number | undefined { + const converted = toFiniteNumber(holding.marketValueBase) + if (converted !== undefined) return converted + const raw = toFiniteNumber(holding.marketValue) + if (raw === undefined) return undefined + // `sameCurrency` must be false when either side is unknown — an unconverted + // `marketValue` divided by a base-currency total is only meaningful when both + // currencies are known to be the same one (mirrors evaluatePositionWeight). + const holdingCurrency = holding.currency?.trim().toUpperCase() + const base = baseCurrency?.trim().toUpperCase() + const sameCurrency = Boolean(holdingCurrency && base && holdingCurrency === base) + if (!sameCurrency) return undefined + return raw +} + /** Holding market value as a share of portfolio total assets (%), when computable. */ export function portfolioExposurePercent( symbol: string, @@ -314,7 +340,7 @@ export function portfolioExposurePercent( const target = normalizeSymbol(symbol) for (const holding of portfolio.holdings) { if (normalizeSymbol(holding.symbol) !== target) continue - const weight = holding.marketValueBase ?? holding.marketValue + const weight = baseCurrencyValue(holding, portfolio.baseCurrency) if (weight === undefined || !Number.isFinite(weight)) return undefined return round2((weight / total) * 100) }