# fintrack — 個人財務資料管線設計文件

> 本文件是實作規格。coding agent 在動手前必須讀完 §0、§1、§2.5、§12。
> 基礎設施慣例（systemd、通知、反向代理、路徑規範）沿用 `~/projects/INFRA.md`。
>
> 去個資版說明：機構改成代號（銀行A／B／C、券商A～D、加密交易所A），範例金額為示意；§2.2 機構清單、§10 目錄樹、§11 CLI 有節錄。

---

## §0 專案目的與成功判準

### 目的

把多家銀行、證券商、加密貨幣交易所的收支與交易紀錄，從多元來源
（自動抓取的 Gmail 附件、手動下載的 CSV/PDF）整合成一份
**可追溯、可重建、可驗證**的 SQLite 資料庫，用於掌握自身資產狀況。

### ⚠️ 成功判準有明確的優先順序

| 優先級 | 判準 | 標準 |
|---|---|---|
| **1（絕對）** | **`txn` 層完整且正確** | 每個帳戶每個期間的餘額連續性檢查通過（無餘額欄的來源改用 §9 的替代組合）；無漏行、無重複、無金額錯誤 |
| **2（絕對）** | **可追溯** | 任何一個數字都能回推到某份原始檔的某一列 |
| **3（絕對）** | **可重建** | 砍掉資料庫可完整重建，結果一致 |
| **4（可接受不完美）** | `event` 層語意完整 | 大量 `unknown` 是可接受的常態，逐步收斂即可 |

> **進出資料正確 > 事件語意完整。**
> 若必須取捨，永遠選擇保住 `txn` 層的正確性。
> 一筆分類不明的支出只是待辦事項；一筆漏掉或金額錯誤的交易是資料污染。

### 階段劃分

**Phase 1（本文件的全部重點）：建立帳本。** 把資料完整、可追溯地收進資料庫。

**Phase 2（已開始，住在 pipeline 之外）：分析。** 估值、報酬率、資產配置。
理由不變：只要 Phase 1 的資料模型正確，任何分析都是事後在同一份資料上外掛的查詢，
隨時可加；反過來則不成立。**所以分析一律外掛，永遠不得回頭改動帳本層。**
落腳處是 `analysis/`：唯讀取用 DB、輸出報告到 `reports/`，不是 pipeline 的一部分（見 §7）。

**兩階段的優先順序沒有變。** 帳本正確性仍是優先級 1；分析壞掉只是報告不能看，
帳本壞掉是資料污染。**agent 不得為了讓某張報表好看而動 Phase 1 的資料或判準**，
也不應在使用者沒要求時自行擴充分析功能。

### 範圍

- **時間範圍**：能挖到多早就多早。各機構起始日不同是正常的（見 §8）。
- **納入**：銀行、證券／債券、加密貨幣的收支與交易；房產等非流動資產的手動估值。
- **暫不納入**：信用卡逐筆消費明細。信用卡**繳費**（從銀行扣款）要記錄。
- **不納入**：預算管理、稅務相關功能。**本系統不處理任何報稅需求，agent 不需考慮稅務規則、不需產生稅務報表、不需實作成本基礎算法。**（對帳單上出現的預扣稅金額仍要如實記錄，因為那是現金流事實，不是稅務計算。）

### 使用者的工作分工

| 角色 | 負責 |
|---|---|
| **使用者** | 提供原始檔案；裁決 agent 判不出來的連結與分類；回答 agent 的格式疑問 |
| **AI agent** | 撰寫並驗證 parser；建立機構／帳戶設定；提出提案；產生報表；**回報哪些部分無法自動化** |
| **系統** | 確定性地執行 pipeline；偵測解析錯誤；曝光所有未知與待確認項目 |

使用者不會手工填寫格式規格表。Agent 的工作模式是：
**收到一批新資料 → 檢視格式 → 建立 parser 並自我驗證 → 跑通對帳 → 回報結果與限制。**

---

## §1 設計原則

### 1.1 三個持久性等級

| 等級 | 內容 | 特性 | 存放位置 |
|---|---|---|---|
| **L0 不可重建** | 原始檔（CSV、PDF、XLSX、EML）與取得 metadata | 唯讀、不可變、遺失即永久遺失 | `data/raw/` + 備份 |
| **L1 人工／AI 判斷** | parser 程式碼、帳戶設定、分類規則、連結決策、目視補登、手動估值 | 不可重建，但版本控制、可 diff、可審核 | `config/` + `src/`（進 Git） |
| **L2 可完全重建** | 解析結果、交易、事件、分錄、報表 | 純函數輸出 `f(L0, L1, code)`，隨時可砍掉重跑 | `data/db/fintrack.db` |

### 1.2 鐵則

1. **`data/raw/` 唯讀。** 任何程式碼不得修改或刪除其中檔案。
2. **判斷結果絕不寫入 DB。** 分類、連結、目視補登、估值一律寫 `config/`，由 pipeline 當成確定性步驟套用。
   > 違反這條的後果：你會不敢 rebuild，pipeline 隨即腐爛。
