diff --git a/README.md b/README.md index d4f9e11..0bc5d2b 100644 --- a/README.md +++ b/README.md @@ -1,186 +1,190 @@ -# FolioPulse - -> [在线试用 FolioPulse](https://folio-pulse-dashboard.lch262.chatgpt.site/) · [提交问题或建议](https://github.com/lch262/folio-pulse/issues/new/choose) · [参与产品讨论](https://github.com/lch262/folio-pulse/discussions) - -FolioPulse 是一个 SEC 13F 数据引擎与可视化仪表盘。它从 SEC EDGAR 读取机构最近两份 -**原始** `13F-HR`,生成标准化持仓、季度变化和可浏览的投资人档案。目前已接入伯克希尔 -与 Scion 的真实公开快照。 - -`13F-HR/A` 修订申报会被忽略,避免把修订文件误当成新的季度。 - -## 公开试用 - -无需安装即可打开[在线仪表盘](https://folio-pulse-dashboard.lch262.chatgpt.site/)。建议重点体验: - -- 从投资人中心切换伯克希尔与 Scion,核对报告期和提交日期; -- 搜索持仓,并按新建、增持、减持和清仓筛选季度动作; -- 打开单项持仓详情,检查跳转、移动端显示和数据解释是否清楚; -- 对不准确的数据、难理解的文案或希望增加的机构提出建议。 - -发现问题请使用[反馈模板](https://github.com/lch262/folio-pulse/issues/new/choose);想讨论产品方向、机构优先级或界面方案,请前往 [Discussions](https://github.com/lch262/folio-pulse/discussions)。提交反馈时不要粘贴 Token、密码、邮箱验证码或其他敏感信息。 - -## 要求 - -- Python 3.10 或更高版本 -- 不需要 SEC API Key -- 不需要第三方运行时依赖 - -SEC 要求自动请求声明带有联系信息的 `User-Agent`。PowerShell 示例: - -```powershell -$env:FOLIOPULSE_SEC_USER_AGENT = "FolioPulse your-email@example.com" -python -m backend.main 0001067983 -``` - -不填写 CIK 时,默认使用 Berkshire Hathaway: - -```powershell -python -m backend.main -``` - -也可以直接传入 User-Agent,并输出 JSON: - -```powershell -python -m backend.main 0001067983 ` - --user-agent "FolioPulse your-email@example.com" ` - --json -``` - -下载并解析最新一期完整持仓: - -```powershell -python -m backend.main 0001067983 ` - --holdings-output data/berkshire_latest.json -``` - -程序会自动读取 filing 目录,识别根元素为 `informationTable` 的 XML,并把每行 -转换成包含 issuer、CUSIP、可选 FIGI、shares、value 和 voting authority 的 JSON。 - -SEC 从 2023 年 1 月 3 日起把 13F `value` 改为按美元报告;程序会把旧 filing -的千美元数值乘以 1,000,统一输出 `value_usd`。13F Information Table 通常不含 -ticker,因此当前版本不会猜测股票代码,ticker 映射留到后续版本。 - -比较最近两个季度并导出变化: - -```powershell -python -m backend.main 0001067983 ` - --changes-output data/berkshire_changes.json -``` - -变化引擎先按 `CUSIP + Put/Call + SH/PRN` 合并同一证券的拆分行,再根据报告 -数量分类: - -- `NEW`:上一季度没有,本季度出现 -- `ADDED`:本季度数量增加 -- `REDUCED`:本季度数量减少 -- `UNCHANGED`:数量未变 -- `EXIT`:上一季度存在,本季度消失 - -比较使用 shares/principal amount,而不是随股价变化的市值。每条变化同时输出 -previous/current amount、差额、百分比和两期报告市值。 - -生成可直接导入网站的快照: - -```powershell -python -m backend.main 0001067983 ` - --web-snapshot-output data/berkshire_web_snapshot.json ` - --ticker-map data/tickers.json -``` - -`--ticker-map` 是可选的 CUSIP 到 ticker JSON 对象;未提供映射的证券会以 CUSIP -作为显示代码,不会猜测 ticker。快照会计算组合权重、带入五类季度变化,并保留 -已清仓证券,同时写入相邻两个报告期日期。登录 FolioPulse 后点击右上角“导入快照” -即可写入持久化数据库;页面可查看上季持仓、本季持仓、股份变化量并展开单股详情。 - -## 监控新的 13F - -先执行一次检查并建立基线: - -```powershell -python -m backend.filing_watcher 0001067983 --once -``` - -首次运行会显示 `INITIALIZED`,并把最新 accession 写入 -`data/watcher_state.json`,不会把已有 filing 误报成新事件。再次检查时,相同 -accession 显示 `UNCHANGED`;检测到更晚的 filing 时显示 `NEW_FILING`。 - -持续监控,默认每 60 秒检查一次: - -```powershell -python -m backend.filing_watcher 0001067983 -``` - -可用 `Ctrl+C` 安全停止。也可以通过 `--interval 120` 调整轮询秒数,或使用 -`--json` 输出单行 JSON 事件。Watcher 使用同一个 `FOLIOPULSE_SEC_USER_AGENT` -环境变量,并且状态文件已被 `.gitignore` 排除。 - -## 预期输出 - -```text -FolioPulse SEC Tracker - -Fund: BERKSHIRE HATHAWAY INC -CIK: 0001067983 - -Latest 13F-HR -Form: 13F-HR -Report period: ... -Filed: ... -Accession: ... -Primary document: ... -URL: ... - -Previous 13F-HR -... -``` - -## 测试 - -测试全部使用固定的本地样本,不会请求 SEC: - -```powershell -python -m unittest discover -s tests -v -``` - -测试覆盖 CIK 规范化、排除修订申报、历史 submissions 文件、无 13F 结果, -SEC 限流、Information Table 发现、XML 命名空间、可选 FIGI、数值单位转换和 -持仓 JSON 导出,以及重复行聚合、期权分离和五类季度变化。 -Watcher 测试还覆盖首次基线、无变化、新 filing、旧结果保护和损坏状态保护。 - -## 可视化仪表盘 - -`web/` 是面向用户的 FolioPulse 仪表盘,包含组合概览、前五大持仓、季度信号、 -变化筛选、公司搜索与 `/api/portfolio` JSON 接口。页面现在从 D1 持久化数据库 -读取快照;数据库首次使用时写入明确标记的演示数据。登录且获授权的管理员可以 -通过 `POST /api/portfolio` 导入标准化快照,前端会自动读取最新报告期。 - -```powershell -cd web -npm install -npm run dev -``` - -打开 `http://localhost:3000`。生产构建使用 `npm run build`。 - -## 当前范围 - -- [x] 规范化为十位 CIK -- [x] 请求 filer submissions JSON -- [x] 找到最近两份原始 `13F-HR` -- [x] 输出人类可读文本或 JSON -- [x] 离线单元测试 -- [x] 定位并解析 13F Information Table(V0.02) -- [x] 输出标准化持仓 JSON -- [x] 比较季度持仓并分类变化(V0.03) -- [x] 输出季度 changes JSON -- [x] 监控新的原始 13F-HR(V0.04) -- [x] 原子保存 watcher accession 状态 -- [x] 面向用户的响应式持仓仪表盘(V0.05) -- [x] 持仓变化筛选与公司搜索 -- [x] Portfolio JSON 接口 -- [x] D1 持久化基金、申报与持仓数据(V0.06) -- [x] 受账号权限保护的持仓快照导入接口 -- [x] Python SEC 数据到网站快照的转换器(V0.07) -- [x] 管理员 JSON 导入界面与动态调仓信号 -- [x] 多机构档案、按 CIK 隔离读取与 Scion 真实 13F 快照(V0.08) +# FolioPulse + +> [在线试用 FolioPulse](https://folio-pulse-dashboard.lch262.chatgpt.site/) · [提交问题或建议](https://github.com/lch262/folio-pulse/issues/new/choose) · [参与产品讨论](https://github.com/lch262/folio-pulse/discussions) + +FolioPulse 是一个 SEC 13F 数据引擎与可视化仪表盘。它从 SEC EDGAR 读取机构最近两份 +**原始** `13F-HR`,生成标准化持仓、季度变化和可浏览的投资人档案。目前已接入伯克希尔、 +Scion、ARK Invest(木头姐)、H&H International(段永平相关)、桥水、潘兴广场、 +Appaloosa 与 Duquesne 的真实公开快照。 + +`13F-HR/A` 修订申报会被忽略,避免把修订文件误当成新的季度。 + +## 公开试用 + +无需安装即可打开[在线仪表盘](https://folio-pulse-dashboard.lch262.chatgpt.site/)。建议重点体验: + +- 从投资人中心切换八家机构,核对各自报告期和提交日期; +- 搜索持仓,并按新建、增持、减持和清仓筛选季度动作; +- 打开单项持仓详情,检查跳转、移动端显示和数据解释是否清楚; +- 对不准确的数据、难理解的文案或希望增加的机构提出建议。 + +发现问题请使用[反馈模板](https://github.com/lch262/folio-pulse/issues/new/choose);想讨论产品方向、机构优先级或界面方案,请前往 [Discussions](https://github.com/lch262/folio-pulse/discussions)。提交反馈时不要粘贴 Token、密码、邮箱验证码或其他敏感信息。 + +## 要求 + +- Python 3.10 或更高版本 +- 不需要 SEC API Key +- 不需要第三方运行时依赖 + +SEC 要求自动请求声明带有联系信息的 `User-Agent`。PowerShell 示例: + +```powershell +$env:FOLIOPULSE_SEC_USER_AGENT = "FolioPulse your-email@example.com" +python -m backend.main 0001067983 +``` + +不填写 CIK 时,默认使用 Berkshire Hathaway: + +```powershell +python -m backend.main +``` + +也可以直接传入 User-Agent,并输出 JSON: + +```powershell +python -m backend.main 0001067983 ` + --user-agent "FolioPulse your-email@example.com" ` + --json +``` + +下载并解析最新一期完整持仓: + +```powershell +python -m backend.main 0001067983 ` + --holdings-output data/berkshire_latest.json +``` + +程序会自动读取 filing 目录,识别根元素为 `informationTable` 的 XML,并把每行 +转换成包含 issuer、CUSIP、可选 FIGI、shares、value 和 voting authority 的 JSON。 + +SEC 从 2023 年 1 月 3 日起把 13F `value` 改为按美元报告;程序会把旧 filing +的千美元数值乘以 1,000,统一输出 `value_usd`。13F Information Table 通常不含 +ticker,因此当前版本不会猜测股票代码,ticker 映射留到后续版本。 + +比较最近两个季度并导出变化: + +```powershell +python -m backend.main 0001067983 ` + --changes-output data/berkshire_changes.json +``` + +变化引擎先按 `CUSIP + Put/Call + SH/PRN` 合并同一证券的拆分行,再根据报告 +数量分类: + +- `NEW`:上一季度没有,本季度出现 +- `ADDED`:本季度数量增加 +- `REDUCED`:本季度数量减少 +- `UNCHANGED`:数量未变 +- `EXIT`:上一季度存在,本季度消失 + +比较使用 shares/principal amount,而不是随股价变化的市值。每条变化同时输出 +previous/current amount、差额、百分比和两期报告市值。 + +生成可直接导入网站的快照: + +```powershell +python -m backend.main 0001067983 ` + --web-snapshot-output data/berkshire_web_snapshot.json ` + --ticker-map data/tickers.json +``` + +`--ticker-map` 是可选的 CUSIP 到 ticker JSON 对象;未提供映射的证券会以 CUSIP +作为显示代码,不会猜测 ticker。快照会计算组合权重、带入五类季度变化,并保留 +已清仓证券,同时写入相邻两个报告期日期。登录 FolioPulse 后点击右上角“导入快照” +即可写入持久化数据库;页面可查看上季持仓、本季持仓、股份变化量并展开单股详情。 + +## 监控新的 13F + +先执行一次检查并建立基线: + +```powershell +python -m backend.filing_watcher 0001067983 --once +``` + +首次运行会显示 `INITIALIZED`,并把最新 accession 写入 +`data/watcher_state.json`,不会把已有 filing 误报成新事件。再次检查时,相同 +accession 显示 `UNCHANGED`;检测到更晚的 filing 时显示 `NEW_FILING`。 + +持续监控,默认每 60 秒检查一次: + +```powershell +python -m backend.filing_watcher 0001067983 +``` + +可用 `Ctrl+C` 安全停止。也可以通过 `--interval 120` 调整轮询秒数,或使用 +`--json` 输出单行 JSON 事件。Watcher 使用同一个 `FOLIOPULSE_SEC_USER_AGENT` +环境变量,并且状态文件已被 `.gitignore` 排除。 + +## 预期输出 + +```text +FolioPulse SEC Tracker + +Fund: BERKSHIRE HATHAWAY INC +CIK: 0001067983 + +Latest 13F-HR +Form: 13F-HR +Report period: ... +Filed: ... +Accession: ... +Primary document: ... +URL: ... + +Previous 13F-HR +... +``` + +## 测试 + +测试全部使用固定的本地样本,不会请求 SEC: + +```powershell +python -m unittest discover -s tests -v +``` + +测试覆盖 CIK 规范化、排除修订申报、历史 submissions 文件、无 13F 结果, +SEC 限流、Information Table 发现、XML 命名空间、可选 FIGI、数值单位转换和 +持仓 JSON 导出,以及重复行聚合、期权分离和五类季度变化。 +Watcher 测试还覆盖首次基线、无变化、新 filing、旧结果保护和损坏状态保护。 + +## 可视化仪表盘 + +`web/` 是面向用户的 FolioPulse 仪表盘,包含组合概览、前五大持仓、季度信号、 +变化筛选、公司搜索与 `/api/portfolio` JSON 接口。页面现在从 D1 持久化数据库 +读取快照;数据库首次使用时写入明确标记的演示数据。登录且获授权的管理员可以 +通过 `POST /api/portfolio` 导入标准化快照,前端会自动读取最新报告期。 + +```powershell +cd web +npm install +npm run dev +``` + +打开 `http://localhost:3000`。生产构建使用 `npm run build`。 + +## 当前范围 + +- [x] 规范化为十位 CIK +- [x] 请求 filer submissions JSON +- [x] 找到最近两份原始 `13F-HR` +- [x] 输出人类可读文本或 JSON +- [x] 离线单元测试 +- [x] 定位并解析 13F Information Table(V0.02) +- [x] 输出标准化持仓 JSON +- [x] 比较季度持仓并分类变化(V0.03) +- [x] 输出季度 changes JSON +- [x] 监控新的原始 13F-HR(V0.04) +- [x] 原子保存 watcher accession 状态 +- [x] 面向用户的响应式持仓仪表盘(V0.05) +- [x] 持仓变化筛选与公司搜索 +- [x] Portfolio JSON 接口 +- [x] D1 持久化基金、申报与持仓数据(V0.06) +- [x] 受账号权限保护的持仓快照导入接口 +- [x] Python SEC 数据到网站快照的转换器(V0.07) +- [x] 管理员 JSON 导入界面与动态调仓信号 +- [x] 多机构档案、按 CIK 隔离读取与 Scion 真实 13F 快照(V0.08) - [x] 机构持仓搜索、动作筛选与机构专属单项详情 +- [x] ARK 与 H&H 最新公开 13F 快照、独立档案与数据边界说明(V0.09) +- [x] 八家机构全量接入与 Apple 风格扁平化界面(V0.10) +- [x] 八家机构独立季度增减持折线图、指标切换与持仓交互明细(V0.11) diff --git a/web/app/Dashboard.tsx b/web/app/Dashboard.tsx index 7382f76..57127eb 100644 --- a/web/app/Dashboard.tsx +++ b/web/app/Dashboard.tsx @@ -1,235 +1,228 @@ -"use client"; - +"use client"; + import { Fragment, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; +import { ArrowRight, CalendarDays, Search } from "lucide-react"; +import ManagerIcon from "./components/ManagerIcon"; import SiteHeader from "./components/SiteHeader"; -import { managerProfiles } from "./lib/managers"; -import { portfolioSnapshot, type ChangeType, type PortfolioSnapshot } from "./lib/portfolio-data"; - -const filters: Array<{ key: "ALL" | ChangeType; label: string }> = [ - { key: "ALL", label: "全部" }, { key: "NEW", label: "新建仓" }, - { key: "ADDED", label: "增持" }, { key: "REDUCED", label: "减持" }, - { key: "EXIT", label: "清仓" }, -]; - -const changeLabels: Record = { - NEW: "新建仓", ADDED: "增持", REDUCED: "减持", EXIT: "清仓", UNCHANGED: "未变", -}; - -type SortKey = "value" | "weight" | "shares" | "shareChange"; -type SortDirection = "asc" | "desc"; - -function money(value: number) { return value === 0 ? "—" : `$${value.toFixed(2)}B`; } -function compactShares(value: number) { - const abs = Math.abs(value); - if (abs >= 100000000) return `${(value / 100000000).toFixed(2)}亿`; - if (abs >= 10000) return `${(value / 10000).toFixed(1)}万`; - return value.toLocaleString("zh-CN"); -} - -function dateLabel(value: string) { - const [year, month, day] = value.split("-"); - return year && month && day ? `${year}.${month}.${day}` : value; -} - -function previousShares(shares: number, shareChange: number) { - return Math.max(0, shares - shareChange); -} - -function Icon({ name }: { name: "search" | "arrow" | "calendar" }) { - const paths = { - search: <>, - arrow: <>, - calendar: <>, - }; - return ; -} - -export default function Dashboard() { - const [filter, setFilter] = useState<"ALL" | ChangeType>("ALL"); - const [query, setQuery] = useState(""); - const [onlyChanged, setOnlyChanged] = useState(false); - const [sortKey, setSortKey] = useState("value"); - const [sortDirection, setSortDirection] = useState("desc"); - const [density, setDensity] = useState<"comfortable" | "compact">("comfortable"); - const [expandedKey, setExpandedKey] = useState(null); - const [snapshot, setSnapshot] = useState(portfolioSnapshot); - const [storageStatus, setStorageStatus] = useState("正在连接数据层"); - const [importState, setImportState] = useState<"idle" | "uploading" | "success" | "error">("idle"); - const [importMessage, setImportMessage] = useState(""); - const importInput = useRef(null); - - useEffect(() => { - let active = true; - fetch("/api/portfolio") - .then((response) => response.json()) - .then((payload) => { - if (!active || !payload?.data) return; - setSnapshot(payload.data); - setStorageStatus(payload.meta?.status === "persistent" ? "持久化快照" : "演示快照"); - const requestedTicker = new URLSearchParams(window.location.search).get("ticker"); - if (requestedTicker) { - setQuery(requestedTicker); - setExpandedKey(requestedTicker); - } - }) - .catch(() => active && setStorageStatus("演示快照")); - return () => { active = false; }; - }, []); - - const positions = useMemo(() => { - const normalized = query.trim().toLowerCase(); - const direction = sortDirection === "asc" ? 1 : -1; - return snapshot.positions - .filter((item) => - (filter === "ALL" || item.changeType === filter) && - (!onlyChanged || item.changeType !== "UNCHANGED") && - (!normalized || item.ticker.toLowerCase().includes(normalized) || item.issuer.toLowerCase().includes(normalized))) - .sort((left, right) => { - const leftValue = sortKey === "shareChange" ? Math.abs(left.shareChange) : left[sortKey]; - const rightValue = sortKey === "shareChange" ? Math.abs(right.shareChange) : right[sortKey]; - return (leftValue - rightValue) * direction || left.ticker.localeCompare(right.ticker); - }); - }, [filter, onlyChanged, query, snapshot, sortDirection, sortKey]); - - const signals = useMemo( - () => snapshot.positions.filter((item) => item.changeType !== "UNCHANGED").slice(0, 3), - [snapshot], - ); - - function toggleSort(nextKey: SortKey) { - if (sortKey === nextKey) setSortDirection((value) => value === "desc" ? "asc" : "desc"); - else { - setSortKey(nextKey); - setSortDirection("desc"); - } - } - - function clearFilters() { - setFilter("ALL"); - setOnlyChanged(false); - setQuery(""); - } - - async function importSnapshot(event: ChangeEvent) { - const file = event.target.files?.[0]; - event.target.value = ""; - if (!file) return; - if (file.size > 5 * 1024 * 1024) { - setImportState("error"); - setImportMessage("文件超过 5 MB,请确认选择的是网站快照 JSON。 "); - return; - } - setImportState("uploading"); - setImportMessage("正在验证并导入快照…"); - try { - const payload = JSON.parse(await file.text()); - const response = await fetch("/api/portfolio", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(payload), - }); - const result = await response.json(); - if (!response.ok) throw new Error(result.error || "导入失败。"); - setSnapshot(result.data); - setFilter("ALL"); - setOnlyChanged(false); - setQuery(""); - setExpandedKey(null); - setStorageStatus("已导入快照"); - setImportState("success"); - setImportMessage(`已导入 ${result.data.reportDate} 报告期,共 ${result.data.positionCount} 个持仓。`); - } catch (error) { - setImportState("error"); - setImportMessage(error instanceof Error ? error.message : "无法读取该 JSON 文件。"); - } - } - - return ( -
- - - - } /> - -
-
SEC 13F 追踪 · {storageStatus}
-
-

