334 lines
14 KiB
Python
334 lines
14 KiB
Python
"""预测模拟API — 管理会计OS"""
|
||
import logging
|
||
from fastapi import APIRouter, HTTPException
|
||
from app.utils.predict_engine import (
|
||
cvp_analysis, npv, irr,
|
||
sensitivity_analysis, scenario_analysis,
|
||
)
|
||
|
||
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)}")
|
||
|
||
|
||
# ── 实物期权计算器 ─────────────────────────────────────────────
|
||
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)}")
|