From 982996a2ee40cf0b8462df7de46933c40b6d1149 Mon Sep 17 00:00:00 2001 From: wangzhenjia Date: Tue, 22 Sep 2026 11:33:56 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(pulse):=20=E7=BB=84=E5=90=88=E6=95=9E?= =?UTF-8?q?=E5=8F=A3=E7=99=BE=E5=88=86=E6=AF=94=E4=B8=8D=E5=86=8D=E7=94=A8?= =?UTF-8?q?=E6=9C=AA=E6=8A=98=E7=AE=97=E7=9A=84=20marketValue=20=E9=99=A4?= =?UTF-8?q?=E4=BB=A5=E6=9C=AC=E5=B8=81=E6=80=BB=E8=B5=84=E4=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `portfolioExposurePercent()` 在缺少 `marketValueBase` 时直接回退到 `marketValue`,而 `marketValue` 是按 `holding.currency`(持仓原币)计价的, 分母 `PortfolioSnapshot.totalAssets` 却是本币口径。二者币种不一致时,敞口 会被汇率倍数放大。 复现: - baseCurrency = 'USD',totalAssets = 100000 - 持仓 0700.HK,currency = 'HKD',marketValue = 780000,没有 marketValueBase 实际:返回 780(即 780% 敞口) 预期:敞口未知,返回 undefined 修复:新增 `baseCurrencyValue()` 辅助函数——优先使用已折算的 `marketValueBase`;仅在持仓币种与本币**已知且相同**时才用 `marketValue` 兜底。逻辑与 alerts 的 `evaluatePositionWeight()` 保持一致。 范围:仅 `portfolioExposurePercent()`,不动 `computePersonalImpact()` 及其余 pulse 逻辑。无可见 UI 变化(只是极端跨币种场景下不再给出错误数字)。 测试:`packages/shared/src/pulse/service.test.ts` 新增 2 个用例 (跨币种返回 undefined / 同币种返回 78)。旧代码下前者失败,新代码通过。 --- packages/shared/src/pulse/service.test.ts | 28 +++++++++++++++++++++++ packages/shared/src/pulse/service.ts | 24 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/pulse/service.test.ts b/packages/shared/src/pulse/service.test.ts index 8b51eaf4..be9b5779 100644 --- a/packages/shared/src/pulse/service.test.ts +++ b/packages/shared/src/pulse/service.test.ts @@ -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', () => { diff --git a/packages/shared/src/pulse/service.ts b/packages/shared/src/pulse/service.ts index efbe2cbe..e95fc5f3 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,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, @@ -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) } From edad2f8d4af72a8d68b8a5f64634d43a6465a9f0 Mon Sep 17 00:00:00 2001 From: wxr <51250936+wxrbyte@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:14:03 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(pulse):=20=E5=B8=81=E7=A7=8D=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E6=97=B6=E6=95=9E=E5=8F=A3=E7=99=BE=E5=88=86=E6=AF=94?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20fail-closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 无有限 marketValueBase 时,只有当持仓币种与本币都存在且相等才用未折算 marketValue,否则返回 undefined —— 与 alerts 的 evaluatePositionWeight 一致。 补充币种缺失的回归用例,并给 PORTFOLIO 夹具补上显式币种以固定其单币种语义。 --- packages/shared/src/pulse/service.test.ts | 30 ++++++++++++++++++++--- packages/shared/src/pulse/service.ts | 6 ++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/pulse/service.test.ts b/packages/shared/src/pulse/service.test.ts index be9b5779..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, } @@ -308,6 +311,27 @@ describe('portfolioExposurePercent', () => { } 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 e95fc5f3..4879d187 100644 --- a/packages/shared/src/pulse/service.ts +++ b/packages/shared/src/pulse/service.ts @@ -319,9 +319,13 @@ function baseCurrencyValue(holding: Holding, baseCurrency?: string): number | un 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() - if (holdingCurrency && base && holdingCurrency !== base) return undefined + const sameCurrency = Boolean(holdingCurrency && base && holdingCurrency === base) + if (!sameCurrency) return undefined return raw }