机构持仓档案

{snapshot.managerShort}

把难读的监管文件,变成一眼看懂的持仓变化。

-
-
最新报告期{snapshot.reportDate}
-
提交日期{snapshot.filedAt}
-
{snapshot.source}CIK {snapshot.cik}
-
-
-
- -
-
Investor network

追踪更多投资人

从伯克希尔开始,把不同投资策略放进同一个机构网络。

打开投资人中心 →
- -
- -
-
-
披露持仓市值${snapshot.totalValue.toFixed(2)}B约 {(snapshot.totalValue * 10).toLocaleString("zh-CN")} 亿美元
-
持仓数量{snapshot.positionCount}本季度披露标的
-
新建 / 增持{snapshot.changes.NEW + snapshot.changes.ADDED}{snapshot.changes.NEW} 笔新建仓 · {snapshot.changes.ADDED} 笔增持
-
减持 / 清仓{snapshot.changes.REDUCED + snapshot.changes.EXIT}{snapshot.changes.REDUCED} 笔减持 · {snapshot.changes.EXIT} 笔清仓
-
- -
- - -
- 本季信号

资金动作速览

-
{signals.map((item, index) => { - const signalClass = item.changeType === "NEW" ? "new" : item.changeType === "ADDED" ? "up" : "down"; - const signalMark = item.changeType === "NEW" ? "N" : item.changeType === "ADDED" ? "↗" : "↘"; - const detail = item.changePercent === null ? money(item.value) : `${item.changePercent > 0 ? "+" : ""}${item.changePercent.toFixed(2)}%`; - return {signalMark}{changeLabels[item.changeType]} {item.ticker}{item.issuer} · {detail}; - })}
- 查看完整调仓记录 -
-
- -
-
完整明细

