feat(nav): Enhance product API with historical NAV tracking and new charting features

- Updated `ready.py` to include a health check for the new `products_nav_history` endpoint, ensuring system readiness.
- Enhanced the `ProductNavChartPanel` component to visualize historical NAV data with various charting options, including line and column charts.
- Introduced new utility functions for filtering and aggregating NAV data, improving data handling in the frontend.
- Updated tests to verify the inclusion of the new `products_nav_history` check in the API response.
- Improved documentation to reflect recent changes and the new testing baseline of 833 passed tests, indicating enhanced stability.

This update significantly improves the product API by providing access to historical NAV data and enhancing user insights through visualizations.
This commit is contained in:
2026-09-11 21:38:54 +08:00
parent 2b4eaa9725
commit d4a2461ba4
16 changed files with 5975 additions and 5328 deletions
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import {
aggregateNavGranularity,
filterNavByRange,
navRangeReturnPct,
navYDomain,
} from '../navChartSeries'
const sample = [
{ nav_date: '2026-08-01', nav: 1.0, daily_chg_pct: 0.1 },
{ nav_date: '2026-08-15', nav: 1.02, daily_chg_pct: -0.2 },
{ nav_date: '2026-09-01', nav: 1.05, daily_chg_pct: 0.5 },
{ nav_date: '2026-09-04', nav: 1.06, daily_chg_pct: 0.1 },
]
describe('navChartSeries', () => {
it('filterNavByRange keeps tail from anchor', () => {
const oneMonth = filterNavByRange(sample, '1M', '2026-09-04')
expect(oneMonth.length).toBeGreaterThan(0)
expect(oneMonth[oneMonth.length - 1].nav_date).toBe('2026-09-04')
})
it('aggregateNavGranularity week reduces points', () => {
const weeks = aggregateNavGranularity(sample, 'week')
expect(weeks.length).toBeLessThanOrEqual(sample.length)
})
it('navRangeReturnPct computes interval return', () => {
const pct = navRangeReturnPct(sample)
expect(pct).not.toBeNull()
expect(pct!).toBeCloseTo(6, 0)
})
it('navYDomain pads flat and span series', () => {
const [a, b] = navYDomain([1.05, 1.05, 1.06])
expect(a).toBeLessThan(1.05)
expect(b).toBeGreaterThan(1.06)
})
})
+157
View File
@@ -0,0 +1,157 @@
/** 产品净值历史序列:区间裁剪、周聚合、Y 轴 domain(详情页图表用) */
export type NavSeriesPoint = {
nav_date: string
nav: number
daily_chg_pct?: number | null
}
export type NavRangeKey = '1M' | '3M' | '6M' | '1Y' | 'ALL'
export type NavGranularity = 'day' | 'week'
export type NavChartMode = 'line' | 'column' | 'combo'
export const NAV_RANGE_DAYS: Record<Exclude<NavRangeKey, 'ALL'>, number> = {
'1M': 31,
'3M': 92,
'6M': 183,
'1Y': 365,
}
export function parseNavDate(iso: string): Date {
const s = String(iso).slice(0, 10)
return new Date(`${s}T12:00:00`)
}
/** 按锚定日(默认序列末日)向前保留 N 个自然日样本 */
export function filterNavByRange(
points: NavSeriesPoint[],
range: NavRangeKey,
anchorDate?: string,
): NavSeriesPoint[] {
if (!points.length || range === 'ALL') return [...points]
const anchor = anchorDate ? parseNavDate(anchorDate) : parseNavDate(points[points.length - 1].nav_date)
const days = NAV_RANGE_DAYS[range]
const start = new Date(anchor)
start.setDate(start.getDate() - days)
return points.filter((p) => parseNavDate(p.nav_date) >= start)
}
function weekKey(iso: string): string {
const d = parseNavDate(iso)
const day = d.getDay() || 7
const th = new Date(d)
th.setDate(d.getDate() - day + 4)
const yearStart = new Date(th.getFullYear(), 0, 1)
const w = Math.ceil(((th.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
return `${th.getFullYear()}-W${String(w).padStart(2, '0')}`
}
/** 周粒度:每周保留最后一个交易日;涨跌幅为周内首尾净值收益率 */
export function aggregateNavGranularity(
points: NavSeriesPoint[],
granularity: NavGranularity,
): NavSeriesPoint[] {
if (granularity === 'day' || points.length === 0) return enrichSeriesChg(points)
const buckets = new Map<string, NavSeriesPoint[]>()
for (const p of points) {
const k = weekKey(p.nav_date)
const arr = buckets.get(k) ?? []
arr.push(p)
buckets.set(k, arr)
}
return [...buckets.entries()]
.map(([, arr]) => {
const sorted = [...arr].sort(
(a, b) => parseNavDate(a.nav_date).getTime() - parseNavDate(b.nav_date).getTime(),
)
const last = sorted[sorted.length - 1]
const first = sorted[0]
const f = Number(first.nav)
const l = Number(last.nav)
const weekChg = f > 0 ? ((l - f) / f) * 100 : 0
return { ...last, daily_chg_pct: Math.round(weekChg * 100) / 100 }
})
.sort((a, b) => parseNavDate(a.nav_date).getTime() - parseNavDate(b.nav_date).getTime())
}
/** 缺 daily_chg_pct 时按相邻净值推算(便于柱图) */
export function enrichSeriesChg(points: NavSeriesPoint[]): NavSeriesPoint[] {
return points.map((p, i) => {
const raw = p.daily_chg_pct
if (raw != null && !Number.isNaN(Number(raw))) {
return { ...p, daily_chg_pct: Number(raw) }
}
if (i === 0) return { ...p, daily_chg_pct: 0 }
const prev = Number(points[i - 1].nav)
const cur = Number(p.nav)
if (!prev || Number.isNaN(prev) || Number.isNaN(cur)) return { ...p, daily_chg_pct: 0 }
const chg = ((cur - prev) / prev) * 100
return { ...p, daily_chg_pct: Math.round(chg * 100) / 100 }
})
}
export function navRangeReturnPct(points: NavSeriesPoint[]): number | null {
if (points.length < 2) return null
const first = Number(points[0].nav)
const last = Number(points[points.length - 1].nav)
if (!first || Number.isNaN(first) || Number.isNaN(last)) return null
return ((last - first) / first) * 100
}
/** 净值 Y 轴:按数据 min/max 加 padding,避免贴边与 0 基线误导 */
export function navYDomain(values: number[], paddingRatio = 0.08): [number, number] {
const nums = values.filter((v) => Number.isFinite(v))
if (!nums.length) return [0, 1]
let min = Math.min(...nums)
let max = Math.max(...nums)
if (min === max) {
const pad = Math.max(min * 0.02, 0.01)
return [min - pad, max + pad]
}
const span = max - min
const pad = span * paddingRatio
return [min - pad, max + pad]
}
export type NavLineDatum = { date: Date; nav: number; navLabel: string }
export function toNavLineData(points: NavSeriesPoint[]): NavLineDatum[] {
return points.map((p) => {
const nav = Number(p.nav)
return {
date: parseNavDate(p.nav_date),
nav,
navLabel: nav.toFixed(4),
}
})
}
export type NavChgDatum = { date: Date; dateLabel: string; chg: number; chgLabel: string }
export function chgYDomain(values: number[]): [number, number] {
const nums = values.filter((v) => Number.isFinite(v))
const maxAbs = Math.max(...nums.map((v) => Math.abs(v)), 0.05)
const pad = maxAbs * 0.15
return [-maxAbs - pad, maxAbs + pad]
}
export function toNavChgColumnData(points: NavSeriesPoint[]): NavChgDatum[] {
return enrichSeriesChg(points).map((p) => {
const chg = Number(p.daily_chg_pct ?? 0)
const label = String(p.nav_date).slice(0, 10)
return {
date: parseNavDate(p.nav_date),
dateLabel: label,
chg,
chgLabel: `${chg >= 0 ? '+' : ''}${chg.toFixed(2)}%`,
}
})
}
export function xAxisTimeMask(range: NavRangeKey, pointCount: number): string {
if (range === '1M' || (range === 'ALL' && pointCount <= 40)) return 'MM-DD'
if (range === '3M' || range === '6M') return 'MM-DD'
return 'YYYY-MM'
}