Files
group_xinghuo_jinrong/docs/memory/2026-09-10.md
T
GaoYiYuan_0626 a6ac39edd6 基金转换 T-11:工具与 SQL 汇总去重(FR-C15 / R-d)
convert 在 core_trade 落两条流水(转出 redeem + 转入 subscribe,共享 convert_group_id),
所有金额聚合读取方必须只计一次,否则同笔转换金额翻倍。

落地(改码 3 处 + 新增 1 脚本 + 测试 3 文件)
- rules.py:_amount_view → 公开 amount_view(提升而非复制别名,全仓唯一金额聚合口径);
  docstring 补「跨模块共用」说明
- core_tools.py:query_recent_trades 的 sum_amount 改走 amount_view(FR-C15);
  items / total_count 保持全量不变——两条流水是真实的两笔权益变动,读到两条是对的
- core_ro.py:list_holdings 加 h.qty > 0(convert 转出全部份额留下的 qty=0 归零行不是持仓);
  sum_trades_on_date 加 convert 去重条件(R-d),组内只计转出端
- 新增 tests/test_core_tools.py(3 条:汇总不翻倍 / 跨口径一致性 / 持仓不返回 qty=0 行)
- tests/test_core_ro_sum.py 追加 1 条 convert 去重用例(既有断言零改动)
- 新增 scripts/dev/verify_convert_tools.py:真库验证脚本(MySQL 8.0.46)

执行期裁定 2 条(已留痕)
- _amount_view 提升为公开而非复制:一个函数两个名字会漂移(自检第 13 问)
- SQL 条件从 IS NULL 扩为 IS NULL OR = '':Python 的 if not gid 把空串当无组,
  而 SQL 里 '' IS NULL 恒假。真库实证:只写 IS NULL → 合计数 400000(正确 450000,漏算 50000)。
  不加则 RISK-002 漏算,且 sqlite 单测若只造 NULL 数据永远发现不了
- 等价性边界:组内无 redeem 时 SQL 丢整组、amount_view 保留首条;由 R-b 保证不可达

验证
- pytest -q → 718 passed / 3 skipped(基线 714 加 4,零回归)
- 突变验证 3 组均精准命中:去掉去重条件(2 红)/ 去掉 qty>0(1 红)/ 汇总不走 amount_view(2 红)
- 真库 verify_convert_tools.py 14/14,隔离数据零残留
- 复跑受影响真库脚本零回归:T-8 31/31、T-7 35/35、T-10 20/20
2026-09-10 18:40:27 +08:00

39 KiB
Raw Blame History

2026-09-10 工作日志

本日两条线:架构改进线收尾闭环(本文主体)+ 基金转换线第 4 步开发计划待启动。


一、接手与状态核对(会话起始)

读入:docs/交接文档-基金转换.md(v1.0)· docs/memory/MEMORY.md §0 · docs/交接文档-架构改进.md(v1.1) · docs/项目框架设计/架构设计-基金转换交易.md §15/§15.1 · docs/memory/{TODO,ITERATION}.md · AGENTS.md。

基线复核:python -m pytest -q(系统 Python 3.13.14)→ 510 passed(9.93s)。

设计资产核对:tests/_ddl.py:50-54 的 core_holding 仍为 market_value/quantity 且无 PK —— 与 T-0 描述一致,架构文档无误。

⚠️ 核对中发现的两处「文档与实况不符」(以实况为准)

  1. 037ce7e「未 push」是过期表述。 实测 git ls-remote origin:refs/heads/risk-control-agent = fffb78a = 本地 HEAD; git merge-base --is-ancestor 037ce7e fffb78a → 成立。即架构改进线全部提交(含其后 5 个文档提交)早已推送。 本地 git branch -vv 显示 origin/risk-control-agent: gone 只是本地远程跟踪引用失效,非远程分支被删。
  2. 基金转换线 6 个设计文件全部 untracked(未纳入版本库):docs/PRD/PRD-基金转换交易.md、 docs/项目框架设计/架构设计-基金转换交易.md、基金转换-审查意见处置表.md、 评审待办-风控主架构与基金转换.md、docs/交接文档-基金转换.md、scripts/dev/calc_convert_demo.py。 → 用户拍板:先 commit 入库(只 commit,不 push)。

二、架构改进线 §7.2 手工冒烟(七项,全部代跑,2026-09-10)

用户授权「七项全部代跑」。所有临时改动跑完即恢复。

第 1 项 · 无 key 启动告警 ✅ PASS

  • 本机 .env 的 DEEPSEEK_API_KEY 本就为空(实测 raw_value_len = 0),故「清空」态即当前态。
  • uvicorn app.main:app(端口 8123)→ 1 秒就绪,/health → 200 {"status":"ok","env":"development"}。
  • 进程存活 → 告警不阻塞启动 ✓。
  • 日志命中:DEEPSEEK_API_KEY 未配置,对话将走降级回复(前缀 (LLM 未配置:请在 .env 设置 DEEPSEEK_API_KEY 后重启)),LLM 能力不可用(app/main.py:66-72)。

第 2 项 · 恢复 key → 无告警 ✅ PASS(口径替代,已留痕)

  • 替代说明:本机无真实 DeepSeek key,「恢复 key」一支改用非空占位值(sk-smoke-placeholder-not-a-real-key)。 告警分支只判 not settings.deepseek_api_key,不校验 key 有效性,故该替代足以验证条件分支。
  • 改后 key_present: True → 启动(端口 8124)→ /health 200 → 日志未出现该告警 ✓。
  • 顺带验证 trace 中间件:响应头 x-trace-id: trc-48349abc526e4055 / x-request-id: req-612a3f2acb8349d6 ✓。
  • .env 已还原,key_present: False(原状)✓。