持仓与变化

比较 {dateLabel(snapshot.previousReportDate)} → {dateLabel(snapshot.reportDate)} · 于 {dateLabel(snapshot.filedAt)} 披露

-
-
{filters.map((item) => )}
-
- -
- {(filter !== "ALL" || onlyChanged || query) && } -
-
-
{positions.length} 个结果 · 点击列名排序,点击公司展开完整变化
-
- {positions.map((item, index) => { - const rowKey = `${item.ticker}-${item.changeType}-${index}`; - const oldShares = previousShares(item.shares, item.shareChange); - const expanded = expandedKey === item.ticker; - return - - - - - - {expanded && }; - })} -
公司 / 代码变化与周期
{changeLabels[item.changeType]}{dateLabel(snapshot.previousReportDate)} → {dateLabel(snapshot.reportDate)}{compactShares(item.shares)}上季 {compactShares(oldShares)} 0 ? "gain" : item.shareChange < 0 ? "loss" : ""}`}>{item.shareChange === 0 ? "—" : `${item.shareChange > 0 ? "+" : ""}${compactShares(item.shareChange)}`}{item.changePercent !== null && item.changePercent !== 0 && {item.changePercent > 0 ? "+" : ""}{item.changePercent.toFixed(2)}%}{money(item.value)}{item.weight ? `${item.weight.toFixed(2)}%` : "—"}
-
上季持仓{compactShares(oldShares)}{dateLabel(snapshot.previousReportDate)}
0 ? "gain" : item.shareChange < 0 ? "loss" : ""}`}>{item.shareChange === 0 ? "持仓未变" : `${item.shareChange > 0 ? "+" : ""}${compactShares(item.shareChange)}`}
本季持仓{compactShares(item.shares)}{dateLabel(snapshot.reportDate)}
-
SEC 披露日期{dateLabel(snapshot.filedAt)}
股份变动比例 0 ? "gain" : item.shareChange < 0 ? "loss" : ""}>{item.changePercent === null ? (item.changeType === "NEW" ? "新建仓" : item.changeType === "EXIT" ? "已清仓" : "—") : `${item.changePercent > 0 ? "+" : ""}${item.changePercent.toFixed(2)}%`}
本季组合权重{item.weight ? `${item.weight.toFixed(2)}%` : "—"}
当前披露市值{money(item.value)}
打开 {item.ticker} 独立详情页 →
-
{positions.length === 0 &&
没有符合条件的持仓
}
-
-
- -
数据口径

来自原始文件,不靠二手摘要

FolioPulse 读取 SEC EDGAR 的原始 13F-HR 与信息表,按 CUSIP 聚合后比较相邻季度。13F 最长可滞后 45 天,页面只用于研究,不构成投资建议。

查看完整数据说明 →
- {importMessage &&
{importState === "success" ? "导入完成" : importState === "error" ? "导入未完成" : "正在处理"}{importMessage}
} -
FolioPulse · 让机构持仓更易读v0.7 · 投资人网络已启用
-
- ); -} +import { managerProfiles } from "./lib/managers"; +import { portfolioSnapshot, type ChangeType, type PortfolioSnapshot } from "./lib/portfolio-data"; + +const filters: Array<{ key: "ALL" | ChangeType; label: string }> = [ + { key: "ALL", label: "全部" }, { key: "NEW", label: "新建仓" }, + { key: "ADDED", label: "增持" }, { key: "REDUCED", label: "减持" }, + { key: "EXIT", label: "清仓" }, +]; + +const changeLabels: Record = { + NEW: "新建仓", ADDED: "增持", REDUCED: "减持", EXIT: "清仓", UNCHANGED: "未变", +}; + +type SortKey = "value" | "weight" | "shares" | "shareChange"; +type SortDirection = "asc" | "desc"; + +function money(value: number) { return value === 0 ? "—" : `$${value.toFixed(2)}B`; } +function compactShares(value: number) { + const abs = Math.abs(value); + if (abs >= 100000000) return `${(value / 100000000).toFixed(2)}亿`; + if (abs >= 10000) return `${(value / 10000).toFixed(1)}万`; + return value.toLocaleString("zh-CN"); +} + +function dateLabel(value: string) { + const [year, month, day] = value.split("-"); + return year && month && day ? `${year}.${month}.${day}` : value; +} + +function previousShares(shares: number, shareChange: number) { + return Math.max(0, shares - shareChange); +} + +export default function Dashboard() { + const [filter, setFilter] = useState<"ALL" | ChangeType>("ALL"); + const [query, setQuery] = useState(""); + const [onlyChanged, setOnlyChanged] = useState(false); + const [sortKey, setSortKey] = useState("value"); + const [sortDirection, setSortDirection] = useState("desc"); + const [density, setDensity] = useState<"comfortable" | "compact">("comfortable"); + const [expandedKey, setExpandedKey] = useState(null); + const [snapshot, setSnapshot] = useState(portfolioSnapshot); + const [storageStatus, setStorageStatus] = useState("正在连接数据层"); + const [importState, setImportState] = useState<"idle" | "uploading" | "success" | "error">("idle"); + const [importMessage, setImportMessage] = useState(""); + const importInput = useRef(null); + + useEffect(() => { + let active = true; + fetch("/api/portfolio") + .then((response) => response.json()) + .then((payload) => { + if (!active || !payload?.data) return; + setSnapshot(payload.data); + setStorageStatus(payload.meta?.status === "persistent" ? "持久化快照" : "演示快照"); + const requestedTicker = new URLSearchParams(window.location.search).get("ticker"); + if (requestedTicker) { + setQuery(requestedTicker); + setExpandedKey(requestedTicker); + } + }) + .catch(() => active && setStorageStatus("演示快照")); + return () => { active = false; }; + }, []); + + const positions = useMemo(() => { + const normalized = query.trim().toLowerCase(); + const direction = sortDirection === "asc" ? 1 : -1; + return snapshot.positions + .filter((item) => + (filter === "ALL" || item.changeType === filter) && + (!onlyChanged || item.changeType !== "UNCHANGED") && + (!normalized || item.ticker.toLowerCase().includes(normalized) || item.issuer.toLowerCase().includes(normalized))) + .sort((left, right) => { + const leftValue = sortKey === "shareChange" ? Math.abs(left.shareChange) : left[sortKey]; + const rightValue = sortKey === "shareChange" ? Math.abs(right.shareChange) : right[sortKey]; + return (leftValue - rightValue) * direction || left.ticker.localeCompare(right.ticker); + }); + }, [filter, onlyChanged, query, snapshot, sortDirection, sortKey]); + + const signals = useMemo( + () => snapshot.positions.filter((item) => item.changeType !== "UNCHANGED").slice(0, 3), + [snapshot], + ); + + function toggleSort(nextKey: SortKey) { + if (sortKey === nextKey) setSortDirection((value) => value === "desc" ? "asc" : "desc"); + else { + setSortKey(nextKey); + setSortDirection("desc"); + } + } + + function clearFilters() { + setFilter("ALL"); + setOnlyChanged(false); + setQuery(""); + } + + async function importSnapshot(event: ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + if (file.size > 5 * 1024 * 1024) { + setImportState("error"); + setImportMessage("文件超过 5 MB,请确认选择的是网站快照 JSON。 "); + return; + } + setImportState("uploading"); + setImportMessage("正在验证并导入快照…"); + try { + const payload = JSON.parse(await file.text()); + const response = await fetch("/api/portfolio", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "导入失败。"); + setSnapshot(result.data); + setFilter("ALL"); + setOnlyChanged(false); + setQuery(""); + setExpandedKey(null); + setStorageStatus("已导入快照"); + setImportState("success"); + setImportMessage(`已导入 ${result.data.reportDate} 报告期,共 ${result.data.positionCount} 个持仓。`); + } catch (error) { + setImportState("error"); + setImportMessage(error instanceof Error ? error.message : "无法读取该 JSON 文件。"); + } + } + + return ( +
+ + + + } /> + +
+
SEC 13F 追踪 · {storageStatus}
+
+

机构持仓档案

{snapshot.managerShort}

把难读的监管文件,变成一眼看懂的持仓变化。

+
+
最新报告期{snapshot.reportDate}
+
提交日期{snapshot.filedAt}
+
+
+
+
+ +
+
Investor network

追踪更多投资人

从伯克希尔开始,把不同投资策略放进同一个机构网络。

打开投资人中心 →
+ +
+ +
+
+
披露持仓市值${snapshot.totalValue.toFixed(2)}B约 {(snapshot.totalValue * 10).toLocaleString("zh-CN")} 亿美元
+
持仓数量{snapshot.positionCount}本季度披露标的
+
新建 / 增持{snapshot.changes.NEW + snapshot.changes.ADDED}{snapshot.changes.NEW} 笔新建仓 · {snapshot.changes.ADDED} 笔增持
+
减持 / 清仓{snapshot.changes.REDUCED + snapshot.changes.EXIT}{snapshot.changes.REDUCED} 笔减持 · {snapshot.changes.EXIT} 笔清仓
+
+ +
+ + +
+ 本季信号

资金动作速览

+
{signals.map((item, index) => { + const signalClass = item.changeType === "NEW" ? "new" : item.changeType === "ADDED" ? "up" : "down"; + const signalMark = item.changeType === "NEW" ? "N" : item.changeType === "ADDED" ? "↗" : "↘"; + const detail = item.changePercent === null ? money(item.value) : `${item.changePercent > 0 ? "+" : ""}${item.changePercent.toFixed(2)}%`; + return {signalMark}{changeLabels[item.changeType]} {item.ticker}{item.issuer} · {detail}; + })}
+ 查看完整调仓记录 +
+
+ +
+
完整明细

持仓与变化

比较 {dateLabel(snapshot.previousReportDate)} → {dateLabel(snapshot.reportDate)} · 于 {dateLabel(snapshot.filedAt)} 披露

+
+
{filters.map((item) => )}
+
+ +
+ {(filter !== "ALL" || onlyChanged || query) && } +
+
+
{positions.length} 个结果 · 点击列名排序,点击公司展开完整变化
+
+ {positions.map((item, index) => { + const rowKey = `${item.ticker}-${item.changeType}-${index}`; + const oldShares = previousShares(item.shares, item.shareChange); + const expanded = expandedKey === item.ticker; + return + + + + + + {expanded && }; + })} +
公司 / 代码变化与周期
{changeLabels[item.changeType]}{dateLabel(snapshot.previousReportDate)} → {dateLabel(snapshot.reportDate)}{compactShares(item.shares)}上季 {compactShares(oldShares)} 0 ? "gain" : item.shareChange < 0 ? "loss" : ""}`}>{item.shareChange === 0 ? "—" : `${item.shareChange > 0 ? "+" : ""}${compactShares(item.shareChange)}`}{item.changePercent !== null && item.changePercent !== 0 && {item.changePercent > 0 ? "+" : ""}{item.changePercent.toFixed(2)}%}{money(item.value)}{item.weight ? `${item.weight.toFixed(2)}%` : "—"}
+
上季持仓{compactShares(oldShares)}{dateLabel(snapshot.previousReportDate)}
0 ? "gain" : item.shareChange < 0 ? "loss" : ""}`}>{item.shareChange === 0 ? "持仓未变" : `${item.shareChange > 0 ? "+" : ""}${compactShares(item.shareChange)}`}
本季持仓{compactShares(item.shares)}{dateLabel(snapshot.reportDate)}
+
SEC 披露日期{dateLabel(snapshot.filedAt)}
股份变动比例 0 ? "gain" : item.shareChange < 0 ? "loss" : ""}>{item.changePercent === null ? (item.changeType === "NEW" ? "新建仓" : item.changeType === "EXIT" ? "已清仓" : "—") : `${item.changePercent > 0 ? "+" : ""}${item.changePercent.toFixed(2)}%`}
本季组合权重{item.weight ? `${item.weight.toFixed(2)}%` : "—"}
当前披露市值{money(item.value)}
打开 {item.ticker} 独立详情页 →
+
{positions.length === 0 &&
没有符合条件的持仓
}
+
+
+ +
数据口径

