Izero/msp-csv-format

GitHub: Izero/msp-csv-format

非官方的 MSP 投资组合 App CSV 导出格式规范及零依赖参考解析器,解决了官方缺失字段级文档的问题。

Stars: 0 | Forks: 0

# MSP CSV 导出格式 这是一份关于 **My Stocks Portfolio & Market**(MSP,由 Peeksoft 开发)导出的 CSV 文件的非官方规范,以及一个无依赖的参考解析器。 - iOS App Store ID `923544282` · Android 包名 `co.peeksoft.stocks` - · - **根据 iOS 版本 2.522.0 产生的导出文件推导** ## 为什么会有这个项目 首先声明:MSP 是一款功能异常强大的投资组合追踪器。多币种 记账、做空、四种成本基准计算方法(FIFO / LIFO / 加权 平均 / 特定批次)、期货、针对单笔交易的手动 FX 覆盖 —— 这是一个比大多数个人理财应用 所尝试实现的功能更深度的特性集,这也是为什么它的导出文件值得被解析的根本原因。开发者 还在 公开了应用的翻译字符串, 这很罕见,并且事实证明,这也是弄清这些列含义的 最有效证据。 缺失的部分在于对导出格式本身的文档说明。官方帮助中心确认 存在 CSV 导入和导出功能,但没有发布关于导出格式的**字段级规范**。 唯一的官方 CSV 列文档是应用内的字符串 `csvImport_recognizedHeadersHelp`,而且它描述的是一个 8 列的*导入*格式 (Symbol / Portfolio / Shares / Type / Price / Commission / Date / Notes)—— 这与 19-20 列的*导出*格式是 两码事。 因此,如果你想读取自己导出的数据 —— 比如将其移入电子表格、输入给 报税工具,或是与券商对账单进行核对 —— 你必须首先自己弄清楚 格式。这份文档记录了这项工作,这样下一个人 就不必重复造轮子了。这里的一切都是通过将导出文件相互交叉比对,并与应用显示的内容核对而推断出来的 —— 一个真实的 投资组合,跨越几十个组合的数千笔交易、十一次 每周导出,以及公开的翻译字符串。没有任何内容取自于 规范,因为根本就不存在规范。 每一个结论都标注了其确认方式: | 标签 | 含义 | |---|---| | **[Official]**(官方) | 在位于 的官方帮助页面或公开字符串文件中声明 | | **[Verified]**(已验证) | 通过与真实导出数据进行比对复现;算术验证无误 | | **[Convention]**(惯例) | 该格式允许的一种用户记账习惯,而非应用强制执行的规则 | | **[Unconfirmed]**(未确认) | 以上皆非 —— 依然只是猜测 | ## 快速开始 ``` python3 msp_export_parser.py --self-test ``` 这会解析内置的 `example-export.csv`(一个涵盖以下每种交易类型和陷阱的合成文件)并检查推导出的持仓: ``` parsed 5 blocks, 14 transactions, 20 columns ok Main ACME net= 150.00 ok Main GLOBEX.L net= 0.00 ok Main USD=CASH net= -4,956.00 ok Margin EURUSD=X net= -70,250.00 ok Main ^GSPC net= 250.00 ``` 解析你自己的导出文件: ``` python3 msp_export_parser.py path/to/MSP-Portfolios-YYYY-MM-DD.csv python3 msp_export_parser.py path/to/export.csv --portfolio Main python3 msp_export_parser.py path/to/export.csv --raw Margin EURUSD=X ``` 需要 Python 3.9+,仅使用标准库。(3.9 是 CI 实际运行的最低版本; 代码没有使用任何更新的特性,但也没有测试过更低的版本。) ## 1. 文件结构 ``` Id,Symbol,Name,...,OutgoingCashLink <- header (1 line) ,,,,,,,,,,,,,,,,,,, <- blank row "1","ACME",...,,,,,,,,, <- snapshot row (no Transaction Date) "2","ACME",...,"2024-03-15 GMT+0800",... <- transaction row "3","ACME",... <- transaction row ,,,,,,,,,,,,,,,,,,, <- blank row (block separator) "6","GLOBEX.L",... <- next block's snapshot row ``` - **一个数据块代表一个 `(Portfolio, Symbol)` 组合。** - 每个区块的第一行是一个**快照行**:`Transaction Date` 为空, 并且它包含 `Name` / `Exchange` / `Currency` / `Last Traded Price`。 **它不携带任何持仓信息** —— `Shares Owned` 是空白的。持仓必须 通过重放其下方的交易来推导。没有任何捷径。[Verified] - 各区块之间由空行分隔。空行中的逗号数量并不是 固定的,因此请跳过所有单元格均为空的行,而不是去匹配精确的 行宽。[Verified] - `Id` **在同一个文件内**是唯一且单调递增的;快照行也会 占用一个 Id。它**在多次导出之间会被重新编号** —— 见第 7 节。[Verified] **列数会根据应用版本而变化。** 样本中较早的导出文件有 19 列;较晚的有 20 列,新增的一列是 `Purchase Exchange Currencies` (在整个过程中均为空)。**请通过表头名称来解析列。绝不要硬编码索引。** [Verified] ### 区块内的行顺序 **交易按文件顺序重放。** 应用是否保证这与 `Transaction Date` 的顺序 匹配尚无文档说明,但这比看起来更重要。 `Buy` / `Sell` / `Sell Short` / `Buy to Cover` / `Dividend` / `Interest` 与顺序无关 —— 因为加法满足交换律。`Sell All` / `Buy to Cover All` (平仓)和 `Split`(乘法)则不然。本文档描述的每个持仓都 依赖于按文件顺序重放的假设,而平仓类型正是本文档关注的 核心。 数据显示的内容:在十一次导出中,**每一个**多交易区块都已经 处于日期升序排列,毫无例外 —— 并且没有任何一个平仓行的 后面跟着一笔日期早于它自身的交易。因此,该假设在所有可测试的地方都成立。[Verified] 它是否得到了*保证*则是另一个问题,答案是未知的。 [Unconfirmed] 所有十一次导出都反映了同一个人的数据录入习惯。一个对交易进行倒签日期的人很可能会 得到不同的排列。 ⚠ **按 `(Transaction Date, Transaction Time)` 排序并不是安全的替代方案。** 具有相同日期和相同时间的 `Sell All` 和 `Buy` 之间没有定义明确的 顺序,任选其一都会悄无声息地改变结果。 要在你自己的数据中验证这一点:在同一区块中现有 `Sell All` 之前添加一笔日期*更早*的交易,重新导出, 看看应用会把它放在哪里。 ### 观察到的版本 这里的一切都来自于下方的导出文件。如果你的文件超出了这个范围 —— 无论是不同的平台、不同的应用版本,还是不同的列数 —— 都请将每一个结论 视为一个初步假设,而不是事实。 | 列数 | 样本占比 | 应用版本 | 平台 | |---|---|---|---| | 19 | 较早的导出文件 | 未知 | iOS | | 20 | 较晚的导出文件,包含最新的一次 | `2.522.0`(仅限最新) | iOS | | — | 从未检查过 | — | **Android (`co.peeksoft.stocks`)** | **应用版本不会记录在 CSV 中的任何地方**,因此只有最近的一次 导出可以确定地绑定到某个版本。较早的导出是由当时 当前版本生成的,这无法从文件中恢复。 ## 2. 列 | 列 | 来源 | 含义 | |---|---|---| | `Id` | [Verified] | 文件内的唯一行号。`OutgoingCashLink` 会指向它。**在多次导出之间不稳定**(第 7 节) | | `Symbol` | [Verified] | Yahoo Finance 代码,或第 3 节中的虚拟符号之一 | | `Name` | [Verified] | 金融工具名称;可能为空 | | `Display Symbol` | [Verified] | 在检查的每一次导出(两个版本)中均为空 | | `Exchange` | [Verified] | 交易所代码;可能为空 | | `Portfolio` | [Verified] | 账户名称,自由文本,由用户选择 | | `Currency` | [Verified] | 该金融工具的计价货币 | | `Last Traded Price` | [Verified] | 导出时的价格。**同一个符号在整个文件中共享一个价格**,无论由哪个投资组合持有 | | `Shares Owned` | [Verified] | **含义取决于 `Type`**(第 4 节)。它不是“当前持有的股份”—— 它是该行的数量 *或金额* | | `Cost Per Share` | [Verified] | 该行的执行价格。在 `Dividend` / `Interest` 行上,它是 0 或 1,不携带任何信息 | | `Commission` | [Verified] | 该行的佣金 | | `Transaction Date` | [Verified] | `YYYY-MM-DD GMT+HHMM`。**空值表示这是一个快照行** | | `Transaction Time` | [Verified] | `HH:MM:SS` | | `Purchase Exchange Rate` | [Official] | 此交易的手动 FX 覆盖(金融工具货币 → 本地货币)。空值表示使用了应用的自动汇率 | | `Purchase Exchange Currencies` | [Unconfirmed] | 在 20 列版本中添加;在检查的所有数据中均为空 | | `Type` | [Official]+[Verified] | 交易类型 —— 见第 4 节 | | `Accounting` | [Official] | 成本基准方法:`FIFO`(默认)/ `LIFO` / `Weighted Average` / `Specific Lots`。**仅出现在卖出方行上** | | `Accounting Execution Ids` | [Unconfirmed] | 与 `Specific Lots` 一起使用的批次标识符。几乎总是为空 | | `Notes` | [Verified] | 自由文本。在实践中,这里是交易*意图*的所在之处 —— FX 转换详情、利率、展期算术 | | `OutgoingCashLink` | [Verified] | 配对的现金交易的 `Id` —— 见第 5 节 | ## 3. 虚拟符号 有三种符号形态并不是真实可交易的金融工具: | 形态 | 示例 | 含义 | |---|---|---| | `XXX=CASH` | `USD=CASH` | [Official] 该货币的现金头寸。官方字符串 `portfolio_thisIsCurrencyCashPosition` 显示为 "This is a %s cash position"。`Last Traded Price` 始终为 1 | | `XXXYYY=X` | `EURUSD=X` | Yahoo FX 货币对。在应用中可以像任何金融工具一样交易,这使其可用于 FX 记账(第 4 节,`Sell Short`) | | `^INDEX` | `^GSPC` | 指数。现实中不可交易,但可用作期货头寸的替代(第 6 节) | 真实的期货代码(`ES=F`、`ZF=F`)和 Yahoo 共同基金代码(`0P…`)也会 出现,其行为类似于普通证券。 ## 4. 交易类型 在实际导出中出现了十个值。官方帮助文档记录了七个 —— **`Interest`、`Sell All` 和 `Buy to Cover All` 在 官方文档中均未被提及。** 下文关于它们的语义来自于数据推导。 | 类型 | 对头寸的影响 | `Shares Owned` 包含的内容 | 来源 | |---|---|---|---| | `Buy` | **+shares** | 股票数量 | [Official] | | `Sell` | **−shares** | 股票数量 | [Official] | | `Sell Short` | **−shares** | 股票数量 | [Official](UI 标签为 "Short") | | `Buy to Cover` | **+shares** | 股票数量 | [Official] | | `Sell All` | **平仓至零** | **不可靠 —— 不要使用** | [Verified] | | `Buy to Cover All` | **平仓至零** | **不可靠 —— 不要使用** | [Verified] | | `Dividend Reinvest` | **+shares** | 股票数量 | [Verified] | | `Dividend` | **无** | **现金金额** | [Verified] | | `Interest` | **无** | **现金金额** | [Verified] | | `Split` | **× shares ÷ cost** | 比率的分子 | [Verified] | ### 符号惯例 `Shares Owned` 是一个**带符号的**值,其符号与 `Type` 无关。净 效应是 `方向× 值`,因此负值会 反转该类型通常暗示的方向。 在一次真实的导出中,负值出现在十种类型中的五种上 —— 包括 `Buy` 和 `Sell`。**每一笔负值都发生在 `=CASH` 区块上;没有一笔发生在真实 证券上。** [Verified] 典型的场景是 `Buy USD=CASH` 行带有负 数金额,用于记录资金流出。 对于实现者来说,这意味着:不要取绝对值,也不要假设该 列是无符号的。带有负值的 `Sell` 行必须通过负 金额来减少头寸 —— 也就是说,增加它 —— 因为这就是录入它的 人的意图。对 `abs(value)` 应用 `−1` 在大多数行上偶然会得到正确的方向,但在那些行上却是错的。 未知负值是否会出现在真实证券上。[Unconfirmed] ### `Sell All` 陷阱 **这是该格式中可能引发的最严重的错误。** `Sell All` 和 `Buy to Cover All` 会无条件地平仓。这些行上的 `Shares Owned` 列不能作为增量使用。在一次真实导出的每一此类 数据中: | `Shares Owned` 包含的内容 | 出现频率 | |---|---| | `0` — 毫无信息 | 绝大多数 | | 平仓前的确切余额 | 少数 | | 余额因四舍五入的尾数而有偏差 | 一次 | 同一列,在同一种交易类型上,却以两种互斥的方式填充。只有“平仓”这种理解对两者都适用。 将其作为增量处理会**悄无声息地**破坏投资组合。在一个 被测量的案例中,它在**数十种金融工具**中产生了虚假头寸 —— 一些变成了 负数(一个价值六位数的虚构空头头寸),另一些则留下了 已经全部卖出的股票,因为减去常见的 `0` 使头寸保持 未被动过的状态。 两个可以盖棺定论的案例: - 一行 `Sell All` 的 Notes 记录了整个 余额的完整 FX 转换。相减后留下了剩余的空头;该头寸明显已平仓。 - 一行带有 `shares = 0` 的 `Sell All` 出现在一个拥有数百股 未平仓股票的金融工具上。减去 0 会让整个头寸永远留在那里。 内置的 `example-export.csv` 包含此案例(`Main / GLOBEX.L`),因此你可以据此检查自己的实现。 ### `Dividend` 和 `Interest` 包含的是金额,而非股票数量 这两种类型都不会移动头寸。`Shares Owned` 携带的是一个**现金金额** —— 收入为正,支出为负。这些行上的 `Cost Per Share` 是 0 或 1,没有任何意义;不要乘以它。[Verified] 因为应用没有专门的字段用于记录费用、税收或期货展期 差额,所以一种常见的惯例是将它们作为针对 相关金融工具的负 `Dividend` 或负 `Interest` 行进行入账。[Convention] 这样可以保持 标的头寸的成本基准是连续的,而不是将其分割成多个部分。如果你正在计算 收入,请按符号进行过滤 —— 否则你就会将真实的 股息与佣金抵消掉。 ### `Dividend Reinvest` 的方向 方向是 **+1**,与 `Buy` 相同。[Verified] 这个结论需要进行三方对账才能敲定,因为样本中的这些行 都是*负数*的 —— 该类型被用来将应计 利息资本化为贷款本金,而不是用于股息的再投资。 检查过程:同一种负债在同一个 文件中以两种独立的方式被追踪 —— 一次作为空头 FX 货币对(第 3 节),另一次作为 `XXX=CASH` 余额。将 两者对齐到同一日期,它们之间的差额正好是该 `Dividend Reinvest` 行的 `shares`。方向为 +1 时,这两种记账方式对账 结果为 0.00;方向为 −1 时,它们会出现两倍金额的偏差。 ⚠ **该类型的证据覆盖率低于其他类型。** 样本中的每一次 出现都是同样的用法 —— 将应计利息资本化 为本金。该类型的常规目的,即实际的股息再投资, 完全没有被观察到。`+1` 方向基于算术对账,而 非意图,因此它应该适用于任何一种用法;但它只是针对 一种使用模式进行了确认,而不是两种。应将其视为弱于 `Sell All` 的发现,后者是从几个方向确认的。 ## 5. `OutgoingCashLink` 一个指向配对的现金方交易的 `Id`。[Verified] 发生的配对情况: | 证券方 | → 现金方 | |---|---| | `Buy` 证券 | `Sell XXX=CASH`(现金流出) | | `Sell` 证券 | `Buy XXX=CASH`(现金流入) | | 现金上的 `Interest` | `Buy XXX=CASH` | | 证券上的 `Dividend` | `Buy XXX=CASH` | **双方始终位于同一个投资组合中。** 在任何导出中均未观察到 跨投资组合的配对。 这与官方 UI 字符串匹配:`withdrawCashFromPortfolioToPurchase`、 `depositCashToPortfolioFromSale` 和 `portfolio_link_cashFound`("Linked cash transaction found")。 ⚠ **覆盖率很低。** 在样本中,只有不到 5% 的交易带有 链接 —— 其他所有的现金部分都是手工记录的,没有 机器可读的关联关系。任何仅基于此列构建的现金流分析都 将只能看到实际流量的一小部分。 ## 6. 期货和指数头寸 对于期货和指数区块,`Shares Owned` 存储的是**每个点的价值 × 合约** 数量。[Verified] 有用的推论是: ``` shares × last_traded_price == notional value ``` 不需要应用任何合约乘数 —— 它已经包含在 `shares` 中了。 要恢复合约数量,请除以每个点的价值。 这在两种具有不同点值和不同 货币的金融工具上得到了验证;两者都完全对账。 ⚠ 存在一个反面案例:一个*没有* `=F` 后缀的金属期货符号,其 `shares` 与 预期的合约乘数不对应。如果你的符号缺少 `=F`,在信任该乘积之前请先进行验证。[Unconfirmed] ## 7. `Id` 在多次导出之间会重新编号 **`Id` 仅在单个文件内有效。**[Verified] 比较相隔两天的两次导出:在两次导出中都存在的交易中,**有 15.5% 具有不同的 `Id`**。 在单个区块内,Id 通常是稳定的 —— 变动的是文件级别的编号,这显然是因为 导出是按照内部排序重新编号的,因此投资组合或符号相对位置的任何变化都会 导致其后的所有内容发生位移。 后果: - `OutgoingCashLink` 指向的是一个 `Id`,因此**链接解析 仅在一个文件内有效**。绝不要用本周导出的文件去解析上周的 链接值。 - 要对比多次导出中的交易,请使用内容键,例如 `(Portfolio, Symbol, Transaction Date, Shares Owned, Type)`。 这是通过惨痛教训才发现的:使用 `Id` 作为键来寻找“新交易” 时,结果查出的是一笔来自六个月前仅仅被重新编号的行。 ## 8. 已知的限制 1. **导出文件不包含“从投资组合中排除”标志。** 应用允许 你将账户标记为从总计中排除,但该状态在 CSV 中是缺失的。仅从文件中根本无法判断哪些投资组合应该 计入净资产数字。如果你正在构建一个汇总,你需要自己维护该 列表。 2. **快照行不包含头寸。** 始终需要重放交易。 3. **每个文件中的每个符号仅有一个价格。** 跨投资组合的估值在内部是 一致的,但该价格是导出时的价格,而不是任何 交易日期的价格。 4. **`Sell All` / `Buy to Cover All` 必须被解读为“平仓”**(第 4 节)。 5. **已关闭和休眠的账户保留着非零余额。** 在不进行过滤的情况下汇总整个文件 会将历史遗留视为当前持有。 6. **同一种金融工具可以出现在多个投资组合中。** 如果其中两个 代表相同的现实世界持仓,天真的求和会导致重复计算。该 格式无法告诉你哪个是哪个。 7. **`Dividend` / `Interest` 行上的 `Cost Per Share` 是没有意义的**(第 4 节)。 8. **`Id` 不是跨文件的键**(第 7 节)。 9. **一个 `(Portfolio, Symbol)` 组合可以占据多个区块。** 在某一样本的每次导出中 都能看到:同一对出现两次,每一对都有各自 的快照行,其中一个完全没有携带任何交易。任何以该 组合为键的操作 —— 比如字典或 `GROUP BY` —— 都会悄无声息地丢弃 其中一个。请按*区块*求和,而不是按组合求和。内置的解析器会报告这种 情况,而不是将其合并掉。 10. **带有空 `Transaction Date` 的交易行将无法与 快照行区分开来。** 空日期是该格式提供的唯一标记, 并且没有备用信号可以依赖。这样的行会被当作 快照读取,并从头寸中完全消失。虽然从未被观察到,但在格式上 也无法阻止它发生。[Unconfirmed] 11. **数字格式假定为 `.` 作为小数分隔符,`,` 作为千位 分隔符。** 这尚未在设备区域设置颠倒了两者的 设备上进行检查。如果你的设备如此,那么参考解析器和第 2 节中的 几个数值结论都需要重新验证。检查方法:切换设备区域设置,重新导出,并 与已知文件进行比对。[Unconfirmed] ## 官方参考 - 交易类型 — - 成本基准方法 — - FX 汇率覆盖 — - 导入和导出 — - 公开翻译字符串,作为字段语义的最佳第三方证据 — 先前技术:[Ghostfolio-MSP-Importer](https://github.com/TheekshanaA/Ghostfolio-MSP-Importer) 处理 `Buy` / `Sell` / `Sell All`,但不尝试处理 `OutgoingCashLink`。 ## 免责声明 这是一个对观察到的文件格式的**非官方**描述,不附带 任何正确性保证。 这里的每一个结论都是通过反复推断得出的 —— 阅读导出文件, 形成关于某列含义的假设,然后将其与其他 行、其他导出文件以及应用本身的显示内容进行核对。这个过程 足以发现几个不明显的陷阱,但还不足以被称为 权威。某些解读可能是错误的。有些被标记为 `[Unconfirmed]` 的结论 正是因为它们仍然是猜测。 证据范围:导出自 **iOS 版本 2.522.0**,一个投资组合,十一个 每周文件。其他应用版本的行为可能会有所不同,并且该格式可能会 随时更改,恕不另行通知。 ### 非附属 非由 Peeksoft LLC 生产、审查、认可、赞助或支持。 "My Stocks Portfolio"、"MSP" 和 "Peeksoft" 是其各自所有者的商标, 此处仅用于标识本项目读取的格式。 ### 产生方式 每项结论均源自作者从其**自己的账户**导出的 CSV 文件, 使用的是应用**自带的导出功能**,且仅包含作者 自己的数据。具体来说,这项工作不涉及以下任何内容: - 没有反编译、反汇编或二进制分析 - 没有检查或修改应用源代码 - 没有拦截或分析网络流量 - 没有规避任何技术保护措施 - 没有访问任何其他用户的数据 - 没有任何形式的非公开材料 唯一的外部参考是上面链接的供应商自己的公开帮助中心和公开 翻译代码库。第 3 节和第 5 节引用了两个简短的 UI 字符串,以 说明某列的含义;此处未复制应用中的任何代码、资产或 大量文本。参考解析器是原创作品。 ### 如果你是 Peeksoft 如果你对此仓库中的任何内容感到担忧,请提交 issue 或联系 维护者。我们将及时且真诚地予以处理。 **在依赖这些内容之前,请务必与你自己的导出文件进行核对**,尤其 是对于任何涉及资金的内容。 参考解析器是原创作品,不包含任何来自该应用的代码。 ## 许可证 Apache License 2.0 —— 见 [LICENSE](LICENSE) 和 [NOTICE](NOTICE)。 版权所有 2026 Izero。 同样涵盖规范文本、解析器和示例文件。请注意, Apache 2.0 要求下游用户保留 `NOTICE` 文件,因此商标 和非附属声明将伴随任何分支传播。
标签:CSV, 个人财务, 数据解析, 规范文档, 逆向工具, 金融理财