修研判请求体的 Decimal: 账本流水里的 price_at 是 DECIMAL 列, 导致研判闸第一次真发请求就全军覆没; 出口统一做 JSON 安全转换
This commit is contained in:
parent
d8829daa6b
commit
90d27d43fd
50
DEVLOG.md
50
DEVLOG.md
|
|
@ -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 PASS,469 例**。
|
||||||
|
|
||||||
|
**顺带一条结构性发现,还没处置**:`300570.SZ` 是今天唯一一只既在候选池、
|
||||||
|
又被决策系统判过盘中转多的票,它倒在规则闸的 `NO_CHASE_MA5` 上(距 MA5 12.22%,上限 6%)。
|
||||||
|
而那 67 条转多理由里满是「放量突破」「量比 8.55」「站上关键位」——**按定义就是脱离均线的形态**。
|
||||||
|
所以「信号只做加速器、其余闸门一道不放宽」这个口径,在结构上就注定收效甚微:
|
||||||
|
就算把候选池调大、重合率提上去,信号票也会成片倒在不追高那道闸上。
|
||||||
|
要让信号真正有增量,只能是口径三(信号票在不追高或择时上放宽一档),
|
||||||
|
而那要碰「不追高、接受买不上」这条纪律,必须先拍板。**本轮不动。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-08-06 · 新建仓盘中真跑了 40 分钟,第一份实测;跳过原因改成分得开
|
## 2026-08-06 · 新建仓盘中真跑了 40 分钟,第一份实测;跳过原因改成分得开
|
||||||
|
|
||||||
**这一条是实测记录,不是设计。** 新建仓在今天下午约 14:07 起真跑了四十分钟
|
**这一条是实测记录,不是设计。** 新建仓在今天下午约 14:07 起真跑了四十分钟
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 _():
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue