fix(dupont): reject out-of-domain window + bidirectional clip + suffix discipline (R1-R4/M2)
ci/woodpecker/push/main Pipeline failed

Per finance ruling 2026-10-05 (project-20261005-dupont-oob-window-fix):

- R1: no intersection with available month domain => explicit error (endpoint 400), no window fields

- R2: with intersection => intersection window, months>=1 (kills inversion and months<=0)

- R3: quarter/half/... suffix kept only when returned window == nominal window (clipped flag)

- R4: symmetric lower-bound clip note; M2: TTM nominal window = [anchor-11, anchor] (12 months)

- M1: docstring correction (lower-bound overflow is clipped, not 400)

No push / no deploy / no restart / no DB write.
This commit is contained in:
wecom-fullstack
2026-10-05 02:02:21 +08:00
parent 3ed9fa0611
commit 4f9bab6bc0
4 changed files with 278 additions and 21 deletions
+4 -4
View File
@@ -23,7 +23,7 @@ from app.utils.deviation_engine import calc_period_deviation, calc_period_diff,
from app.utils.alert_entity_scope import month_period_clause
from app.utils.profitability_params import load_profitability_params
from app.utils.dupont_basis import (
annualize_factors, available_months, build_window_meta, check_identity,
annualize_factors, apply_period_suffix, available_months, build_window_meta, check_identity,
compute_factors, latest_stock_month, load_dupont_params, months_between, prev_month,
resolve_window, stock_balance, sum_flows,
)
@@ -2330,9 +2330,9 @@ def get_dupont_analysis(
name_row = db.execute(
text("SELECT name FROM entities WHERE id = :eid"), {"eid": entity_id}).first()
entity_name = (name_row[0] if name_row else None) or ("陕西酣客文化传媒" if slug == "hanke" else "陕西博海网络科技")
suffix = {"ytd": "(YTD 年初至今)", "ttm": "(TTM 滚动 12 月)", "month": "(单月)",
"quarter": "(季度)", "half": "(半年)", "year": "(全年)", "custom": "(自定义区间)"}
label = win["label"] + suffix.get(win["period_key"], "")
# R3 后缀纪律:仅在「返回窗口 == 名义窗口」(未裁剪)时保留「(季度)/(半年)…」后缀;
# 发生任何裁剪 ⇒ 去后缀,period = 实际窗口标签(判据由 resolve_window 回传 `clipped`)。
label = apply_period_suffix(win["label"], win["period_key"], bool(win.get("clipped")))
cur = _dupont_window_values(db, entity_id, caliber, win["period_start"], win["period_end"])
ann = annualize_factors(dict(cur["factors"]), win["months"], annualize)
+63 -15
View File
@@ -31,7 +31,7 @@ from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional
from sqlalchemy import text
@@ -152,6 +152,24 @@ def _fmt_cn(start: str, end: str) -> str:
return f"{start[:4]}年{int(start[5:7])}-{int(end[5:7])}月"
#: 区间后缀(R3:仅在「返回窗口 == 名义窗口」时保留 —— 统一经 `apply_period_suffix` 施加)
PERIOD_SUFFIX: Dict[str, str] = {
"ytd": "(YTD 年初至今)", "ttm": "(TTM 滚动 12 月)", "month": "(单月)",
"quarter": "(季度)", "half": "(半年)", "year": "(全年)", "custom": "(自定义区间)",
}
def apply_period_suffix(label: str, period_key: Optional[str], clipped: bool = False) -> str:
"""R3 后缀纪律:只在**未发生裁剪**(返回窗口 == 名义窗口)时保留「(季度)/(半年)…」。
发生任何裁剪 ⇒ 去后缀(`label` 已是实际窗口的 `_fmt_cn`)。`period_key` 只表**请求形态**,
**不等于**返回窗口类型(返回窗口类型由 `months` + `period` 标签表达)。
"""
if clipped:
return label
return f"{label}{PERIOD_SUFFIX.get(period_key or '', '')}"
def resolve_window(period: Optional[str], start: Optional[str], end: Optional[str],
available: List[str], preset: Optional[str] = None,
today: Optional[str] = None, anchor: Optional[str] = None) -> Dict[str, Any]:
@@ -161,21 +179,28 @@ def resolve_window(period: Optional[str], start: Optional[str], end: Optional[st
可用月域 `available`(升序 YYYY-MM 列表)用于:① 缺省锚定;② 区间裁剪。
`anchor` = 默认区间的锚月(端点传入「存量最新月」`latest_stock_month`,缺省取 available 末月)。
⚠️ 裁剪纪律:区间上界**只裁不扩**——超出可用月域即裁到可用域(并在 `clip_note` 留痕),
绝不因裁剪而静默改变用户已选下界(下界越界则显式 400,见 `validate`)。
裁剪纪律(财务侧 2026-10-05 口径裁定 R1–R4,件
`_reports/project-20261005-dupont-oob-ruling-finance.md`):
· **R1 无交集 ⇒ 显式拒绝**:`e < avail_start` 或 `s > latest` ⇒ 返回 `error`
(调用侧转 HTTP 400),**不返回任何窗口/数值字段**(不得以 null/空串占位);
· **R2 有交集 ⇒ 双向裁剪**:窗口 = 交集 `[max(s, avail_start), min(e, latest)]`(只裁不扩),
`months` = 实际月数 ⇒ 恒 ≥ 1(消灭 `months = 0/负数` 与窗口倒挂);
· **R3 后缀纪律**:是否允许调用侧挂「(季度)/(半年)…」后缀 = `clipped` 字段
(未裁剪才保留;裁剪后 `period` = 实际窗口标签);
· **R4 裁剪留痕**:见 `clip_note`(上界沿用既有文案;下界为对称文案;两端同时裁剪则并存)。
⚠️ 更正(2026-10-05,同裁定 M1):下界越界**不是**无条件 400 —— 「有交集但下界越界」
= 裁到 `avail_start` + 留痕 + 去后缀(R2/R3/R4);**只有「整区间与可用月域无交集」才 400**。
旧 docstring 声称「下界越界则显式 400,见 `validate`」,而本模块从未有 `validate` 函数。
"""
avail = sorted(available or [])
latest = avail[-1] if avail else None
avail_start = avail[0] if avail else None
today = today or datetime.now().strftime("%Y-%m")
anchor = min(anchor or latest or today, today)
if latest:
anchor = min(anchor, latest)
def clip_end(e: str) -> Tuple[str, Optional[str]]:
if latest and e > latest:
return latest, f"上界 {e} 超出可用月域(最新 {latest}),已裁至可用域"
return e, None
p = (period or "").strip().lower()
if preset:
p = preset.strip().lower()
@@ -188,8 +213,12 @@ def resolve_window(period: Optional[str], start: Optional[str], end: Optional[st
elif p in ("ttm",):
if not anchor:
return {"error": "无可用数据月域,无法解析 TTM 区间"}
# M2(同裁定 §4):TTM 名义区间 = [anchor−11, anchor](**12 个月**)。
# 历史实现取 anchor 的「去年同月」(如 anchor=2026-08 ⇒ 2025-08)= 13 个月,与后缀
# 「(TTM 滚动 12 月)」自相矛盾,且 `months` 进年化分母 ⇒ 年化被低估 12/13 ≈ 7.7%。
y, m = int(anchor[:4]), int(anchor[5:7])
s = f"{y - 1}-{m:02d}" if m != 12 else f"{y}-01"
idx = y * 12 + (m - 1) - 11
s = f"{idx // 12}-{idx % 12 + 1:02d}"
e = anchor
key = "ttm"
elif p in ("custom",) or start or end:
@@ -214,13 +243,32 @@ def resolve_window(period: Optional[str], start: Optional[str], end: Optional[st
else:
return {"error": f"无法识别的区间参数 period={period!r}(支持 ytd/ttm/YYYY-MM/YYYY-Qn/YYYY-Hn/YYYY/custom)"}
e, cn = clip_end(e)
note = cn
months = months_between(s, e)
# ── R1/R2:与可用月域求交集(无交集 ⇒ 显式拒绝;有交集 ⇒ 双向裁剪) ──────────
if not avail:
return {"error": "无可用数据月域,无法计算区间指标"}
latest = avail[-1]
avail_start = avail[0]
if e < avail_start or s > latest:
return {"error": f"请求区间 {s}~{e} 与可用月域 {avail_start}~{latest} 无交集,无法计算区间指标"}
notes: List[str] = []
lo, hi = s, e
if s < avail_start:
lo = avail_start
notes.append(f"下界 {s} 早于可用月域起点({avail_start}),已裁至可用域")
if e > latest:
hi = latest
notes.append(f"上界 {e} 超出可用月域(最新 {latest}),已裁至可用域")
if lo > hi: # 名义区间自身颠倒(如 custom start > end)——不得产出倒挂窗口
return {"error": f"请求区间 {s}~{e} 与可用月域 {avail_start}~{latest} 无交集,无法计算区间指标"}
clipped = (lo, hi) != (s, e)
months = months_between(lo, hi) # R2 ⇒ 恒 ≥ 1
return {
"period_key": key, "period_start": s, "period_end": e, "months": months,
"label": _fmt_cn(s, e), "clip_note": note,
"available_start": avail[0] if avail else None, "available_end": latest,
"period_key": key, "period_start": lo, "period_end": hi, "months": months,
"label": _fmt_cn(lo, hi),
"nominal_start": s, "nominal_end": e, "nominal_months": months_between(s, e),
"clipped": clipped,
"clip_note": ";".join(notes) if notes else None,
"available_start": avail_start, "available_end": latest,
}
@@ -0,0 +1,205 @@
# -*- coding: utf-8 -*-
"""杜邦区间窗口「完全越出可用月域」回归用例(2026-10-05 口径裁定 R1–R4 + M2)。
裁定件(财物侧口径,执行前必读):
/srv/hermes/mailbox/finance/pending-tasks/_reports/project-20261005-dupont-oob-ruling-finance.md
施工单: project-20261005-dupont-oob-window-fix-fullstack.md
缺陷(改前实测,部署树 HEAD 3ed9fa0):
· `?period=2026-Q4` → 200 / period_start=2026-10 > period_end=2026-09 / months=0 /
label「2026年10-9月(季度)」/ 四因子全 None(**窗口倒挂 + 名号不符 + 静默空数据**);
· `?period=2026-H2` → 裁至 2026-07..09(实 3 个月)却仍标「(半年)」。
裁定后口径(本文件逐条断言):
· R1 整区间与可用月域 D 无交集 ⇒ `error`(端点转 400),**不返回任何窗口/数值字段**;
· R2 有交集 ⇒ 窗口 = 交集(双向裁剪),`months` = 实际月数 ≥ 1(不倒挂);
· R3 后缀((季度)/(半年)…)仅在「返回窗口 == 名义窗口」时保留 ⇒ 由 `clipped` 判;
· R4 裁剪留痕:上界沿用既有文案;下界为对称文案;
· M2 TTM 名义区间 = [anchor−11, anchor] = 12 个月。
可用月域取裁定 §2 场景:D = 2026-01 ~ 2026-09(latest=2026-09)。
本文件只测纯函数(`resolve_window` / `apply_period_suffix`),不连库、不写盘、不碰服务。
"""
import pytest
from app.utils import dupont_basis as _du
from app.utils.dupont_basis import months_between, resolve_window
def apply_period_suffix(label, period_key, clipped=False):
"""R3 判据入口(经模块属性取,便于负对照)。
改前件(origin/main 3ed9fa0)**没有** `apply_period_suffix` ⇒ 走显式断言失败
(AssertionError,而不是 import 期 ImportError 导致整个用例文件收集失败),
这样「把实现换回改前版本」时这些断言会以**可读的失败**呈现。
"""
fn = getattr(_du, "apply_period_suffix", None)
assert fn is not None, ("resolve_window/dupont_basis 未落地 R3 后缀纪律(clipped 判据)"
"—— 改前件预期失败")
return fn(label, period_key, clipped)
#: 裁定 §2/§4 场景可用月域(latest = 2026-09,博海账套实测形态)
D_AVAIL = [f"2026-{m:02d}" for m in range(1, 10)]
#: 整区间无交集的统一 400 文案(端点直接把它作为 `detail`,前端零改动可正确呈现)
Q4_DETAIL = "请求区间 2026-10~2026-12 与可用月域 2026-01~2026-09 无交集,无法计算区间指标"
def rwd(period, start=None, end=None, **kw):
kw.setdefault("today", "2026-10")
return resolve_window(period, start, end, D_AVAIL, **kw)
# ── R1:整区间与可用月域无交集 ⇒ 显式拒绝(不返回窗口/数值字段) ──────────────
def test_q4_out_of_domain_rejected_with_exact_detail():
"""`?period=2026-Q4`(A=[2026-10,2026-12] 与 D 无交集)⇒ error,文案逐字符合裁定 §2.1。"""
w = rwd("2026-Q4")
assert w.get("error") == Q4_DETAIL, w
# 不得以 null/空串占位:窗口/月数/标签/裁剪留痕一律**不存在**
for k in ("period_start", "period_end", "months", "months_label", "label",
"period", "clip_note", "nominal_months", "clipped"):
assert k not in w, (k, w)
@pytest.mark.parametrize("period,start,end", [
("2026-Q4", None, None), # 未来侧整季度越界
("2026-10", None, None), # 未来侧单月越界
("2027", None, None), # 未来侧整年越界
("2025-Q1", None, None), # 历史侧整季度(域前)
("2025-Q4", None, None), # 历史侧整季度(域前)
("custom", "2026-10", "2026-12"), # custom 未来侧整段
("custom", "2026-11", "2026-11"), # custom 未来侧单月(改前 months=-1)
("custom", "2025-01", "2025-12"), # custom 历史侧整段
])
def test_all_no_intersection_forms_rejected(period, start, end):
w = rwd(period, start, end)
assert w.get("error"), (period, start, end, w)
assert "无交集" in w["error"]
assert "period_start" not in w and "months" not in w
def test_nominal_inverted_custom_never_returns_inverted_window():
"""名义区间自身颠倒(custom start > end)⇒ 拒绝,绝不产出倒挂窗口。"""
w = rwd("custom", "2026-05", "2026-03")
assert w.get("error"), w
assert "period_start" not in w
# ── R2 + R3 + R4:有交集 ⇒ 双向裁剪 + 月数/标签如实 ──────────────────────────
def test_h2_clips_upper_bound_label_and_months():
"""`?period=2026-H2` ⇒ 窗口 [2026-07,2026-09]、months=3、标签无「(半年)」、上界留痕。"""
w = rwd("2026-H2")
assert "error" not in w
assert w["period_key"] == "half" # 请求形态不变(向后兼容)
assert (w["period_start"], w["period_end"]) == ("2026-07", "2026-09")
assert w["months"] == 3
assert w["label"] == "2026年7-9月"
assert w["nominal_start"] == "2026-07" and w["nominal_end"] == "2026-12"
assert w["nominal_months"] == 6
assert w["clipped"] is True
assert w["clip_note"] == "上界 2026-12 超出可用月域(最新 2026-09),已裁至可用域"
# R3:发生裁剪 ⇒ 去后缀(裁定 §2.2 期望标签「2026年7-9月」,不得带「(半年)」)
assert apply_period_suffix(w["label"], w["period_key"], w["clipped"]) == "2026年7-9月"
assert "半年" not in apply_period_suffix(w["label"], w["period_key"], w["clipped"])
def test_h2_window_equals_q3_window():
"""强判据(裁定 §2.2):同一返回窗口 [2026-07,2026-09] ⇒ H2 与 Q3 窗口逐项相同。"""
h2, q3 = rwd("2026-H2"), rwd("2026-Q3")
assert "error" not in h2 and "error" not in q3
for k in ("period_start", "period_end", "months", "label"):
assert h2[k] == q3[k], (k, h2[k], q3[k])
def test_full_year_clips_lower_upper_and_label():
"""`?period=2026`(全年)⇒ [2026-01,2026-09]、months=9、标签无「(全年)」。"""
w = rwd("2026")
assert (w["period_start"], w["period_end"], w["months"]) == ("2026-01", "2026-09", 9)
assert w["label"] == "2026年1-9月"
assert w["clipped"] is True
assert apply_period_suffix(w["label"], w["period_key"], w["clipped"]) == "2026年1-9月"
def test_custom_lower_bound_clip_note_symmetric():
"""R4:下界越界(域起点晚于名义起点)⇒ 裁到 avail_start + 对称留痕 + 去后缀。"""
w = rwd("custom", "2025-01", "2026-06")
assert (w["period_start"], w["period_end"], w["months"]) == ("2026-01", "2026-06", 6)
assert w["clipped"] is True
assert w["clip_note"] == "下界 2025-01 早于可用月域起点(2026-01),已裁至可用域"
assert apply_period_suffix(w["label"], w["period_key"], w["clipped"]) == "2026年1-6月"
def test_ttm_nominal_12_months_and_lower_clip():
"""M2:TTM 名义 12 个月;域内 ⇒ 下界裁剪 + 去「(TTM 滚动 12 月)」后缀。"""
nom = resolve_window("ttm", None, None, [f"2025-{m:02d}" for m in range(1, 13)]
+ [f"2026-{m:02d}" for m in range(1, 13)], anchor="2026-08")
assert (nom["period_start"], nom["period_end"], nom["nominal_months"]) == (
"2025-09", "2026-08", 12)
w = rwd("ttm", anchor="2026-08")
assert (w["period_start"], w["period_end"], w["months"]) == ("2026-01", "2026-08", 8)
assert w["clipped"] is True
assert w["clip_note"] == "下界 2025-09 早于可用月域起点(2026-01),已裁至可用域"
assert apply_period_suffix(w["label"], w["period_key"], w["clipped"]) == "2026年1-8月"
def test_ytd_unclipped_keeps_suffix():
"""未裁剪(返回窗口 == 名义窗口)⇒ 后缀保留(R3 的另一半)。"""
w = rwd("ytd", anchor="2026-08")
assert w["clipped"] is False
assert w["clip_note"] is None
assert apply_period_suffix(w["label"], w["period_key"], w["clipped"]) == "2026年1-8月(YTD 年初至今)"
# ── R3 后缀纪律(纯函数) ─────────────────────────────────────────────────────
@pytest.mark.parametrize("label,key,unclipped,clipped", [
("2026年1-6月", "half", "2026年1-6月(半年)", "2026年1-6月"),
("2026年1-3月", "quarter", "2026年1-3月(季度)", "2026年1-3月"),
("2026年1-12月", "year", "2026年1-12月(全年)", "2026年1-12月"),
("2026年8月", "month", "2026年8月(单月)", "2026年8月"),
("2026年1-8月", "ytd", "2026年1-8月(YTD 年初至今)", "2026年1-8月"),
("2025年9月-2026年8月", "ttm", "2025年9月-2026年8月(TTM 滚动 12 月)", "2025年9月-2026年8月"),
("2026年6-9月", "custom", "2026年6-9月(自定义区间)", "2026年6-9月"),
])
def test_apply_period_suffix_discipline(label, key, unclipped, clipped):
"""R3:未裁剪 ⇒ 保留后缀;发生任何裁剪 ⇒ 去后缀。"""
assert apply_period_suffix(label, key, False) == unclipped
assert apply_period_suffix(label, key, True) == clipped
# ── 全形态矩阵:任意入参下不得倒挂、months 与标签自洽 ────────────────────────
@pytest.mark.parametrize("period,start,end", [
("2026-Q1", None, None), ("2026-Q3", None, None), ("2026-Q4", None, None),
("2026-H1", None, None), ("2026-H2", None, None),
("2026-08", None, None), ("2026-10", None, None),
("2026", None, None), ("2026-01", None, None), ("2027", None, None),
("2025-Q1", None, None), ("2025-Q4", None, None),
("ytd", None, None), ("ttm", None, None),
("custom", "2026-06", "2026-12"), ("custom", "2026-10", "2026-12"),
("custom", "2026-11", "2026-11"), ("custom", "2025-01", "2026-06"),
])
def test_matrix_no_inversion_and_self_consistency(period, start, end):
w = rwd(period, start, end, anchor="2026-08")
if w.get("error"):
assert "无交集" in w["error"] or "无可用" in w["error"], w
return
assert w["period_start"] <= w["period_end"], w
assert w["months"] == months_between(w["period_start"], w["period_end"]), w
assert w["months"] >= 1, w
assert D_AVAIL[0] <= w["period_start"] <= w["period_end"] <= D_AVAIL[-1], w
assert w["clipped"] is (w["nominal_months"] != w["months"]), w
def test_clip_note_present_iff_clipped():
for period, start, end in [("2026-Q3", None, None), ("2026-H2", None, None),
("2026", None, None), ("2025-Q1", None, None),
("custom", "2025-01", "2026-06")]:
w = rwd(period, start, end)
if w.get("error"):
assert "clip_note" not in w
continue
assert (w["clip_note"] is not None) is w["clipped"], (period, w)
+6 -2
View File
@@ -100,10 +100,14 @@ def test_ytd_ttm_unchanged():
assert (ytd["period_start"], ytd["period_end"]) == ("2026-01", "2026-10")
assert ytd["months"] == 10
# M2(2026-10-05 裁定 §4):TTM 名义区间 = [anchor−11, anchor] = **12 个月**。
# 历史断言写的是 13 个月(anchor 的「去年同月」),即被裁定为缺陷的口径,见
# test_dupont_oob_window_ruling_20261005.py / 本文件 §裁定回归。
ttm = rw("ttm", anchor="2026-10")
assert ttm["period_key"] == "ttm"
assert (ttm["period_start"], ttm["period_end"]) == ("2025-10", "2026-10")
assert ttm["months"] == 13
assert (ttm["period_start"], ttm["period_end"]) == ("2025-11", "2026-10")
assert ttm["nominal_months"] == 12
assert ttm["months"] == 12
def test_custom_unchanged():