3. **DB 是可拋棄的。** rebuild 必須能刪掉 L2 全部內容重跑，結果一致。
4. **每一列 L2 資料都必須有 lineage 欄位**指回上一層（`record_id` / `event_id` / `run_id`）。
5. **不確定就記 `unknown`，絕不猜測後靜默處理。**

### 1.3 AI 在系統中的位置

本專案大量依賴 AI，但必須遵守一條線：

> **AI 的產出是「凍結的 artifact」（程式碼、設定檔、目視補登紀錄），不是 runtime 的一部分。**

| ✅ 正確用法 | ❌ 禁止 |
|---|---|
| AI **撰寫並反覆驗證 parser 程式碼**，直到輸出與目視內容吻合（§2.5） | pipeline 每次執行時呼叫 LLM 解析檔案 |
| AI 提出連結／分類**提案**寫進 `config/`，標記 `decided_by: ai` | AI 直接修改 `txn` / `event` 資料表 |
| AI 把判讀線索寫成 `config/rules/` 的規則，由 pipeline 確定性套用 | runtime 讓 LLM 決定兩筆交易要不要連起來 |
| AI 產生報表 | AI 在報表生成時「順手修正」資料 |
| 真的無法程式化時，AI **目視補登**進 `config/manual_records.jsonl` 並附完整 lineage | 每次 rebuild 重新讓 LLM 讀一次 PDF |

**核心要求：優先寫程式，不要用 LLM 直接輸出資料。**
即使是版面複雜的 PDF，agent 也應該持續迭代程式，直到程式輸出與自己目視所見一致。**只有在窮盡程式化手段後**，才允許目視補登，且必須依 §2.5 Stage E 明確回報。

### 1.4 未知優先（unknown-first）

Parser 在建立 `event` 時：**知道**這筆是什麼 → 產生完整雙邊 `posting`；**不知道** → `event(kind='unknown')` + 單邊 posting，對側掛 `Imbalance`。

`unknown` 不是錯誤，是待處理佇列，且**不阻擋任何流程**。但要被追蹤：`fintrack status` 與報表頂端固定顯示 unknown 事件數、金額佔比、較上次增減。

---

## §2 資料來源

### 2.1 取得管道

| 管道 | 說明 | 冪等鍵 |
|---|---|---|
| `gmail` | 自動抓取郵件附件：買賣報告書、對帳單、交易通知 | Gmail `message_id` + 附件 sha256 |
| `manual` | 人工從網銀／券商下載，放進 `data/inbox/` | 檔案 sha256 |

**Gmail 規則**：只抓附件與 metadata；**不刪信、不標已讀**；郵件本文含交易資訊但無附件者，另存 `.eml` 進 raw。

### 2.2 機構清單（節錄）

| institution_id | 類型 | 備註 |
|---|---|---|
| `bank_a` | bank | 本國；同集團有券商 `broker_a`，**是不同機構，勿混用** |
| `bank_b` | bank | 本國；**多幣別** |
| `bank_c` | bank | 本國；多種文件家族（明細、對帳單、通知書、信用卡） |
| `broker_a` | broker | 複委託；以 `bank_a` 外匯帳戶交割（鏡像帳戶，§2.6.7） |
| `broker_b`／`broker_c` | broker | 海外券商 |
| `broker_d` | broker | 外國；日股／投信；含外幣建倉 |
| `crypto_a` | crypto_exchange | 與 `broker_d` 同集團但**系統與檔案格式獨立，parser 分開寫** |

清單會持續增加。**Agent 不需事先填滿設定**，收到新機構資料時才建立。

**多幣別帳戶的建模規則（重要）**：一個帳號下有多種幣別的情況，**必須拆成多個 account**（`bank_b:savings:JPY`、`bank_b:savings:USD`）。理由：`balance_continuity` 是逐幣別檢查的；混在一個 account 裡無法驗證。同機構內的換匯因此變成兩個 account 間的 `fx_conversion` 事件。

### 2.3 資料格式的可能維度（檢視新檔案時的 checklist）

這一節取代逐機構規格表。收到任何新檔案時，agent 沿此清單逐項判定，記錄到 `docs/sources/{institution_id}.md`。

- **交付形式**：CSV / TSV / XLSX / PDF（可抽文字）/ PDF（掃描影像）/ HTML / EML
- **編碼與換行**：UTF-8（有無 BOM）／各種本地編碼；CRLF / LF；分隔符號；欄位內含逗號或換行
- **版面結構**：表頭行數；表頭前的說明行；檔尾合計行；同一檔案含**多個帳戶**或多個幣別區塊；PDF 跨頁截斷、每頁重複表頭
- **日期**：各種格式；**和曆／民國年**；成交日 vs 交割日 vs 記帳日 vs 起息日
- **金額**：千分位；全形數字；負數表示（`-1,000` ／ `(1,000)` ／ `1,000-` ／ **借貸分兩欄**）；空值表示；幣別欄存在與否
- **掃描影像 PDF**：各數字欄印刷的固定小數位數 ← 決定性修復「OCR 吃掉小數點」的唯一依據；同一金額是否在文件內重印 ← 冗餘是 OCR 驗算與修復的錨點；表格以何種印刷記號結束，**以記號而非固定頁數判定表格邊界**；同一張表是否混多種列——**先窮舉列的種類再寫欄位規則**，誤把配息當賣出會憑空改變部位
- **餘額**：有無「交易後餘額」欄 ← **最優先確認，這是驗證的基礎**；若無逐筆餘額，是否有期初／期末合計行可替代
- **交易屬性**：來源自訂的交易類型字串（原文保留，不翻譯）；券商專屬欄位；加密貨幣專屬欄位
- **排序與期間**；**穩定性**（機構改版歷史：改版時新增 fixture、升 parser 版本，**舊版必須仍能跑**）