来自原始文件,不靠二手摘要

FolioPulse 读取 SEC EDGAR 的原始 13F-HR 与信息表,按 CUSIP 聚合后比较相邻季度。13F 最长可滞后 45 天,页面只用于研究,不构成投资建议。

查看完整数据说明 →
+ {importMessage &&
{importState === "success" ? "导入完成" : importState === "error" ? "导入未完成" : "正在处理"}{importMessage}
} +
FolioPulse · 让机构持仓更易读v0.7 · 投资人网络已启用
+
+ ); +} diff --git a/web/app/components/FollowManagerButton.tsx b/web/app/components/FollowManagerButton.tsx index 736e0ad..124e8e4 100644 --- a/web/app/components/FollowManagerButton.tsx +++ b/web/app/components/FollowManagerButton.tsx @@ -1,28 +1,29 @@ -"use client"; - +"use client"; + import { useEffect, useState } from "react"; - -const storageKey = "folio-pulse-followed-managers"; - -function readFollowed() { - try { return new Set(JSON.parse(localStorage.getItem(storageKey) || "[]")); } - catch { return new Set(); } -} - -export default function FollowManagerButton({ slug }: { slug: string }) { - const [followed, setFollowed] = useState(false); - - useEffect(() => { - const timer = window.setTimeout(() => setFollowed(readFollowed().has(slug)), 0); - return () => window.clearTimeout(timer); - }, [slug]); - - function toggle() { - const values = readFollowed(); - if (values.has(slug)) values.delete(slug); else values.add(slug); - localStorage.setItem(storageKey, JSON.stringify([...values])); - setFollowed(values.has(slug)); - } - - return ; +import { Check, Heart } from "lucide-react"; + +const storageKey = "folio-pulse-followed-managers"; + +function readFollowed() { + try { return new Set(JSON.parse(localStorage.getItem(storageKey) || "[]")); } + catch { return new Set(); } +} + +export default function FollowManagerButton({ slug }: { slug: string }) { + const [followed, setFollowed] = useState(false); + + useEffect(() => { + const timer = window.setTimeout(() => setFollowed(readFollowed().has(slug)), 0); + return () => window.clearTimeout(timer); + }, [slug]); + + function toggle() { + const values = readFollowed(); + if (values.has(slug)) values.delete(slug); else values.add(slug); + localStorage.setItem(storageKey, JSON.stringify([...values])); + setFollowed(values.has(slug)); + } + + return ; } diff --git a/web/app/components/ManagerChangeChart.tsx b/web/app/components/ManagerChangeChart.tsx new file mode 100644 index 0000000..0b5f668 --- /dev/null +++ b/web/app/components/ManagerChangeChart.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ArrowDownRight, ArrowUpRight, ChartNoAxesCombined } from "lucide-react"; +import type { ChangeType, PortfolioSnapshot, Position } from "../lib/portfolio-data"; + +type Metric = "percent" | "shares"; + +type ChartPoint = Position & { + value: number; + displayValue: string; +}; + +const labels: Record = { + NEW: "新建仓", + ADDED: "增持", + REDUCED: "减持", + EXIT: "清仓", + UNCHANGED: "未变化", +}; + +const colors: Record = { + NEW: "#0071e3", + ADDED: "#34c759", + REDUCED: "#ff9f0a", + EXIT: "#ff3b30", + UNCHANGED: "#86868b", +}; + +function compactNumber(value: number) { + const absolute = Math.abs(value); + const sign = value > 0 ? "+" : value < 0 ? "−" : ""; + if (absolute >= 100000000) return `${sign}${(absolute / 100000000).toFixed(1)}亿股`; + if (absolute >= 10000) return `${sign}${(absolute / 10000).toFixed(1)}万股`; + return `${sign}${absolute.toLocaleString("zh-CN")}股`; +} + +function percentageValue(position: Position) { + if (position.changeType === "NEW") return 100; + if (position.changeType === "EXIT") return -100; + return position.changePercent ?? 0; +} + +function buildPoints(snapshot: PortfolioSnapshot, metric: Metric): ChartPoint[] { + return snapshot.positions + .filter((position) => position.changeType !== "UNCHANGED") + .map((position) => { + const value = metric === "percent" ? percentageValue(position) : position.shareChange; + return { + ...position, + value, + displayValue: metric === "percent" + ? `${value > 0 ? "+" : ""}${value.toFixed(1)}%` + : compactNumber(value), + }; + }) + .sort((left, right) => Math.abs(right.value) - Math.abs(left.value)) + .slice(0, 12); +} + +function axisLabel(value: number, metric: Metric) { + if (metric === "percent") return `${Math.round(value)}%`; + const absolute = Math.abs(value); + const sign = value < 0 ? "−" : ""; + if (absolute >= 100000000) return `${sign}${(absolute / 100000000).toFixed(0)}亿`; + if (absolute >= 10000) return `${sign}${(absolute / 10000).toFixed(0)}万`; + return `${sign}${absolute.toFixed(0)}`; +} + +export default function ManagerChangeChart({ snapshot }: { snapshot: PortfolioSnapshot }) { + const [metric, setMetric] = useState("percent"); + const points = useMemo(() => buildPoints(snapshot, metric), [metric, snapshot]); + const [selectedTicker, setSelectedTicker] = useState(null); + + if (points.length === 0) return null; + + const selected = points.find((point) => point.ticker === selectedTicker) ?? points[0]; + const width = 960; + const height = 320; + const left = 72; + const right = 28; + const top = 30; + const bottom = 72; + const plotWidth = width - left - right; + const plotHeight = height - top - bottom; + const maxAbsolute = Math.max(...points.map((point) => Math.abs(point.value)), metric === "percent" ? 100 : 1); + const paddedMax = maxAbsolute * 1.12; + const x = (index: number) => points.length === 1 ? left + plotWidth / 2 : left + (index * plotWidth) / (points.length - 1); + const y = (value: number) => top + ((paddedMax - value) / (paddedMax * 2)) * plotHeight; + const zeroY = y(0); + const polyline = points.map((point, index) => `${x(index)},${y(point.value)}`).join(" "); + const guideValues = [paddedMax, paddedMax / 2, 0, -paddedMax / 2, -paddedMax]; + + return
+
+
+ +

季度增减持折线图

+

{snapshot.previousReportDate} → {snapshot.reportDate} · 按绝对变化幅度展示前 {points.length} 项

+
+
+ + +
+
+ +
+
+ + {snapshot.managerShort}季度增减持折线图 + 展示从{snapshot.previousReportDate}到{snapshot.reportDate}变化幅度最大的持仓。 + {guideValues.map((value) => + + {axisLabel(value, metric)} + )} + + {points.map((point, index) => { + const isSelected = selected.ticker === point.ticker; + return setSelectedTicker(point.ticker)} + onFocus={() => setSelectedTicker(point.ticker)} + onClick={() => setSelectedTicker(point.ticker)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setSelectedTicker(point.ticker); + } + }} + > + + + {point.ticker.length > 9 ? `${point.ticker.slice(0, 7)}…` : point.ticker} + ; + })} + + + +
+ + +
+ +
+ {(["NEW", "ADDED", "REDUCED", "EXIT"] as ChangeType[]).map((type) => {labels[type]})} + {metric === "percent" ? "新建仓与清仓用 +100% / −100% 动作标记呈现。" : "股数变化用于观察申报数量,不代表交易成交金额。"} +
+ +
    + {points.map((point) =>
  • {point.ticker}:{labels[point.changeType]} {point.displayValue}
  • )} +
+
; +} diff --git a/web/app/components/ManagerIcon.tsx b/web/app/components/ManagerIcon.tsx new file mode 100644 index 0000000..a0fa248 --- /dev/null +++ b/web/app/components/ManagerIcon.tsx @@ -0,0 +1,27 @@ +import { + Aperture, + Building2, + ChartNoAxesCombined, + Gem, + Landmark, + Orbit, + Sparkles, + Telescope, + type LucideIcon, +} from "lucide-react"; + +const managerIcons: Record = { + berkshire: Landmark, + ark: Sparkles, + "hh-international": Gem, + bridgewater: Orbit, + scion: Aperture, + "pershing-square": Building2, + appaloosa: ChartNoAxesCombined, + duquesne: Telescope, +}; + +export default function ManagerIcon({ slug, size = 22 }: { slug: string; size?: number }) { + const Icon = managerIcons[slug] ?? Building2; + return