修研判请求体的 Decimal: 账本流水里的 price_at 是 DECIMAL 列, 导致研判闸第一次真发请求就全军覆没; 出口统一做 JSON 安全转换

This commit is contained in:
zlt 2026-08-06 17:25:54 +08:00
parent d8829daa6b
commit 90d27d43fd
5 changed files with 132 additions and 3 deletions

View File

@ -172,6 +172,56 @@ PMS_UNAVAILABLE 三个值,而 `pms_advisor._today_exit_verdict` 第 102 行明
--- ---
## 2026-08-06 · 研判闸第一次真发请求就全军覆没:请求体里的 Decimal
**这是今天最要紧的一条。**
三条新建仓提议进了人工确认队列,档位明明是 `full`。查提议的研判理由,三条一模一样:
```
研判: UNAVAILABLE | 研判请求失败: TypeError: Object of type Decimal is not JSON serializable
```
**源头**`proposal_service._recent_ledger()` 把评审账本流水塞进研判请求的 context
其中 `"price": r.get("price_at")` —— `pms_action_ledger.price_at` 是数据库的 DECIMAL 列,
读出来是 Python 的 `Decimal``requests.post(json=payload)` 序列化不了,直接抛 TypeError。
**危害是渐进的,这才是它可怕的地方**:只要一只票在评审账本里有过**任何一行**
它的研判请求就必然失败;而账本只会越积越多,于是过几天几乎所有票的研判都会失败,
全部降级成人工确认——「不用每天靠人」这件事会一点点失效,
而每一条看起来都只是「研判不可用,降级人工确认」这种系统里天天都有的正常降级,
不会有人觉得不对。今天只撞上三只,是因为多数候选还是第一次出现在账本里。
**为什么今天才暴露**:研判闸此前从没真正接通过——`PMS_JUDGE_API_BASE` 一直是空的,
`judge.available()` 为假就直接返回不可用,请求体根本没构造过。
今天是它第一次真发请求,第一次就撞上。这也意味着接口契约 §6 里 #10 那条判收
(「评审账本出现 arbiter=judge 的行」)今天才第一次真正达成。
**降级逻辑救了场**:错误没有被吞掉——报错、回 UNAVAILABLE、入人工队列、理由写进提议
一路留痕,所以一条查询就看见了。这正是「拿不到意见绝不当成通过」那条纪律的价值。
**改法**`judge.jsonable()` 递归把 Decimal 换成 float、日期换成字符串
在请求出口统一拦一道。修在出口而不是修 `_recent_ledger` 一处,是因为请求体的任何一层
将来都可能再冒出数据库类型——一次拦住比每加一个字段就想一次靠谱。
`_recent_ledger` 那边也顺手把 `price` 转成 float让日志与留痕里的数字也干净。
择时那条路(`exec_advisor._advice`)目前不受影响:它的 refs 与 position 都取自
`positions_view()`,那里每个数值都过了 `float()`/`round()`;哪天它也报同样的错,
`jsonable` 的写法在它的 `_post` 前包一层即可。
单测里放了一条**复现用例**:用 Decimal 与 date 造出当天的现场,转换后必须真的
`json.dumps` 得出来。另加一条源码检查,钉住 `_recent_ledger` 的 float 转换。
全量单测 **ALL SUITES PASS469 例**
**顺带一条结构性发现,还没处置**`300570.SZ` 是今天唯一一只既在候选池、
又被决策系统判过盘中转多的票,它倒在规则闸的 `NO_CHASE_MA5` 上(距 MA5 12.22%,上限 6%)。
而那 67 条转多理由里满是「放量突破」「量比 8.55」「站上关键位」——**按定义就是脱离均线的形态**。
所以「信号只做加速器、其余闸门一道不放宽」这个口径,在结构上就注定收效甚微:
就算把候选池调大、重合率提上去,信号票也会成片倒在不追高那道闸上。
要让信号真正有增量,只能是口径三(信号票在不追高或择时上放宽一档),
而那要碰「不追高、接受买不上」这条纪律,必须先拍板。**本轮不动。**
---
## 2026-08-06 · 新建仓盘中真跑了 40 分钟,第一份实测;跳过原因改成分得开 ## 2026-08-06 · 新建仓盘中真跑了 40 分钟,第一份实测;跳过原因改成分得开
**这一条是实测记录,不是设计。** 新建仓在今天下午约 14:07 起真跑了四十分钟 **这一条是实测记录,不是设计。** 新建仓在今天下午约 14:07 起真跑了四十分钟