### 2.4 增量上線流程（「來一批就建一批」）

```
1. fintrack acquire inbox   → 檔案入 data/raw/，建立 source_file 與 .meta.json（永遠成功，不需要 parser）
2. agent 依 §2.3 檢視格式，寫 docs/sources/{id}.md
3. agent 建立／更新 config/institutions、accounts（多幣別帳戶記得拆分）
4. agent 依 §2.5 的五階段流程開發並驗證 parser
5. build
6. 檢查 reconciliation → balance_continuity 必須通過；未通過則回步驟 4，不得放行
7. 回報使用者：新增筆數、涵蓋期間、balance_continuity 結果、unknown 事件數與金額、pending 提案數、⚠️ 無法自動化的部分
```

**步驟 1 與步驟 4 之後完全解耦。** 使用者可先把所有機構檔案全部倒進來，parser 慢慢補。這很重要，因為部分網銀明細只保留 3 個月——**`acquire` 有時效性，其他階段沒有。**

### 2.5 Parser 開發迴圈（本專案的核心工作流程）

目標：agent 用程式讀檔，**不用 LLM 直接輸出資料**，並且能證明程式讀對了。

#### 為什麼需要兩道獨立檢查

單純「agent 目視 → 改程式 → 吻合」有一個漏洞：**agent 目視所得的內容本身沒有被驗證過。**
若 agent 抄錯一列，parser 忠實地與錯誤的 ground truth 吻合，兩邊一致但資料是錯的。

解法是引入一個 **agent 無法自我影響的外部錨點：來自機構的餘額欄位**。餘額連續性是銀行算好寫在檔案上的事實，agent 抄錯任何一列都會導致對不起來。

#### Stage A — 目視建立 ground truth

Agent 直接檢視檔案，逐列抄出結構化內容，寫入 `tests/fixtures/{institution}/{sample}.expected.jsonl`：

```json
{"row_ref":"p1.r03","booked_date":"2026-03-14","amount":"-1000000","currency":"JPY","balance":"5000000","description":"<對手方摘要>","raw_kind":"振込"}
```

- 檔案小 → 全部抄；檔案大 → **首頁全部 + 尾頁全部 + 每個跨頁交界處前後各 3 列 + 隨機抽樣 20 列**（邊界是錯誤最常發生的地方）
- 樣本必須去識別化（金額可改動但**須自洽**，帳號遮蔽），且**保留原始格式特徵**

#### Stage B — 驗證 ground truth ⚠️ 不可略過

在寫 parser **之前**，先驗證 Stage A 抄的東西是對的：

- **有餘額欄**：逐列檢查 `前一列餘額 + 本列金額 = 本列餘額`。
- **無逐筆餘額但有期初／期末合計**：`期初 + Σ 金額 = 期末`
- **無餘額但每列有算式冗餘**（券商報告書常見）：逐列驗 `價 × 量 = 額`、`額 ± 費稅 = 淨額`，再以文件印出的小計驗 `Σ 淨額`。
- **兩者皆無**：標記該來源為 `low_verifiability`，**在 Stage E 回報中明確列出**，並提高抽樣比例。

Stage B 失敗 → 回 Stage A 重抄，**不得進入 Stage C**。

#### Stage C — 寫 parser 並迴圈

逐欄 diff parser 輸出與 `expected.jsonl`，不符則修 parser、重跑，直到全綠。

> **硬規則：不符時只准改 parser，不准改 `expected.jsonl` 去遷就 parser。**
> 唯一例外是確認 Stage A 抄錯——此時必須重跑 Stage B 驗證，並在 commit message 註明「fixture corrected: {原因}」。
>
> 這條規則存在的理由：沒有它，迴圈會退化成兩邊互相遷就直到收斂，而收斂點可能離事實很遠。

#### Stage D — 全量套用

parser 跑該機構**所有**檔案，逐帳戶逐幣別逐期間跑 `balance_continuity`。失敗的期間 → 針對該檔案回到 Stage A 建立新 fixture。

#### Stage E — 回報自動化限制 ⚠️ 必須產出

窮盡程式化手段後仍無法處理的部分，寫入 `docs/sources/{institution_id}.md` 的「自動化限制」章節，並同步輸出結構化摘要給使用者。**每一項必須指明具體範圍與分類：**

| 分類代碼 | 意義 |
|---|---|
| `scanned_image` | 掃描影像無文字層 |
| `layout_unstable` | 版面每期不同，無穩定規則 |
| `data_missing` | 來源本身缺欄位（如無餘額、無幣別） |
| `ambiguous_semantics` | 欄位語意無法從檔案本身確定 |
| `encoding_corrupt` | 編碼損毀 |
| `multi_line_record` | 單筆跨多行且無穩定分隔特徵 |
| `low_verifiability` | 無餘額欄也無合計，無法做連續性檢查 |
| `ambiguous_direction` | 數量／金額可讀，但方向無法判讀；不猜方向 |
| `unverified` | 資料已保存，但其獨立驗證來源尚未實作 |