第 3 项 · 交易阻断 + 放行各一次 ✅ PASS

场景 请求(debug 头 X-Debug-Role: risk_demo / X-Debug-Actor: STAFF-DEMO) 响应
阻断(SOP A-2) CUST-4001 / PROD-161725 / subscribe / 20000 blocked=true,block_response_code=SUIT_AGE_CONFIRM,needs_branch_confirm=true,rule_refs=["FM-01"]
放行(SOP A-3) CUST-3001 / PROD-510300 / subscribe / 500000 blocked=false,triggered_rules=["RISK-001","RISK-002"],alert_ids=["ALT-20260910-7BCBBC94"]

与 SOP 记录的预期逐字一致。 库内核验:

  • core_trade:阻断单 TRD-20260910-C1D8AA26 未落库 ✓;放行单 TRD-20260910-FE545DF0 已落库(confirmed, 500000.00)✓
  • risk_suitability_log:CUST-4001 is_blocked=1 ✓;risk_alert 出 suitability 单 ALT-20260910-652280E1(pending_review, score 90)✓
  • audit_log:suitability_block / suitability_alert_created、trade_request / suitability_blocked(rule_id=SUIT_AGE_CONFIRM)、 trade_request / trade_accepted(放行)、risk_judgement / alert_created ✓

第 4 项 · 预警聚合行为不变 ✅ PASS

SOP A-4:CUST-9527 / PROD-510300 / subscribe / 1000 连发 4 笔:

笔次 triggered_rules alert_ids
1 [] []
2 [] []
3 ["RISK-003"] ["ALT-20260910-C8600425"]
4 ["RISK-003"] ["ALT-20260910-C8600425"] ← 与第 3 笔同一张单(并入,非新建)

