diff --git a/DEVLOG.md b/DEVLOG.md index 0ad1a7a..dc74201 100644 --- a/DEVLOG.md +++ b/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 分钟,第一份实测;跳过原因改成分得开 **这一条是实测记录,不是设计。** 新建仓在今天下午约 14:07 起真跑了四十分钟 diff --git a/app/services/judge.py b/app/services/judge.py index 7e54965..70a431a 100644 --- a/app/services/judge.py +++ b/app/services/judge.py @@ -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} +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: 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)) url = base_url() + (param_store.get("PMS_JUDGE_PATH", "/api/intraday/pms_judge") or "") + payload = jsonable(payload) # 数据库类型 (Decimal/日期) 换成 JSON 认得的, 见 jsonable try: import requests r = requests.post(url, json=payload, timeout=to) diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index 58423c9..06d6b49 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -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) except Exception: return [] + # price_at 是数据库的 DECIMAL 列, 直接带出去会是 Decimal —— 那是 2026-08-06 研判闸 + # 第一次真发请求就全军覆没的原因 (json 序列化不了)。judge.jsonable 已经在出口统一拦了, + # 这里再就地转成 float, 是为了让日志、页面、留痕里的数字也是干净的, 不必依赖出口那一道。 return [{"at": str(r.get("decided_at")), "action": r.get("action"), "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: diff --git a/scripts/run_tests.py b/scripts/run_tests.py index eeb6344..ebb51ad 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -17,9 +17,10 @@ test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (48 例) test_batch11_units.py 择时实现A: 本地检查等价/应答折算/缓存冷却/退B (24 例) test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/ - 转多信号插队与留痕/窗口末日只对命令/跳过原因分得开 (42 例) + 转多信号插队与留痕/窗口末日只对命令/跳过原因分得开/ + 研判请求体的数据库类型 (44 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例) - 共 467 例 + 共 469 例 任一子集失败即整体失败 (退出码 1)。 """ import os diff --git a/scripts/test_batch12_units.py b/scripts/test_batch12_units.py index 7611e53..b9d33f1 100644 --- a/scripts/test_batch12_units.py +++ b/scripts/test_batch12_units.py @@ -395,6 +395,44 @@ def _(): 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("参考位漂移·只管新建仓, 别的动作一律不比对") def _():