此清單可擴充：實作中出現新的限制型態時，**先在此表補列代碼再使用**，不得自創未登記的代碼。

#### 目視補登（最後手段）

確定無法程式化時，agent 將內容寫入 `config/manual_records.jsonl`，`entered_by: ai`、`verified_against`、完整 lineage 指回 `file_id` 與頁次。目視補登的批次也必須通過 `balance_continuity`。

### 2.6 權威來源（同一事實出現在多種文件時）

同一機構常以多種文件描述同一筆經濟事實：券商的每日買賣報告書與月對帳單、銀行的逐筆明細與月結單。這是**冗餘，不是待合併的重複**。原則：

1. **每一類事實指定唯一的權威來源。** 判定依據：**資訊嚴格較多者為權威**。**只有權威來源可建 `txn`**。指定結果寫進 `docs/sources/`——這是 L1 判斷，必須明文記錄。**權威可以只涵蓋一段期間**：明細涵蓋到的日子由明細建 txn，涵蓋不到的日子維持原來的來源；涵蓋範圍由**資料本身**算出，並要有一條檢查確認兩者沒有重疊入帳。
2. **次要來源止於 `raw_record`**：仍完整解析入庫，但不產生 txn。價值在 reconciliation 的雙向比對：次要有、權威無 → **漏收檔案**；權威有、次要無 → 權威 parser 解析錯誤。比對用**多重集合**——同日同標的同金額可能真有兩筆。
3. **不可依賴 fingerprint 做跨來源去重**：指紋處理的是同一來源期間重疊的重複下載。「誰可建 txn」是明文政策，不是演算法。
4. **存量斷言（snapshot）不是異動**：月末持倉、期末餘額是機構對「某時點存量」的斷言。只供 reconciliation 比對，**絕不產生 posting**。機構印的衍生值（市值、報酬率）不入庫為事實。
5. **次要來源可補權威來源缺的維度**（保管機構拆分、投資成本），存在 snapshot／raw_record，不回寫 txn。
6. **權威來源缺件時的補建（resolution）**：經**人工決策**補建，一案一檔記錄於 `config/resolutions/`（L1）。鑰匙比對**事實本身**（日期、代號、數量、金額），不比對錯誤訊息；必須**唯一命中**才套用；**雙向防腐**：權威來源日後涵蓋同一事實 → 決策標 obsolete 警告。**每個 L0 檔案都要有去處**：`unprocessed_source` 檢查要求每個檔案不是已入庫、就是有 `exclude_source` 決策。
7. **鏡像帳戶**：機構 A 不自行持有現金、直接以機構 B 的帳戶收付時，兩邊都照常各建 txn，其中一個指定為鏡像（`include_in_portfolio = 0`），必須有交叉檢查證明兩個視角相等。

已登記的決策類型：`adopt_secondary_record`（由次要來源建 txn）、`accept_difference`（**不修改任何資料**；差異由 fail 降為 warn 並標註決策 id，永久可見；算式類檢查的失敗永遠不可接受）、`exclude_source`（檔案留在 raw，狀態改為「已檢視、確認排除」）。

### 2.6.8 斷言帳戶（使用者的第一手陳述當來源）

有些錢看得到進、看得到出，只是對側帳戶**調不到任何文件**（只保留三個月紀錄的外幣積立、不出明細的定存）。原本它們只能標 `account_untracked`，錢在報表上憑空消失一段時間。

使用者對自己帳戶的陳述（「這筆定存只有轉入與到期轉回」）與目視補登同級，是**可以入帳的 L1 判斷**，條件有三：**明確記錄**（`config/asserted_accounts.json`，含 `assertion` 原文、`asserted_by`、`asserted_at`）；**可被推翻**（`asserted_closing_balance` 由 verify 從分錄重算，對不上就 fail——日後多出一筆沒預期的進出，斷言立刻被資料戳破，而不是繼續騙報表）；**限制要曝光**（外幣帳戶標 `value_is_cost_only`，掛 warn）。

**貸款金流是同一個思路的文件版**：契約條件有真文件（`config/loans.json`），撥款列印出貸款帳號可精確對應，建 `liability_mortgage` 負債帳戶；對帳檢查要求帳上撥款累計＝契約印的未償本金——開始還本金的那天它會 fail，規則就得更新，錯不會靜默留著。

---

## §3 目標資料形式

### 3.1 事件類型（`event.kind`）

`unknown`（預設）、`income`、`expense`、`transfer_internal`、`fx_conversion`、`trade_buy`、`trade_sell`、`dividend`、`interest`、`fee`、`tax_withheld`（**僅記錄現金流事實**）、`corporate_action`、`crypto_transfer`、`opening_balance`、`valuation_manual`。每種對應固定的 posting 結構。

> **`corporate_action` 補充**：單一來源常印出股數但**方向無法機器判讀**。此時記 event 但**不產生數量 posting**，寫入 reconciliation `corporate_action_unresolved`（warn），待另一來源解出方向後再補。**不猜方向**。