库内核验:

  • risk_alert CUST-9527 当日 pending_review 单数 = 1 ✓;payload.events 长度 = 2 ✓
  • audit_log 呈 alert_created(1184) → alert_appended(1187)` 序列,聚合语义在审计层可证 ✓
  • 4 笔交易全部落库(TRD-20260910-B66CB0D8 / B39E1456 / 83D58A41 / 79EDC16E)✓

注:risk_alert.status 实际取值为 pending_review(文档口头简称「pending」)。首次核库我按 status='pending' 查得 0 条, 系断言写错而非系统问题,修正后为 1 ✓。

第 5 项 · Redis 不可用 → 退回进程内锁 ✅ PASS(口径替代,已留痕)

  • 替代说明:本机 Redis 为 redis-server.exe(PID 7716,Services 会话),无文档化启动方式、redis-server 不在 PATH、 sc.exe 被本机安全策略拉黑,杀掉后无法保证原样拉起(且 FLOW.md 明示其非权威数据源)。 改用 REDIS_URL=redis://127.0.0.1:6399/0(死端口):应用侧抛 ConnectionError,与真宕机同一条代码路径 (redis_gateway._ensure() → 命令异常 → locks._acquire_redis 的 except Exception → unavailable → 进程内锁),且完全可逆。
  • 结果:启动不被阻塞(1s,/health 200)→ 交易返回 blocked=false,alert_ids=["ALT-20260910-7BCBBC94"],业务照常完成 ✓
  • 日志明确出现 2 次 Redis 锁不可用,退回进程内锁: lock:agg:event:CUST-3001:2026-09-10(alert_service 聚合锁)、lock:l3:CUST-3001(profile_l3 L3 锁)✓
  • 底层异常:redis.exceptions.ConnectionError: Error 10061 connecting to 127.0.0.1:6399,被正确吞噬未上抛 ✓

第 6 项 · Redis 恢复 → 行为一致 ✅ PASS

  • redis.Redis.from_url(settings.redis_url).ping() → True(redis://127.0.0.1:6379/0)。
  • 同笔交易 → blocked=false,alert_ids=["ALT-20260910-7BCBBC94"](并入同单)✓
  • 日志中 退回进程内锁 命中 0 行 → 确实走 Redis 锁路径,未降级 ✓
  • triggered_rules 由 ["RISK-001","RISK-002"] 变为 +RISK-003:CUST-3001 当日累计至第 3 笔触发频次规则, 属预期累积行为,非不一致。

第 7 项 · 调换装饰器验证 T-202 守卫变红 ✅ PASS(本项价值最高)

守用例:tests/test_audit_middleware.py::test_audit_middleware_runs_inside_trace_middleware:210。

步骤 结果
① 基线 1 passed(绿)
② 调换 main.py 两装饰器(trace 先注册 → 变最内层;audit 后注册 → 变最外层) 已调换,行序:trace_middleware@89 / audit_middleware_entry@117
③ 跑守卫 1 failed ✓ 变红
④ 失败断言原文 AssertionError: audit 若先于 trace 执行,此处会静默为空 / assert '' @ test_audit_middleware.py:221
⑤ 还原 sha256 前后同为 d9d7559445dffd2a4576cdc5454030d3acae5fd4c076de8daa5a35f53802510a → 字节级一致 ✓
⑥ 再跑守卫 1 passed(转绿)✓
⑦ 现场核对 git status --short app/main.py 空、无残留 *.smoke.bak ✓

→ 守卫确实能抓住「audit 先于 trace 执行 → 全站审计静默丢 trace_id 且不报错」这一回归。设计意图达成。


三、冒烟副产物与现场恢复

3.1 集成测试失败(防护行为,非回归)

冒烟后在真 MySQL 上跑全量:507 passed / 3 failed,全部落在 tests/test_integration_risk.py:

  • _assert_a3_clean → CUST-3001 今日已有 confirmed 交易(影响 RISK-002 累计/RISK-003 频次口径)(我的 TRD-20260910-FE545DF0)
  • _assert_a4_clean → CUST-9527 今日已有 confirmed 交易(A-4 频次计数失真)(我的 TRD-20260910-B66CB0D8)
  • test_a7_handle_state_machine_compliance_forbidden → 级联 KeyError: 'a3_alert_id'

→ 这正是核查单⑥「演示/测试同日交叉防护」前置断言在按设计工作(交接文档已预警)。

3.2 演示库重灌(用户拍板:现在重灌)

按 docs/项目框架设计/演示SOP-风控模块.md §2 脚本化方式执行(MYSQL_PWD 传密码,避开 reset.ps1 交互 -p 卡死):

  1. DROP DATABASE IF EXISTS jinrong_core; DROP DATABASE IF EXISTS jinrong_agent;
  2. 依序灌 11 个 SQL:scripts/core/00~06 → 表设计/01-mysql-共用底座.sql → 表设计/02-mysql-agent专用.sql → scripts/agent/seed-aml-list.sql → scripts/demo/prepare_risk_demo.sql
  3. python scripts/sync/sync_advisor_rel.py

完成标志三项全部吻合:AML 名单 8 条 · prepare_risk_demo 7 行测评(expires_at 剩余 275 天)· sync_advisor_rel upserted 33 rows。

3.3 全量复跑(用户要求「改完后再跑一次」)

510 passed, 656 warnings in 9.52s → 复绿 ✓

现场状态:.env 已还原(key 为空、REDIS_URL 为 6379)✓ · app/main.py 字节级还原且 git 无改动 ✓ · 无残留 .smoke.bak ✓。


四、回写清单

文档 改动
docs/项目框架设计/TODO-架构改进.md T-107 手工冒烟勾选 + 完成判定块三勾 + 状态块改「全部闭环」+ push 状态纠正
docs/交接文档-架构改进.md v1.1 → v1.2;状态块重写(push 纠正 + 两处替代口径留痕);§7.3 全勾;§10 重写为结项
docs/交接文档-基金转换.md §9「另一条线的遗留」改为已闭环,纠正 push 表述
docs/memory/MEMORY.md §0 末尾「待办两条线」改为「架构改进线已结项」+ push 纠正常见坑
docs/memory/TODO.md 模块侧开放项「架构改进线 · 收尾」勾选并附七项证据摘要
docs/memory/ITERATION.md 新增 2026-09-10 架构改进线收尾闭环 迭代行
docs/memory/2026-09-10.md 本文件(新建)

五、下一步

架构改进线:结项,无待办。(可选:push 后打里程碑 tag,待用户拍板)

基金转换线:第 4 步「产出开发计划」 —— 输入已齐备: docs/项目框架设计/架构设计-基金转换交易.md §15(任务映射 T-0~T-13)+ §15.1(依赖拓扑与并行分组)+ §12(风险表); 产出落点 docs/项目框架设计/开发计划-基金转换交易.md(待创建)。


六、交接文档合并为单一入口 + 全仓指向统一(2026-09-10 晚)

6.1 用户指令与判定

  • 指令:「由版本库的交接文档为主,这两个交接文档你合并成一个新的放到这次更新」+ 指向项目根 交接文档.md。
  • 追加纠正:「统一一下,不是早都说过交接文档是给你 AI 接手用的吗」 —— 交接文档的定位 = AI 接手入口,所以全仓指向必须唯一,且绝不能指向过期文件,否则新会话会被误导。 (docs/ 下两份留档停在 v1.0 / v1.1,不含 T-2/T-2b、609 passed、D20 实施等进度 —— 是个真实陷阱。)

6.2 落地

项目根 交接文档.md → v3.0(545 行),三线合并为单一入口: §0 公共层(项目是什么 / 铁律与禁止事项 / 开发运行手册 / 已知坑 10 条 / 发现问题怎么办)· §A 风控模块主线(原主干)· §B 基金转换线(含工作区最新版 v1.4)· §C 架构改进线(含工作区最新版 v1.2)。 最大价值:这两份工作区最新版从未提交,随时可能丢,现已完整固化。

全仓 10 处指向统一 → 交接文档.md §A/§B/§C: AGENTS.md(含顶部新增「⚡ 需要接手/开工先读交接文档.md」)· docs/memory/{MEMORY,TODO,FRAMEWORK} · docs/PRD/PRD-架构改进与稳定性加固.md · docs/项目框架设计/{开发计划-架构改进,TODO-架构改进,开发计划-基金转换交易}.md。

两份 docs/交接文档-*.md 保留为历史留档,顶部加醒目横幅:「本文件已废弃(2026-09-10)—— 请勿据此开工, 正确入口 交接文档.md §B/§C,内容已过期,勿读」 —— 不删除(保留仓库历史),但明确失效。

未入库:交接文档.md 在 .gitignore:47 内,用户确认「不进提交」。

6.3 ⚠️ 事故:git rm 静默抹除整个 docs/(复现 2 次,已零损失恢复)

  • 现象:git rm(带不带 -f 均如此)删除 docs/ 下两个中文名文件 → docs/ 全部 54 个文件从工作区消失, 命令返回退出码 0 不报错。
  • 定位:① ls/find/Read 三方确认真实删除;② git checkout HEAD -- docs/ 完整还原 54 文件(索引与 HEAD blob 完好); ③ 无自定义 hooks(core.hooksPath 空);④ 同一中文路径 ls 完全正常 → 触发条件就是 git rm 本身; ⑤ 换 纯 rm -f <精确路径> → 正常。
  • 损失:零内容丢失(HEAD blob 完好)。代价:本轮引用修改被连带还原 2 次(重做 3 次)。
  • 纪律(已写入 交接文档.md §0.4 第 10 条 + .workbuddy/memory/MEMORY.md):
    • ❌ 本仓库禁止对 docs/ 下文件用 git rm;
    • ✅ 删文件用纯 rm -f <精确路径> + git add -A 记录删除;
    • ✅ 批量删除后立刻 find <dir> -type f | wc -l 复核,异常立即 git checkout HEAD -- <dir>。
    • 根因未查明,规避即可。

七、设计方法论固化为 skill + 记忆精简(2026-09-10 晚)

动机:项目记忆 MEMORY.md 与交接文档膨胀——13 问 / AIcoding 六步 / 独立审查协议 / 写计划 4 法 等项目无关的方法论每个项目都重写一遍,且挤占了项目状态信息。

落地:

  1. 新建用户级 skill design-doc-selfcheck(~/.workbuddy/skills/design-doc-selfcheck/SKILL.md)—— 固化:AIcoding 六步流程 · 设计自检 13 问(含 why/翻车案例)· PRD 独立 AI 审查协议(自包含包 + 只报告不改动 + 逐条判定)· 写开发计划 4 法 · 真实翻车案例库(4 处外部事实漏网 / 分类混用 / 公式副本 / 重置伴随数据 / 方言互斥)。
  2. 精简项目 MEMORY.md(205 行 → ~70 行):删除已入 skill 的方法论 verbatim,只留三条线状态、git rm 禁忌、交接文档定位、基金转换核心事实与业务口径、交易发起主体合规、D20 账号,并指向 skill。
  3. 交接文档 §B 顶部加 skill 指针:本线设计方法论已固化,避免重复维护。

效果:方法论跨项目可复用(新项目直接加载 skill);本项目记忆回到「状态 + 红线 + 口径」本职。


深夜 · 基金转换线 T-8(规则引擎改造)完成

范围:开发计划 §7.1 —— rules._amount_view + engine.process_convert_event + alert_service.events(并行组 B)。

改码三处(均在代码注释留痕)

  • app/service/risk/rules.py:+_amount_view(trades)(同 convert_group_id 组内只留 redeem;无 gid 恒等通过;组内无 redeem 保首条);run_rules 双视图分流 —— eligible(全量)供 RISK-001/003/004,_amount_view(eligible) 供 RISK-002/005。
  • app/service/risk/alert_service.py:+公开 build_trade_event(trade, hits)(结构定义唯一副本);record_trade_alerts(..., events=None) 缺省退化为单条,既有调用零改动;新建单落 payload.events 全部,聚合追加只追首条(与架构 §5.4「只认转出端」同口径)。
  • app/service/risk/engine.py:抽 _run() 共用实现;process_trade_event 变薄封装(签名与行为不变);新增 process_convert_event(out_trade, in_trade, *, core_ro, risk_repo, thresholds, on_error_hook=None) + _notify_error_hook(hook 自身异常吞掉、原始异常照常上抛)。

⭐ 实质影响:阶段 1.5 从「静默跳过」变「真跑」

T-7 落地时 process_convert_event 不存在 → _run_engine 走 ImportError 分支跳过; T-8 落地后同一笔转换会真实出单 + 写 customer_profile_l3 + 落审计。 → verify_convert_service.py 的执行语义随之变化,复跑仍 35/35 绿(无连带破坏)。

验证

  • 新增 tests/test_convert_engine.py 15 用例;test_convert_service.py +1 条接线回归(大额转换真出单,防 _run_engine 退回跳过)。
  • pytest -q → 672 passed / 3 skipped(基线 656 +16,零回归)。
  • 突变验证(防假绿):临时把 amount_view = eligible → 4 条变红,其中 detail 直接暴露 当日申赎累计 599000 元(共 2 笔) 的翻倍错误;恢复后复绿。
  • 新增 scripts/dev/verify_convert_engine.py 真库 31/31(A 引擎真跑出单 / B RISK-002 不翻倍夹逼:单条 512000 < 800000 < 两条之和 1019975.33 / C 不删行 + DECIMAL 零漂移 / D 幂等重试不重复出单 / E 无命中只落 pass 审计 / F 六表零残留)。

真库脚本两条踩坑(与 T-7 同款 + 新一条,供后续 verify_*.py 参考)

  1. id_factory 必须注入:默认生成 CNV-<日期>-<uuid>,与清理口径 LIKE 'CNV-T8M%' 不匹配 → 重跑遗留行撞 uk_group。注入 _t8m_id 并把清理条件补 client_request_id LIKE 'T8M-%' 兜底。
  2. core_trade.trade_type 在 MySQL 是 ENUM,ORDER BY 按定义序(实测 [subscribe, redeem])→ 断言改用 {trade_type: row} 字典定位,不依赖排序。

发现的既有行为(不在本任务范围,未顺手改)

更正(同日):初次记录误写成「RISK-001 实为当日累计口径」,错误。RISK-001 与 RISK-002 是两条独立规则——前者单笔(rules.py:128-133)、后者当日累计(:137-145)。T-8 只切后者。

真实根因在引擎入参范围:process_trade_event 拉当日全量流水重跑规则 (engine.py 的 list_trades_range(customer, day_start, day_end))→ 触发这笔与命中那笔 可以不是同一笔:真库 E 组实测,同客户同日先有一笔大额转换时,后续一笔 1000 元小额交易也带出 RISK-001。 既有聚合逻辑(同客户同日一张 pending 单)兜住了重复出单,属既有设计,T-8 不改变也不修。

文档回写

开发计划(§0 速览标 ✅ + 测试基线改 672 · §7.1 DoD 全勾 + 新增执行记录 + 3 条实施级收敛 + 2 条脚本坑)· 根 交接文档.md §B → v1.9(§0 导航 · §B 头部 · §B.1 状态与代码改动 · 新增 §B.6.1 T-8 小节 · §B.6 任务树与基线)· docs/memory/{MEMORY,TODO} · 本条。

下一步 = T-9(api/simulate.py 模型与错误码 + trade_gateway convert 分派 · 关键路径)/ T-11(core_tools 与 sum_trades_on_date 汇总去重 · 依赖 T-8 已解锁)。


深夜 · 基金转换线 T-9(API 模型 + 网关分派 · 关键路径)完成

范围:开发计划 §7.2 —— api/simulate.py 三型模型 + trade_gateway convert 分派 + 错误码映射。至此 HTTP 层 convert 端到端走通。

改码 5 处

  • app/utils/trace.py:_HEADER_ID_PATTERN → 公开 HEADER_ID_PATTERN(单点定义)。 执行期发现 trace.py:16 与 main.py:44 各有一份内容完全相同的正则 —— S4 要防的「白名单漂移」其实已经发生。
  • app/main.py:删掉那份重复副本 + import re,改 import 上面的常量。
  • app/utils/response.py:_api_error_handler 合入 exc.extra(getattr 取;既有 ApiError 无此属性 → 错误体逐字节不变)。
  • app/gateway/trade_gateway.py:移除 convert 显式拒绝;新增 _submit_convert(只做参数映射 + 仓储装配,模块级符号作 monkeypatch 注入点)。
  • app/api/simulate.py:TradeRequest 三型字段分池 + @model_validator 分支校验(client_request_id 复用同一白名单); model_dump(exclude_none=True);except LookupError 收窄为 except NotFoundError;PROCESSING → 202。

⚠️ 3 条与计划原文的出入(已写入开发计划 §7.2 执行记录)

  1. 正则落点:计划写「复用 main.py:44」,但 app.main → app.api.simulate 单向导入链,反向 import 成环 → 上移 trace.py。连带突破 §12 R14「本次不动 main.py」(中间件注册顺序未动,守卫用例仍绿)。
  2. gateway_repository.insert_trade 加列:裁定不需要 —— convert 写路径在 convert_core_repository.apply_convert,该文件零改动。
  3. R7 归 T-10;R16 零改动通过。

另一条实施级发现(已上报,未顺手改)

幂等重放响应的数值位数与首次不一致:首次走 calc(2 位)vs 重放 _rebuild_quote 直读 DECIMAL(18,4)(4 位) → "53456.95" vs "53456.9500"(数值相等)。违反 PRD「对外一律 2 位」展示契约,属 T-7 范畴。 集成测试已改为比数值不比字符串并在 docstring 钉住偏差。

验证

  • 新增 tests/test_convert_integration.py(7 条真 MySQL 集成):端到端与 PRD §5.3 逐项吻合(调生产纯函数算期望,禁手算) · 两条流水同组同前缀 · 持仓与批次如实变动(转出归零保留行 / 转入新建)· 明细 completed + 审计 · 幂等重试不产生第二组 · 跨主体 400 · 未知类型 400。
  • test_trade_gateway.py +17(11 条错误码映射全表参数化含 extra 展开 · 202 · 200 透传 · 不写 trade_request 审计 · 3 条 422 分支)。
  • test_integration_risk.py R15 处置:端到端已迁入新文件;原槽位未删除,改造为 test_invalid_type_400_and_no_new_trade_audit(改用 purchase 触发),保住「校验失败不落审计」不变量。
  • pytest -q → 696 passed / 3 skipped(基线 672 +24,零回归)。
  • 突变验证 3 组:① 关掉 convert 分派 → 21 条红;② 关掉错误体 extra 展开 → 精准 1 条; ③ 关掉 client_request_id 正则 → 精准 1 条。三处已恢复,grep MUTATION-TEST app/ 为空。
  • 真库验证载体 = 集成测试本身(非脚本):T-9 不新增 SQL、不涉方言语义,故无需另写 verify_convert_*.py; 该文件经 ensure_risk_demo_ready() 在无 MySQL 环境整模块 skip,不炸 CI。

🔴 本日第二次误报「远程分支不见了」(教训)

提交 T-8 时依据 git branch -vv / git branch -r 判定远程 risk-control-agent gone、仅剩 main/dev, 据此向用户报警。结论错误 —— git ls-remote --heads origin 实测远程 risk-control-agent = fffb78a,健在; git branch -r 只剩两个分支纯属本地远程跟踪引用 stale。 本日第 368-371 行已记录同款教训(037ce7e 误报),当日复发第二次 → 已升格为 .workbuddy/memory/MEMORY.md 铁律 + docs/memory/MEMORY.md 交接清单第 7 问。

文档回写

开发计划(§0 速览标 ✅ + 基线 696 · §7.2 DoD 全勾 + 执行记录 + 4 条裁定 + 1 条发现 + 3 组突变 · §12 R15 补实际处置 并订正事件名 trade_accepted→convert_accepted)· 根 交接文档.md §B → v2.0(§0 导航 · §B 头部 · §B.1 状态与代码改动 · 新增 §B.6.2 T-9 小节 · §B.6 任务树与基线)· docs/memory/{MEMORY,TODO} · .workbuddy/memory/{MEMORY,2026-09-10} · 本条。

下一步 = T-10(普通申赎批次维护 · 改 trade_gateway 主流程 · 回归风险最大,先跑基线再动)/ T-11(core_tools 与 sum_trades_on_date 汇总去重 · 依赖 T-8 已解锁)。


深夜 · T-9 收尾:展示位数口径修复(用户「你先改问题,按照贴近现实业务改」)

问题:幂等重放响应的数值位数与首次不一致(首次 "53456.95" vs 重放 "53456.9500",数值相等、字符串不等)。

根因不是 T-7 写错,是契约缺位

§2.5 只规定了「金额/份额 2 位」,净值、费率、申请份额的回显位数根本没定义 → 实现只能把 Decimal 原样 str() 出网 → 位数随数据来源漂移:

  • 首次路径走 calc 纯函数(已 2 位量化)
  • 重放路径由 _rebuild_quote 从 core_trade/core_convert_lot_detail(DECIMAL(18,4))重建后直读

联网核验(7 家管理人公告,2026-09-10)

口径 依据
金额 2 位四舍五入 「转出金额以四舍五入的方式保留至小数点后两位」(中银/人保/浦银安盛/中欧/南方/申万菱信一致)
份额 2 位四舍五入 「转入份额以四舍五入的方式保留至小数点后两位」;「申请转换份额精确到小数点后两位」(中银)
净值 4 位 「份额净值保留 4 位、第 5 位四舍五入」(中欧/国泰公告:由 3 位提高至 4 位);巨额赎回极端可 8 位
费率 4 位 公告以百分比 2 位表示(0.30% ↔ 0.0030)
⚠️ 已知不统一 易方达(ETF 场外)份额取整数位、南方基金取截断(非四舍五入)→ 本期取主流口径 + 记入 PRD §10 已知差异

修复(贴近现实业务)

convert_service 新增 _q(value, unit) + _D2/_D4 规格常量作对外唯一出口:

  • 金额 / 份额 → 2 位(_D2)
  • 净值 / 费率 / 份额尾差 → 4 位(_D4)
  • 响应、审计 summary、异常日志共用同一出口;原 _s() 已全部替换(grep _s( 为空)
  • 首次路径幂等:除 requested_qty/actual_qty/lot[].qty 由 4 位补齐至 2 位外逐字节不变

文档订正(用户「文档不准,你去联网查」)

  • PRD → v0.9.2:§2.5 拆 2.5.1 计算精度 / 2.5.2 展示位数(新增分类规格表 + 外部依据 + 已知差异); §5.3 示例 requested_qty/actual_qty/lot_breakdown[].qty 4 位 → 2 位(原示例与 §2.5「对外展示按 2 位」自相矛盾,属漏改); §5.3 字段类型约定补「位数不自由 + 两条产出路径必须逐字节一致」;新增 v0.9.1→v0.9.2 变更表与根因
  • 架构 → v1.0.1:§1 原则 11 补「str() 前必须按 §2.5.2 量化」
  • 开发计划 §7.2「发现」条改为「已修复」并补完整证据链;交接文档 §B.6.2 同步

验证

  • 集成测试改用逐字段逐字节比对(REPLAY_IDENTICAL_FIELDS + lot_breakdown 整体相等)
    • 新增位数规格断言(test_convert_response_field_scales,逐字段验 split(".")[1] 位数,并卡住 requested_qty == "50000.00")
  • pytest -q → 697 passed / 3 skipped(零回归)
  • 真库全复跑:T-6 verify_convert_apply 24/24 · T-7 verify_convert_service 35/35 · T-8 verify_convert_engine 31/31
  • calc_convert_demo.py 15/15 与 PRD §5.3 一致
  • 突变验证:把 _q() 的量化去掉 → 2 条红,assert '50000.0000' == '50000' 直接复现修复前现象;已恢复,grep MUTATION-TEST 为空

教训已固化

skill design-doc-selfcheck → v1.2.0:新增 「二补 · 格式契约最低三问」 + 铁律 6(格式契约铁律): 凡 str(Decimal) 直接出网的字段,先问「这个字段的展示位数写在哪」—— 没答案就是契约缺位; 存储精度 ≠ 展示精度;多条产出路径必须共用一个格式出口 + 写逐字节相等断言。


深夜 · 收尾:refs/remotes 写入不落盘根因排查(推翻此前「stale ref」结论)

背景:提交 T-8/T-9 时两次依据 git branch -r / git branch -vv 判定「远程分支不见了」并报警, 两次均误报(用户被迫去仓库核实两次)。用户指令:「你先改问题……改完确认没问题再提交」→ 先做根因排查。

已确认事实(可复现)

# 事实 证据
1 远程健在 git ls-remote --heads origin → refs/heads/risk-control-agent = fffb78a;另有 6 个分支
2 fetch 抓取正常,引用不落盘 git fetch origin 报 * [new branch] risk-control-agent -> origin/risk-control-agent,FETCH_HEAD 写对;.git/refs/remotes/ 仍为空
3 写 remotes 会删整个 origin 目录 git update-ref refs/remotes/origin/X <sha> → EXIT=0、reflog 建了,但松散引用消失,连手工 echo > 进去的文件一起被删
4 只 remotes namespace 有问题 写 refs/heads/tmpX 时,放在 refs/remotes/origin/ 的 marker 存活;写 remotes 才触发删除
5 只此仓库特有 同一 git(2.55.0.windows.3)在 /tmp 新建仓库写 refs/remotes/origin/probeX 完全正常
6 排除外部进程 marker 静置 6 秒 + 跑只读 git 命令均存活,只有写 remotes 的那一下才被删
7 排除目录属性 Python lstat 查 refs/remotes:非重解析点(FILE_ATTRIBUTE_REPARSE_POINT = False);无 hooks、无 commondir、非浅克隆

根因未定位(倾向 git 写 remotes 引用时被本地环境干预)。此前的记录写「stale ref,git fetch 即恢复」是错的 —— fetch 并不修复。

✅ 绕过方案(已验证)

.git/packed-refs 是可靠存储(origin/dev、origin/main 一直从这里读到)。 手工追加 fffb78a11c7d4e05f2aae53c76ceec3ad5d7d33c refs/remotes/origin/risk-control-agent 后: git branch -r 立即显示 origin/risk-control-agent、git rev-parse 可解析。还原 = 删掉该行。

影响面(重要)

  • 不受影响:提交(refs/heads/** 正常)、git fetch 抓对象、push (git push --dry-run origin risk-control-agent:refs/heads/risk-control-agent 实测 fffb78a..d0097d6 成功)。
  • 受影响:仅本地「显示 / 上游跟踪」——git branch -r、git branch -vv、git pull(无参) 的默认上游推断。

状态

本地 HEAD d0097d6(T-9)· 领先远程 fffb78a 9 个提交 · 工作区干净 · 探针与临时文件已全部清理。

文档回写

.workbuddy/memory/MEMORY.md(远程铁律条重写,含 7 条证据 + 绕过方案)· docs/memory/MEMORY.md(交接清单第 7 问重写 + 顺手订正验证条目里过期的「516 passed」→ 697 + 第 73 行旧表述订正)· 本条。


T-10(普通申赎批次维护 FR-C16)完成(2026-09-10 · 用户「进入 T10 阶段」)

开工前基线快照(计划硬要求):pytest -q → 697 passed / 3 skipped (开发计划 §8 写的「当前 516」是撰写期快照,已过期 → 已在文档中订正)。

改码 4 处:gateway_repository.py(+insert_share_lots/deduct_share_lots,各单事务、走 rw, 不含分配逻辑)· share_lot_repository.py(__init__ 增可选 core_ro → 复用同一份 FIFO 口径)· trade_gateway.py(+_nav_as_of/_maintain_lots,在落流水后、引擎前调用,整段 try 包住)· 新增 scripts/core/rebuild_lots.py(快照重建批次 L-7,与网关 D8 同调 bootstrap_lots,role="admin")。

两条执行期裁定(计划未点明,已留痕)

  1. redeem 份额折算 = amount ÷ 净值(2 位 HALF_UP) —— 计划只写「FIFO 扣减」, 未说扣减的份额从哪来(赎回请求体给的是金额)。取不到净值 → 降级跳过。

    ⚠️ 2026-09-10 18:10 订正(联网查证 4 家管理人业务规则后):本条初稿写「按未知价法」, 定性错误。真实行业铁律 = 「金额申购、份额赎回」—— 投资者以份额申报, 登记机构按 T 日净值算金额(睿远 §65 / 华泰保兴 §69 / 东方基金 §57 / 国投瑞银(货基)§33, 无一家公募支持「按金额赎回」)。本项目是简化建模(TradeRequest.amount 为申赎共用 入参,app/api/simulate.py:60),方向与行业相反 → 已记入 PRD §10.1 已知差异 ①②。 另发现:T 日净值当日不可得(T+1 公告),_nav_as_of 实取 T−1 净值 → 折算份额只是估算值,与最终确认份额有偏差。折算逻辑保留,定性已同步订正 (trade_gateway.py 注释 / 开发计划 §8 / 交接文档 §B / PRD §10.1)。

  2. rebuild_lots.py 默认只补建无批次行(幂等、不误删转换转入的真实批次),--force 才先删后建。

R-c 两条落地:R-c(1) 降级(整段 try,源数据缺失只 warning,绝不阻断交易)→ 既有 env(无净值无持仓)自然走降级,§12 R16 test_redeem_accepted_without_alert 零改动通过; R-c(2) 覆盖率补偿(真实路径自建完整种子,不得让降级充当覆盖)→ test_share_lot.py 新增 14 条。

验证:pytest -q → 714 passed / 3 skipped(+17,零回归); 新增 scripts/dev/verify_convert_lots.py 真库 20/20(隔离 CUST-LOTT/PROD-LOTT*,跑完零残留); T-6 24/24 · T-7 35/35 复跑零回归;突变验证 2 组(切断接线 → 精准 2 条红;关 D8 兜底 → 精准 2 条红含 D18), 均已恢复、无残留。

★ 真库脚本的核心价值:扣减 SQL 的 (:q + 0.0) 是为绕开 sqlite 的 TEXT 绑定坑而加(§B.8 第 11 条); 它在 MySQL 上是否同样生效,sqlite 侧无从证明 —— 若 MySQL 未隐式转数值,扣减会静默失效 (批次永不减少 → convert 超扣)。这正是用户 2026-09-10 定的「涉及方言语义的任务必须上真库」的意义。

D18 双保险:除「同一持仓两侧算出的批次逐字段相等」外,另加机制断言 (rebuild.bootstrap_lots is bootstrap_lots)—— 防「两侧各写一份但碰巧同值」的假绿。

未顺手改(已上报):core_holding 不随普通申赎更新(T-10 只维护 core_share_lot,计划范围即如此)。

文档回写:开发计划 §8(DoD 全勾 + 执行记录 + 2 条裁定 + 基线订正)· 交接文档.md(§0 导航 / §B v2.1 / B.1 状态 / B.6 拓扑与基线 / 新增 B.6.3)· docs/memory/{TODO,MEMORY} · .workbuddy/memory/2026-09-10.md · 本条。

下一步 = T-11(core_tools.query_recent_trades 汇总走 _amount_view FR-C15 + 持仓过滤 qty <= 0 + core_ro.sum_trades_on_date 加 convert 去重 R-d;DoD 见开发计划 §7.3)。


✅ T-10 已提交(18:30)

提交:5e6fa06「基金转换 T-10:普通申赎批次维护(FR-C16)」—— 12 文件 / +1383 −22。 工作区干净;本地 HEAD 5e6fa06,领先远程 fffb78a 11 个提交,未推送(推送与否由用户拍板)。

本次提交含三部分:① T-10 主改动(3 处改码 + 2 个新脚本 + 2 个测试文件); ② 口径订正(trade_gateway 注释 / PRD §10.1 新增已知差异清单 / 开发计划 §8 / 本条日志); ③ 文档状态回写(交接文档 §B、docs/memory/{TODO,MEMORY})。

订正后的口径(后续任务按此):redeem 的 amount ÷ 净值 是本项目简化(申赎共用 amount 入参), 不是行业标准;行业铁律是「金额申购、份额赎回」。派生风险:未知价法下 T 日净值当日不可得 (T+1 公告),实取 T−1 → 折算份额只是估算值。详见 PRD §10.1 已知差异 ①②。

顺带订正:交接文档 §B 两处状态漏改(第 208 行「下一步 = T-10」、第 210 行右列「下一步 T-10 / T-11」) 已改为 T-10 ✅ / 下一步 T-11。

下一步 = T-11(core_tools 汇总去重 + 持仓过滤 qty <= 0 + sum_trades_on_date 加 convert 去重 R-d)。


✅ T-11 已完成(2026-09-10 · 尚未提交)

任务:工具与 SQL 汇总去重(FR-C15 / R-d)—— 让 convert 落两条流水后,所有金额聚合读取方都只计一次。

改码 3 处 + 新增 1 脚本 + 测试 3 文件

  1. app/service/risk/rules.py —— _amount_view 提升为公开 amount_view(跨层复用;全仓唯一金额聚合口径); run_rules 内局部变量改名 amt_view;docstring 补 SQL 等价条件。
  2. app/tool/core_tools.py —— query_recent_trades 的 sum_amount 走 amount_view (明细与 total_count 保持全量:一次转换两条流水是真实的两笔权益变动); query_holdings docstring 说明归零行已在 SQL 层过滤。
  3. app/repository/core_ro.py —— list_holdings 加 AND h.qty > 0; sum_trades_on_date 加 convert 去重条件(R-d)。
  4. scripts/dev/verify_convert_tools.py【新增】—— 真库 14/14。
  5. tests/test_core_tools.py【新增】3 条;tests/test_core_ro_sum.py fixture 增 gid + 追加 1 条; tests/test_convert_engine.py 随改名同步(断言零改动)。

执行期裁定 2 条(计划未点明,已留痕)

  1. _amount_view 提升为公开,而非在 core_tools 复制一份 —— 计划只说「汇总走 _amount_view」, 但它是私有名。按自检第 13 问提升为公开保单一实现;否决「公开别名」方案(一函数两名 = 新混淆源)。 依赖方向已核实:app/tool/kb_tools.py:22 早有 tool→service 先例,无循环导入。
  2. ⭐ SQL 去重条件从计划的 IS NULL 扩为 IS NULL OR convert_group_id = ''。 依据:Python 侧 if not gid 把空串也当无组,而 SQL 三值逻辑下 '' IS NULL 恒为假。 真库实证(脚本 [C] 对照组):只写 IS NULL → 400000,正确口径 → 450000, 差额恰为空串那笔 50000。不加则该笔从当日累计里消失 → RISK-002 漏算, 且只在「普通交易被写成空串」时才暴露(sqlite 单测若只造 NULL 数据永远发现不了)。

验证

  • pytest -q → 718 passed / 3 skipped(基线 714 +4,零回归)
  • sum_trades_on_date 既有 4 条用例(5 条断言)零改动通过;test_convert_engine.py 15 条断言零改动
  • 突变验证 3 组:① 去掉 gid 条件 → 精准 2 红;② 去掉 qty > 0 → 精准 1 红; ③ 不走 amount_view → 精准 2 红。均已恢复,grep "1 = 1\|MUTATION" 无残留
  • 真库:verify_convert_tools.py 14/14、跑完零残留
  • 回归复跑(铁律 2:接线变化必跑):T-8 31/31(amount_view 改名影响面)· T-7 35/35 · T-10 20/20 均零回归

未顺手改(已上报):concentration_profile 走独立 SQL、未过滤 qty = 0 (归零行市值为 0,对 R4+R5 占比无实际影响,但与「与 list_holdings 同源同口径」的注释有措辞落差)。

文档回写:开发计划 §7.3(DoD 全勾 + 执行记录 + 2 条裁定 + 未顺手改)· 交接文档.md v2.2 (§0 导航 / §B 状态 / B.1 / B.6 拓扑与基线 / 新增 B.6.4)· docs/memory/{TODO,MEMORY} · 本条。

下一步 = T-12(补偿脚本)→ T-13(全量回归 + 50 并发压测 + PRD §5.3 数字回填)。