"""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 def save_forecast_logs(entity_id: int, results: list, db: Session, model: str = "linear") -> int: """预测结果落库 kpi_forecast_log(预测偏差告警数据源, 2026-08-25 升级2a) 存每KPI的下一期预测;同KPI同预测期覆盖(保留最新)""" from app.models import KpiForecastLog, KPIDefinition # 预加载 KPI id 映射(返回结果里的 kpi 无 id 字段,需从DB查) kpi_map = {k.kpi_code: k.id for k in db.query(KPIDefinition).filter( KPIDefinition.entity_id == entity_id).all()} saved = 0 for r in results: kpi_info = r.get("kpi", {}) fc = r.get("forecast") or [] if not fc: continue first = fc[0] period = first.get("period", "") val = first.get("predicted") or first.get("value") or first.get("next_value") if not period or val is None: continue kpi_code = kpi_info.get("code", "") kpi_id = kpi_info.get("id") or kpi_map.get(kpi_code) if not kpi_id: continue # 同KPI同预测期覆盖 existing = db.query(KpiForecastLog).filter( KpiForecastLog.entity_id == entity_id, KpiForecastLog.kpi_id == kpi_id, KpiForecastLog.period == period, ).first() if existing: existing.forecast_value = float(val) existing.model = model existing.confidence = r.get("confidence") existing.trend = r.get("trend") else: db.add(KpiForecastLog( entity_id=entity_id, kpi_id=kpi_id, kpi_code=kpi_code, period=period, forecast_value=float(val), model=model, confidence=r.get("confidence"), trend=r.get("trend"), )) saved += 1 db.commit() return saved # ════════════════════════════════════════════════════════════ # 宏观敏感性因素联动(IMA 2026.7 Predictive Cost Intelligence 完整版) # 内置宏观因素 → 按KPI类型推断弹性系数 → 调整预测值 # MVP:弹性系数为规则推断+可调,非历史回归(诚实标注"模型弹性") # v2(2026-08-25): 内置宏观历史数据 → 变化率弹性校准(有数据用回归,无数据回退规则) # ════════════════════════════════════════════════════════════ MACRO_FACTORS = [ {"key": "oil", "name": "原油价格", "unit": "美元/桶", "desc": "油价↑ → 运输/能源成本↑ → 成本类KPI↑、利润类KPI↓"}, {"key": "usd", "name": "美元汇率", "unit": "USD/CNY", "desc": "美元↑ → 进口成本↑(成本类↑)、出口收入↑(营收类↑)"}, {"key": "cpi", "name": "CPI通胀率", "unit": "%", "desc": "CPI↑ → 成本↑、名义营收↑"}, ] # 内置宏观因素历史数据(月度,2026-01 ~ 2026-07,供变化率弹性校准) MACRO_FACTOR_HISTORY = { "oil": [ {"period": "2026-01", "value": 74.0}, {"period": "2026-02", "value": 78.0}, {"period": "2026-03", "value": 76.0}, {"period": "2026-04", "value": 82.0}, {"period": "2026-05", "value": 79.0}, {"period": "2026-06", "value": 85.0}, {"period": "2026-07", "value": 88.0}, ], "usd": [ {"period": "2026-01", "value": 7.05}, {"period": "2026-02", "value": 7.08}, {"period": "2026-03", "value": 7.06}, {"period": "2026-04", "value": 7.10}, {"period": "2026-05", "value": 7.12}, {"period": "2026-06", "value": 7.15}, {"period": "2026-07", "value": 7.18}, ], "cpi": [ {"period": "2026-01", "value": 1.8}, {"period": "2026-02", "value": 1.9}, {"period": "2026-03", "value": 1.9}, {"period": "2026-04", "value": 2.0}, {"period": "2026-05", "value": 2.1}, {"period": "2026-06", "value": 2.1}, {"period": "2026-07", "value": 2.2}, ], } # 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, "elasticity_source": "rule", }) return out def _rate_of_change(series: list) -> list: """相邻期变化率列表 [(period, pct), ...]""" out = [] for i in range(1, len(series)): prev, cur = series[i - 1], series[i] if prev and prev.get("value"): pct = (cur["value"] - prev["value"]) / prev["value"] * 100 out.append((cur["period"], pct)) return out def elasticity_from_history(kpi_history: list, factor_key: str, direction: str) -> Optional[dict]: """变化率弹性校准:KPI历史 vs 宏观因素历史(同period匹配) 弹性 = mean(KPI变化率 / 因素变化率)(符号由实际数据决定) 匹配期数 < 2 或无因素数据 → 返回 None(回退规则) """ factor_hist = MACRO_FACTOR_HISTORY.get(factor_key) if not factor_hist or not kpi_history: return None kpi_by_period = {h.get("period"): h.get("value") for h in kpi_history if h.get("value") is not None} ratios = [] # 因素相邻期变化率 for i in range(1, len(factor_hist)): fp = factor_hist[i]["period"] fv = factor_hist[i]["value"] fv_prev = factor_hist[i - 1]["value"] if not fv_prev: continue f_chg = (fv - fv_prev) / fv_prev * 100 # KPI 同期值(以及上一期,用于算KPI变化) k_cur = kpi_by_period.get(fp) # KPI 在因素上一期的值(模糊匹配上一月度) k_prev = kpi_by_period.get(factor_hist[i - 1]["period"]) if k_cur is not None and k_prev not in (None, 0) and abs(f_chg) > 0.01: k_chg = (k_cur - k_prev) / k_prev * 100 ratios.append(k_chg / f_chg) if len(ratios) < 2: return None import statistics raw_elasticity = statistics.median(ratios) # 弹性合理性校验: |弹性| 超出 [0.01, 0.5] 视为数据噪声 → 回退规则推断(诚实标注,不用失真校准) if not (0.01 <= abs(raw_elasticity) <= 0.5): return None elasticity = round(raw_elasticity, 4) # 方向由数据符号决定;数据符号与规则方向冲突时以数据为准(标注) data_direction = "+" if elasticity >= 0 else "-" return { "elasticity": abs(elasticity), "direction": data_direction, "matched_periods": len(ratios), "elasticity_source": "history", "rule_direction": direction, } def factor_sensitivity_with_history(kpi_name: str, kpi_code: str = "", kpi_history: Optional[list] = None) -> list: """增强版敏感性:有历史数据用变化率弹性校准,无数据回退规则推断""" base = factor_sensitivity_for_kpi(kpi_name, kpi_code) out = [] for s in base: hist_el = elasticity_from_history(kpi_history or [], s["factor_key"], s["direction"]) if kpi_history else None if hist_el: out.append({ **s, "elasticity": hist_el["elasticity"], "direction": hist_el["direction"], "elasticity_source": hist_el["elasticity_source"], "matched_periods": hist_el["matched_periods"], "rule_direction": hist_el["rule_direction"], }) else: out.append(s) 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)