feat: 实物期权计算器 — 后端BSM+二叉树API+前端交互页面
This commit is contained in:
@@ -152,3 +152,182 @@ def api_cvp_detailed(data: dict):
|
||||
}
|
||||
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)}")
|
||||
|
||||
@@ -61,6 +61,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
{ path: '/maps-review', label: '战略回顾会', icon: 'TrendCharts', roles: ['ceo', 'finance'], group: '复盘与改进' },
|
||||
{ path: '/predict', label: '预测模拟', icon: 'DataLine', roles: ['ceo', 'finance', 'it'], group: '复盘与改进' },
|
||||
{ path: '/real-options', label: '实物期权计算器', icon: 'TrendCharts', roles: ['ceo', 'finance'], group: '复盘与改进' },
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// GROUP 5: 系统与支持(Infra)
|
||||
|
||||
@@ -27,6 +27,7 @@ const routes = [
|
||||
{ path: 'deviations', name: 'DeviationDashboard', component: () => import('@/views/DeviationDashboard.vue'), meta: { title: '差异分析', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
{ path: 'cost', name: 'CostDashboard', component: () => import('@/views/CostDashboard.vue'), meta: { title: '成本分析', roles: ['ceo', 'finance', 'it'] } },
|
||||
{ path: 'predict', name: 'PredictDashboard', component: () => import('@/views/PredictDashboard.vue'), meta: { title: '预测模拟', roles: ['ceo', 'finance', 'it'] } },
|
||||
{ path: 'real-options', name: 'RealOptions', component: () => import('@/views/RealOptions.vue'), meta: { title: '实物期权计算器', roles: ['ceo', 'finance'] } },
|
||||
{ path: 'action-plans', name: 'ActionPlans', component: () => import('@/views/ActionPlanLibrary.vue'), meta: { title: '改善行动', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
{ path: 'reports', name: 'ReportCenter', component: () => import('@/views/ReportCenter.vue'), meta: { title: '管理报表', roles: ['ceo', 'finance', 'business'] } },
|
||||
{ path: 'alignment', name: 'KPIAlignment', component: () => import('@/views/KPIAlignment.vue'), meta: { title: '战略执行看板', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="page-view">
|
||||
<div class="page-header">
|
||||
<h3 class="page-title">实物期权计算器</h3>
|
||||
</div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-card>
|
||||
<template #header><span>📋 参数设置</span></template>
|
||||
<el-form label-width="120px" size="small">
|
||||
<el-form-item label="期权类型">
|
||||
<el-select v-model="form.option_type" style="width:100%">
|
||||
<el-option value="expansion" label="扩张期权(看涨)" />
|
||||
<el-option value="abandon" label="放弃期权(看跌)" />
|
||||
<el-option value="delay" label="延迟期权(看涨)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="计算模型">
|
||||
<el-select v-model="form.model" style="width:100%">
|
||||
<el-option value="bs" label="Black-Scholes" />
|
||||
<el-option value="binomial" label="二叉树" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="标的资产 S₀ (万元)">
|
||||
<el-input-number v-model="form.S0" :min="1" :step="10" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="执行价格 X (万元)">
|
||||
<el-input-number v-model="form.X" :min="1" :step="10" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="到期时间 t (年)">
|
||||
<el-input-number v-model="form.t" :min="0.1" :step="0.5" :precision="1" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="无风险利率 r (%)">
|
||||
<el-input-number v-model="form.r" :min="0.1" :step="0.5" :precision="2" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="波动率 σ (%)">
|
||||
<el-input-number v-model="form.sigma" :min="5" :step="5" :precision="0" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="calculate" :loading="loading" style="width:100%">计算</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="resetDefaults" size="small">重置默认值</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card v-if="result">
|
||||
<template #header><span>📊 计算结果</span></template>
|
||||
<div style="text-align:center;padding:20px 0;">
|
||||
<div style="font-size:12px;color:#999;">期权价值</div>
|
||||
<div style="font-size:48px;font-weight:700;" :style="{ color: result.option_value > 0 ? '#67c23a' : '#f56c6c' }">
|
||||
{{ result.option_value.toFixed(2) }} <span style="font-size:16px;font-weight:400;">万元</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="d₁">{{ result.d1?.toFixed(4) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="d₂">{{ result.d2?.toFixed(4) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="N(d₁)">{{ result.Nd1?.toFixed(4) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="N(d₂)">{{ result.Nd2?.toFixed(4) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="模型" :span="2">{{ form.model === 'bs' ? 'Black-Scholes' : '二叉树' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-alert :type="result.option_value > 0 ? 'success' : 'warning'" show-icon style="margin-top:12px;">
|
||||
<template #title>{{ result.suggestion }}</template>
|
||||
</el-alert>
|
||||
</el-card>
|
||||
<el-card v-else>
|
||||
<el-empty description="设置参数后点击「计算」查看结果" />
|
||||
</el-card>
|
||||
<el-card v-if="result?.sensitivity?.length" style="margin-top:12px;">
|
||||
<template #header><span>📈 波动率敏感性</span></template>
|
||||
<div ref="chartRef" style="height:200px;"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '../api/index'
|
||||
|
||||
const loading = ref(false)
|
||||
const result = ref<any>(null)
|
||||
const chartRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
option_type: 'expansion',
|
||||
model: 'bs',
|
||||
S0: 100, X: 80, t: 3, r: 2.8, sigma: 30
|
||||
})
|
||||
|
||||
async function calculate() {
|
||||
loading.value = true
|
||||
result.value = null
|
||||
try {
|
||||
const payload = {
|
||||
option_type: form.option_type,
|
||||
model: form.model,
|
||||
S0: form.S0,
|
||||
X: form.X,
|
||||
t: form.t,
|
||||
r: form.r / 100,
|
||||
sigma: form.sigma / 100,
|
||||
}
|
||||
const r: any = await api.post('/predict/real-option', payload)
|
||||
result.value = r
|
||||
nextTick(() => drawChart())
|
||||
} catch (e) {
|
||||
ElMessage.error('计算失败')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function resetDefaults() {
|
||||
form.S0 = 100; form.X = 80; form.t = 3
|
||||
form.r = 2.8; form.sigma = 30
|
||||
form.option_type = 'expansion'; form.model = 'bs'
|
||||
result.value = null
|
||||
}
|
||||
|
||||
function drawChart() {
|
||||
if (!result.value?.sensitivity?.length || !chartRef.value) return
|
||||
const points = result.value.sensitivity
|
||||
const svg = `<svg width="100%" height="200" viewBox="0 0 600 200">
|
||||
<text x="10" y="20" font-size="12" fill="#999">期权价值(万)</text>
|
||||
<text x="580" y="195" font-size="12" fill="#999" text-anchor="end">波动率(%)</text>
|
||||
<polyline fill="none" stroke="#409eff" stroke-width="2"
|
||||
points="${points.map((p: any, i: number) => `${(i / (points.length - 1)) * 580},${180 - (p.value / Math.max(...points.map((x: any) => x.value))) * 160}`).join(' ')}" />
|
||||
${points.map((p: any, i: number) => {
|
||||
const x = (i / (points.length - 1)) * 580
|
||||
const y = 180 - (p.value / Math.max(...points.map((x: any) => x.value))) * 160
|
||||
return `<circle cx="${x}" cy="${y}" r="3" fill="#409eff"/><text x="${x}" y="${y - 8}" font-size="10" text-anchor="middle">${p.sigma_pct}%</text>`
|
||||
}).join('')}
|
||||
<line x1="0" y1="180" x2="580" y2="180" stroke="#e0e0e0" stroke-width="1"/>
|
||||
<line x1="0" y1="20" x2="0" y2="180" stroke="#e0e0e0" stroke-width="1"/>
|
||||
</svg>`
|
||||
chartRef.value.innerHTML = svg
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-card { margin-bottom:12px; }
|
||||
.el-form-item { margin-bottom:12px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user