feat: 预测性成本智能·宏观敏感性因素联动(IMA 2026.7完整版)
- 内置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
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"""预测模拟API — 管理会计OS"""
|
"""预测模拟API — 管理会计OS"""
|
||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
from fastapi import APIRouter, HTTPException, Depends, Request, Query
|
||||||
from app.utils.predict_engine import (
|
from app.utils.predict_engine import (
|
||||||
cvp_analysis, npv, irr,
|
cvp_analysis, npv, irr,
|
||||||
sensitivity_analysis, scenario_analysis,
|
sensitivity_analysis, scenario_analysis,
|
||||||
@@ -719,6 +719,7 @@ def api_growth_quality(request: Request, data: dict):
|
|||||||
# ── KPI趋势预测(预测性成本智能 MVP) ────────────────────────────
|
# ── KPI趋势预测(预测性成本智能 MVP) ────────────────────────────
|
||||||
from app.utils.kpi_forecast_engine import ( # noqa: E402
|
from app.utils.kpi_forecast_engine import ( # noqa: E402
|
||||||
MODELS, forecast_kpi, forecast_finance_kpis,
|
MODELS, forecast_kpi, forecast_finance_kpis,
|
||||||
|
MACRO_FACTORS, factor_sensitivity_for_kpi, adjusted_next_with_factor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -764,3 +765,56 @@ def api_kpi_forecast_finance(
|
|||||||
"total": len(results),
|
"total": len(results),
|
||||||
"data": results,
|
"data": results,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/kpi-forecast/sensitivity")
|
||||||
|
def api_kpi_forecast_sensitivity(
|
||||||
|
pct: float = Query(10, description="宏观因素变动幅度% (±)"),
|
||||||
|
periods: int = Query(3),
|
||||||
|
model: str = Query("linear"),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""宏观敏感性因素联动(IMA 2026.7)— 财务KPI × 宏观因素(油价/汇率/CPI)敏感性矩阵
|
||||||
|
输出:每个KPI的预测值 + 各因素 ±pct% 情景下的调整后预测值
|
||||||
|
MVP:弹性系数为规则推断(按KPI类别),诚实标注"模型弹性"非历史回归"""
|
||||||
|
if abs(pct) > 50:
|
||||||
|
raise HTTPException(400, "pct 必须在 ±50 以内")
|
||||||
|
if model not in MODELS:
|
||||||
|
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
|
||||||
|
results = forecast_finance_kpis(entity_id, db, periods=periods, model=model)
|
||||||
|
matrix = []
|
||||||
|
for r in results:
|
||||||
|
kpi_info = r.get("kpi", {})
|
||||||
|
sens = factor_sensitivity_for_kpi(kpi_info.get("name", ""), kpi_info.get("code", ""))
|
||||||
|
next_val = r.get("next_target")
|
||||||
|
factor_effects = []
|
||||||
|
for s in sens:
|
||||||
|
up_val = adjusted_next_with_factor(next_val, pct, s["direction"], s["elasticity"])
|
||||||
|
down_val = adjusted_next_with_factor(next_val, -pct, s["direction"], s["elasticity"])
|
||||||
|
factor_effects.append({
|
||||||
|
"factor_key": s["factor_key"],
|
||||||
|
"factor_name": s["factor_name"],
|
||||||
|
"factor_unit": s["factor_unit"],
|
||||||
|
"direction": s["direction"],
|
||||||
|
"elasticity": s["elasticity"],
|
||||||
|
"adj_up": up_val,
|
||||||
|
"adj_down": down_val,
|
||||||
|
})
|
||||||
|
matrix.append({
|
||||||
|
"kpi": kpi_info,
|
||||||
|
"category": sens[0]["category"] if sens else "profit",
|
||||||
|
"next_target": next_val,
|
||||||
|
"confidence": r.get("confidence"),
|
||||||
|
"trend": r.get("trend"),
|
||||||
|
"factors": factor_effects,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"model": model,
|
||||||
|
"periods": periods,
|
||||||
|
"pct": pct,
|
||||||
|
"factors": MACRO_FACTORS,
|
||||||
|
"total": len(matrix),
|
||||||
|
"data": matrix,
|
||||||
|
}
|
||||||
|
|||||||
@@ -293,3 +293,92 @@ def forecast_finance_kpis(entity_id: int, db: Session,
|
|||||||
score = {"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)
|
results.sort(key=lambda r: (score.get(r["confidence"], 0), r["history_count"]), reverse=True)
|
||||||
return results
|
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)
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export const predictApi = {
|
|||||||
// KPI趋势预测(预测性成本智能)
|
// KPI趋势预测(预测性成本智能)
|
||||||
kpiForecast: (params?: any) => api.get('/predict/kpi-forecast', { params }),
|
kpiForecast: (params?: any) => api.get('/predict/kpi-forecast', { params }),
|
||||||
kpiForecastFinance: (params?: any) => api.get('/predict/kpi-forecast/finance', { params }),
|
kpiForecastFinance: (params?: any) => api.get('/predict/kpi-forecast/finance', { params }),
|
||||||
|
kpiForecastSensitivity: (params?: any) => api.get('/predict/kpi-forecast/sensitivity', { params }),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deviationPushApi = {
|
export const deviationPushApi = {
|
||||||
|
|||||||
@@ -15,7 +15,13 @@
|
|||||||
<el-option label="6期" :value="6" />
|
<el-option label="6期" :value="6" />
|
||||||
<el-option label="12期" :value="12" />
|
<el-option label="12期" :value="12" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button type="primary" :loading="loading" style="margin-left: 16px;" @click="loadData">刷新预测</el-button>
|
<span class="ctrl-label" style="margin-left: 16px;">敏感性幅度</span>
|
||||||
|
<el-select v-model="sensPct" style="width: 90px" @change="loadSensitivity">
|
||||||
|
<el-option label="±5%" :value="5" />
|
||||||
|
<el-option label="±10%" :value="10" />
|
||||||
|
<el-option label="±20%" :value="20" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" :loading="loading" style="margin-left: 16px;" @click="loadAll">刷新预测</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ctrl-right">
|
<div class="ctrl-right">
|
||||||
<span class="mapping-hint">基于 kpi_values 历史数据 · 线性回归/移动平均 · 置信度诚实标注</span>
|
<span class="mapping-hint">基于 kpi_values 历史数据 · 线性回归/移动平均 · 置信度诚实标注</span>
|
||||||
@@ -92,6 +98,39 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 宏观敏感性联动(IMA 2026.7) -->
|
||||||
|
<el-card shadow="never" style="margin-top: 12px;">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>🌐 宏观敏感性因素联动</span>
|
||||||
|
<el-tag size="small" type="warning">模型弹性(规则推断,非历史回归)</el-tag>
|
||||||
|
<span class="empty-hint">因素变动 {{ sensPct }}% 对预测值的影响</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table :data="sensList" size="small" border stripe max-height="360">
|
||||||
|
<el-table-column prop="kpi.name" label="KPI" min-width="110" show-overflow-tooltip />
|
||||||
|
<el-table-column label="类别" width="70" align="center">
|
||||||
|
<template #default="{ row }">{{ catLabel(row.category) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="基准预测" width="100" align="right">
|
||||||
|
<template #default="{ row }">{{ fmtVal(row.next_target) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-for="f in sensFactors" :key="f.key" :label="f.name" min-width="150" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="factorOf(row, f.key)">
|
||||||
|
<span :style="{ color: factorOf(row, f.key).direction === '+' ? '#F56C6C' : '#67C23A' }">
|
||||||
|
{{ factorOf(row, f.key).direction === '+' ? '同向' : '反向' }}
|
||||||
|
</span>
|
||||||
|
<span class="sens-val">↑{{ fmtVal(factorOf(row, f.key).adj_up) }} ↓{{ fmtVal(factorOf(row, f.key).adj_down) }}</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="note-line" style="margin-top:8px;">
|
||||||
|
弹性系数按KPI类别推断(成本类对油价最敏感0.15、利润类0.12、营收类0.08),MVP规则模型,后续可用宏观历史数据回归校准。
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<!-- 预测说明 -->
|
<!-- 预测说明 -->
|
||||||
<el-card shadow="never" style="margin-top: 12px;">
|
<el-card shadow="never" style="margin-top: 12px;">
|
||||||
<div class="note-line">
|
<div class="note-line">
|
||||||
@@ -114,6 +153,18 @@ const selected = ref<any>(null)
|
|||||||
const chartRef = ref<HTMLElement | null>(null)
|
const chartRef = ref<HTMLElement | null>(null)
|
||||||
let chart: any = null
|
let chart: any = null
|
||||||
|
|
||||||
|
// ── 宏观敏感性联动 ──
|
||||||
|
const sensPct = ref(10)
|
||||||
|
const sensList = ref<any[]>([])
|
||||||
|
const sensFactors = ref<any[]>([])
|
||||||
|
|
||||||
|
function catLabel(c: string) {
|
||||||
|
return { profit: '利润', revenue: '营收', cost: '成本', cash: '现金流' }[c] || c
|
||||||
|
}
|
||||||
|
function factorOf(row: any, key: string) {
|
||||||
|
return (row.factors || []).find((f: any) => f.factor_key === key)
|
||||||
|
}
|
||||||
|
|
||||||
function lastHistory(row: any) {
|
function lastHistory(row: any) {
|
||||||
const h = row.history || []
|
const h = row.history || []
|
||||||
return h.length ? h[h.length - 1].value : null
|
return h.length ? h[h.length - 1].value : null
|
||||||
@@ -164,6 +215,20 @@ async function loadData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSensitivity() {
|
||||||
|
try {
|
||||||
|
const r: any = await predictApi.kpiForecastSensitivity({ pct: sensPct.value, periods: periods.value, model: model.value })
|
||||||
|
sensList.value = r.data || []
|
||||||
|
sensFactors.value = r.factors || []
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('敏感性加载失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
await Promise.all([loadData(), loadSensitivity()])
|
||||||
|
}
|
||||||
|
|
||||||
function onSelect(row: any) {
|
function onSelect(row: any) {
|
||||||
selected.value = row
|
selected.value = row
|
||||||
renderChart()
|
renderChart()
|
||||||
@@ -207,7 +272,7 @@ function resizeChart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData()
|
loadAll()
|
||||||
window.addEventListener('resize', resizeChart)
|
window.addEventListener('resize', resizeChart)
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -267,6 +332,11 @@ onBeforeUnmount(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #C0C4CC;
|
color: #C0C4CC;
|
||||||
}
|
}
|
||||||
|
.sens-val {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
.trend-chart {
|
.trend-chart {
|
||||||
height: 520px;
|
height: 520px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user