View File

@ -49,6 +49,42 @@ def _judge_hard_numbers(action: str, hard: dict) -> dict:
return {k: v for k, v in (hard or {}).items() if k in OPEN_JUDGE_KEYS} return {k: v for k, v in (hard or {}).items() if k in OPEN_JUDGE_KEYS}
def jsonable(v):
"""把请求体里 `requests` 的 `json=` 序列化不了的类型换掉。递归处理字典与列表。
2026-08-06 实机撞上的一条: 研判闸第一次真正接通并发出请求, 三条新建仓提议全部失败于
TypeError: Object of type Decimal is not JSON serializable
源头是 `proposal_service._recent_ledger()` 它把评审账本流水塞进 context, 其中
`price` 取自 `pms_action_ledger.price_at`, 那是数据库的 DECIMAL , 读出来是 Decimal
**这条的危害是渐进的, 所以特别值得写下来**: 只要一只票在评审账本里有过任何一行,
它的研判请求就必然失败; 而账本只会越积越多, 于是过几天几乎所有票的研判都会失败,
全部降级成人工确认 不用每天靠人这件事会一点点失效, 而每一条看起来都只是
研判不可用, 降级人工确认这种系统里天天都有的正常降级, 没人会觉得不对
它今天才第一次有机会暴露, 是因为在此之前 PMS_JUDGE_API_BASE 一直是空的:
`available()` 为假就直接返回不可用, 请求体根本没构造过
修在这里而不是修 `_recent_ledger` 一处, 是因为请求体的任何一层将来都可能再冒出
数据库类型 一次拦住比每加一个字段就想一次靠谱`_recent_ledger` 那边也顺手把
`price` 转成了 float, 让日志与留痕里的数字也是干净的
择时那条路 (`exec_advisor._advice`) 目前不受影响: 它的 refs position 都取自
`portfolio.positions_view()`, 那里每个数值都过了 float()/round()哪天它也报同样的
TypeError, 照这里的写法在它的 `_post` 前包一层即可
"""
from datetime import date, datetime
from decimal import Decimal
if isinstance(v, Decimal):
return float(v)
if isinstance(v, (datetime, date)):
return str(v)
if isinstance(v, dict):
return {k: jsonable(x) for k, x in v.items()}
if isinstance(v, (list, tuple, set)):
return [jsonable(x) for x in v]
return v
def enabled() -> bool: def enabled() -> bool:
return param_store.get_bool("PMS_JUDGE_ENABLED", True) return param_store.get_bool("PMS_JUDGE_ENABLED", True)
@ -100,6 +136,7 @@ def request(candidate: dict, context: dict = None, *, timeout: int = None) -> di
} }
to = int(timeout or param_store.get_int("PMS_JUDGE_TIMEOUT", 90)) to = int(timeout or param_store.get_int("PMS_JUDGE_TIMEOUT", 90))
url = base_url() + (param_store.get("PMS_JUDGE_PATH", "/api/intraday/pms_judge") or "") url = base_url() + (param_store.get("PMS_JUDGE_PATH", "/api/intraday/pms_judge") or "")
payload = jsonable(payload) # 数据库类型 (Decimal/日期) 换成 JSON 认得的, 见 jsonable
try: try:
import requests import requests
r = requests.post(url, json=payload, timeout=to) r = requests.post(url, json=payload, timeout=to)

View File

