Skip to content

Commit 4162281

Browse files
committed
feat: Add finance MVP with AkShare data service and landing page
1 parent 89da576 commit 4162281

File tree

11 files changed

+1994
-83
lines changed

11 files changed

+1994
-83
lines changed

components/Finance/FundCard.tsx

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { FC, useMemo } from 'react';
2+
import { Badge, Card } from 'react-bootstrap';
3+
4+
import { INDEX_CATEGORY_LABELS, INDEX_RISK_LABELS } from '../../constants/finance';
5+
import styles from '../../styles/Finance.module.scss';
6+
import { IndexFundSnapshot, IndexHistoryPoint } from '../../types/finance';
7+
8+
export interface FundCardProps {
9+
data: IndexFundSnapshot;
10+
}
11+
12+
const formatNumber = (value: number | null | undefined) =>
13+
value == null ? '--' : value.toLocaleString('zh-CN', { maximumFractionDigits: 2 });
14+
15+
const formatPercent = (value: number | null | undefined) =>
16+
value == null ? '--' : `${(value * 100).toFixed(2)}%`;
17+
18+
const valueTone = (value: number | null | undefined) =>
19+
value == null ? styles.mutedText : value >= 0 ? styles.positiveText : styles.negativeText;
20+
21+
const Sparkline: FC<{ points: IndexHistoryPoint[]; chartId: string }> = ({ points, chartId }) => {
22+
const { polyline, gradientStops } = useMemo(() => {
23+
if (!points.length) return { polyline: '', gradientStops: [] as number[] };
24+
25+
const values = points.map(point => point.value);
26+
const min = Math.min(...values);
27+
const max = Math.max(...values);
28+
const delta = max - min || 1;
29+
30+
const polylinePoints = points
31+
.map((point, index) => {
32+
const x = (index / Math.max(points.length - 1, 1)) * 100;
33+
const y = ((max - point.value) / delta) * 40;
34+
35+
return `${x.toFixed(2)},${y.toFixed(2)}`;
36+
})
37+
.join(' ');
38+
39+
const gradientOffsets = [0, 50, 100];
40+
41+
return { polyline: polylinePoints, gradientStops: gradientOffsets };
42+
}, [points]);
43+
44+
if (!points.length) return <div className={styles.sparklinePlaceholder}>数据准备中</div>;
45+
46+
const gradientId = `${chartId}-gradient`;
47+
48+
return (
49+
<div className={styles.sparkline}>
50+
<svg viewBox="0 0 100 40" role="img" aria-label="近 60 日走势">
51+
<defs>
52+
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
53+
{gradientStops.map(offset => (
54+
<stop
55+
key={offset}
56+
offset={`${offset}%`}
57+
stopColor="var(--bs-primary)"
58+
stopOpacity="0.5"
59+
/>
60+
))}
61+
</linearGradient>
62+
</defs>
63+
<polyline
64+
fill="none"
65+
stroke={`url(#${gradientId})`}
66+
strokeWidth="2"
67+
strokeLinejoin="round"
68+
strokeLinecap="round"
69+
points={polyline}
70+
/>
71+
</svg>
72+
</div>
73+
);
74+
};
75+
76+
export const FundCard: FC<FundCardProps> = ({ data }) => {
77+
const {
78+
displayName,
79+
category,
80+
riskLevel,
81+
latestValue,
82+
dailyChangePct,
83+
oneYearReturnPct,
84+
maxDrawdownPct,
85+
tags,
86+
sparkline,
87+
updatedAt,
88+
fallback,
89+
description,
90+
} = data;
91+
const sparklineId = useMemo(() => `fund-${data.symbol}`, [data.symbol]);
92+
93+
return (
94+
<Card className={`${styles.fundCard} h-100`}>
95+
<Card.Body className="d-flex flex-column gap-3">
96+
<div
97+
className={`${styles.fundCardHeader} d-flex justify-content-between align-items-start`}
98+
>
99+
<div>
100+
<h3 className="h5 mb-1">{displayName}</h3>
101+
<div className="d-flex flex-wrap gap-2 align-items-center">
102+
<Badge bg="light" text="dark">
103+
{INDEX_CATEGORY_LABELS[category]}
104+
</Badge>
105+
<Badge
106+
bg={
107+
riskLevel === 'aggressive'
108+
? 'danger'
109+
: riskLevel === 'balanced'
110+
? 'warning'
111+
: 'success'
112+
}
113+
>
114+
{INDEX_RISK_LABELS[riskLevel]}
115+
</Badge>
116+
{fallback && (
117+
<Badge bg="secondary" text="light">
118+
离线数据
119+
</Badge>
120+
)}
121+
</div>
122+
</div>
123+
<small className="text-muted text-end">
124+
数据源 <br />
125+
{data.source.historyEndpoint}
126+
</small>
127+
</div>
128+
129+
<p className="text-muted mb-0">{description}</p>
130+
131+
<div className={`${styles.metricRow} d-flex flex-wrap gap-4 align-items-center`}>
132+
<div>
133+
<p className="text-muted mb-1">最新点位</p>
134+
<span className={styles.metricValue}>{formatNumber(latestValue)}</span>
135+
</div>
136+
<div>
137+
<p className="text-muted mb-1">日涨跌</p>
138+
<strong className={valueTone(dailyChangePct)}>{formatPercent(dailyChangePct)}</strong>
139+
</div>
140+
<div>
141+
<p className="text-muted mb-1">近 1 年收益</p>
142+
<strong className={valueTone(oneYearReturnPct)}>
143+
{formatPercent(oneYearReturnPct)}
144+
</strong>
145+
</div>
146+
<div>
147+
<p className="text-muted mb-1">最大回撤</p>
148+
<strong className={valueTone(maxDrawdownPct)}>{formatPercent(maxDrawdownPct)}</strong>
149+
</div>
150+
</div>
151+
152+
<Sparkline points={sparkline} chartId={sparklineId} />
153+
154+
<div className="d-flex flex-wrap gap-2">
155+
{tags?.map(tag => (
156+
<Badge key={tag} bg="light" text="dark" className={styles.tagBadge}>
157+
{tag}
158+
</Badge>
159+
))}
160+
</div>
161+
162+
<div className="d-flex justify-content-between align-items-center">
163+
<small className="text-muted">更新于 {updatedAt || '--'}</small>
164+
<a href={`/finance/${data.symbol}`} className="text-primary fw-semibold">
165+
查看详情 →
166+
</a>
167+
</div>
168+
</Card.Body>
169+
</Card>
170+
);
171+
};