### 3.2 收支分類（`event.category`）

定義於 `config/categories`，可持續擴充：`income:salary`、`income:dividend_family`（家族公司股息）、`income:dividend_market`、`income:interest`、`income:gift`、`expense:credit_card`、`expense:misc_card`、`expense:tax`、`expense:fee`、`expense:living`、`unknown`（預設）。

### 3.3 帳戶（`account`）

- `account_id` 人可讀：`{institution}:{kind}:{identifier}`，多幣別加幣別後綴
- **虛擬帳戶**（`Income:*` `Expense:*` `Equity:Opening` `Equity:Revaluation` `Imbalance`）放同一張表，`is_own = 0`
- **不需事先列完**，收到資料時建立

### 3.4 商品（`commodity`）

法幣（decimals 0 或 2）、加密貨幣（8）、股票／ETF（6，小數股／投信口數）、債券（ISIN／CUSIP 為 symbol）、非流動資產（`RE:<代號>`，0）。

### 3.5 非流動資產（房產）

- `account.kind = 'real_estate'`，`include_in_portfolio = 0`
- 估值寫 `config/manual_valuations.json`（L1），**必須含推導依據**：

```json
{"symbol": "RE:HOME-XXX", "as_of": "2026-06-30",
 "value": 10000000, "currency": "JPY", "confidence": "medium",
 "method": "公開成交資料 同棟同期 ~單價 × 面積，扣 5% 樓層調整"}
```

`method` 比數字重要——一年後才知道當初憑什麼、該不該更新。

**取得成本不明時，只建估值、不建帳戶。** 沒有取得成本、也沒有可對帳的現金流時，`real_estate` 帳戶只會是一個沒有 lineage 的數字掛在帳本裡，那違反鐵則 4。此時報表層直接讀這個 L1 檔。估值由借款金額之類的間接證據回推者一律 `low`，且必須在報表上說明「這個估值是從貸款回推的，所以『估值 − 貸款』不是獨立資訊」。

---

## §4 資料模型：四層

```
L0  data/raw/*                   原始檔（唯讀）
     ↓ parse
L2a raw_record                   原始列的結構化鏡像，零語意轉換
     ↓ normalize (+ config)
L2b txn                          單邊交易列，忠實反映對帳單  ← 正確性的絕對底線
     ↓ compose (+ config/decisions, config/rules)
L2c event + posting              複式記帳事件（大量為 unknown，可接受）
     ↓ [Phase 2] analysis/（唯讀，DB 之外）
    報酬率／估值／資產配置報告      → reports/（不進 Git，不回寫 DB）
```

**為什麼 `txn` 與 `posting` 兩層都要**：`txn` **忠實鏡像對帳單**，一列對帳單 = 一筆 txn，可直接與銀行對帳，**這一層完全不需要理解交易語意**；`posting` 表達**經濟真實**：跨帳戶、跨幣別、平衡。

---

## §5 SQLite Schema（節錄）

主要表：`source_file`（L0 索引，sha256 為主鍵）、`ingest_run`（每次 run 記 `git_commit`、`git_dirty`、`config_hash`）、`institution`、`account`、`commodity`、`raw_record`（`payload` JSON 照原文字串；`parser`、`parser_ver`）、`txn`（`amount_minor` INTEGER 最小貨幣單位、`balance_minor` ← 驗證的錨點、`suspect`、`record_id`、`run_id`、`event_id`）、`txn_trade_detail`、`position_snapshot`（存量斷言，只供比對）、`event`（`kind`、`category`、`origin`、`decided_by`、`confidence`）、`posting`（`units` Decimal 字串、`cost_basis_quality`）、`proposal`、`fx_rate`／`price`（Phase 1 只收集）、`reconciliation`（`check_type`、`status: pass|fail|warn`）。

### 5.1 數值表示法（強制）

| 場合 | 型別 | 理由 |
|---|---|---|
| `txn.amount_minor` `balance_minor` | `INTEGER` 最小貨幣單位 | 法幣對帳單，整數精確、可直接加總比對餘額 |
| `posting.units` `price` `quantity` `rate` | `TEXT` Decimal 字串 | 8 位小數、小數股、匯率無法用整數表達 |

- Python 端一律 `decimal.Decimal`。**禁止 float 參與任何金額運算。**
- **不在 txn / posting 層做換匯。** 原幣別入庫。

### 5.2 Migration
純 SQL 檔 `db/migrations/NNN_*.sql` + `PRAGMA user_version`。不用 Alembic。

---

## §6 組合階段（compose）

### 6.0 兩種不同的問題

**類型 1：機構內事件 — parser 直接處理，不需推測。** 買賣報告書寫明「賣出現金、買進 X」→ parser 產生雙邊 posting，`origin='parser'`。**這類事件不進 matcher。** 若需要靠 matcher 猜同機構內的配對，代表 parser 沒做好，回去修 parser。

**類型 2：跨機構資金移動 — matcher 依 L1 規則判定。** 兩份對帳單，無共同 ID → matcher 產生提案，信心達自動門檻就直接建立事件，達不到就進待審佇列。

