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, 个人财务, 数据解析, 规范文档, 逆向工具, 金融理财