Files
cma-management/backend/app/utils/dupont_basis.py
T
Hermes CI Fix 4fe956fcf9
ci/woodpecker/push/main Pipeline failed
fix(dupont): resolve YYYY-Qn / YYYY-Hn period windows (500 on quarter/half)
Root cause: resolve_window() matched quarter/half with p[4] == "q" / "h"
(always '-' for "2026-q2"/"2026-h1"), and the branches sat after the
single-month branch -> dead code. Real input fell into the month branch,
so months_between() raised ValueError on int('q2') -> HTTP 500 for all
18 quarter/half options in the period selector.

Fix: judge on p[5] in ("q","h") with a digit guard on p[6], evaluated
before the single-month branch; month branch tightened to p[5:].isdigit().
Existing error-branch wording unchanged.

Tests: backend/tests/test_dupont_period_options.py (18 defect options,
case equivalence, month/year/ytd/ttm/custom non-regression, malformed
input returns error dict instead of raising).
2026-10-04 18:34:05 +08:00

392 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""杜邦分析「区间取值」口径单一事实源 —— 流量累计 × 存量平均 × 可选年化 + 恒等式守护
依据(唯一有效出处):
· 任务件 `finance-20261003-dupont-period-basis-design-fullstack.md`
· 放行出处:任总 2026-10-03 09:49 CST DM「立」(承 09:31「杜邦分析如何进行区间取值设计」)
· 财务口径裁定见任务件 §二(本模块逐条落地,不另立口径)
核心原则(§二 开篇):
**区间一旦选定,流量(分子)与存量平均(分母)必须同区间;且三因子必须同区间——杜邦恒等式才成立。**
取值规则(§二.1,硬性):
· 损益类(流量) → 区间**累计发生额**(期间 [start, end] 内净额)
· 资产负债类(存量)→ 区间**平均余额** = (期初 + 期末) / 2
· 跨期比较 → **年化** = 流量 × 12 / 区间月数(比率型因子见 `annualize_factors` 说明)
fail-closed(§二.6):
期初余额缺失 ⇒ **显式**回落期末,并在返回值标注 `basis_fallback=true`;**禁静默**。
单一事实源(§二.5):
总资产 / 净资产 / 权益乘数 不再由前端拼 `raw_data`,而是由
`scripts/kpi_dupont_deriver.py` 统一派生为 `F_ASSET_TOTAL` / `F_EQUITY_TOTAL` /
`F_EQUITY_MULTIPLIER`(`source_type='derived'`);本模块是其**唯一读取入口**(派生器与
只读端点共用),口径参数落 `kpi_definitions.data_source_config.params`。
⚠️ 取数源(§二.5 注):**不用**旧脚本 `register_dupont_kpis.py` 的 ERP 直连近似口径
(那套只有「现金银行 + 固定资产」,缺应收/存货);一律走 CMA 库内
`subject_balances`(存量)+ `voucher_details`(流量)。
"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import text
#: 恒等式容差(百分点)——§二.3 建议 0.05
IDENTITY_TOLERANCE = 0.05
#: 支持的区间维度(前端选择器 §二.2)
PERIOD_PRESETS = ("ytd", "ttm", "month", "quarter", "half", "year", "custom")
# ────────────────────────── 账套口径(单一事实源) ──────────────────────────
#: 各账套会计科目体系差异(实测 2026-10-03):
#: · entity 1(酣客):旧准则科目 —— 收入 5001,权益 3xxx,资产 1xxx,负债 2xxx;
#: 净利润有期末结转腿(`carry_forward=1` 的 3xxx 行),与已上线 verified 的月度
#: F_NET_PROFIT/F_ROE/F_ROA 同口径(实测 2026-08:F_NET_PROFIT=-10.46、F_ROE=-0.95、
#: F_ROA=-0.57 与本模块输出逐项一致)。
#: · entity 2(博海):新 30 号准则科目 —— 收入 6001/6051/6052,权益 4xxx,费用 6xxx;
#: `voucher_details` 无任何 `carry_forward=1` 行 ⇒ 净利润走损益类净额口径。
CALIBER_DEFAULTS: Dict[str, Any] = {
"asset_prefix": "1",
"liability_prefix": "2",
"1": { # entity_id=1 酣客
"equity_prefixes": ["3"],
"revenue_codes": ["5001"],
"pnl_prefixes": ["5", "6"],
"net_profit_mode": "carry_forward", # Σ(贷−借) over carry_forward=1 且 权益前缀
},
"2": { # entity_id=2 博海
"equity_prefixes": ["4"],
"revenue_codes": ["6001", "6051", "6052"],
"pnl_prefixes": ["6"],
"net_profit_mode": "pnl_net", # Σ(贷−借) over carry_forward=0 且 损益前缀
},
#: 未登记账套的保守兜底(新准则形态)
"_fallback": {
"equity_prefixes": ["4"],
"revenue_codes": ["6001", "6051", "6052"],
"pnl_prefixes": ["6"],
"net_profit_mode": "pnl_net",
},
}
#: 参数读取入口(可被 `kpi_definitions.data_source_config.params` 覆盖;禁散落字面量)
PARAM_KPI_CODE = "F_ASSET_TOTAL"
def caliber_for(entity_id: int) -> Dict[str, Any]:
"""账套科目体系口径(内置默认;可供 DB 覆盖的键:equity_prefixes/revenue_codes/net_profit_mode)"""
ent = CALIBER_DEFAULTS.get(str(entity_id)) or CALIBER_DEFAULTS["_fallback"]
return {
"asset_prefix": CALIBER_DEFAULTS["asset_prefix"],
"liability_prefix": CALIBER_DEFAULTS["liability_prefix"],
"equity_prefixes": list(ent["equity_prefixes"]),
"revenue_codes": list(ent["revenue_codes"]),
"pnl_prefixes": list(ent["pnl_prefixes"]),
"net_profit_mode": ent["net_profit_mode"],
}
def load_dupont_params(db, entity_id: int) -> Dict[str, Any]:
"""读取账套口径参数:`kpi_definitions.data_source_config.params` → 内置默认兜底。
兼容形态:`{"params": {...}}` / 裸 `{...}` / JSON 字符串 / None。读取失败绝不抛错
(口径回落默认,端点不因参数缺件 500)。
"""
caliber = caliber_for(entity_id)
try:
row = db.execute(text(
"SELECT data_source_config FROM kpi_definitions "
"WHERE entity_id = :eid AND kpi_code = :code LIMIT 1"
), {"eid": entity_id, "code": PARAM_KPI_CODE}).first()
except Exception: # pragma: no cover - 表缺失等极端情形
row = None
raw = row[0] if row else None
if isinstance(raw, (bytes, bytearray)):
raw = raw.decode("utf-8", "replace")
if isinstance(raw, str):
try:
raw = json.loads(raw)
except Exception:
raw = None
if isinstance(raw, dict):
params = raw.get("params") if isinstance(raw.get("params"), dict) else raw
for key in ("equity_prefixes", "revenue_codes", "pnl_prefixes", "net_profit_mode",
"asset_prefix", "liability_prefix"):
if key in params:
caliber[key] = params[key]
caliber["_params_source"] = "kpi_definitions.data_source_config.params"
else:
caliber["_params_source"] = "builtin-default"
return caliber
# ────────────────────────── 区间窗口 ──────────────────────────
def prev_month(period: str) -> str:
y, m = int(period[:4]), int(period[5:7])
if m == 1:
return f"{y - 1}-12"
return f"{y}-{m - 1:02d}"
def month_index(period: str) -> int:
return int(period[5:7])
def months_between(start: str, end: str) -> int:
"""闭区间 [start, end] 的月数(含首尾)。"""
if not start or not end:
return 0
return (int(end[:4]) - int(start[:4])) * 12 + (int(end[5:7]) - int(start[5:7])) + 1
def _fmt_cn(start: str, end: str) -> str:
if start == end:
return f"{start[:4]}年{int(start[5:7])}月"
return f"{start[:4]}年{int(start[5:7])}-{int(end[5:7])}月"
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]:
"""区间维度 → [period_start, period_end](闭区间,YYYY-MM)。
§二.2 区间维度:月 / 季 / 半年 / 年 / YTD(默认)/ TTM / 自定义起止。
可用月域 `available`(升序 YYYY-MM 列表)用于:① 缺省锚定;② 区间裁剪。
`anchor` = 默认区间的锚月(端点传入「存量最新月」`latest_stock_month`,缺省取 available 末月)。
⚠️ 裁剪纪律:区间上界**只裁不扩**——超出可用月域即裁到可用域(并在 `clip_note` 留痕),
绝不因裁剪而静默改变用户已选下界(下界越界则显式 400,见 `validate`)。
"""
avail = sorted(available or [])
latest = avail[-1] 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()
note = None
if not p or p in ("ytd", "default"):
if not anchor:
return {"error": "无可用数据月域,无法解析默认 YTD 区间"}
s, e = f"{anchor[:4]}-01", anchor
key = "ytd"
elif p in ("ttm",):
if not anchor:
return {"error": "无可用数据月域,无法解析 TTM 区间"}
y, m = int(anchor[:4]), int(anchor[5:7])
s = f"{y - 1}-{m:02d}" if m != 12 else f"{y}-01"
e = anchor
key = "ttm"
elif p in ("custom",) or start or end:
if not (start and end):
return {"error": "自定义区间须同时给出 start 与 end(YYYY-MM)"}
s, e = start, end
key = "custom"
elif len(p) == 7 and p[5] == "q" and p[6].isdigit(): # 2026-q3
y, q = int(p[:4]), int(p[6])
s, e = f"{y}-{(q - 1) * 3 + 1:02d}", f"{y}-{q * 3:02d}"
key = "quarter"
elif len(p) == 7 and p[5] == "h" and p[6].isdigit(): # 2026-h1
y, h = int(p[:4]), int(p[6])
s, e = f"{y}-{(h - 1) * 6 + 1:02d}", f"{y}-{h * 6:02d}"
key = "half"
elif len(p) == 7 and p[5:].isdigit(): # 2026-08 → 单月
s = e = p
key = "month"
elif len(p) == 4 and p.isdigit(): # 2026 → 全年
s, e = f"{p}-01", f"{p}-12"
key = "year"
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)
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,
}
# ────────────────────────── 取数(流量 / 存量) ──────────────────────────
def _flow_expr(field: str) -> str:
"""流量表达式。命名显式化(避免与存量 net 的借−贷/贷−借混淆):
· `credit` → 贷方发生额
· `debit` → 借方发生额
· `credit_debit` → 贷−借(收入类、结转腿自然方向)
· `debit_credit` → 借−贷(成本费用类自然方向)
"""
expr = {"credit": "credit_amount", "debit": "debit_amount",
"credit_debit": "(credit_amount - debit_amount)",
"debit_credit": "(debit_amount - credit_amount)"}.get(field)
if expr is None:
raise ValueError(f"未知流量字段 {field!r}(credit/debit/credit_debit/debit_credit)")
return expr
def sum_flows(db, entity_id: int, start: str, end: str, spec: Dict[str, Any]) -> float:
"""流量累计(元)。spec: {codes:[...]|prefixes:[...], field: 见 _flow_expr, carry_forward: 0|1}"""
parts, args = [], {"eid": entity_id, "s": start, "e": end}
where = ["entity_id = :eid", "period >= :s", "period <= :e"]
cf = spec.get("carry_forward")
if cf is not None:
where.append("carry_forward = :cf")
args["cf"] = int(cf)
for i, code in enumerate(spec.get("codes") or []):
parts.append(f"subject_code = :c{i}")
args[f"c{i}"] = code
for i, pfx in enumerate(spec.get("prefixes") or []):
parts.append(f"subject_code LIKE :p{i}")
args[f"p{i}"] = f"{pfx}%"
if parts:
where.append("(" + " OR ".join(parts) + ")")
sql = (f"SELECT COALESCE(SUM({_flow_expr(spec.get('field', 'credit_debit'))}), 0) "
f"FROM voucher_details WHERE " + " AND ".join(where))
return float(db.execute(text(sql), args).scalar() or 0.0)
def _bal_expr(field: str) -> str:
return {"cb": "(cb_dr - cb_cr)", "cb_rev": "(cb_cr - cb_dr)",
"ob": "(ob_dr - ob_cr)", "ob_rev": "(ob_cr - ob_dr)"}[field]
def stock_balance(db, entity_id: int, period: str, prefixes: List[str],
side: str = "net") -> Dict[str, Optional[float]]:
"""存量余额(元):期初 `ob_*` / 期末 `cb_*`,一级科目(`level=1`,防跨层双计)。
side: 'net' = 借−贷(资产用)/ 'net_rev' = 贷−借(权益、负债用)。
返回 {"begin": 元|None, "end": 元|None, "row_found": bool}
ob 字段: net→ob / net_rev→ob_rev;cb 同理。
"""
ob_f = "ob" if side == "net" else "ob_rev"
cb_f = "cb" if side == "net" else "cb_rev"
pfx_sql, args = [], {"eid": entity_id, "period": period}
for i, pfx in enumerate(prefixes):
pfx_sql.append(f"subject_code LIKE :p{i}")
args[f"p{i}"] = f"{pfx}%"
sql = (f"SELECT COALESCE(SUM({_bal_expr(ob_f)}), 0), COALESCE(SUM({_bal_expr(cb_f)}), 0), COUNT(*) "
f"FROM subject_balances WHERE entity_id = :eid AND period = :period AND `level` = 1 "
f"AND (" + " OR ".join(pfx_sql) + ")")
row = db.execute(text(sql), args).first()
if not row or not row[2]:
return {"begin": None, "end": None, "row_found": False}
return {"begin": float(row[0] or 0.0), "end": float(row[1] or 0.0), "row_found": True}
def available_months(db, entity_id: int) -> List[str]:
"""可用月域 = `voucher_details` 与 `subject_balances` 期间并集(升序)。
⚠️ 两次单表查询后 Python 合并,**不用 SQL `UNION`** —— 两表 `period` 列 collation
不同,MySQL 直接 UNION 会报 `1271 Illegal mix of collations`(2026-10-03 实测)。
"""
months: set = set()
for tbl in ("voucher_details", "subject_balances"):
rows = db.execute(text(
f"SELECT DISTINCT period FROM {tbl} WHERE entity_id = :eid"
), {"eid": entity_id}).fetchall()
months.update(str(r[0]) for r in rows if r[0])
return sorted(months)
def latest_stock_month(db, entity_id: int) -> Optional[str]:
"""最后一个**存量可取**的期间(subject_balances 有该期一级科目行)。
默认区间锚点必须取它而非流量最新月:两账套刻度不同(实测 entity2 流量到 2026-09、
存量只到 2026-08),若按流量最新月锚定,默认视图会落到「期末存量缺失」的区间上
(fail-closed ⇒ 全空),属可用性缺陷而非诚实的 fail-closed。
"""
row = db.execute(text(
"SELECT MAX(period) FROM subject_balances WHERE entity_id = :eid AND `level` = 1"
), {"eid": entity_id}).scalar()
return str(row) if row else None
# ────────────────────────── 纯计算(无 DB,便于单测) ──────────────────────────
def annualize_factors(factors: Dict[str, Optional[float]], months: int,
annualize: bool) -> Dict[str, Any]:
"""年化(§二.1):**流量** × 12 / 区间月数。
比率型因子的年化是非平凡的,本函数显式给出每因子的实际缩放倍数,避免「看着像没生效」:
· 净利率 = 净利润/营业收入 —— 分子分母同为流量,×k 相消 ⇒ scale=1.0(**值不变**)
· 总资产周转率 = 营业收入/平均总资产 —— 仅分子年化 ⇒ scale=k
· 权益乘数 = 平均总资产/平均净资产 —— 分子分母同为存量 ⇒ scale=1.0(**值不变**)
· ROE = 净利润/平均净资产 —— 仅分子年化 ⇒ scale=k
这也正是恒等式在年化下继续成立的原因:
k·净利润/平均净资产 ≡ 净利率 × (k·周转率) × 权益乘数。
"""
k = (12.0 / months) if (annualize and months) else 1.0
scales = {"net_profit_margin": 1.0, "asset_turnover": k, "equity_multiplier": 1.0,
"roe": k}
out: Dict[str, Any] = {"scale": k, "factors": {}, "per_factor_scale": scales}
for name, val in (factors or {}).items():
s = scales.get(name, 1.0)
out["factors"][name] = None if val is None else val * s
return out
def compute_factors(net_profit_w: Optional[float], revenue_w: Optional[float],
total_assets_w: Optional[float], equity_w: Optional[float]) -> Dict[str, Optional[float]]:
"""三因子 + ROE(万元口径;ROE/净利率为百分点)。任一分母为 0/缺失 ⇒ 该因子 None(fail-closed)。"""
out = {"net_profit_margin": None, "asset_turnover": None,
"equity_multiplier": None, "roe": None}
if revenue_w:
out["net_profit_margin"] = net_profit_w / revenue_w * 100.0 if net_profit_w is not None else None
if total_assets_w:
out["asset_turnover"] = revenue_w / total_assets_w if revenue_w is not None else None
if equity_w:
out["equity_multiplier"] = total_assets_w / equity_w
if net_profit_w is not None:
out["roe"] = net_profit_w / equity_w * 100.0
return {k: (None if v is None else v) for k, v in out.items()}
def check_identity(roe: Optional[float], net_profit_margin: Optional[float],
asset_turnover: Optional[float], equity_multiplier: Optional[float],
tolerance: float = IDENTITY_TOLERANCE) -> Dict[str, Any]:
"""恒等式守护(§二.3):| ROE − 净利润率 × 总资产周转率 × 权益乘数 | ≤ 容差(百分点)。
不成立 ⇒ `status='identity_broken'`,调用方**不得返回数值**(只返回三因子各自的区间元数据)。
"""
if None in (roe, net_profit_margin, asset_turnover, equity_multiplier):
return {"checked": False, "status": "unavailable", "tolerance": tolerance,
"reason": "三因子或 ROE 不可得(分母缺失),恒等式免检"}
product = net_profit_margin / 100.0 * asset_turnover * equity_multiplier * 100.0
diff = abs(roe - product)
return {"checked": True, "status": "pass" if diff <= tolerance else "identity_broken",
"roe": round(roe, 6), "product": round(product, 6), "diff": round(diff, 6),
"tolerance": tolerance}
def build_window_meta(label: str, start: str, end: str, months: int,
basis: str, annualized: bool, fallback: bool,
annualize_scale: float = 1.0,
fallback_reason: Optional[str] = None) -> Dict[str, Any]:
"""§二.4 口径元数据(随值返回):period_start / period_end / basis / annualized 四项。"""
meta = {"period": label, "period_start": start, "period_end": end, "months": months,
"basis": basis, "annualized": bool(annualized), "basis_fallback": bool(fallback),
"annualize_scale": annualize_scale}
if fallback_reason:
meta["basis_fallback_reason"] = fallback_reason
return meta