3. 坏账率计算模块:逐笔交易坏账标记、滚动窗口坏账率计算、加权坏账率
坏账率是量化策略回测中衡量信用风险的核心指标。本模块从三个层次构建坏账率计算体系:逐笔交易坏账标记(微观判定)、滚动窗口坏账率计算(时序聚合)、加权坏账率(风险暴露调整)。三者形成从单笔到组合、从静态到动态的完整计算链路。
3.1 逐笔交易坏账标记
坏账标记是后续所有计算的基石。每笔交易在生命周期结束后,根据逾期状态和回收情况判定是否标记为坏账。判定规则如下表:
| 判定条件 | 坏账标记值 | 说明 |
|---|---|---|
| 逾期天数 >= 90 且未全额回收 | 1(坏账) | 符合监管对不良贷款的认定标准 |
| 逾期天数 < 90 或已全额回收 | 0(正常) | 未达到坏账阈值或风险已化解 |
| 交易未到期(仍在还款中) | 0(暂不标记) | 仅对已结清或已逾期终止的交易判定 |
关键实践:坏账标记必须与策略的持有期对齐。对于滚动策略,建议在交易退出(平仓或到期)后延迟一个观察窗口再标记,避免因回收延迟导致误判。
以下代码实现逐笔交易的坏账标记逻辑:
def mark_bad_debt(trades_df, overdue_threshold=90):
"""
逐笔交易坏账标记
trades_df: 包含 'overdue_days', 'recovery_rate', 'status' 列的 DataFrame
"""
import numpy as np
conditions = [
(trades_df['overdue_days'] >= overdue_threshold) & (trades_df['recovery_rate'] < 1.0),
(trades_df['overdue_days'] < overdue_threshold) | (trades_df['recovery_rate'] == 1.0),
(trades_df['status'] == 'active')
]
choices = [1, 0, 0]
trades_df['bad_debt_flag'] = np.select(conditions, choices, default=0)
return trades_df
3.2 滚动窗口坏账率计算
滚动窗口坏账率用于捕捉坏账率随时间的变化趋势,避免静态截面指标掩盖风险积累过程。核心参数包括窗口长度、步长和聚合方式。
计算步骤:
- 确定窗口参数:窗口长度(如 30 天、90 天)、滑动步长(如 7 天)。
- 窗口内聚合:对每个窗口内的交易,计算坏账笔数 / 总笔数(笔数口径)或坏账金额 / 总金额(金额口径)。
- 时间对齐:将窗口坏账率对齐到窗口结束日期,形成时序序列。
推荐口径:金额口径坏账率更能反映风险暴露的真实影响,尤其当单笔交易金额差异较大时。笔数口径适合作为辅助监控指标。
滚动窗口坏账率计算示例:
def rolling_bad_debt_rate(trades_df, window_days=90, step_days=7):
"""
滚动窗口坏账率计算(金额口径)
"""
trades_df = trades_df.sort_values('exit_date')
results = []
max_date = trades_df['exit_date'].max()
min_date = trades_df['exit_date'].min()
current_start = min_date
while current_start + pd.Timedelta(days=window_days) <= max_date:
window_end = current_start + pd.Timedelta(days=window_days)
window_data = trades_df[
(trades_df['exit_date'] >= current_start) &
(trades_df['exit_date'] < window_end)
]
if len(window_data) > 0:
bad_amount = window_data.loc[window_data['bad_debt_flag'] == 1, 'amount'].sum()
total_amount = window_data['amount'].sum()
rate = bad_amount / total_amount if total_amount > 0 else 0.0
results.append({
'window_end': window_end,
'bad_debt_rate': rate,
'trade_count': len(window_data)
})
current_start += pd.Timedelta(days=step_days)
return pd.DataFrame(results)
3.3 加权坏账率
加权坏账率在滚动窗口基础上引入风险暴露权重,解决不同时期、不同产品风险敞口不均等的问题。常见加权方式包括:
- 余额加权:以窗口内每日未偿还余额为权重,反映资金占用期间的真实风险。
- 时间加权:以交易存续天数为权重,长周期交易对坏账率贡献更大。
- 风险敞口加权:以授信额度或风险价值(VaR)为权重,适用于组合层面风险度量。
| 加权方式 | 权重因子 | 适用场景 | 计算复杂度 |
|---|---|---|---|
| 余额加权 | 每日未偿还余额 | 循环贷、信用卡分期 | 中 |
| 时间加权 | 交易存续天数 | 固定期限贷款、债券 | 低 |
| 风险敞口加权 | 授信额度 / VaR | 组合风控、压力测试 | 高 |
避坑提示:加权坏账率不能直接与简单坏账率比较。当风险敞口集中在高坏账率区间时,加权坏账率会显著高于简单坏账率,这是正常现象,反映风险集中度。在哨兵告警阈值调优时,建议对加权坏账率设置更宽松的初始阈值。
余额加权坏账率计算实现:
def weighted_bad_debt_rate(trades_df, daily_balance_df, window_days=90):
"""
余额加权坏账率计算
daily_balance_df: 包含 'date', 'trade_id', 'balance' 的每日余额表
"""
# 标记坏账交易
trades_df = mark_bad_debt(trades_df)
bad_trade_ids = trades_df[trades_df['bad_debt_flag'] == 1]['trade_id'].unique()
# 计算窗口内每日坏账余额占比
daily_balance_df['is_bad'] = daily_balance_df['trade_id'].isin(bad_trade_ids).astype(int)
daily_balance_df['bad_balance'] = daily_balance_df['balance'] * daily_balance_df['is_bad']
# 滚动窗口聚合
daily_agg = daily_balance_df.groupby('date').agg(
total_balance=('balance', 'sum'),
total_bad_balance=('bad_balance', 'sum')
).reset_index()
daily_agg['weighted_rate'] = daily_agg['total_bad_balance'] / daily_agg['total_balance']
daily_agg['weighted_rate'] = daily_agg['weighted_rate'].fillna(0)
# 窗口平均
daily_agg['rolling_weighted_rate'] = (
daily_agg['weighted_rate']
.rolling(window=window_days, min_periods=1)
.mean()
)
return daily_agg[['date', 'rolling_weighted_rate']]
3.4 三种坏账率的对比与选择
| 指标 | 计算粒度 | 时间维度 | 权重调整 | 告警灵敏度 | 推荐用途 |
|---|---|---|---|---|---|
| 逐笔坏账标记 | 单笔交易 | 静态(交易结束时) | 无 | 低(离散信号) | 数据清洗、基础统计 |
| 滚动窗口坏账率 | 窗口聚合 | 动态(时序滑动) | 无 | 中(趋势敏感) | 日常监控、阈值告警 |
| 加权坏账率 | 窗口聚合 | 动态(时序滑动) | 有(余额/时间/敞口) | 高(暴露敏感) | 组合风控、压力测试 |
最佳实践:在哨兵告警系统中,建议同时监控滚动窗口坏账率(趋势)和加权坏账率(风险暴露),设置双层阈值。当加权坏账率超过滚动坏账率 1.5 倍时,触发风险集中度预警。