@ -540,9 +540,12 @@ def _recent_ledger(ts_code: str, limit: int = 10) -> list:
rows = pms_repo.list_ledger(ts_code=ts_code, limit=limit) rows = pms_repo.list_ledger(ts_code=ts_code, limit=limit)
except Exception: except Exception:
return [] return []
# price_at 是数据库的 DECIMAL 列, 直接带出去会是 Decimal —— 那是 2026-08-06 研判闸
# 第一次真发请求就全军覆没的原因 (json 序列化不了)。judge.jsonable 已经在出口统一拦了,
# 这里再就地转成 float, 是为了让日志、页面、留痕里的数字也是干净的, 不必依赖出口那一道。
return [{"at": str(r.get("decided_at")), "action": r.get("action"), return [{"at": str(r.get("decided_at")), "action": r.get("action"),
"arbiter": r.get("arbiter"), "verdict": r.get("verdict"), "arbiter": r.get("arbiter"), "verdict": r.get("verdict"),
"price": r.get("price_at"), "reason": r.get("reason")} for r in rows] "price": float(r.get("price_at") or 0), "reason": r.get("reason")} for r in rows]
def _brief(c: dict) -> dict: def _brief(c: dict) -> dict:

View File

@ -17,9 +17,10 @@
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (48 ) test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (48 )
test_batch11_units.py 择时实现A: 本地检查等价/应答折算/缓存冷却/退B (24 ) test_batch11_units.py 择时实现A: 本地检查等价/应答折算/缓存冷却/退B (24 )
test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/ test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/
转多信号插队与留痕/窗口末日只对命令/跳过原因分得开 (42 ) 转多信号插队与留痕/窗口末日只对命令/跳过原因分得开/
研判请求体的数据库类型 (44 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 ) test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 )
467 469
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 1)
""" """
import os import os

View File

@ -395,6 +395,44 @@ def _():
assert jd._judge_hard_numbers("DCA", None) == {} assert jd._judge_hard_numbers("DCA", None) == {}
@case("研判请求·数据库类型不许把请求体炸掉 (Decimal / 日期)")
def _():
import datetime as _dt
from decimal import Decimal
payload = {
"qty": 600,
"hard_numbers": {"price": Decimal("34.75"), "score": 268.5},
"context": {
# 这就是 2026-08-06 的现场: 评审账本流水里的 price 取自 DECIMAL 列
"recent_ledger": [{"at": "2026-08-06 14:08:35", "price": Decimal("10.02"),
"verdict": "REJECT"}],
"position": {"opened_date": _dt.date(2026, 8, 4),
"updated_at": _dt.datetime(2026, 8, 6, 14, 8, 35)},
},
}
got = jd.jsonable(payload)
assert got["hard_numbers"]["price"] == 34.75
assert isinstance(got["hard_numbers"]["price"], float)
assert isinstance(got["context"]["recent_ledger"][0]["price"], float)
assert got["context"]["position"]["opened_date"] == "2026-08-04"
assert got["context"]["position"]["updated_at"].startswith("2026-08-06 14:08:35")
# 换完之后必须真的能序列化 —— 这一条才是这个用例的重点
import json as _json
_json.dumps(got, ensure_ascii=False)
# 其余类型原样不动
assert jd.jsonable({"a": 1, "b": "x", "c": None, "d": True}) == \
{"a": 1, "b": "x", "c": None, "d": True}
@case("研判请求·账本流水里的价格就地转成 float (不靠出口那一道兜)")
def _():
import inspect
from app.services import proposal_service as prs
src = inspect.getsource(prs._recent_ledger)
assert 'float(r.get("price_at") or 0)' in src, \
"_recent_ledger 必须把 price_at 转成 float, 否则日志与留痕里带的是 Decimal"
# ================================================================ 参考位漂移 # ================================================================ 参考位漂移
@case("参考位漂移·只管新建仓, 别的动作一律不比对") @case("参考位漂移·只管新建仓, 别的动作一律不比对")
def _(): def _():