**「自動」不等於「AI 說了算」。** 自動採用的依據是 `config/rules/` 裡寫死的確定性規則與判讀線索，那些檔案本身是 L1，可讀、可 diff、可被使用者推翻。runtime 沒有任何 LLM 參與。

### 6.1 txn fingerprint（去重）

```
txn_id = blake2b_16(account_id | booked_date | amount_minor | currency | normalize(description) | balance_minor | seq_in_file_if_no_balance)
```

- 對帳單期間重疊時，同一筆交易產生相同 `txn_id`，自然合併
- **同日同額的重複交易**是陷阱。`balance_minor` 是救命稻草；無餘額欄的來源必須把「該檔案內同 key 的序號」納入 fingerprint
- **改動 `normalize()` 必須升版並全量 rebuild。**
- **fingerprint 只負責同一來源的重複下載去重。** 跨來源的重複由 §2.6 權威來源政策處理

### 6.2 連結規則（依序套用，先到先得）

| 規則 | 條件 | 信心 |
|---|---|---|
| `R1_exact` | 同幣別、金額完全相反、日期差 ≤ 3 天、不同自有帳戶、雙方未歸屬 | 0.85 |
| `R2_fee` | 同上但到帳金額少一個手續費，差額 ≤ 該幣別**已登記**的上限；差額記成 `Expense:Fee` posting | 0.75 |
| `R3_settlement` | 銀行扣款 ⟷ 券商當日交割金額 | 0.90 |
| `R4_fx` | 跨幣別、日期差 ≤ 3 天、隱含匯率落在宣告區間內 | 0.70（＋0.10） |
| `R5_crypto_xfer` | 同 commodity、數量差 ≤ 已知 network fee、時間差 ≤ 24h | 0.80 |
| `R6_boundary` | 對側事實在本資料庫裡不可能存在（見 §8.2） | 0.90（單邊） |
| `R7_hint` | 摘要命中 `config/rules/transfer_hints.json` | 指名對側 +0.10；只知是本人 +0.05；**指向別處則淘汰** |
| `R8_reversal` | 同帳戶、金額完全相反、日期差 ≤ 3 天，且摘要有沖正字樣 | 0.90 |

- 信心 ≥ 0.90 → `auto`；0.5～0.90 → `pending`；< 0.5 → 不提案
- **同分且對側不唯一 → 全部降級為 `pending`**。此時「取最高信心」等於隨機挑一個。唯一例外：整組同幣別同金額、出入款數相等且兩兩皆可配（完全二分圖）——任何配法產生的帳完全相同。
- **`R1_exact` 光靠金額與日期不到自動門檻**，必須再命中 `R7_hint`。實測理由見 `DESIGN-CHANGELOG.md`。

### 6.2.1 只有一邊的連結

有一類資金移動確定是自有帳戶之間的搬錢，但**對側在本資料庫裡沒有列可以指**。處理方式寫在 `config/rules/internal_no_counterpart.json`：**只改事件語意**（`transfer_internal`、`is_external=0`）、**不生成對側 posting**（對側事實不存在，憑空造一條就是違反鐵則 5）、`reason='account_untracked'` 者另外掛 `untracked_own_account` warn——「錢移到看不見的地方」必須被曝光，不能被 `is_external=0` 悄悄吸收。

### 6.3 AI 提案

規則無法命中時，agent 可對 `unknown` 事件批次提出 LLM 提案，寫入 `proposal` 表，**一律 `status='pending'`**（AI 提案不得自動接受，無論信心多高）；`evidence` 必須包含推理依據。

### 6.4 核准與 L1 回寫 — 優先產生規則而非逐筆決策

**批次處理的正確做法是新增規則，不是展開成幾百行決策。** 當一批 unknown 有共同特徵（同一對手方、同一摘要樣式）時，寫入 `config/rules/categorize.json`。規則的好處：可讀、可 diff、**對未來新進資料自動生效**。逐筆決策只用在規則涵蓋不到的例外，寫入 `config/decisions.jsonl`（append-only）。套用順序：`規則 → 逐筆決策`。

---

## §7 Phase 2：分析與報告層

### 7.2 `analysis/` — 報告層的位置與規矩

分析程式集中在 `analysis/`，**不在 `src/fintrack/` 內**。帳本層必須能被獨立重建與驗證，分析若長進 pipeline 裡，rebuild 就會開始依賴一堆與正確性無關的東西。

| | |
|---|---|
| 讀什麼 | `data/db/fintrack.db`，以 `mode=ro` 開啟 |
| 寫哪裡 | `reports/`，**在 .gitignore 內**（報告含實際金額，與 raw PDF 同級） |
| 誰依賴誰 | `analysis/` 依賴 DB；**pipeline 絕不依賴 `analysis/`** |
| 外部行情 | 快取在 `data/staging/market/`（L2，可砍）；`--offline` 必須能完整跑完 |

**九條規矩：**

