From 692ec07dca9aa805aa3714aa148bb5e6739fca0a Mon Sep 17 00:00:00 2001 From: wangzhenjia Date: Sun, 20 Sep 2026 11:44:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(screening):=20=E6=9C=80=E6=96=B0=E6=9C=9F?= =?UTF-8?q?=E9=97=B4=E6=97=A0=E6=95=B0=E5=80=BC=E6=97=B6=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E5=88=B0=E6=9C=80=E6=97=A7=E6=9C=9F=E9=97=B4?= =?UTF-8?q?=E8=B4=A2=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 财务指标按最新期间优先(values[0])读取,但当该期间没有可用数值时, latestAccountValue 会回退到数组最后一个元素,也就是最旧期间, 把多年前的 ROE / 毛利率当作"最新"参与 high-roe、quality-growth 评选。 现在改为取最新一个真正有数值的期间(find 第一个有效 value),既保留 "最新已披露数值"的语义,也绝不会跳到最旧期间。extractFinancialMetrics 的 ROE / 毛利率 / 净利率与依赖它们的策略同时修正。 新增回归测试:最新期间只有 yoy、无数值时,ROE 取次新期间 12 而不是 最旧期间 30;修复前该测试失败。revenueGrowth 的 yoy 计算路径不变。 --- .../shared/src/screening/strategies.test.ts | 22 +++++++++++++++++++ packages/shared/src/screening/strategies.ts | 5 ++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/screening/strategies.test.ts b/packages/shared/src/screening/strategies.test.ts index 6f0c0c1f..07acfabb 100644 --- a/packages/shared/src/screening/strategies.test.ts +++ b/packages/shared/src/screening/strategies.test.ts @@ -256,6 +256,28 @@ describe('fundamental rules', () => { expect(metrics.revenueGrowth).toBe(12) }) + it('never reads the oldest period when the newest one carries no number', () => { + // Longbridge-style multi-period account, newest first. The newest period + // ships only a yoy (value unknown to the provider), so the latest usable + // number is Q4 2026 = 12 — the oldest 30 must never be reported as latest. + const report = financialReport({}) + report.statements.IS!.indicators[0].accounts = [ + { + field: 'ROE', + name: 'ROE', + values: [ + { fpEnd: NOW_SECONDS - 86_400, period: 'Q1 2027', year: 2027, value: Number.NaN, yoy: '20' }, + { fpEnd: NOW_SECONDS - 90 * 86_400, period: 'Q4 2026', year: 2026, value: 12 }, + { fpEnd: NOW_SECONDS - 180 * 86_400, period: 'Q3 2026', year: 2026, value: 30 }, + ], + }, + ] + const metrics = extractFinancialMetrics(makeContext({ financials: report }).data) + expect(metrics.roe).toBe(12) + // 12% is below the bar; the stale 30% must not produce a candidate. + expect(getScreeningStrategy('high-roe')!.compute(makeContext({ financials: report }))).toBeNull() + }) + it('high-dividend requires yield above the bar and cites payment history', () => { const rule = getScreeningStrategy('high-dividend')! const result = rule.compute( diff --git a/packages/shared/src/screening/strategies.ts b/packages/shared/src/screening/strategies.ts index 189030d5..dd4578a3 100644 --- a/packages/shared/src/screening/strategies.ts +++ b/packages/shared/src/screening/strategies.ts @@ -184,7 +184,10 @@ function statementAccounts(report: FinancialReport | undefined, kind: 'IS' | 'BS function latestAccountValue(accounts: ReportAccount[], field: string): number | undefined { const account = accounts.find((entry) => entry.field === field) if (!account) return undefined - return account.values[0]?.value ?? account.values[account.values.length - 1]?.value + // Values are newest-first: the latest *reported* number is the first one with + // a usable value. Never fall back to the array tail — that is the oldest + // period and would report stale financials as the latest figures. + return account.values.find((entry) => entry.value !== undefined)?.value } function latestAccountYoy(accounts: ReportAccount[], field: string): number | undefined {