Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/shared/src/pulse/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,34 @@ 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)
})
})

describe('computePersonalImpact', () => {
Expand Down
24 changes: 23 additions & 1 deletion packages/shared/src/pulse/service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
CapabilityRegistry,
Holding,
MarketStatus,
MarketTemperature,
PortfolioSnapshot,
Expand Down Expand Up @@ -303,6 +304,27 @@ 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
const holdingCurrency = holding.currency?.trim().toUpperCase()
const base = baseCurrency?.trim().toUpperCase()
if (holdingCurrency && base && holdingCurrency !== base) return undefined
return raw
}

/** Holding market value as a share of portfolio total assets (%), when computable. */
export function portfolioExposurePercent(
symbol: string,
Expand All @@ -314,7 +336,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)
}
Expand Down
Loading