1. **唯讀。** 要修數字就回去修 parser 或 config，不准在報告裡「順手修正」。
2. **金額用 `Decimal`。** `float` 只准出現在畫圖座標與 XIRR 這類必須數值求根的地方。
3. **假設要寫在報告裡。** 每份報告都要有一段「限制與假設」。**分析可以近似，但不可以讓看的人不知道它近似在哪。**
4. **每份報告自帶至少一項交叉驗證**，用另一條路徑重算同一件事並印在報告上。**機構之間的慣例不可互相套用**：實測某券商的月底庫存是成交日基準、另一家是交割日基準。
5. **機構印的衍生值可以用，但要標明出處**（「這是對帳單上印的，不是我算的」）。
6. **報告是離線可開的單一 HTML 檔。** 資料撐不起某個指標時，**就不要生一個看起來像那回事的數字**——寫清楚缺什麼。
7. **外部行情一律先落地再用**，不寫進 DB。**還原價與未還原價是兩種用途，不可混用**：持股估值要「未還原價 × 原始股數」，對照組指數化要用還原價。用錯會把一次 10:1 分割讀成跌九成。
8. **報表口徑 ≠ 帳本口徑。** 使用者可能因為家庭約定之類的理由，不想把某個帳戶算進報表——那是**報表的口徑**，宣告在 `config/report_scope.json`，只有 `analysis/` 讀。帳本一切照舊，**不得動 `account.is_own` 或 `include_in_portfolio`**。**排除的金額必須印在報表上**：排除可以，靜靜消失不行。
9. **報表層可以整層拋棄重建。** 前提是**報表層的判斷一律在 `config/`，不在程式裡**。程式碼裡出現帳戶 id 或日期字面值，就是判斷漏進程式的訊號。

**分析發現的資料問題要回報，不要在分析層繞過。**

---

## §8 資料邊界（各機構起始日不一致）

- **8.1 期初餘額**：每個帳戶各自有一個 `opening_balance` 事件，日期為 `data_from` 前一天。例外：來源自己印出開戶列時，掛在那一列上（有 lineage），不另外合成。部位的期初：`units` 可靠、`cost_price` 留空或估、`cost_basis_quality = 'estimated'`。
- **8.2 跨越邊界的轉帳**：`R6_boundary`——未配對的 txn 若指向某自有帳戶，且該帳戶的 `data_from` 晚於此 txn 日期 → `transfer_internal`。不需要猜到對側是哪一筆，只需判斷「對側本來就不可能存在」。
- **8.3** `account.data_from` 必須被正確填寫；分析的起算點由此推導。

---

## §9 對帳與驗證

Parser 寫再仔細都會錯，尤其 PDF。唯一可靠的偵錯機制是**利用資料本身的冗餘**。

| check_type | 檢查內容 | 抓到什麼 | gate |
|---|---|---|---|
| `balance_continuity` | 逐帳戶逐幣別逐期間：期初 + Σ txn = 期末 | 漏行、正負號顛倒、千分位誤判、跨頁截斷 — **約 95% 的解析錯誤** | ✅ 硬 |
| `period_coverage` | 對帳單期間無缺口 | 忘記下載某個月 | ⚠️ warn |
| `position_rollforward` | 期初 snapshot + Σ 買賣 = 期末 snapshot | 券商 PDF 解析錯誤、遺漏 corporate action | ✅ 硬 |
| `document_subtotal` | 機構印的小計 vs 逐筆加總 | OCR 錯誤、漏列 | ✅ 硬 |
| `statement_crosscheck` | 次要來源 vs 權威來源多重集合雙向比對 | 漏收檔案、parser 錯誤 | ✅ 硬 |
| `event_balance` | 每個 event 的 posting 依 commodity 總和為 0 | 組合邏輯錯誤 | ✅ 硬 |
| `transfer_link_criteria` | 每一條自動連結回頭用 L1 規則重判 | matcher 寫出自己的規則不支持的連結 | ✅ 硬 |
| `txn_single_event` | 一筆 txn 只屬於一個事件 | 改寫事件時漏改 | ✅ 硬 |
| `untracked_own_account` | 自有資金移動到完全沒有資料來源的帳戶 | 錢從報表消失 | ⚠️ warn |
| `unify_master_conflict` | 併檔時同一主檔列在兩家 DB 內容不同 | 定義分歧 | ⚠️ warn |
| `unknown_ratio` | unknown 事件數與金額佔比趨勢 | 待處理佇列腐爛 | ℹ️ info |

- **無餘額欄的來源**以「逐筆算式 + `document_subtotal` + `statement_crosscheck` + `position_rollforward`」的組合替代。**缺哪一項就明確掛 warn，不得假裝 pass。**
- **`balance_continuity` fail 時**：該期間 txn 設 `suspect = 1`，在所有報表中標示，parser 必須修到通過才算完成。

---

## §10 目錄結構（節錄）

