feat: 预测性成本智能升级 — 历史回归弹性校准 + 预测偏差告警
升级1: 宏观敏感性弹性历史校准 - 内置宏观历史数据(oil/usd/cpi 2026-01~07月度) - 变化率弹性: 同period匹配KPI历史vs因素历史算弹性 - 合理性校验: |弹性|超出[0.01,0.5]视为噪声回退规则(诚实标注) 升级2: 预测偏差告警闭环 - 新表 kpi_forecast_log(预测历史)+模型KpiForecastLog - 预测时落库(同KPI同预测期覆盖) - alert_rules 支持 rule_type=forecast_deviation(threshold_pct) - POST /alert-rules/run-forecast-deviation: 预测vs实际偏差>阈值生成预警(去重, 超2倍阈值红色) - 端到端验证: 模拟实际500vs预测399.55→偏差20.1%>5%→红色预警生成 回归: pytest 40 passed(predict+alerts)
This commit is contained in:
@@ -107,7 +107,7 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if rule_type not in ("static", "dynamic", "trend_up", "trend_down"):
|
||||
if rule_type not in ("static", "dynamic", "trend_up", "trend_down", "forecast_deviation"): # 升级2b: 预测偏差
|
||||
raise HTTPException(400, f"不支持的规则类型: {rule_type}")
|
||||
|
||||
rule = AlertRule(
|
||||
@@ -619,3 +619,78 @@ def generate_alert_suggestions(db: Session = Depends(get_db)):
|
||||
|
||||
db.commit()
|
||||
return {"message": f"已为{updated}条预警生成情景建议", "updated": updated}
|
||||
|
||||
|
||||
@router.post("/run-forecast-deviation")
|
||||
def run_forecast_deviation_check(
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""预测偏差检查(升级2b, 2026-08-25)— alert_rules type=forecast_deviation
|
||||
对每条偏差规则: 取最新预测log(kpi_forecast_log) vs 该期实际值(kpi_values),
|
||||
偏差 > threshold_pct → 生成/更新 pending 预警(去重)"""
|
||||
from app.models import KpiForecastLog
|
||||
rules = db.query(AlertRule).filter(
|
||||
AlertRule.entity_id == entity_id,
|
||||
AlertRule.rule_type == "forecast_deviation",
|
||||
AlertRule.enabled == 1,
|
||||
).all()
|
||||
if not rules:
|
||||
return {"message": "无预测偏差规则,可先创建 rule_type=forecast_deviation 规则", "generated": 0}
|
||||
|
||||
generated = 0
|
||||
for rule in rules:
|
||||
try:
|
||||
params = rule.params or {}
|
||||
threshold = float(params.get("threshold_pct", 15))
|
||||
# 最新预测
|
||||
log = db.query(KpiForecastLog).filter(
|
||||
KpiForecastLog.entity_id == entity_id,
|
||||
KpiForecastLog.kpi_id == rule.kpi_id,
|
||||
).order_by(KpiForecastLog.created_at.desc()).first()
|
||||
if not log or log.forecast_value is None:
|
||||
continue
|
||||
# 该预测期的实际值(同period匹配;兼容 2026-H1 等半年度)
|
||||
actual = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == rule.kpi_id,
|
||||
KPIValue.period == log.period,
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
if not actual or not actual.actual_value:
|
||||
continue
|
||||
base = abs(actual.actual_value)
|
||||
if base < 1e-9:
|
||||
continue
|
||||
deviation_pct = abs(log.forecast_value - actual.actual_value) / base * 100
|
||||
if deviation_pct <= threshold:
|
||||
continue
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
|
||||
kpi_label = f"{kpi.kpi_name}({kpi.kpi_code})" if kpi else f"KPI#{rule.kpi_id}"
|
||||
alert_level = "red" if deviation_pct > threshold * 2 else "yellow"
|
||||
alert_message = (
|
||||
f"预测偏差 {deviation_pct:.1f}% > 阈值{threshold}%:"
|
||||
f"{kpi_label} 预测{log.period}={log.forecast_value},实际={actual.actual_value}"
|
||||
)
|
||||
# 去重: 同KPI+period 已有 pending 偏差预警
|
||||
existing = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == rule.kpi_id,
|
||||
KPIAlert.alert_message.like(f"%预测偏差%{log.period}%"),
|
||||
KPIAlert.status == "pending",
|
||||
).first()
|
||||
if existing:
|
||||
existing.alert_message = alert_message
|
||||
existing.alert_level = alert_level
|
||||
else:
|
||||
db.add(KPIAlert(
|
||||
kpi_id=rule.kpi_id,
|
||||
kpi_value_id=actual.id,
|
||||
alert_level=alert_level,
|
||||
alert_message=alert_message,
|
||||
alert_type="forecast",
|
||||
status="pending",
|
||||
))
|
||||
generated += 1
|
||||
except Exception as e:
|
||||
logger.error(f"预测偏差检查失败 rule_id={rule.id}: {e}")
|
||||
continue
|
||||
db.commit()
|
||||
return {"message": f"预测偏差检查完成: {generated}条", "generated": generated}
|
||||
|
||||
@@ -719,7 +719,8 @@ def api_growth_quality(request: Request, data: dict):
|
||||
# ── KPI趋势预测(预测性成本智能 MVP) ────────────────────────────
|
||||
from app.utils.kpi_forecast_engine import ( # noqa: E402
|
||||
MODELS, forecast_kpi, forecast_finance_kpis,
|
||||
MACRO_FACTORS, factor_sensitivity_for_kpi, adjusted_next_with_factor,
|
||||
MACRO_FACTORS, factor_sensitivity_for_kpi, factor_sensitivity_with_history,
|
||||
adjusted_next_with_factor, save_forecast_logs,
|
||||
)
|
||||
|
||||
|
||||
@@ -758,6 +759,10 @@ def api_kpi_forecast_finance(
|
||||
if model not in MODELS:
|
||||
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
|
||||
results = forecast_finance_kpis(entity_id, db, periods=periods, model=model)
|
||||
try:
|
||||
save_forecast_logs(entity_id, results, db, model=model) # 升级2a: 预测落库(供偏差告警)
|
||||
except Exception as e:
|
||||
logger.warning(f"预测落库失败(不影响返回): {e}")
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"model": model,
|
||||
@@ -786,7 +791,9 @@ def api_kpi_forecast_sensitivity(
|
||||
matrix = []
|
||||
for r in results:
|
||||
kpi_info = r.get("kpi", {})
|
||||
sens = factor_sensitivity_for_kpi(kpi_info.get("name", ""), kpi_info.get("code", ""))
|
||||
# v2: 有历史数据用变化率弹性校准,无数据回退规则推断
|
||||
sens = factor_sensitivity_with_history(
|
||||
kpi_info.get("name", ""), kpi_info.get("code", ""), r.get("history", []))
|
||||
next_val = r.get("next_target")
|
||||
factor_effects = []
|
||||
for s in sens:
|
||||
@@ -798,6 +805,9 @@ def api_kpi_forecast_sensitivity(
|
||||
"factor_unit": s["factor_unit"],
|
||||
"direction": s["direction"],
|
||||
"elasticity": s["elasticity"],
|
||||
"elasticity_source": s.get("elasticity_source", "rule"),
|
||||
"matched_periods": s.get("matched_periods"),
|
||||
"rule_direction": s.get("rule_direction"),
|
||||
"adj_up": up_val,
|
||||
"adj_down": down_val,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user