新增 /predict/relevant-decision API + 前端Tab(3场景):
1. 自制vs外购: 相关成本比较+无差别点(增量成本视角)
2. 特殊订单: 剩余产能下价格>变动成本即接受(增量利润)
3. 产品组合(约束理论): 单位约束资源边际贡献排序
验证: 自制850k vs 外购900k→自制; 特殊订单单位贡献25→接受;
产品组合B产品单位约束贡献50最高→优先
922 lines
41 KiB
Python
922 lines
41 KiB
Python
"""预测模拟API — 管理会计OS"""
|
||
import logging
|
||
from fastapi import APIRouter, HTTPException, Depends, Request, Query
|
||
from app.utils.predict_engine import (
|
||
cvp_analysis, npv, irr,
|
||
sensitivity_analysis, scenario_analysis,
|
||
)
|
||
from app.utils.cash_forecast_engine import (
|
||
forecast_cash_flow, save_forecast_to_db,
|
||
calculate_accuracy, generate_scenario_suggestion,
|
||
)
|
||
from app.database import get_db
|
||
from app.deps import get_entity_id, resolve_entity_for_request
|
||
from sqlalchemy.orm import Session
|
||
|
||
logger = logging.getLogger("cma.predict")
|
||
router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"])
|
||
|
||
|
||
@router.post("/cvp")
|
||
def api_cvp_analysis(data: dict):
|
||
"""CVP本量利分析"""
|
||
try:
|
||
result = cvp_analysis(
|
||
unit_price=float(data.get("unit_price", 0)),
|
||
unit_variable_cost=float(data.get("unit_variable_cost", 0)),
|
||
fixed_cost=float(data.get("fixed_cost", 0)),
|
||
target_profit=float(data["target_profit"]) if data.get("target_profit") else None,
|
||
actual_volume=float(data["actual_volume"]) if data.get("actual_volume") else None,
|
||
)
|
||
return result
|
||
except Exception as e:
|
||
raise HTTPException(400, f"CVP计算失败: {str(e)}")
|
||
|
||
|
||
@router.post("/investment")
|
||
def api_investment_analysis(data: dict):
|
||
"""投资决策分析(NPV/IRR/回收期)"""
|
||
try:
|
||
initial = float(data.get("initial_investment", 0))
|
||
rate = float(data.get("discount_rate", 10))
|
||
cash_flows = [float(cf) for cf in data.get("cash_flows", [])]
|
||
|
||
if not cash_flows:
|
||
raise HTTPException(400, "现金流列表不能为空")
|
||
|
||
npv_result = npv(initial, cash_flows, rate)
|
||
irr_result = irr(initial, cash_flows)
|
||
|
||
return {
|
||
"npv_analysis": npv_result,
|
||
"irr_analysis": irr_result,
|
||
}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
raise HTTPException(400, f"投资决策计算失败: {str(e)}")
|
||
|
||
|
||
@router.post("/sensitivity")
|
||
def api_sensitivity_analysis(data: dict):
|
||
"""敏感性分析"""
|
||
try:
|
||
result = sensitivity_analysis(
|
||
base_revenue=float(data.get("base_revenue", 0)),
|
||
base_cost=float(data.get("base_cost", 0)),
|
||
base_profit=float(data["base_profit"]) if data.get("base_profit") else None,
|
||
step=int(data.get("step", 5)),
|
||
max_step=int(data.get("max_step", 20)),
|
||
)
|
||
return result
|
||
except Exception as e:
|
||
raise HTTPException(400, f"敏感性分析失败: {str(e)}")
|
||
|
||
|
||
@router.post("/scenario")
|
||
def api_scenario_analysis(data: dict):
|
||
"""情景模拟"""
|
||
try:
|
||
optimistic = data.get("optimistic", {})
|
||
pessimistic = data.get("pessimistic", {})
|
||
base = data.get("base", {})
|
||
|
||
if not all([optimistic, pessimistic, base]):
|
||
raise HTTPException(400, "需要提供乐观/中性/悲观三个情景的参数")
|
||
|
||
result = scenario_analysis(
|
||
optimistic={
|
||
"revenue": float(optimistic.get("revenue", 0)),
|
||
"cost": float(optimistic.get("cost", 0)),
|
||
},
|
||
pessimistic={
|
||
"revenue": float(pessimistic.get("revenue", 0)),
|
||
"cost": float(pessimistic.get("cost", 0)),
|
||
},
|
||
base={
|
||
"revenue": float(base.get("revenue", 0)),
|
||
"cost": float(base.get("cost", 0)),
|
||
},
|
||
)
|
||
return result
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
raise HTTPException(400, f"情景模拟失败: {str(e)}")
|
||
|
||
|
||
@router.post("/cvp-detailed")
|
||
def api_cvp_detailed(data: dict):
|
||
"""CVP本量利详细分析 — 含改善方案推演和保本图数据 (CMA P2)"""
|
||
try:
|
||
fixed_cost = float(data.get("fixed_cost", 617))
|
||
variable_cost_rate = float(data.get("variable_cost_rate", 0.4862))
|
||
unit_price = float(data.get("unit_price", 228))
|
||
current_volume = float(data.get("current_volume", 5300))
|
||
|
||
contribution_margin_rate = 1 - variable_cost_rate
|
||
breakeven_revenue = round(fixed_cost / contribution_margin_rate, 2)
|
||
breakeven_units = round(breakeven_revenue * 10000 / unit_price, 0)
|
||
|
||
current_revenue = round(current_volume * unit_price / 10000, 2)
|
||
current_profit = round(current_revenue * (1 - variable_cost_rate) - fixed_cost, 2)
|
||
safety_margin = round((current_revenue - breakeven_revenue) / current_revenue * 100, 2) if current_revenue > 0 else 0
|
||
|
||
scenarios = [
|
||
{"name": "降固定费用至300万", "fixed_cost": 300, "variable_cost_rate": variable_cost_rate,
|
||
"breakeven_revenue": round(300 / contribution_margin_rate, 2),
|
||
"breakeven_units": round(300 / contribution_margin_rate * 10000 / unit_price, 0)},
|
||
{"name": "降变动成本率至30%", "fixed_cost": fixed_cost, "variable_cost_rate": 0.3,
|
||
"breakeven_revenue": round(fixed_cost / 0.7, 2),
|
||
"breakeven_units": round(fixed_cost / 0.7 * 10000 / unit_price, 0)},
|
||
{"name": "两者同时改善", "fixed_cost": 300, "variable_cost_rate": 0.3,
|
||
"breakeven_revenue": round(300 / 0.7, 2),
|
||
"breakeven_units": round(300 / 0.7 * 10000 / unit_price, 0)},
|
||
]
|
||
|
||
# 保本图数据点
|
||
chart_data = []
|
||
max_volume = int(max(breakeven_units * 2, current_volume * 3))
|
||
step = max(1, int(max_volume / 20))
|
||
for vol in range(0, int(max_volume) + step, step):
|
||
rev = round(vol * unit_price / 10000, 2)
|
||
tc = round(fixed_cost + rev * variable_cost_rate, 2)
|
||
chart_data.append({"volume": vol, "revenue": rev, "total_cost": tc, "profit": round(rev - tc, 2)})
|
||
|
||
return {
|
||
"fixed_cost": fixed_cost,
|
||
"variable_cost_rate": round(variable_cost_rate * 100, 2),
|
||
"unit_price": unit_price,
|
||
"contribution_margin_rate": round(contribution_margin_rate * 100, 2),
|
||
"breakeven_revenue": breakeven_revenue,
|
||
"breakeven_units": int(breakeven_units),
|
||
"current_revenue": current_revenue,
|
||
"current_profit": current_profit,
|
||
"current_volume": int(current_volume),
|
||
"safety_margin": safety_margin,
|
||
"scenarios": scenarios,
|
||
"chart_data": chart_data,
|
||
}
|
||
except Exception as e:
|
||
raise HTTPException(400, f"CVP详细分析失败: {str(e)}")
|
||
|
||
|
||
# ── 相关成本决策(CMA P2商业决策分析25%权重核心) ──────────────────
|
||
|
||
|
||
@router.post("/relevant-decision")
|
||
def api_relevant_decision(data: dict):
|
||
"""相关成本决策分析(CMA P2 商业决策分析核心内容)
|
||
场景: make-or-buy自制外购 / special-order特殊订单 / product-mix产品组合
|
||
"""
|
||
try:
|
||
decision_type = data.get("type", "make_or_buy")
|
||
|
||
if decision_type == "make_or_buy":
|
||
# 自制vs外购决策
|
||
# 相关成本 = 增量成本(只有随决策变化的成本才是相关的)
|
||
make_var_cost = float(data.get("make_variable_cost", 0)) # 自制单位变动成本
|
||
make_fixed = float(data.get("make_fixed_cost", 0)) # 自制新增固定成本
|
||
buy_price = float(data.get("buy_price", 0)) # 外购单价
|
||
demand = float(data.get("demand", 0)) # 需求量
|
||
existing_fixed = float(data.get("existing_fixed_cost", 0)) # 现有固定成本(无关成本,自制不增加则忽略)
|
||
|
||
make_total = make_var_cost * demand + make_fixed
|
||
buy_total = buy_price * demand
|
||
diff = buy_total - make_total # >0自制省钱
|
||
|
||
return {
|
||
"type": "自制vs外购",
|
||
"make_total_cost": round(make_total, 2),
|
||
"buy_total_cost": round(buy_total, 2),
|
||
"difference": round(diff, 2),
|
||
"recommendation": "自制" if diff > 0 else "外购",
|
||
"reason": f"自制总成本{make_total:.2f} vs 外购总成本{buy_total:.2f},{'自制节省' + str(round(diff,2)) if diff > 0 else '外购节省' + str(round(-diff,2))}",
|
||
"unit_make_cost": round(make_var_cost + (make_fixed / demand if demand else 0), 2),
|
||
"unit_buy_price": buy_price,
|
||
"indifferent_point": round(make_fixed / (buy_price - make_var_cost), 2) if buy_price > make_var_cost else None,
|
||
"notes": "仅考虑相关成本(增量成本);现有固定成本若不受决策影响则无关",
|
||
}
|
||
|
||
elif decision_type == "special_order":
|
||
# 特殊订单决策(有剩余产能时,只要价格>单位变动成本即接受)
|
||
normal_price = float(data.get("normal_price", 0))
|
||
special_price = float(data.get("special_price", 0))
|
||
var_cost = float(data.get("variable_cost", 0))
|
||
order_qty = float(data.get("order_qty", 0))
|
||
capacity_used = float(data.get("capacity_used", 0)) # 特殊订单占用产能%
|
||
extra_fixed = float(data.get("extra_fixed_cost", 0)) # 一次性额外固定成本
|
||
|
||
contribution_per_unit = special_price - var_cost
|
||
total_contribution = contribution_per_unit * order_qty - extra_fixed
|
||
accept = total_contribution > 0 and capacity_used <= 100
|
||
|
||
return {
|
||
"type": "特殊订单",
|
||
"unit_contribution": round(contribution_per_unit, 2),
|
||
"total_contribution": round(total_contribution, 2),
|
||
"extra_fixed_cost": extra_fixed,
|
||
"capacity_used_pct": capacity_used,
|
||
"recommendation": "接受" if accept else "拒绝",
|
||
"reason": f"单价{special_price} - 变动成本{var_cost} = 单位贡献{contribution_per_unit:.2f}" +
|
||
(f",共{total_contribution:.2f} > 0 且产能{capacity_used}%够用 → 接受(增量利润)" if accept else
|
||
f",总贡献{total_contribution:.2f} ≤ 0 或产能不足 → 拒绝"),
|
||
"notes": "有剩余产能时,只要价格>变动成本且不冲击正常市场即可接受;固定成本无关",
|
||
}
|
||
|
||
elif decision_type == "product_mix":
|
||
# 产品组合决策(约束理论:单位约束资源的边际贡献最大者优先)
|
||
products = data.get("products", []) # [{name, price, var_cost, constraint_usage, demand}]
|
||
results = []
|
||
for p in products:
|
||
cm_per_unit = float(p.get("price", 0)) - float(p.get("var_cost", 0))
|
||
cm_per_constraint = cm_per_unit / float(p.get("constraint_usage", 1))
|
||
results.append({
|
||
"name": p.get("name", ""),
|
||
"unit_contribution": round(cm_per_unit, 2),
|
||
"constraint_usage": float(p.get("constraint_usage", 1)),
|
||
"contribution_per_constraint": round(cm_per_constraint, 2),
|
||
"demand": float(p.get("demand", 0)),
|
||
})
|
||
# 按单位约束资源贡献排序(约束理论优先)
|
||
results.sort(key=lambda x: x["contribution_per_constraint"], reverse=True)
|
||
return {
|
||
"type": "产品组合(约束理论)",
|
||
"ranking": results,
|
||
"recommendation": f"优先生产「{results[0]['name']}」(单位约束贡献{results[0]['contribution_per_constraint']}最高)",
|
||
"notes": "瓶颈资源下,按单位约束资源的边际贡献排序,而非单位边际贡献",
|
||
}
|
||
|
||
raise HTTPException(400, "未知决策类型: " + str(decision_type))
|
||
except Exception as e:
|
||
raise HTTPException(400, f"相关成本决策失败: {str(e)}")
|
||
|
||
|
||
# ── 现金流预测(AI事前预警) ────────────────────────────────────
|
||
|
||
|
||
@router.post("/cash-forecast")
|
||
def api_cash_forecast(request: Request, data: dict, db: Session = Depends(get_db)):
|
||
"""现金流预测 — 根据历史KPI推算未来30天现金流"""
|
||
try:
|
||
entity_id = resolve_entity_for_request(request, int(data.get("entity_id", 1)))
|
||
days = int(data.get("days", 30))
|
||
current_cash = float(data["current_cash"]) if data.get("current_cash") else None
|
||
result = forecast_cash_flow(entity_id, db, days, current_cash)
|
||
# 保存到数据库
|
||
try:
|
||
save_forecast_to_db(entity_id, result, db)
|
||
except Exception as e:
|
||
logger.warning(f"保存预测结果失败: {e}")
|
||
return result
|
||
except Exception as e:
|
||
raise HTTPException(400, f"现金流预测失败: {str(e)}")
|
||
|
||
|
||
@router.get("/cash-forecast/history")
|
||
def api_cash_forecast_history(
|
||
entity_id: int = Depends(get_entity_id),
|
||
days: int = 30,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""获取已保存的现金流预测历史"""
|
||
from app.models import CashForecast
|
||
forecasts = db.query(CashForecast).filter(
|
||
CashForecast.entity_id == entity_id,
|
||
).order_by(CashForecast.forecast_date.desc()).limit(days).all()
|
||
return {
|
||
"data": [{
|
||
"id": f.id,
|
||
"forecast_date": f.forecast_date.isoformat(),
|
||
"predicted_cash": f.predicted_cash,
|
||
"lower_bound": f.lower_bound,
|
||
"upper_bound": f.upper_bound,
|
||
"alert_status": f.alert_status,
|
||
} for f in forecasts]
|
||
}
|
||
|
||
|
||
@router.get("/accuracy")
|
||
def api_forecast_accuracy(
|
||
entity_id: int = Depends(get_entity_id),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""预测准确率报表 — 上期预测 vs 本期实际"""
|
||
try:
|
||
results = calculate_accuracy(entity_id, db)
|
||
|
||
# 计算整体MAE/MAPE
|
||
if results:
|
||
total_mae = sum(r["mae"] for r in results) / len(results)
|
||
total_mape = sum(r["mape"] for r in results) / len(results)
|
||
else:
|
||
total_mae = 0
|
||
total_mape = 0
|
||
|
||
return {
|
||
"data": results,
|
||
"summary": {
|
||
"total_periods": len(results),
|
||
"avg_mae": round(total_mae, 2),
|
||
"avg_mape": round(total_mape, 2),
|
||
},
|
||
}
|
||
except Exception as e:
|
||
raise HTTPException(400, f"获取准确率失败: {str(e)}")
|
||
|
||
|
||
@router.get("/scenario-suggestions")
|
||
def api_scenario_suggestions(alert_type: str = None):
|
||
"""获取情景建议模板"""
|
||
types = ["cash_low", "cash_critical", "cost_high", "revenue_drop"]
|
||
results = []
|
||
for at in types:
|
||
if alert_type and at != alert_type:
|
||
continue
|
||
sug = generate_scenario_suggestion(at, "")
|
||
results.append({"alert_type": at, **sug})
|
||
return {"data": results}
|
||
|
||
|
||
@router.post("/scenario-suggestion/generate")
|
||
def api_generate_suggestion(data: dict):
|
||
"""根据预警信息动态生成情景建议"""
|
||
try:
|
||
alert_type = data.get("alert_type", "cash_low")
|
||
kpi_name = data.get("kpi_name", "未知KPI")
|
||
extra = data.get("extra", {})
|
||
sug = generate_scenario_suggestion(alert_type, kpi_name, extra)
|
||
return sug
|
||
except Exception as e:
|
||
raise HTTPException(400, f"生成建议失败: {str(e)}")
|
||
|
||
|
||
# ── 实物期权计算器 ─────────────────────────────────────────────
|
||
import math
|
||
|
||
def _norm_cdf(x: float) -> float:
|
||
"""标准正态分布CDF — Abramowitz & Stegun 近似 (max error ≈ 1.5×10⁻⁷)"""
|
||
a1, a2, a3, a4, a5 = 0.254829592, -0.284496736, 1.421413741, -1.453152027, 1.061405429
|
||
p = 0.3275911
|
||
sign = 1.0
|
||
if x < 0:
|
||
sign = -1.0
|
||
x_abs = abs(x) / math.sqrt(2.0)
|
||
t = 1.0 / (1.0 + p * x_abs)
|
||
y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * math.exp(-x_abs * x_abs)
|
||
return 0.5 * (1.0 + sign * y)
|
||
|
||
def _black_scholes_call(S0: float, X: float, t: float, r: float, sigma: float) -> dict:
|
||
"""BSM看涨期权定价(扩张期权/延迟期权)"""
|
||
sqrt_t = math.sqrt(t)
|
||
d1 = (math.log(S0 / X) + (r + 0.5 * sigma ** 2) * t) / (sigma * sqrt_t)
|
||
d2 = d1 - sigma * sqrt_t
|
||
nd1 = _norm_cdf(d1)
|
||
nd2 = _norm_cdf(d2)
|
||
call_value = max(S0 * nd1 - X * math.exp(-r * t) * nd2, 0.0)
|
||
return {"value": round(call_value, 4), "d1": round(d1, 4), "d2": round(d2, 4), "Nd1": round(nd1, 4), "Nd2": round(nd2, 4)}
|
||
|
||
def _black_scholes_put(S0: float, X: float, t: float, r: float, sigma: float) -> dict:
|
||
"""BSM看跌期权定价(放弃期权/收缩期权)"""
|
||
sqrt_t = math.sqrt(t)
|
||
d1 = (math.log(S0 / X) + (r + 0.5 * sigma ** 2) * t) / (sigma * sqrt_t)
|
||
d2 = d1 - sigma * sqrt_t
|
||
nd1 = _norm_cdf(-d1)
|
||
nd2 = _norm_cdf(-d2)
|
||
put_value = max(X * math.exp(-r * t) * nd2 - S0 * nd1, 0.0)
|
||
return {"value": round(put_value, 4), "d1": round(d1, 4), "d2": round(d2, 4), "N(-d1)": round(nd1, 4), "N(-d2)": round(nd2, 4)}
|
||
|
||
def _binomial_tree_call(S0: float, X: float, t: float, r: float, sigma: float, n: int = 100) -> float:
|
||
"""二叉树欧式看涨期权定价(延迟期权)"""
|
||
dt = t / n
|
||
u = math.exp(sigma * math.sqrt(dt))
|
||
d = 1.0 / u
|
||
p = (math.exp(r * dt) - d) / (u - d)
|
||
discount = math.exp(-r * dt)
|
||
prices = [S0 * (u ** (n - j)) * (d ** j) for j in range(n + 1)]
|
||
values = [max(p - X, 0.0) for p in prices]
|
||
for i in range(n - 1, -1, -1):
|
||
for j in range(i + 1):
|
||
values[j] = discount * (p * values[j] + (1 - p) * values[j + 1])
|
||
return max(values[0], 0.0)
|
||
|
||
def _binomial_tree_american_put(S0: float, X: float, t: float, r: float, sigma: float, n: int = 100) -> float:
|
||
"""二叉树美式看跌期权定价(可随时放弃的放弃期权)"""
|
||
dt = t / n
|
||
u = math.exp(sigma * math.sqrt(dt))
|
||
d = 1.0 / u
|
||
p = (math.exp(r * dt) - d) / (u - d)
|
||
discount = math.exp(-r * dt)
|
||
prices = [S0 * (u ** (n - j)) * (d ** j) for j in range(n + 1)]
|
||
values = [max(X - p, 0.0) for p in prices]
|
||
for i in range(n - 1, -1, -1):
|
||
for j in range(i + 1):
|
||
hold = discount * (p * values[j] + (1 - p) * values[j + 1])
|
||
exercise = X - (S0 * (u ** (i - j)) * (d ** j))
|
||
values[j] = max(hold, exercise)
|
||
return max(values[0], 0.0)
|
||
|
||
@router.post("/real-option")
|
||
def api_real_option(data: dict):
|
||
"""实物期权计算器"""
|
||
try:
|
||
opt_type = data.get("opt_type", "expansion") # expansion|abandon|delay|shrink
|
||
model = data.get("model", "bs") # bs|binomial
|
||
S0 = float(data.get("S0", 100.0))
|
||
X = float(data.get("X", 80.0))
|
||
t = float(data.get("t", 3.0))
|
||
r = float(data.get("r", 0.0174))
|
||
sigma = float(data.get("sigma", 0.30))
|
||
expansion_factor = float(data.get("expansion_factor", 1.5))
|
||
salvage_value = float(data.get("salvage_value", S0 * 0.3))
|
||
n_steps = int(data.get("n_steps", 100))
|
||
|
||
# 输入校验
|
||
if S0 <= 0 or X <= 0 or t <= 0 or sigma <= 0:
|
||
raise HTTPException(400, "参数必须为正数")
|
||
if sigma > 2.0:
|
||
raise HTTPException(400, "波动率σ不能超过200%")
|
||
|
||
result = {"option_type": opt_type, "model": model, "S0": S0, "X": X, "t": t, "r": r, "sigma": sigma}
|
||
|
||
# 计算期权价值
|
||
if opt_type in ("expansion", "delay") and model == "bs":
|
||
bs = _black_scholes_call(S0, X, t, r, sigma)
|
||
result["option_value"] = bs["value"]
|
||
result["intermediate"] = {k: v for k, v in bs.items() if k != "value"}
|
||
elif opt_type == "expansion" and model == "binomial":
|
||
adj_X = X / expansion_factor
|
||
bt_val = _binomial_tree_call(S0, adj_X, t, r, sigma, n_steps)
|
||
option_value = max(bt_val * expansion_factor, 0.0)
|
||
result["option_value"] = round(option_value, 4)
|
||
result["intermediate"] = {"expansion_factor": expansion_factor, "adjusted_X": round(adj_X, 4), "tree_value": round(bt_val, 4)}
|
||
elif opt_type == "delay" and model == "binomial":
|
||
option_value = _binomial_tree_call(S0, X, t, r, sigma, n_steps)
|
||
result["option_value"] = round(option_value, 4)
|
||
# Also compute BS for reference
|
||
bs = _black_scholes_call(S0, X, t, r, sigma)
|
||
result["intermediate"] = {"n_steps": n_steps, "bs_reference": round(bs["value"], 4)}
|
||
elif opt_type in ("abandon", "shrink") and model == "bs":
|
||
effective_X = salvage_value if opt_type == "abandon" else X
|
||
bs = _black_scholes_put(S0, effective_X, t, r, sigma)
|
||
result["option_value"] = bs["value"]
|
||
result["intermediate"] = {k: v for k, v in bs.items() if k != "value"}
|
||
if opt_type == "abandon":
|
||
result["intermediate"]["salvage_value"] = effective_X
|
||
elif opt_type == "abandon" and model == "binomial":
|
||
bt_val = _binomial_tree_american_put(S0, salvage_value, t, r, sigma, n_steps)
|
||
result["option_value"] = round(bt_val, 4)
|
||
result["intermediate"] = {"n_steps": n_steps, "salvage_value": salvage_value}
|
||
else:
|
||
raise HTTPException(400, f"不支持的组合: {opt_type} + {model}")
|
||
|
||
# 决策建议
|
||
val = result["option_value"]
|
||
if val > 0:
|
||
result["suggestion"] = "期权价值 > 0,管理弹性有价值,建议保留决策弹性,在有利时机行权"
|
||
result["suggestion_type"] = "positive"
|
||
else:
|
||
result["suggestion"] = "期权价值 ≈ 0,弹性无明显价值,建议按传统NPV决策,无需等待"
|
||
result["suggestion_type"] = "neutral"
|
||
|
||
# 扩展NPV(假设传统NPV = S0 - X)
|
||
npv_without = S0 - X
|
||
expanded_npv = npv_without + val
|
||
result["npv_without_flexibility"] = round(npv_without, 4)
|
||
result["expanded_npv"] = round(expanded_npv, 4)
|
||
|
||
if expanded_npv > 0:
|
||
result["decision"] = "✅ 扩展NPV > 0,含弹性后项目整体值得投资"
|
||
else:
|
||
result["decision"] = "❌ 扩展NPV ≤ 0,含弹性后项目仍不值得投资"
|
||
|
||
# 敏感性分析数据(σ从10%~90%变化)
|
||
sensitivity = []
|
||
for s_pct in range(5, 96, 5):
|
||
s = s_pct / 100.0
|
||
if opt_type in ("expansion", "delay"):
|
||
if model == "bs":
|
||
v = _black_scholes_call(S0, X, t, r, s)["value"]
|
||
else:
|
||
bt = _binomial_tree_call(S0, X, t, r, s, n_steps)
|
||
v = bt * expansion_factor if opt_type == "expansion" else bt
|
||
else:
|
||
eff_X = salvage_value if opt_type == "abandon" else X
|
||
if model == "bs":
|
||
v = _black_scholes_put(S0, eff_X, t, r, s)["value"]
|
||
else:
|
||
v = _binomial_tree_american_put(S0, eff_X, t, r, s, n_steps)
|
||
sensitivity.append({"sigma": s_pct, "option_value": round(v, 4)})
|
||
result["sensitivity"] = sensitivity
|
||
|
||
# 警告提示
|
||
warnings = []
|
||
if t * sigma * sigma * 0.5 > r:
|
||
warnings.append("高波动+长时间,延迟价值显著")
|
||
if S0 < X:
|
||
warnings.append("价外期权,期权价值较低")
|
||
if S0 > X * 1.5:
|
||
warnings.append("深度价内,几乎确定行权")
|
||
if sigma < 0.10:
|
||
warnings.append("波动率过低,期权价值趋近于0")
|
||
if t > 10:
|
||
warnings.append("长期期权,贴现因子影响大")
|
||
result["warnings"] = warnings
|
||
|
||
return result
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
raise HTTPException(400, f"实物期权计算失败: {str(e)}")
|
||
|
||
|
||
# ── 增长质量诊断 ─────────────────────────────────────────────
|
||
|
||
def _score_revenue_structure(entity: dict) -> int:
|
||
"""营收结构评分:渠补率越低越好"""
|
||
rebate_rate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
|
||
if rebate_rate > 80: return 1
|
||
if rebate_rate > 60: return 2
|
||
if rebate_rate > 40: return 3
|
||
if rebate_rate > 20: return 4
|
||
return 5
|
||
|
||
|
||
def _score_profit_structure(entity: dict) -> int:
|
||
"""利润结构评分:真实毛利率越高越好"""
|
||
gross_margin = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
|
||
if gross_margin < 0: return 1
|
||
if gross_margin < 10: return 2
|
||
if gross_margin < 20: return 3
|
||
if gross_margin < 30: return 4
|
||
return 5
|
||
|
||
|
||
def _score_cash_assets(entity: dict) -> int:
|
||
"""现金资产评分:现金比率越高越好"""
|
||
cash_ratio = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
|
||
if cash_ratio < 10: return 1
|
||
if cash_ratio < 30: return 2
|
||
if cash_ratio < 50: return 3
|
||
if cash_ratio < 100: return 4
|
||
return 5
|
||
|
||
|
||
def _score_growth_driver(entity: dict) -> int:
|
||
"""增长驱动评分:费用增速相对收入增速越低越好"""
|
||
expense_growth = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
|
||
revenue_growth = float(entity.get("revenueGrowthRate", entity.get("revenue_growth_rate", 1)))
|
||
if revenue_growth <= 0: revenue_growth = 1 # prevent div by zero
|
||
ratio = expense_growth / revenue_growth
|
||
if ratio > 1.5: return 1
|
||
if ratio > 1.2: return 2
|
||
if ratio > 1.0: return 3
|
||
if ratio > 0.8: return 4
|
||
return 5
|
||
|
||
|
||
def _score_org_efficiency(entity: dict) -> int:
|
||
"""组织效率评分:管理费/净收入越低越好"""
|
||
mgmt_ratio = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
|
||
if mgmt_ratio > 300: return 1
|
||
if mgmt_ratio > 200: return 2
|
||
if mgmt_ratio > 100: return 3
|
||
if mgmt_ratio > 50: return 4
|
||
return 5
|
||
|
||
|
||
def _diagnosis_text(overall: float, dimensions: dict, entity_name: str) -> str:
|
||
"""根据评分生成诊断结论"""
|
||
lines = []
|
||
low_dims = {k: v for k, v in dimensions.items() if v["score"] <= 2}
|
||
mid_dims = {k: v for k, v in dimensions.items() if 2 < v["score"] < 4}
|
||
|
||
dim_labels = {
|
||
"revenueStructure": "营收结构",
|
||
"profitStructure": "利润结构",
|
||
"cashAssets": "现金资产",
|
||
"growthDriver": "增长驱动",
|
||
"orgEfficiency": "组织效率",
|
||
}
|
||
|
||
if overall < 2:
|
||
lines.append(f"{entity_name}的增长质量评分仅{overall}分,属于「越增长越重」类型。")
|
||
lines.append("增长主要依赖资源投入而非核心能力积累,可持续性堪忧。")
|
||
elif overall < 3:
|
||
lines.append(f"{entity_name}的增长质量评分{overall}分,需重点关注。")
|
||
lines.append("部分维度存在风险,增长质量有待改善。")
|
||
elif overall < 4:
|
||
lines.append(f"{entity_name}的增长质量评分{overall}分,处于中等水平。")
|
||
lines.append("多数维度表现尚可,仍有优化空间。")
|
||
else:
|
||
lines.append(f"{entity_name}的增长质量评分{overall}分,「越增长越轻」。")
|
||
lines.append("增长模式健康,具备持续增长能力。")
|
||
|
||
if low_dims:
|
||
low_names = [dim_labels.get(k, k) for k in low_dims]
|
||
lines.append(f"⚠️ 需重点关注:{'、'.join(low_names)}评分偏低(≤2分)。")
|
||
|
||
if mid_dims:
|
||
mid_names = [dim_labels.get(k, k) for k in mid_dims]
|
||
lines.append(f"💡 可优化:{'、'.join(mid_names)}有提升空间。")
|
||
|
||
# 具体建议(硬编码的关键诊断)
|
||
if dimensions.get("revenueStructure", {}).get("score", 5) <= 2:
|
||
lines.append("• 营收依赖渠道返利,建议降低渠补率、拓展直销渠道。")
|
||
if dimensions.get("orgEfficiency", {}).get("score", 5) <= 2:
|
||
lines.append("• 管理费率高企,建议精简费用结构、优化运营效率。")
|
||
if dimensions.get("cashAssets", {}).get("score", 5) <= 2:
|
||
lines.append("• 现金比率极低,存在断流风险,建议加强现金流管理。")
|
||
if dimensions.get("growthDriver", {}).get("score", 5) <= 2:
|
||
lines.append("• 费用增速远超收入增速,增长不可持续,需控制费用膨胀。")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _generate_improvement_suggestions(dimension: str, score: int, entity: dict) -> list:
|
||
"""为指定维度生成改善建议"""
|
||
suggestions = []
|
||
if dimension == "revenueStructure":
|
||
rebate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
|
||
if score <= 2:
|
||
target_rebate = max(rebate - 10, 0)
|
||
savings = f"释放现金{round(rebate - target_rebate, 1)}%/月"
|
||
suggestions.append(f"渠补谈判:{rebate}%→{target_rebate}%({savings})")
|
||
suggestions.append("客户分散:拓展直销渠道,降低渠道依赖")
|
||
suggestions.append("渠补制度:分级管理,差异化返利")
|
||
else:
|
||
suggestions.append("维持现有渠补政策")
|
||
elif dimension == "profitStructure":
|
||
gm = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
|
||
if score <= 2:
|
||
suggestions.append(f"成本优化:毛利率仅{gm}%,需分析成本构成")
|
||
suggestions.append("产品结构:提高高毛利产品占比")
|
||
suggestions.append("定价策略:评估提价空间")
|
||
else:
|
||
suggestions.append("维持毛利率水平")
|
||
elif dimension == "cashAssets":
|
||
cr = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
|
||
if score <= 2:
|
||
suggestions.append(f"现金管理:现金比率仅{cr}%,存在断流风险")
|
||
suggestions.append("应收账款:加快回款周期")
|
||
suggestions.append("融资安排:准备短期授信额度")
|
||
else:
|
||
suggestions.append("维持现金流健康")
|
||
elif dimension == "growthDriver":
|
||
eg = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
|
||
if score <= 2:
|
||
suggestions.append(f"费用管控:费用增速{eg}倍于收入,需严控费用")
|
||
suggestions.append("预算管理:建立费用增长红线机制")
|
||
suggestions.append("投资回报:评估每项投入的ROI")
|
||
else:
|
||
suggestions.append("维持费用增长与收入增长匹配")
|
||
elif dimension == "orgEfficiency":
|
||
mr = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
|
||
if score <= 2:
|
||
suggestions.append(f"管理效率:管理费/净收入{mr}%,急需降本增效")
|
||
suggestions.append("组织精简:评估管理层级压缩空间")
|
||
suggestions.append("流程优化:推进数字化降本")
|
||
else:
|
||
suggestions.append("维持管理效率水平")
|
||
return suggestions
|
||
|
||
|
||
def _get_dim_detail_indicators(dimension: str, entity: dict) -> list:
|
||
"""获取维度的明细诊断指标"""
|
||
indicators = []
|
||
if dimension == "revenueStructure":
|
||
rebate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
|
||
net_ratio = round(100 - rebate, 1)
|
||
indicators.append({"label": "渠补率", "value": f"{rebate}%",
|
||
"verdict": "收入依赖渠道返利" if rebate > 50 else "渠道依赖程度中等",
|
||
"status": "danger" if rebate > 50 else "warning" if rebate > 20 else "success"})
|
||
indicators.append({"label": "净收入占比", "value": f"{net_ratio}%",
|
||
"verdict": f"仅{net_ratio}%归公司" if net_ratio < 30 else "净收入占比合理",
|
||
"status": "danger" if net_ratio < 30 else "success"})
|
||
elif dimension == "profitStructure":
|
||
gm = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
|
||
indicators.append({"label": "真实毛利率", "value": f"{gm}%",
|
||
"verdict": "毛利偏低" if gm < 15 else "毛利正常",
|
||
"status": "danger" if gm < 10 else "warning" if gm < 20 else "success"})
|
||
elif dimension == "cashAssets":
|
||
cr = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
|
||
indicators.append({"label": "现金比率", "value": f"{cr}%",
|
||
"verdict": "断流风险" if cr < 5 else "现金紧张" if cr < 30 else "现金充足",
|
||
"status": "danger" if cr < 5 else "warning" if cr < 30 else "success"})
|
||
elif dimension == "growthDriver":
|
||
eg = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
|
||
rg = float(entity.get("revenueGrowthRate", entity.get("revenue_growth_rate", 1)))
|
||
ratio = eg / rg if rg > 0 else 99
|
||
indicators.append({"label": "费用增速/收入增速", "value": f"{ratio:.1f}倍",
|
||
"verdict": "费用增速过快" if ratio > 1.5 else "费用可控" if ratio > 1 else "增长健康",
|
||
"status": "danger" if ratio > 1.5 else "warning" if ratio > 1 else "success"})
|
||
elif dimension == "orgEfficiency":
|
||
mr = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
|
||
indicators.append({"label": "管理费/净收入", "value": f"{mr}%",
|
||
"verdict": "管理费极高" if mr > 200 else "管理费偏高" if mr > 100 else "管理费正常",
|
||
"status": "danger" if mr > 200 else "warning" if mr > 100 else "success"})
|
||
return indicators
|
||
|
||
|
||
@router.post("/growth-quality")
|
||
def api_growth_quality(request: Request, data: dict):
|
||
"""增长质量诊断 — 五维度评分+综合评分+诊断结论"""
|
||
try:
|
||
entity_id = resolve_entity_for_request(request, data.get("entity_id"))
|
||
ENTITY_DATA = {
|
||
1: {"entity":"陕西酣客文化传媒","rebateRate":82.8,"trueGrossMargin":18.6,"cashRatio":0.6,"expenseGrowthRate":2.2,"revenueGrowthRate":1.0,"mgmtRatio":447},
|
||
2: {"entity":"陕西博海网络科技","rebateRate":0,"trueGrossMargin":13.1,"cashRatio":6.7,"expenseGrowthRate":0.8,"revenueGrowthRate":1.0,"mgmtRatio":1.4},
|
||
}
|
||
entity = ENTITY_DATA.get(entity_id, data.get("entity", data))
|
||
entity_name = entity.get("entity", entity.get("name", "该企业"))
|
||
period = entity.get("period", data.get("period", "当前"))
|
||
|
||
# 五维度评分
|
||
dim_scores = {
|
||
"revenueStructure": _score_revenue_structure(entity),
|
||
"profitStructure": _score_profit_structure(entity),
|
||
"cashAssets": _score_cash_assets(entity),
|
||
"growthDriver": _score_growth_driver(entity),
|
||
"orgEfficiency": _score_org_efficiency(entity),
|
||
}
|
||
|
||
overall = round(sum(dim_scores.values()) / 5, 1)
|
||
|
||
# 综合等级
|
||
if overall >= 4:
|
||
level = "🟢 越增长越轻"
|
||
level_type = "excellent"
|
||
elif overall >= 3:
|
||
level = "🟡 增长质量中等"
|
||
level_type = "medium"
|
||
elif overall >= 2:
|
||
level = "🟠 需关注"
|
||
level_type = "warning"
|
||
else:
|
||
level = "🔴 越增长越重"
|
||
level_type = "danger"
|
||
|
||
# 诊断结论
|
||
dimensions_payload = {}
|
||
detail_payload = {}
|
||
for dim, score in dim_scores.items():
|
||
dimensions_payload[dim] = {"score": score, "weight": 20}
|
||
detail_payload[dim] = {
|
||
"score": score,
|
||
"indicators": _get_dim_detail_indicators(dim, entity),
|
||
"suggestions": _generate_improvement_suggestions(dim, score, entity),
|
||
}
|
||
|
||
diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name)
|
||
|
||
# 对比数据(如果请求中包含多个实体)
|
||
compare = data.get("compare", None)
|
||
compare_result = None
|
||
if compare:
|
||
compare_entity = compare
|
||
compare_name = compare_entity.get("entity", compare_entity.get("name", "对比企业"))
|
||
cdims = {
|
||
"revenueStructure": _score_revenue_structure(compare_entity),
|
||
"profitStructure": _score_profit_structure(compare_entity),
|
||
"cashAssets": _score_cash_assets(compare_entity),
|
||
"growthDriver": _score_growth_driver(compare_entity),
|
||
"orgEfficiency": _score_org_efficiency(compare_entity),
|
||
}
|
||
compare_overall = round(sum(cdims.values()) / 5, 1)
|
||
compare_result = {
|
||
"entity_name": compare_name,
|
||
"overall": compare_overall,
|
||
"dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()},
|
||
"level": ("🟢 越增长越轻" if compare_overall >= 4 else
|
||
"🟡 增长质量中等" if compare_overall >= 3 else
|
||
"🟠 需关注" if compare_overall >= 2 else "🔴 越增长越重"),
|
||
}
|
||
|
||
return {
|
||
"entity_name": entity_name,
|
||
"period": period,
|
||
"overall": overall,
|
||
"level": level,
|
||
"level_type": level_type,
|
||
"dimensions": dimensions_payload,
|
||
"detail": detail_payload,
|
||
"diagnosis": diagnosis,
|
||
"compare": compare_result,
|
||
}
|
||
except Exception as e:
|
||
raise HTTPException(400, f"增长质量诊断失败: {str(e)}")
|
||
|
||
|
||
# ── KPI趋势预测(预测性成本智能 MVP) ────────────────────────────
|
||
from app.utils.kpi_forecast_engine import ( # noqa: E402
|
||
MODELS, forecast_kpi, forecast_finance_kpis,
|
||
MACRO_FACTORS, factor_sensitivity_for_kpi, factor_sensitivity_with_history,
|
||
adjusted_next_with_factor, save_forecast_logs,
|
||
)
|
||
|
||
|
||
@router.get("/kpi-forecast")
|
||
def api_kpi_forecast(
|
||
kpi_code: str,
|
||
periods: int = 3,
|
||
model: str = "linear",
|
||
entity_id: int = Depends(get_entity_id),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""单个财务KPI预测 — 线性回归/移动平均,多租户隔离(entity_id 权限校验)"""
|
||
if periods < 0 or periods > 24:
|
||
raise HTTPException(400, "periods 必须在 0~24 之间")
|
||
if model not in MODELS:
|
||
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
|
||
result = forecast_kpi(entity_id, kpi_code, db, periods=periods, model=model)
|
||
if result is None:
|
||
raise HTTPException(
|
||
404,
|
||
f"KPI {kpi_code} 在企业 entity_id={entity_id} 下不存在,或历史数据不足(至少2条)",
|
||
)
|
||
return result
|
||
|
||
|
||
@router.get("/kpi-forecast/finance")
|
||
def api_kpi_forecast_finance(
|
||
periods: int = 3,
|
||
model: str = "linear",
|
||
entity_id: int = Depends(get_entity_id),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""批量预测该企业全部财务维度KPI(历史≥3条),按可预测性排序"""
|
||
if periods < 0 or periods > 24:
|
||
raise HTTPException(400, "periods 必须在 0~24 之间")
|
||
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,
|
||
"periods": periods,
|
||
"total": len(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", {})
|
||
# 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:
|
||
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"],
|
||
"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,
|
||
})
|
||
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,
|
||
}
|