```
├── DESIGN.md / DESIGN-CHANGELOG.md / AGENTS.md / STATUS.md / STATUS-ARCHIVE.md
├── docs/sources/{institution_id}.md   # 格式判定、改版歷史、自動化限制
├── docs/data-shape.md                 # 自動生成；樣本欄印格式不印真值，所以可以進 Git
├── src/fintrack/                      # cli、build_{institution}、parse/、compose/、verify/、status、lineage、shape
├── analysis/                          # Phase 2 報告層（進 Git）：run.py、lib/、builders/（一個檔案 = 一份報告）
├── reports/                           # analysis/ 的產出，不進 Git（含實際金額）
├── config/                            # L1，全部進 Git：accounts、asserted_accounts、loans、manual_valuations、
│                                      #   manual_records.jsonl、source_catalog、report_scope、known_gaps、
│                                      #   decisions.jsonl、resolutions/{case_id}.md、rules/
├── data/                              # 不進 Git
│   ├── inbox/                         # **唯一人工投放點**，處理後清空
│   ├── aside/                         # 旁置區：**要備份、但不進檔案庫**
│   ├── raw/{institution}/{account}/{yyyy}/{sha12}_{原檔名}[.meta.json]   # {yyyy} 是文件自己的年份
│   ├── staging/                       # PDF 抽出文字、OCR 快取（L2，可砍）
│   └── db/{institution}.db, fintrack.db
└── tests/fixtures/{institution}/{sample}.{ext} + .expected.jsonl
```

`.meta.json` sidecar 讓 `raw/` 自我描述——DB 全毀時可用 sidecar 重建索引。

### 備份
`data/raw/` 與 `config/` 是唯一不可重建的東西。`data/aside/` 也要備份。`data/db/`、`data/staging/` 不需備份。備份每週自動執行；raw 一機構一年份一包，沒變的包不重傳；覆蓋雲端上的舊版前先留檔到 `archive/`，**archive/ 程式永不刪**。

---

## §11 CLI（節錄）

```bash
fintrack {institution}-build            # 一家一個 DB，各自可 verify
fintrack unify [--rebuild]              # 併成 fintrack.db 並跑 compose
fintrack verify                         # 獨立重算，失敗 exit 1；結果存 verify-latest-{db}.json
fintrack status                         # 一眼看現況（唯讀）
fintrack review / lineage / shape
python -m analysis.run [報告名] [--offline] [--publish]
```

設計時列過、**沒有做也不打算做**的子指令另段註明由什麼承接（不留幽靈指令在文件裡）。

**冪等性**：`unify` 把各機構 DB 的列原封搬進同一個檔案（**不做任何判斷、不改任何一列**），再跑 compose。每條機構鏈路本來就是純函數輸出，併起來仍然是同一個函數的輸出，所以鐵則 3 依然成立；回歸測試會跑兩次 `unify` 並逐表 diff。

---

## §12 給 coding agent 的工作守則

### 不可違反的約束

1. **絕不修改或刪除 `data/raw/` 下的任何檔案。**
2. **絕不把判斷結果寫進 DB。**
3. **絕不用 float 處理金額。**
4. **絕不在 txn / posting 層做幣別換算。**
5. **絕不在 pipeline runtime 呼叫 LLM。**
6. **優先寫程式讀檔，不要用 LLM 直接輸出資料。** 允許無限次迭代修改 parser。
7. **§2.5 Stage B 不可略過。**
8. **Stage C 不符時只准改 parser，不准改 `expected.jsonl` 遷就 parser。**
9. **AI 提案一律 `status='pending'`。**
10. **無法自動化的部分必須依 Stage E 明確回報。不得靜默略過。**
11. **每個 L2 資料表的每一列都必須有 lineage 欄位與 `run_id`。**
12. **改動 parser 邏輯必須升 `VERSION`**；改動 `normalize` 必須全量 rebuild。
13. **新增／修改 parser 必須同時有 fixture、`expected.jsonl` 與回歸測試。**
14. **rebuild 必須永遠可用。** CI 應包含 rebuild-and-diff 測試。
15. **不確定就記 `unknown`。**
16. **不實作任何稅務相關功能。**
17. **OCR 數值修復只允許「由文件本身唯一決定」的還原**，修復後仍須通過全部原有硬 gate，並記錄供稽核；無法唯一決定時整份拒收，**不得推測**。
18. **同一經濟事實只有權威來源可建 txn。** 存量斷言**絕不產生 posting**。

### 實作慣例

- Parser 是純函數 `parse(data: bytes, meta: dict) -> list[dict]`。不碰 DB、不碰檔案系統、不碰網路。
- PDF（有文字層）：先抽 text 與 table，**原始文字存進 `data/staging/`**。機構改版時 diff 前後兩期文字結構比重看 PDF 快十倍。
- PDF（掃描影像）：固定 DPI 渲染 + 本機 OCR（引擎與語言資料版本固定，絕不呼叫雲端）。**OCR 文字快取的鍵含渲染幾何 + OCR 引擎版本、不含 parser 版本**——修 parser 不應重跑 OCR（實測：全量重解析數秒 vs 全量 OCR 約 40 分鐘）。
- 每次 run 記錄 `git_commit` + `config_hash`。
- 秘密不進 Git、不進 `config/`。

### 遇到歧義時

不要猜測後靜默處理：保留原文進 `raw_record.payload` → 產生 `unknown` 事件或 `pending` 提案，附上 `evidence` → 在 `fintrack status` 與報表中曝光 → 向使用者提問。

---

## §13 修訂紀錄

本文件是憲法：實作與文件衝突時，要嘛修實作，要嘛留下修訂紀錄後修文件，**不得放任兩者長期不一致**。逐條修訂紀錄在 `DESIGN-CHANGELOG.md`。修訂本文件時必須同步在那裡補一列。
