- 内置3宏观因素: 原油价格/美元汇率/CPI通胀率 - 敏感性引擎: 按KPI类别推断弹性(成本类油价0.15/利润类0.12/营收类0.08), 方向+因素涨KPI涨 - 负值KPI(亏损)方向反转修复: 油价涨→净利更亏 - API: GET /predict/kpi-forecast/sensitivity?pct=10 → KPI×因素矩阵(±pct调整后预测) - 前端: 敏感性幅度选择(±5/10/20%) + 敏感性矩阵表(同向/反向+↑↓调整值) - 诚实标注: 模型弹性(规则推断,非历史回归), 后续可用宏观历史数据回归校准 - 验证: Chrome实测页面+API矩阵, pytest 46 passed
385 lines
15 KiB
Python
385 lines
15 KiB
Python
"""KPI预测引擎 — 基于历史KPI值做趋势预测(预测性成本智能 MVP)
|
||
|
||
模型(MVP原则:简单可用,不上深度学习):
|
||
- linear 线性回归(最小二乘 y = a + b·x),输出95%预测区间
|
||
- moving_average 简单移动平均(默认窗口3期),输出均值±波动区间
|
||
|
||
置信度诚实标注:基于历史数据量 + 拟合优度(R² / 波动率CV)综合打分,
|
||
数据不足时明确给出 low,不做虚假高置信。
|
||
|
||
复用 cash_forecast_engine.get_entity_kpi_history 取历史数据(不重复写查询)。
|
||
"""
|
||
import logging
|
||
import math
|
||
from typing import Optional
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.utils.cash_forecast_engine import get_entity_kpi_history, find_kpi
|
||
|
||
logger = logging.getLogger("cma.kpi_forecast")
|
||
|
||
MODELS = ("linear", "moving_average")
|
||
DEFAULT_PERIODS = 3
|
||
TREND_THRESHOLD_PCT = 3.0 # |趋势百分比| ≥ 3% 判定为有明确趋势方向
|
||
|
||
# 置信度档位
|
||
CONF_LEVELS = {3: "high", 2: "medium", 1: "low"}
|
||
# 中文映射(供 summary 使用)
|
||
TREND_CN = {"up": "上升", "down": "下降", "flat": "基本平稳"}
|
||
CONF_CN = {"high": "高", "medium": "中", "low": "低"}
|
||
|
||
|
||
def next_period(period: str, steps: int = 1) -> str:
|
||
"""期数递增:"2026-05" + 1 → "2026-06";解析失败时退化为 period+N"""
|
||
try:
|
||
y, m = str(period).split("-")
|
||
total = int(y) * 12 + (int(m) - 1) + steps
|
||
return f"{total // 12:04d}-{total % 12 + 1:02d}"
|
||
except Exception:
|
||
return f"{period}+{steps}"
|
||
|
||
|
||
def _t_crit(n: int) -> float:
|
||
"""95%双尾学生t临界值近似(小样本查表取保守值,大样本趋近1.96)"""
|
||
table = {
|
||
2: 12.71, 3: 4.30, 4: 3.18, 5: 2.78, 6: 2.57, 7: 2.45,
|
||
8: 2.31, 9: 2.26, 10: 2.23, 12: 2.18, 15: 2.13,
|
||
20: 2.09, 30: 2.04, 60: 2.00,
|
||
}
|
||
for k in sorted(table):
|
||
if n <= k:
|
||
return table[k]
|
||
return 1.96
|
||
|
||
|
||
def _std(values: list) -> float:
|
||
"""样本标准差(n>=2),n==1 返回0"""
|
||
n = len(values)
|
||
if n < 2:
|
||
return 0.0
|
||
mean = sum(values) / n
|
||
return math.sqrt(sum((v - mean) ** 2 for v in values) / (n - 1))
|
||
|
||
|
||
def _rel_trend_pct(values: list) -> float:
|
||
"""趋势百分比 = 线性回归斜率 / |均值| × 100(与 cash_forecast_engine.calc_trend 同口径)"""
|
||
n = len(values)
|
||
if n < 2:
|
||
return 0.0
|
||
xbar = (n - 1) / 2.0
|
||
ybar = sum(values) / n
|
||
sxx = sum((i - xbar) ** 2 for i in range(n))
|
||
if sxx == 0:
|
||
return 0.0
|
||
slope = sum((i - xbar) * (values[i] - ybar) for i in range(n)) / sxx
|
||
return slope / max(abs(ybar), 1.0) * 100
|
||
|
||
|
||
def judge_trend(trend_pct: float, threshold: float = TREND_THRESHOLD_PCT) -> str:
|
||
"""趋势方向判定:up / down / flat"""
|
||
if trend_pct > threshold:
|
||
return "up"
|
||
if trend_pct < -threshold:
|
||
return "down"
|
||
return "flat"
|
||
|
||
|
||
def _compute_r2(values: list, pred_fn) -> float:
|
||
"""拟合优度 R²(0~1),数据无波动时视为完全拟合"""
|
||
ybar = sum(values) / len(values)
|
||
ss_tot = sum((v - ybar) ** 2 for v in values)
|
||
if ss_tot == 0:
|
||
return 1.0
|
||
ss_res = sum((v - pred_fn(i)) ** 2 for i, v in enumerate(values))
|
||
return max(0.0, 1.0 - ss_res / ss_tot)
|
||
|
||
|
||
def compute_confidence(n: int, model: str, r2: Optional[float] = None,
|
||
cv: Optional[float] = None) -> str:
|
||
"""置信度诚实标注:数据量基数 + 拟合优度修正
|
||
|
||
- 数据量:n>=12 → 3分;n>=6 → 2分;否则 1分
|
||
- linear:R²>=0.7 +1;R²<0.3 -1
|
||
- moving_average:CV<0.3 +1(低波动更可信);CV>0.6 -1
|
||
"""
|
||
score = 3 if n >= 12 else (2 if n >= 6 else 1)
|
||
# 拟合度修正仅在样本量足够时生效:
|
||
# n<4 时 R² 无统计意义(2点直线必然R²=1.0),CV 也噪声大,不做上调,避免虚假高置信
|
||
if n >= 4:
|
||
if model == "linear" and r2 is not None:
|
||
if r2 >= 0.7:
|
||
score += 1
|
||
elif r2 < 0.3:
|
||
score -= 1
|
||
elif model == "moving_average" and cv is not None:
|
||
if cv < 0.3:
|
||
score += 1
|
||
elif cv > 0.6:
|
||
score -= 1
|
||
score = max(1, min(3, score))
|
||
return CONF_LEVELS[score]
|
||
|
||
|
||
def linear_forecast(values: list, periods: int = 3) -> dict:
|
||
"""线性回归预测 — 返回未来periods期预测值 + 95%预测区间 + 拟合统计量"""
|
||
n = len(values)
|
||
x = list(range(n))
|
||
xbar = (n - 1) / 2.0
|
||
ybar = sum(values) / n
|
||
sxx = sum((i - xbar) ** 2 for i in x)
|
||
slope = sum((i - xbar) * (values[i] - ybar) for i in x) / sxx if sxx else 0.0
|
||
intercept = ybar - slope * xbar
|
||
|
||
def pred(i: int) -> float:
|
||
return intercept + slope * i
|
||
|
||
# 残差标准误(n>=3 用 n-2 自由度;n==2 用样本标准差近似)
|
||
if n >= 3:
|
||
resid = [values[i] - pred(i) for i in x]
|
||
se = math.sqrt(sum(r * r for r in resid) / (n - 2))
|
||
else:
|
||
se = _std(values)
|
||
if se == 0:
|
||
se = max(abs(ybar) * 0.05, 1e-9) # 完全拟合时给最小带,避免零宽区间
|
||
|
||
t_crit = _t_crit(n)
|
||
forecast = []
|
||
for k in range(periods):
|
||
x0 = n + k
|
||
predicted = pred(x0)
|
||
se_pred = se * math.sqrt(1.0 + 1.0 / n + (x0 - xbar) ** 2 / max(sxx, 1e-9)) * t_crit
|
||
band = max(se_pred, abs(predicted) * 0.02)
|
||
forecast.append({
|
||
"predicted": round(predicted, 2),
|
||
"lower": round(predicted - band, 2),
|
||
"upper": round(predicted + band, 2),
|
||
})
|
||
|
||
r2 = _compute_r2(values, pred)
|
||
trend_pct = slope / max(abs(ybar), 1.0) * 100
|
||
return {
|
||
"forecast": forecast,
|
||
"slope": slope,
|
||
"intercept": intercept,
|
||
"r2": round(r2, 3),
|
||
"trend_pct": round(trend_pct, 2),
|
||
"se": round(se, 4),
|
||
}
|
||
|
||
|
||
def moving_average_forecast(values: list, periods: int = 3, window: int = 3) -> dict:
|
||
"""简单移动平均预测 — 未来各期预测值 = 最近window期均值;区间=均值±1.96×波动"""
|
||
n = len(values)
|
||
w = max(1, min(window, n))
|
||
base = sum(values[-w:]) / w
|
||
std = _std(values)
|
||
if std == 0:
|
||
std = max(abs(base) * 0.05, 1e-9)
|
||
band = max(1.96 * std, abs(base) * 0.02)
|
||
|
||
forecast = [{
|
||
"predicted": round(base, 2),
|
||
"lower": round(base - band, 2),
|
||
"upper": round(base + band, 2),
|
||
} for _ in range(periods)]
|
||
|
||
cv = std / abs(base) if base else 0.0
|
||
trend_pct = _rel_trend_pct(values)
|
||
return {
|
||
"forecast": forecast,
|
||
"window": w,
|
||
"mean": round(base, 2),
|
||
"std": round(std, 4),
|
||
"cv": round(cv, 3),
|
||
"trend_pct": round(trend_pct, 2),
|
||
}
|
||
|
||
|
||
def build_summary(kpi_name: str, unit: str, trend: str, next_target: Optional[float],
|
||
periods: int, n_history: int, confidence: str, model: str) -> str:
|
||
"""中文一句话解读"""
|
||
trend_cn = TREND_CN.get(trend, trend)
|
||
conf_cn = CONF_CN.get(confidence, confidence)
|
||
unit_txt = unit or ""
|
||
if periods <= 0:
|
||
return f"基于{n_history}期历史数据,{kpi_name}当前趋势{trend_cn}(模型:{model},置信度:{conf_cn}),未请求未来期数预测"
|
||
target_txt = f"{next_target:,.2f}{unit_txt}" if next_target is not None else "—"
|
||
return (
|
||
f"基于{n_history}期历史数据,{kpi_name}未来{periods}期预计{trend_cn},"
|
||
f"下一期预测值约{target_txt}(模型:{model},置信度:{conf_cn})"
|
||
)
|
||
|
||
|
||
def forecast_kpi(entity_id: int, kpi_code: str, db: Session,
|
||
periods: int = DEFAULT_PERIODS, model: str = "linear") -> Optional[dict]:
|
||
"""单个KPI预测(多租户隔离:历史数据通过 entity_id 维度查询)
|
||
|
||
返回 None 表示 KPI 不存在或历史数据不足(<2条)。
|
||
"""
|
||
if model not in MODELS:
|
||
model = "linear"
|
||
|
||
history = get_entity_kpi_history(entity_id, kpi_code, db, limit_months=120)
|
||
if not history:
|
||
return None
|
||
hist_asc = list(reversed(history)) # 按 period 升序
|
||
values = [float(v.actual_value) for v in hist_asc if v.actual_value is not None]
|
||
if len(values) < 2:
|
||
return None
|
||
|
||
kpi_def = find_kpi(db, entity_id, [kpi_code])
|
||
kpi_name = str(kpi_def.kpi_name) if kpi_def else kpi_code
|
||
unit = str(kpi_def.unit or "") if kpi_def else ""
|
||
|
||
if model == "moving_average":
|
||
res = moving_average_forecast(values, periods)
|
||
confidence = compute_confidence(len(values), model, cv=res["cv"])
|
||
else:
|
||
res = linear_forecast(values, periods)
|
||
confidence = compute_confidence(len(values), model, r2=res["r2"])
|
||
|
||
trend = judge_trend(res["trend_pct"])
|
||
|
||
# 未来期数(基于最近一期 period 递增)
|
||
last_period = hist_asc[-1].period
|
||
forecast = []
|
||
for k in range(periods):
|
||
fp = res["forecast"][k]
|
||
forecast.append({
|
||
"period": next_period(last_period, k + 1),
|
||
"predicted": fp["predicted"],
|
||
"lower": fp["lower"],
|
||
"upper": fp["upper"],
|
||
})
|
||
|
||
next_target = forecast[0]["predicted"] if forecast else None
|
||
summary = build_summary(kpi_name, unit, trend, next_target, periods,
|
||
len(values), confidence, model)
|
||
|
||
return {
|
||
"entity_id": entity_id,
|
||
"kpi": {"code": kpi_code, "name": kpi_name, "unit": unit},
|
||
"model": model,
|
||
"periods": periods,
|
||
"trend": trend,
|
||
"trend_pct": res["trend_pct"],
|
||
"confidence": confidence,
|
||
"history_count": len(values),
|
||
"history": [{"period": v.period, "value": round(float(v.actual_value), 2)} for v in hist_asc],
|
||
"forecast": forecast,
|
||
"next_target": next_target,
|
||
"summary": summary,
|
||
}
|
||
|
||
|
||
def forecast_finance_kpis(entity_id: int, db: Session,
|
||
periods: int = DEFAULT_PERIODS, model: str = "linear",
|
||
min_history: int = 3) -> list:
|
||
"""批量预测该企业全部财务维度KPI(历史≥min_history条),按可预测性排序"""
|
||
from app.models import KPIDefinition
|
||
kpis = db.query(KPIDefinition).filter(
|
||
KPIDefinition.entity_id == entity_id,
|
||
KPIDefinition.dimension == "finance",
|
||
KPIDefinition.status == "active",
|
||
).all()
|
||
|
||
results = []
|
||
for kpi in kpis:
|
||
r = forecast_kpi(entity_id, str(kpi.kpi_code), db, periods=periods, model=model)
|
||
if r and r["history_count"] >= min_history:
|
||
results.append(r)
|
||
|
||
# 可预测性排序:置信度(high=3/medium=2/low=1) 优先,其次历史数据量
|
||
score = {"high": 3, "medium": 2, "low": 1}
|
||
results.sort(key=lambda r: (score.get(r["confidence"], 0), r["history_count"]), reverse=True)
|
||
return results
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# 宏观敏感性因素联动(IMA 2026.7 Predictive Cost Intelligence 完整版)
|
||
# 内置宏观因素 → 按KPI类型推断弹性系数 → 调整预测值
|
||
# MVP:弹性系数为规则推断+可调,非历史回归(诚实标注"模型弹性")
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
MACRO_FACTORS = [
|
||
{"key": "oil", "name": "原油价格", "unit": "美元/桶",
|
||
"desc": "油价↑ → 运输/能源成本↑ → 成本类KPI↑、利润类KPI↓"},
|
||
{"key": "usd", "name": "美元汇率", "unit": "USD/CNY",
|
||
"desc": "美元↑ → 进口成本↑(成本类↑)、出口收入↑(营收类↑)"},
|
||
{"key": "cpi", "name": "CPI通胀率", "unit": "%",
|
||
"desc": "CPI↑ → 成本↑、名义营收↑"},
|
||
]
|
||
|
||
# KPI 类别关键词 → 因素方向/弹性 (direction: +因素涨KPI涨, -因素涨KPI跌)
|
||
FACTOR_RULES = {
|
||
"cost": { # 成本/费用类: 宏观涨 → 成本涨
|
||
"oil": {"direction": "+", "elasticity": 0.15},
|
||
"usd": {"direction": "+", "elasticity": 0.10},
|
||
"cpi": {"direction": "+", "elasticity": 0.10},
|
||
},
|
||
"revenue": { # 营收类: 通胀涨→名义营收涨
|
||
"oil": {"direction": "-", "elasticity": 0.05},
|
||
"usd": {"direction": "+", "elasticity": 0.08},
|
||
"cpi": {"direction": "+", "elasticity": 0.08},
|
||
},
|
||
"profit": { # 利润类: 宏观涨 → 成本挤压利润
|
||
"oil": {"direction": "-", "elasticity": 0.12},
|
||
"usd": {"direction": "-", "elasticity": 0.08},
|
||
"cpi": {"direction": "-", "elasticity": 0.08},
|
||
},
|
||
"cash": { # 现金流类
|
||
"oil": {"direction": "-", "elasticity": 0.06},
|
||
"usd": {"direction": "-", "elasticity": 0.04},
|
||
"cpi": {"direction": "-", "elasticity": 0.05},
|
||
},
|
||
}
|
||
|
||
# 类别关键词匹配(长词优先)
|
||
CATEGORY_KEYWORDS = [
|
||
("profit", ["净利润", "净利", "利润", "毛利", "ROE", "ROI", "EVA", "收益率", "报酬率"]),
|
||
("revenue", ["营收", "收入", "销售额", "销售", "产值", "客单"]),
|
||
("cost", ["费用率", "成本率", "费用", "成本", "费率", "应付", "返利", "渠补", "税"]),
|
||
("cash", ["现金流", "现金", "回款", "FCF", "资金"]),
|
||
]
|
||
|
||
|
||
def infer_kpi_category(kpi_name: str, kpi_code: str = "") -> str:
|
||
"""按KPI名称/编码推断类别: profit/revenue/cost/cash,兜底 profit(保守)"""
|
||
n = (kpi_name or "") + " " + (kpi_code or "")
|
||
for cat, kws in CATEGORY_KEYWORDS:
|
||
if any(kw in n for kw in kws):
|
||
return cat
|
||
return "profit"
|
||
|
||
|
||
def factor_sensitivity_for_kpi(kpi_name: str, kpi_code: str = "") -> list:
|
||
"""返回该KPI对3个宏观因素的敏感性(方向+弹性)"""
|
||
cat = infer_kpi_category(kpi_name, kpi_code)
|
||
rules = FACTOR_RULES.get(cat, FACTOR_RULES["profit"])
|
||
out = []
|
||
for f in MACRO_FACTORS:
|
||
r = rules.get(f["key"], {"direction": "-", "elasticity": 0.05})
|
||
out.append({
|
||
"factor_key": f["key"],
|
||
"factor_name": f["name"],
|
||
"factor_unit": f["unit"],
|
||
"factor_desc": f["desc"],
|
||
"direction": r["direction"],
|
||
"elasticity": r["elasticity"],
|
||
"category": cat,
|
||
})
|
||
return out
|
||
|
||
|
||
def adjusted_next_with_factor(next_target: Optional[float], pct: float,
|
||
direction: str, elasticity: float) -> Optional[float]:
|
||
"""因素变动 pct% → 调整后预测值: 方向+ 因素涨预测涨; 方向- 因素涨预测跌
|
||
负值KPI(亏损)方向反转: 方向- 时因素涨 → 更亏(更负)"""
|
||
if next_target is None:
|
||
return None
|
||
factor_change = pct * 0.01 # ±5% → 0.05
|
||
sign = 1.0 if direction == "+" else -1.0
|
||
if next_target < 0:
|
||
sign = -sign # 负值(亏损): 因素涨 → 更亏
|
||
return round(next_target * (1 + sign * factor_change * elasticity), 2)
|