lzf_0626
|
ff681df143
|
落地第十五条豁免额度校验;登记三处业务裁定
一、第十五条豁免规则(docs/25 第七节 #2,业务裁定:实现)
政策原文:C3→R4 签署风险揭示书后**可买**,但单只 R4 持仓不超过总资产 20%;
C4→R5 同理,上限 10%。越级购买本身不是违规,超出额度才是 —— 原先扫描侧只看
"留痕是否齐全",于是"签了字但买超额度"这种明确违规没有预警;研判侧也把豁免的
前提条件(留痕齐全)当成了结论,直接判"疑似误报"。
- 扫描侧:新增 EXEMPTION_LIMITS 与 RiskRuleEngine._exemption_state,核算
"单只持仓 / 总资产"并写进证据快照;触发条件改为
gap > 0 and (missing_trace or 超出额度)。
- 研判侧:_assess_rw007 先判额度再判留痕。超限 → 证据支持风险;留痕齐全且在额度
内 → 疑似误报;留痕齐全但快照缺总资产/持仓 → 继续复核。
数据前提(tools/probe_exemption_data.py,证据见 docs/evidence/exemption-data-probe.json):
库内 fin_customer_profile 仅 1 行且 total_asset = 0.00、fin_holding 0 行、无任何申购
交易 —— 这条规则当前不会被触发,与 behavior_score 同源(画像与持仓由本项目之外的
流程写入)。因此刻意不把"算不出来"当成"超限":拿 0 去算会让每一笔 C3→R4 都变成违规,
豁免规则反倒成了误报源。上游把数据写入后无需再改代码即可生效。
二、三处业务裁定(此前挂在"待裁定")
- 模型网关 chat + tools 入口:本轮不补,按基座能力缺口记录。它要贯穿
ModelGateway → … → BaseAgent 整条链路,属公共契约变更,演示联调期影响面大于收益。
- exclude(关闭误报)是否必须先"调查中":保持现状,不加门禁。
- 政策冲突:以第十四条 C ≥ R 为准;客服侧 check_suitability 复核后确认本来就按
C ≥ R 实现,无需改动。
三、其他
- 新增 tests/unit/service/test_risk_judgement_rw007.py(6 例)与扫描侧 4 例。
- 风控文档 03/05 同步 RW-007 的豁免额度条件与研判口径。
|
2026-09-11 14:24:45 +08:00 |
|
lzf_0626
|
c1f043684c
|
fix(risk): RW-012 研判补上"≥3 倍历史均值"复核,顺带修一处除零
docs/25 P2。核实后发现**根因不在研判侧偷懒,而在快照缺数据**:
- 扫描侧生成 RW-012 要求 verage > 0 且 amount >= average * 3(risk_scan_service.py:224);
- 研判侧 _assess_rw012 只看了年龄、金额、非常用设备,**完全没核均值** —— 于是达不到
3 倍的交易也会被判"证据支持风险"。它自己的建议文本写着"继续核实一年期历史交易均值":
作者知道该看,只是当时确实没有数据可看。
- 原因:扫描侧写进快照的只有 product_id / age / device_id,**没有均值**
(对比 RW-003 在 :124 就写了 ratio)。
**两侧一起改**:
1. 扫描侧补写 verage_amount 与
atio(用 ratio 与 RW-003 的快照风格保持一致)。
2. 研判侧据此复核:
atio 缺失 → CONTINUE_REVIEW("无法复核 3 倍门槛",
不再顺着"非常用设备"定案);
atio < 3 → SUSPECTED_FALSE_POSITIVE;否则走原有逻辑。
3. **顺带修一处除零**:verage == 0 时 mount < 0*3 恒为 False,会一路走到
mount / average 直接崩。改为同时挡住 verage_amount <= 0。
新增 tests/unit/service/test_risk_judgement_rw012.py(3 条):ratio 缺失时不落到风险成立、
ratio < 3 判误报并给出倍数、ratio >= 3 继续往下走。三个用例都停在登录记录判断之前,
不必构造复杂的登录证据。
ruff / mypy(136 文件) / 632 unit+contract / 29 integration 全绿。
|
2026-09-11 13:48:08 +08:00 |
|
lzf_0626
|
24de6c34f7
|
fix(risk): 扫描健壮性——脏数据不再中断整批,调度器重启后仍会执行
docs/25 P0 的后两项,都属于"风控看起来在工作、其实没在跑"那一类。
**② 一条脏数据中断整批**
_level_value 原先直接 int(level.replace(prefix, "")):等级字段只要有一条不是
R1-R5 / C1-C5(例如写了「中风险」),就抛 ValueError 并冒到 scan() 的兜底 → **整批
rollback**,本次扫描前面已经生成的预警全部作废。数据脏属于运维问题,不该升级成
"整个风控停摆"。
改为返回 int | None;调用点跳过该条并记 warning(带上 transaction_id 与两个原始值,
便于运维直接定位)。顺带把
eplace 换成
emoveprefix:原先 "R2R" 会被错当成 2,
现在只去掉开头那一个前缀字符。
**③ 调度器重启后永不执行**
last_run_at 只存在内存里,重启后为 None,而 _is_due 此时返回
config.run_immediately(默认 False)⇒ 重启后 _is_due 恒为假,**调度器形同虚设,
而且没有任何告警**。
改为"从未跑过即视为 due":多跑一次的最坏后果是重复扫描,而扫描每条规则都先 _exists
查重、外层还有 MySQL 级锁;反过来"不跑"的后果可能是永远不跑。
config.run_immediately 不再承担"首次是否执行"的语义(它原本想表达"启动后别马上跑",
但那与"永远不跑"在实现上无法区分),字段保留以免破坏既有配置。
新增 tests/unit/service/test_risk_scan_robustness.py(6 条):脏数据返回 None 而不抛异常、
R2R 不被过度剥离、首次必 due(哪怕 run_immediately=False)、以及间隔前后的判定。
ruff / mypy(136 文件) / 622 unit+contract / 29 integration 全绿。
|
2026-09-11 13:35:58 +08:00 |
|
lzf_0626
|
d331c817ac
|
fix(risk): 通知创建失败必须能被区分出来,不再静默返回 0
docs/25 P2「通知失败被吞」。核实后要把定性说得更准一点:它**有** logger.exception,
不是完全静默;问题在于 _create_notifications 失败时
eturn 0,而"无需通知"
(没有高风险预警、或通知功能关闭)也返回 0 —— 调用方只拿到一个 notification_count,
**分不清"本来就不用通知"和"高风险通知创建失败"**。
风控里这个区别很要紧:通知没发出去等于处置链路的第一环断了,而扫描依旧报"完成"。
这与同一批缺陷里的"定时扫描重启后不再执行"是同一性质 —— 看起来在工作、其实有一环没跑。
改动:
- _create_notifications 返回 (条数, 失败原因),失败原因非空即代表出了问题。
- scan() 在有失败时于返回体里带上
otification_failure,并把 message 改成
"规则扫描完成,但高风险通知创建失败",让上游与运维都能直接看到。
- **失败仍然不回滚预警**:预警已经生成、比通知重要,不该因为通知写失败就丢掉。
新增 tests/unit/service/test_risk_scan_notification.py(4 条),其中一条专门断言
"两种 0 必须可区分" —— 那是本次修复的全部意义。
ruff / mypy(136 文件) / 616 unit+contract 全绿。
|
2026-09-11 13:33:41 +08:00 |
|
lzf_0626
|
8ac0b794ff
|
fix(risk): 收敛剩余的时区口径(REST 时间参数、年龄、日报日期字段)
接 b3da1b6。上一条只改了凌晨规则与日报日界,剩下几处一并收掉:
1. risk_query_service.py:77-78:REST 的 start_time/end_time 是**裸 datetime**,
原先原样透传去比库内 UTC 列,而 Agent 路径本来就带时区
(risk_natural_language.py:117)——同一条筛选条件在界面与对话里会查出不同结果。
timeutil 新增 from_local:裸值按**北京时间**解释(面向中国客户的业务系统,
填表人的预期就是本地时间),带时区的按其自身时区处理。它与 to_utc_naive 的区别
正在裸值上:取库里的值用后者,接客户端输入用这个。
2. risk_scan_service.py 与 risk_judgement_service.py 的 _age:一处用 UTC 日期、
一处用服务器 date.today(),生日边界上同一客户会差一岁、65 岁阈值可能翻面。
统一走 local_date(北京时间)。
3. risk_daily_report_service.py:122 的 report_date 与 :244 的 created_today:
原先取 UTC 日期,北京 08:00 之前会把"今天新增的预警"算成昨天。
ruff / mypy(135 文件) / 603 unit+contract 全绿。
|
2026-09-11 12:55:00 +08:00 |
|
lzf_0626
|
b3da1b65ed
|
fix(risk): 修正风控的时区缺陷——凌晨规则与日报日界
docs/25 P1 #6。根因是"库内存 UTC naive"这个约定在**业务判断层**没被遵守,
而展示层其实已经是对的(risk_daily_report_service.py:418 按配置时区转换)。
1. 新增 app/core/timeutil.py 作为统一换算入口:约定库内 UTC naive,提供
local_hour / local_date / local_day_bounds(后两者用于查库时必须返回 UTC naive,
否则区间与库内值整体错开 8 小时),带时区的入参按其自身时区解释。
2. risk_scan_service.py:248:0 <= confirmed_at.hour < 6 → local_hour(...)。
原先 [0,6) UTC 被当成"凌晨",实际是北京时间 08:00-14:00,整条
「凌晨时段小额操作」规则判的是上午。
3. risk_judgement_service.py:238/243:判断**与展示**都换算。展示不改的话,
风控专员看到的时刻与直觉差 8 小时,无法与客户核对。
4. risk_daily_report_service.py:89:日界改用 local_day_bounds(按北京时间自然日,
再折回 UTC naive)。原先按 UTC 日期切日,北京 08:00 前生成的日报统计窗口跨零点。
测试:
- 新增 tests/unit/core/test_timeutil.py(8 条),含"UTC 凌晨 0-6 点不是北京凌晨"
这一缺陷复现,以及"日界必须返回 UTC naive"。
- 改写 test_risk_scan_service.py::test_night_small_trade_boundaries:它原本就拿
UTC 小时构造数据(写 0 点/6 点),等于在测北京 08:00/14:00;语义一并修正为
北京 00:00(含)与 06:00(不含)两个边界,三个边界场景保持不变。
ruff / mypy(135 文件) / 603 unit+contract 全绿。
|
2026-09-11 12:50:52 +08:00 |
|
zhangshy
|
a94d5c754d
|
feat: 迁移奶龙风控业务模块与演示文档
|
2026-09-10 21:03:44 +08:00 |
|