constants/finance.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { IndexFundCategory, IndexRiskLevel } from '../types/finance';
2+
3+
export const INDEX_CATEGORY_LABELS: Record<IndexFundCategory, string> = {
4+
broad: '宽基',
5+
sector: '行业',
6+
theme: '主题',
7+
};
8+
9+
export const INDEX_RISK_LABELS: Record<IndexRiskLevel, string> = {
10+
conservative: '保守',
11+
balanced: '稳健',
12+
aggressive: '进取',
13+
};

finance_PRD.md

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# 理财产品页面 PRD(国内指数基金精选)
2+
3+
## 1. 背景与目标
4+
5+
- 平台当前缺少针对理财小白的落地页面,用户难以理解「指数基金」这一资产类别并做出选择。
6+
- 国内指数基金数量众多,缺乏以风险/波动友好视角进行筛选的工具。
7+
- 目标:推出一个一屏解释、一屏筛选的理财产品页面,帮助用户基于客观数据挑选国内主流指数基金,并引导至基金详情或购买渠道。
8+
9+
## 2. 用户画像
10+
11+
| 用户类型 | 需求 | 痛点 |
12+
| ----------------------------- | -------------------------------- | ------------------------------- |
13+
| 新手理财用户(25-35 岁白领) | 想开始定投,但不了解指数基金差异 | 术语过多、没有直观风险提示 |
14+
| 进阶用户(有 1-3 年基金经验) | 需要快速对比指数基金表现 | 需要最新净值/回撤数据和多维排序 |
15+
| 内容消费用户 | 想要理解指数基金逻辑 | 缺乏简单的知识卡片和案例 |
16+
17+
## 3. 使用场景
18+
19+
- **首次进入:** 通过首页 Banner/文章跳转到理财产品页,先阅读指数基金为何适合长期定投,再挑选产品。
20+
- **每日查看:** 用户关注 2-3 只指数基金,查看实时涨跌、近期表现及风险信号。
21+
- **策略对比:** 用户在页面内对比宽基 vs 行业指数,组合不同风险等级产品。
22+
23+
## 4. 信息架构与模块
24+
25+
1. **Hero 讲解区**:一句话卖点 + 国内指数基金长期收益对比图 + CTA(开始挑选)。
26+
2. **筛选器**:分类(宽基/行业/主题)、风险级别、跟踪误差、成立年限、基金规模、费率。
27+
3. **指数基金卡片列表**:每张卡片包含基金简称、跟踪指数、近 1/3/5 年年化、最大回撤、规模、费率、风险标签、趋势小图。
28+
4. **精选组合推荐**:根据风险偏好自动推荐 3 套组合(保守/平衡/进取)。
29+
5. **教育内容区**:FAQ、指数基金 vs 主动基金、定投策略提示。
30+
6. **CTA 区**:跳转到基金详情页、模拟组合或第三方购买链接。
31+
32+
## 5. MVP 范围
33+
34+
- 覆盖 30-50 个国内核心指数基金(宽基 + 主要行业指数)。
35+
- 筛选功能支持「风险等级」「年化收益」「最大回撤」排序与多条件过滤。
36+
- 每只基金提供历史净值曲线(近 5 年)、实时涨跌幅及跟踪指数概览。
37+
- 集成 AKShare 指数数据 API,支持每日自动更新和历史回测展示。
38+
39+
## 6. 数据源与技术方案
40+
41+
### 6.1 数据来源:AKShare 指数数据 API
42+
43+
| 功能 | AKShare 接口 | 关键字段 | 使用说明 |
44+
| ------------------------- | ------------------------------ | ---------------------------------------------- | ---------------------------------------------------------------------------------------- |
45+
| 实时指数行情(宽基/行业) | `stock_zh_index_spot_em` | 代码、名称、最新价、涨跌幅、成交额、振幅、量比 | 传入 `symbol="上证系列指数"` / `"深证系列指数"` 获取国内指数实时行情;用于列表实时价格。 |
46+
| 新浪实时指数补充 | `stock_zh_index_spot_sina` | 名称、最新价、涨跌额、成交量 | 兜底数据源,避免单一供应商不可用。 |
47+
| 历史日线 | `stock_zh_index_daily_em` | 日期、开收高低、成交量、成交额、换手率 | 用于绘制近 1/3/5 年走势图、计算年化收益/回撤。 |
48+
| 深度历史(中证指数) | `stock_zh_index_hist_csindex` | 日期、收盘、涨跌幅、换手率、PE、PB | 适配 2005 年前/稀有指数历史,支持长周期回测。 |
49+
| 指数估值 | `stock_zh_index_value_csindex` | PE、PB、股息率、ROE | 用于显示估值温度计,作为风险提示。 |
50+
51+
> 若需要关联具体基金,可与「公募基金数据」章节中的 `fund_etf_spot_em``fund_open_fund_daily_em` 结合,建立「基金 ↔ 跟踪指数」映射表。
52+
53+
### 6.2 数据流程
54+
55+
1. 数据同步服务(CRON 或 serverless job)调用 AKShare 接口,存入 `index_snapshots`(实时)和 `index_history`(日线)表。
56+
2. 数据层计算指标:近 n 年年化收益、最大/最近回撤、波动率、夏普比率、跟踪误差(需要基金净值数据)。
57+
3. API 层提供 `/api/finance/index-funds`(列表 + 筛选)和 `/api/finance/index-funds/{id}`(详情)两个端点,前端页面直接消费。
58+
4. 数据更新频率:实时行情每 1 分钟刷新、历史日线每日收盘后更新、估值数据每日 23:00 同步。
59+
60+
### 6.3 数据字段映射(示例)
61+
62+
| 页面字段 | 计算逻辑 | 数据来源 |
63+
| --------------- | ------------------------------------------------------- | ------------------------------ |
64+
| 最新净值/点位 | `最新价` | `stock_zh_index_spot_em` |
65+
| 近 1/3/5 年年化 | CAGR(`stock_zh_index_daily_em` 收盘价) | 历史日线 |
66+
| 最大回撤 |`stock_zh_index_daily_em` 收盘价计算 | 历史日线 |
67+
| 风险等级 | 基于波动率、最大回撤阈值映射(例:波动率 < 15% → 保守) | 数据计算层 |
68+
| 估值温度计 | 当前 PE/PB vs 历史百分位 | `stock_zh_index_value_csindex` |
69+
70+
## 7. 功能需求
71+
72+
### 7.1 Hero/教育区
73+
74+
- 文案:「长期定投指数基金,抓住经济增长红利」。
75+
- 可切换两张图表(沪深 300 vs 中证 500 累计收益)。
76+
77+
### 7.2 筛选器
78+
79+
- **类别**:宽基(沪深300、中证500、创业板指)、行业(消费、科技、医药等)、主题。
80+
- **风险等级**:保守(年化波动<12%)、稳健(12%-20%)、进取(>20%)。
81+
- **更多条件**:成立年限、基金规模、管理费、跟踪误差、近一年回撤。
82+
- 支持「预设组合」快速按钮(保守定投/成长定投)。
83+
84+
### 7.3 基金卡片
85+
86+
- 顶部显示基金简称 + 基金代码 + 「指数/ETF」标签。
87+
- 中部展示:最新价、日涨跌、近 1/3/5 年年化(可切换)、最大回撤、估值温度条。
88+
- 底部 CTA:「查看详情」「加入对比」「加入定投计划」。
89+
- 卡片右侧 Mini Chart:7D/30D 走势,使用日线数据。
90+
91+
### 7.4 对比与收藏
92+
93+
- 用户可勾选最多 3 支基金进入对比抽屉,展示指标雷达图和关键表格。
94+
- 收藏逻辑存储在本地(未登录)或账号(已登录)。
95+
96+
### 7.5 精选组合
97+
98+
- 基于推荐算法(见 §8)输出三套组合,显示资产占比、预期波动、历史最大回撤。
99+
- CTA 引导至组合策略文章/模拟盘。
100+
101+
### 7.6 教育与风险提示
102+
103+
- FAQ 包含:「什么是指数基金」「如何判断适合自己的风险等级」「定投 vs 一次性买入」。
104+
- 风险提示固定展示在页面底部:指数基金存在市场风险,过往表现不代表未来等。
105+
106+
## 8. 推荐/评分逻辑
107+
108+
- **评分维度**:长期收益(40%)、波动/回撤(25%)、规模与流动性(20%)、费用(10%)、估值合理性(5%)。
109+
- **输入数据**:年化收益、最大回撤、日均成交额、管理费率、估值百分位。
110+
- **输出**:综合得分 0-100,映射为「入门优选/稳健优选/进取优选」标签。
111+
- **组合建议**:使用现代投资组合理论(MPT)或经验权重(保守 70% 宽基债、30% 宽基股;进取 80% 行业/主题 + 20% 宽基)。
112+
113+
## 9. UX / UI 细节
114+
115+
- 色彩:Hero 区使用品牌主色,数据卡片背景浅灰,风险等级使用信号色(绿/黄/橙)。
116+
|- 表格滚动:列表默认展示 10 条,滚动加载。
117+
- Skeleton/loading:使用骨架屏 + 最近一次缓存数据,避免白屏。
118+
- 响应式:移动端以纵向卡片排列,筛选器折叠为 Drawer;桌面端使用并排布局。
119+
120+
## 10. 指标与验证
121+
122+
- PV/UV:页面独立访客、回访率。
123+
- 转化:点击「查看详情」/「加入定投」人数。
124+
- 互动:筛选器使用率、对比抽屉打开率、收藏数。
125+
- 数据准确度:定时监控接口失败率、数据延迟。
126+
127+
## 11. 非功能要求
128+
129+
- 数据接口需具备重试/降级策略:主源(东方财富)失败时切换新浪接口。
130+
- 缓存策略:实时行情 60 秒缓存,历史数据 CDN 缓存 24h。
131+
- SEO:页面提供中文 meta、OG 信息,结构化 FAQ(JSON-LD)。
132+
- 可访问性:颜色对比度符合 WCAG AA,图表提供数值读屏。
133+
134+
## 12. 交付里程碑(建议)
135+
136+
| 阶段 | 时间 | 主要输出 |
137+
| -------- | ------- | ------------------------------ |
138+
| 需求冻结 | T+3 天 | PRD、API 合同、指标口径 |
139+
| 设计交付 | T+10 天 | 低保真 + 高保真、响应式规格 |
140+
| 开发联调 | T+20 天 | 前后端开发、数据同步、冒烟测试 |
141+
| Beta | T+26 天 | 小流量灰度,监控数据准确性 |
142+
| 全量上线 | T+30 天 | 对外发布,监控指标 |
143+
144+
## 13. 风险与对策
145+
146+
- **数据接口变动**:AKShare 接口参数或字段变化 → 对接版本监控,配置化字段映射。
147+
- **指数/基金映射不全**:补充人工维护的白名单表,并根据用户搜索行为动态扩充。
148+
- **用户认知不足**:教育内容区加入短视频/图解链接、常见问题提示。
149+
- **性能压力**:列表分页 + 懒加载 mini chart;历史数据预计算。
150+
151+
## 14. Open Points
152+
153+
1. 是否需要在页面内直接引导交易?如果需要,需确认合规与合作渠道。
154+
2. 推荐算法是否可以接入已有的智能投顾引擎?抑或仅基于规则。
155+
3. 是否支持用户登录后的个性化提醒(价格提醒、定投日推送)。
156+
157+
## 15. MVP 实施计划
158+
159+
1. **数据与服务**
160+
- 新增 `/pages/api/finance/index-funds.ts`,封装调用 `stock_zh_index_spot_em``stock_zh_index_daily_em` 的逻辑,返回基础字段(名称、代码、最新价、涨跌幅、近 1 年收益、最大回撤占位)。
161+
- 设计 `types/finance.ts`(或类似文件)统一前端字段定义,方便后续扩展估值、回测指标。
162+
- 加入简单内存缓存(60s)避免接口过度调用。
163+
2. **前端页面结构**
164+
- 新建 `pages/finance/index.tsx`,沿用全局布局,包含 Hero、筛选器占位、基金列表三大模块。
165+
- 创建 `components/finance/FundCard.tsx` 复用卡片 UI,展示名称、指数、最新价、日涨跌、近 1 年收益、风险标签。
166+
- 筛选器 MVP 仅支持「类别」和「风险」两个条件,下钻逻辑后续扩展。
167+
- 数据请求通过 `SWR` 或自定义 hook(`models` 目录)调用 `/api/finance/index-funds`,支持 loading/skeleton。
168+
3. **交互与样式**
169+
- 使用 Bootstrap 栅格 + 自定义 SCSS(`styles/finance.module.scss`)完成响应式布局。
170+
- 提供空数据与错误兜底提示,保留 CTA 占位。
171+
4. **测试与验收**
172+
- `pnpm lint` 通过。
173+
- 本地 `next dev` 打开 `/finance` 页面验证数据渲染。
174+
- 文档更新:在 `README` 或导航处加入入口说明(若需要)。

0 commit comments

Comments
 (0)