ADR-027:账本活动类型
- 状态:🟡 提议(2026-10-18;按 PR #472 评审修订:有状态回放、在途对全部估值方可见、确认 CAS 幂等、按类型的确认延迟、
schema_version命名、复权价核实),评审通过后实现(评审路线 C7); PR1 已实现(#479):Activity schema +calc/ledger_replay.py:replay_ledger+ 消费方切换 + 回放不变量 + 复权价核实(见 D3/D4 的 PR1 注); PR2 已实现(#482):dividend / interest / fee / tax / split / return_of_capital 写入 + MCP/CLI + status 近 30 天分红利息 +daily_prices.close_raw与估值价格口径(见 D4 的 PR2 注); PR3 已实现(#490):场外基金两段式place_fund_order/confirm_fund_order/cancel_fund_order+fund_confirmjob + 在途金额进总资产与业绩回放- status
pending_fund_orders(见 D5 的 PR3 注)
- status
- 背景:2026-10-17 全面评审对照 Wealthfolio / Ghostfolio / Portfolio Performance / Sharesight:它们都有 分红 / 利息 / 费用 / 税 / 拆股 / 退回本金 的活动类型;openInvest 的账本只有买卖存取,
grep dividend|tax在 src/ 零命中,场外基金按"下单即成交"记账(实际 T+1 按当日净值确认)。
现状(只写事实)
- 快照:
memory/portfolio.mdv2 =cash{ccy: amt}+holdings[](symbol / kind / units / avg_cost / cost_currency / proxy_kind …),schema_version钉死2(core/schemas.py)。 - 流水:
memory/portfolio_history.jsonl追加式,记录形状不统一:action有 buy / bought / sold / deposit / withdraw / gift / manual_override / delete_holding / dca_reconcile_*;价格字段有price也有price_per_unit+total_amount;fee只在 19 条里出现;外部现金流靠action ∈ {deposit, withdraw}或funding_source=external_funding识别(account_performance._ledger_events里有一份别名表)。 - 消费方:
services/account_performance(NAV 回放 / TWR / XIRR)、core/decision_ledger(决议↔成交匹配)、services/holdings_digest(#468)、jobs/pnl_snapshot、services/discipline。各自读原始 jsonl。
决策
D1 不改 portfolio.md 的 schema(不做 v3 迁移)
快照仍是 v2。活动只进流水;快照字段的变化(units / avg_cost / cash)由写入方法在同一个 with_portfolio_tx 事务里落下。理由:v2 → v3 迁移是 2026-07-08 覆盖事故的同类风险源, 而新活动不需要新快照字段(场外基金的在途金额例外,见 D4,用 extra 字段承载)。
D2 活动记录统一 schema(新记录带 schema: 2,老记录不动)
core/schemas.py 新增 Activity(Pydantic):
| 字段 | 说明 |
|---|---|
schema_version | 固定 2(老记录无此字段 = 1)。不叫 schema:与 Pydantic v2 的 BaseModel.schema() 同名会告警 |
action | 新写入只用:buy / sell / deposit / withdraw / gift / dividend / interest / fee / tax / split / return_of_capital / fund_order_placed / fund_order_confirmed / fund_order_cancelled / delete_holding / correction。历史动作在解析层归一:bought→buy、sold→sell、dca_reconcile_backfill→buy(source=dca_reconcile, external=True)、dca_reconcile_reverse→sell(同)、manual_override→correction(它记的是绝对值,解析层从前序状态算出增量,见 D3) |
symbol | 标的(现金类活动可空) |
units / price / amount / currency | 数量 / 单价 / 不含费用的总额(amount = units × price);费用与税单列,符号与现金影响只看 cash_effect |
fee / tax_withheld | 可选,与 amount 同币种 |
cash_effect | 该活动对现金的净影响(符号明确:流入正、流出负),读方只认这个字段 |
units_effect | 对持仓数量的净影响(拆股为乘法:见 split_ratio) |
split_ratio | 仅 split:新/旧(10:1 拆写 10,1:10 并写 0.1) |
external | bool:是否外部现金流(deposit / withdraw / gift 为 True;分红利息为 False) |
trade_date / settle_date / nav_date | 日期三件套(场外基金用全部三个) |
source / idempotency_key / note / correction | 来源(cli / mcp / dca_daily / commsec / fund_auto_confirm)、幂等键(#432 已有)、备注、是否修正记录(manual_override 归一后为 True) |
D3 单一解析入口 calc/ledger_replay.py(纯)
replay_ledger(records, *, alias) -> Tuple[List[Activity], LedgerState]:有状态的折叠,不是逐条无状态映射—— manual_override 记的是持仓绝对值,必须从前序状态算出增量再归一成 correction(units_effect = 新−旧, correction=True)。老记录按现有别名表归一(bought→buy、sold→sell、price_per_unit→price、 funding_source=external_funding→external=True、gift→external=True 等),新记录直接校验。 读 portfolio_history.jsonl 的全部消费方切换到它:services/account_performance、core/decision_ledger (含 core/decision_calc)、services/holdings_digest、jobs/pnl_snapshot、services/discipline、 skill_cmds/lifecycle_cmds、connectors/web_api/routers/read.py;core/memory_store 只保留写入与原始读取。 各自的私有别名表删除。
PR1 注(实现与上面清单的差异):实际自己解析流水字段的只有
services/account_performance(含"账本里有、持仓里没有的 symbol"登记)和core/decision_calc.history_as_trades(decision_ledger经它读),这两处已切换。holdings_digest/jobs/pnl_snapshot不读流水(经account_performance取业绩),services/discipline读的是 verdict_review / interventions;skill_cmds/lifecycle_cmds(判空)、routers/read.py与services/skill_views(原样展示原始行)不解析字段,故不动;scripts/reconcile_dca_phantom是写入侧对账脚本,按原始动作名查自己写的行,保持原样。gift保持gift(D2 动作表里它自成一类) 而不是并进buy,否则决议↔成交匹配无法把赠股和下单区分开。tests/test_ledger_replay_invariance.py另守"没人再自抄别名表"。
回放不变量测试:对生产流水只读拷贝,改前改后 NAV 序列逐日相等 (合成脱敏流水进 tests;生产比对结果贴 PR)。
D4 新活动对快照的确定性效果
| action | cash | units | avg_cost | 备注 |
|---|---|---|---|---|
| dividend | +net(gross − tax_withheld) | 0 | 不变 | reinvest=True 时再追加一条 buy(source=drip,external=False) |
| interest | +amount | — | — | 货币基金 / 现金利息 |
| fee | −amount | 0 | 不变 | 账户级或标的级(symbol 可空) |
| tax | −amount | 0 | 不变 | 红利税补缴、港股通股息税差额等 |
| split | 0 | ×ratio | ÷ratio | 市值不变 |
| return_of_capital | +amount | 0 | 减 amount/units | 成本降低,不是收益 |
| fund_order_placed | −amount | 0 | — | 快照 holding.extra.pending_orders[] 记在途(金额 / 下单日 / 预计确认日) |
| fund_order_confirmed | 0 | +amount÷nav(扣申购费) | 加权更新 | 用确认日净值;清掉对应在途 |
| fund_order_cancelled | +amount | 0 | — | 退回在途 |
TWR / XIRR 口径(account_performance):external=True 的进分母与 XIRR 现金流;dividend / interest / fee / tax 都是内部流,进 NAV 不进分母;split 对 NAV 无影响。
在途金额对所有估值方可见(评审指出 D1 的漏洞):fund_order_placed 扣了现金、份额还没到,若只在 TWR 里补回, status / pnl_snapshot / holdings_digest / rebalance_plan / 集中度 在 T 到 T+1 之间都会看到总资产凭空少一笔。 因此在途金额进唯一的总资产实现 utils/fx.total_portfolio_value_cny(#417 起所有估值方都走它):按面值计入, 并由 PortfolioManager.pending_fund_orders() 统一读取;不允许任何估值方自己读 extra.pending_orders。
split 与复权价:若 db/market_data.db 存的是复权价(yfinance auto_adjust 默认 True,需在 PR1 核实 daily_prices.source 的口径),回放时不得再按 split_ratio 缩放历史 units,否则市值双重调整; 正确做法是回放全程用拆股后的数量口径(等价于 原始 units × 未复权价)。PR1 必须先确认价格口径并写进 calc/ledger_replay.py 的 docstring;两种口径各有测试。
PR1 核实结论(PR2 补充:Yahoo 的
Close本身已按拆股复权、只有Adj Close才含分红复权,所以close_raw是"拆股后口径、未含分红",不是拆股前原始价):daily_prices.close是复权价,且拆股与分红都复权(全仓只用yf.Ticker().history(period=...),yfinance 1.7.0 默认auto_adjust=True,无任何调用传auto_adjust=False;生产库实测 NVDA 2024-06-07 收盘 120.68 = 拆股后口径)。精确的回放规则因此是: ① split:价格序列已按拆股后口径连续,回放的数量必须在所有历史日期用拆股后口径(拆股前数量 ×ratio 重述);上一段 "不得再按 split_ratio 缩放历史 units"指的是不能在拆股日才 ×ratio——否则拆股前"拆股前数量 × 复权价"低估 ratio 倍、拆股日 NAV 凭空跳变。 ② 分红:复权价路径已把分红当再投资算进收益,PR2 若再把dividend现金记进 NAV,该标的 TWR 会把同一笔分红算两次—— PR2 须二选一(有分红活动的标的改读未复权价,或分红只做现金流分类不入 NAV)。
PR2 落地(对上面"二选一"的决定):分红必须记真实现金,所以历史估值不能用复权价——
daily_prices加可空列close_raw(yfinanceClose,history(auto_adjust=False);close仍是Adj Close,逐位不变,regime / 指标 / forward_return 零影响)。 账户业绩 / 持仓归因按 symbol 选口径:所需日期范围内close_raw全有 → 原始价,缺一天 → 整只复权价,不混用,返回price_basis。 复权价回退的标的,收入类活动(dividend / return_of_capital / DRIP 买入)不进回放(价格已含,不双计)。split 不论哪种口径都用拆股后 数量(later_split_factors)。场外基金(只有前复权净值)与黄金克价代理(期货 × 外汇无分红)不回填,恒为adjusted。 回填:openinvest backfill_raw_close。record_split补记历史拆股时,账本里已有除权日当天或之后的份数变动 → 拒绝(快照已混拆股后份数,账本无撤销),--ledger-only只补流水(#482 评审)。持仓归因的"今日涨跌"不计入当日分红(除息日原始价会下跌,分红现金不在这一段)——另议。
D5 场外基金两段式 + 自动确认
place_fund_order(symbol, amount, order_date=today):15:00 前下单按当日净值、之后按下一交易日,settle_date默认 T+1 交易日(规则写在calc/fund_rules.py,A 股交易日历用现有is_closed_weekend+ 节假日表,没有节假日表就按工作日并在记录里标calendar=weekday_only)。confirm_fund_order(order_id, nav=None):不传 nav 时用utils/eastmoney_fund.fetch_fund_nav_history取nav_date的净值;新增 jobfund_confirm(工作日 21:30–23:00 每 30 分钟,看门狗纳入)自动确认 已到nav_date且净值已公布的在途单;取不到就留着,status 显示"在途待确认 N 天"。- 确认延迟按基金类型配置:
fund.confirm_lag_days默认{default: 1, qdii: 2, fof: 2}(QDII / FOF 的确认与净值公布 常为 T+2 或更晚;类型从东方财富基金概况取,取不到按 default 并在 status 标"按 T+1 估计")。 - 幂等(ADR-016 红线 4,cron 重跑 + 手动并发):确认是对
order_id的placed → confirmed状态转换,检查与写入在 同一个with_portfolio_tx里完成(CAS,参照 ADR-016 #127);手动confirm_fund_order与fund_confirmjob 同时执行只有 一个生效,另一个返回already_confirmed;fund_order_cancelled对已确认的单子拒绝。PR3 在 ADR-016 的全路径审计表里 为place_fund_order/confirm_fund_order(手动与 job)/fund_order_cancelled/record_dividend等各加一行。 - 旧行为(
buy直接记基金)保留可用,buy对kind=fund且 symbol 是场外代码时打 warning 建议改用两段式。
PR3 落地(与上面草案的差异 / 补充的决定):
nav_date= 定价日 T(所有基金类型一样:申购按 T 日净值;15:00 北京时间前下单且当天是交易日 → T = 当日,否则下一交易日)。expected_confirm_date= T +fund.confirm_lag_days[类型]个交易日,只用于展示和超期提示;fund_confirm的触发条件是 "nav_date≤ 今天且 T 日净值已公布",不是确认日到了——普通基金当晚净值一出就确认,QDII / FOF 净值晚几天公布就连续几晚重试。 规则全在纯模块calc/fund_rules.py;每条订单记calendar=weekday_only、fund_kind、lag_days、lag_source(profile= 从基金概况 取到类型;default= 取不到,status 提示"按 T+N 估计")。基金类型:东方财富基金概况页的基金全称 / 简称 / 类型里带 QDII →qdii、带 FOF →fof。- 没有 A 股节假日表:T 落在休市日(如国庆)时,
confirm_fund_order顺延用之后第一个已公布的净值日(订单记nav_date_planned,返回里说明); 预计确认日仍可能比券商晚几天——这是估计,不是承诺。- 订单存
holding.extra.pending_orders[](状态placed → confirmed | cancelled,与现金同一次 atomic write;在途单全留,已终结的每个基金行保留最近 20 条,CAS 要靠它回答already_confirmed)。没有这只基金就建一行kind=fundunits=0proxy_kind=eastmoney_fund;有在途单的基金行拒绝delete_holding(force 也不行)。- 流水日期:
fund_order_placed的trade_date= 下单日(北京),settle_date= 预计确认日,nav_date= 定价日;fund_order_confirmed/cancelled的trade_date= 动作发生日(账户业绩按它换算成份额 / 退回现金),nav_date= 实际用的净值日。三者都是内部流(external=False,不进 TWR 分母)。 决议↔成交匹配把fund_order_placed当一次 BUY(下单那一刻就是"执行了决议"),被取消的单剔除。- 成本含申购费:
units = (amount − fee) / nav,avg_cost按订单金额(不是amount − fee)加权,所以申购费体现为浮亏而不是凭空消失。自动确认fee=0(job 不知道券商实际收了多少);要记费用,用confirm_fund_order --nav N --fee F在 job 之前手动确认。- 在途金额按面值进总资产:
utils.fx.total_portfolio_value(status keypending_fund:<币种>)经PortfolioManager.pending_fund_orders(); 账户业绩回放用LedgerState.pending的同名读口(placed 当日 NAV 不变,confirmed 当日用份额 × 净值替换在途面值,开盘在途同样锚定当前状态)。rebalance_plan把在途金额算在对应基金头上(否则刚下单的基金显示 0% 仓位,清单会建议再买一遍);委员会的portfolio_summary多一行"在途申购"。fund_confirm的窗口:需求是 21:30–23:00 每 30 分钟,单条 cron 表达不了"半点起、整点止",实现取超集0,30 21-23 * * mon-fri(21:00–23:30,6 次/晚)。 job 失败(单笔确认抛异常)抛 RuntimeError →job_runs记failed。job_watchdog原先只报停摆 / 卡死 / 超时 / 零抓取 / 新闻源断流, 并不会据failed行告警(备份 job 的 docstring 曾同样写了"据此告警",并不成立);PR3 补了判据 (f):yml 标alert_on_failure: true的 job(fund_confirm / backup / verdict_review / price_sentinel)最近一次运行是failed时按 (job, 当日) 去重告警一次,默认不标的 job 行为不变。 预计确认日已过仍没取到净值(这不算 job 失败)由 status 的一行人话提示。
D6 入口(agent 必须拥有全部功能)
MCP 工具:record_dividend、record_fee(含 tax 作为 kind 参数)、record_split、place_fund_order、 confirm_fund_order;CLI 同名子命令;全部走 with_portfolio_tx + idempotency_key(#432 同款); status 显示在途基金单与最近 30 天分红合计;SKILL.md 命令表同步。REST 不新增(已弃用)。
D7 不做
- 税务批次(FIFO/LIFO/specific lot)与资本利得报表:Sharesight/Rotki 级功能,中国个人投资者场景需求弱, 且需要批次级成本模型,另立 ADR。
- 港股通结算汇率两价(参考价 / 结算价):先在
fee/note里手记,等有真实港股通成交流水再做。 - 券商自动同步:C6 的 CSV 快照导入与本 ADR 正交。
一句话信号影响翻译
账本能记分红、利息、费用、税、拆股和场外基金的"下单 → 次日按净值确认";你看到的裁决与委员会输入不变, 变的是业绩和持仓数字更对(分红算进收益、拆股不再把持仓算成暴跌、基金申购不再按下单日价格记)。
验证
- 回放不变量:老流水 NAV 序列逐日不变(
tests/test_ledger_events_invariance.py,用脱敏的合成流水 + 对生产只读拷贝的一次性人工比对贴 PR)。 - 每种活动一个"快照效果"测试 + 幂等重放测试 + TWR 分类测试(external 与否)。
- 场外基金:下单 → 次日自动确认 → units = amount/nav 的端到端(mock 净值);净值未公布时留在途。
- 插件实测:虚构 INVEST_HOME 跑 MCP 五个新工具 + status。
实施切分(三个 PR)
feat(ledger): Activity schema +replay_ledger有状态解析入口 + 上述全部消费方切换 + 回放不变量 + 价格口径核实(纯重构,行为不变)。feat(ledger): dividend / interest / fee / tax / split / return_of_capital 写入方法 + MCP/CLI + status + 业绩口径。feat(fund): 场外基金两段式 +fund_confirmjob + 自动确认 + status 在途显示。