File size: 19,315 Bytes
fab9847
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
"""
analysis.py — NLG报告、行业影响、评估指标、消融实验
====================================================
从 v2_championship.py 拆出的分析与评估模块。
"""

import pandas as pd
import numpy as np
from sklearn.linear_model import QuantileRegressor
from sklearn.preprocessing import StandardScaler

from config import FACTOR_GROUPS, INDUSTRIES, INDUSTRY_ZH


# ═══════════════════════════════════════════════════════════
# INDUSTRY IMPACT RULE ENGINE
# ═══════════════════════════════════════════════════════════

def apply_industry_rules(rec):
    """基于风险等级、偏置、因子等推断各行业的风险和建议。"""
    risk_level = rec.get('risk_level', 'Low')
    risk_bias = rec.get('risk_bias', 'Balanced')
    vol_ratio = rec.get('vol_ratio', 1.0)
    top_fac = rec.get('top_factor', 'Unknown')

    is_high = risk_level == 'High'
    is_medium = risk_level == 'Medium'
    is_upward = risk_bias == 'Upward'
    is_downward = risk_bias == 'Downward'
    wide = vol_ratio > 1.3

    rules = {}
    for industry in INDUSTRIES:
        ind_risk = 'Low'
        ind_action = 'Routine monitoring'

        if industry == 'Aviation':
            if is_high and is_upward:
                ind_risk, ind_action = 'High', 'Increase hedging coverage; review fuel cost budget'
            elif is_high:
                ind_risk, ind_action = 'High', 'Elevated volatility; prepare contingency liquidity'
            elif is_upward:
                ind_risk, ind_action = 'Medium-High', 'Monitor fuel cost exposure; consider forward contracts'
            elif is_medium:
                ind_risk, ind_action = 'Medium', 'Review quarterly fuel hedging strategy'
            if top_fac == 'Demand': ind_action += '; demand-driven → cost pressure may persist'
            elif top_fac == 'Supply': ind_action += '; supply-driven → watch OPEC decisions'
            elif top_fac == 'Risk_Geo': ind_action += '; geopolitical risk → event monitoring'

        elif industry == 'Logistics':
            if is_high and is_upward:
                ind_risk, ind_action = 'Medium-High', 'Review transport cost pass-through; working capital buffer'
            elif is_high:
                ind_risk, ind_action = 'Medium', 'Monitor diesel/freight cost exposure'
            elif is_medium and is_upward:
                ind_risk, ind_action = 'Medium', 'Review fuel surcharge mechanisms'

        elif industry == 'Chemicals':
            if is_upward and top_fac == 'Supply':
                ind_risk, ind_action = 'High', 'Feedstock cost pressure; margin compression likely'
            elif is_high:
                ind_risk, ind_action = 'Medium-High', 'Monitor naphtha/ethylene spread; review procurement'
            elif wide:
                ind_risk, ind_action = 'Medium', 'Profit uncertainty elevated; scenario planning advised'

        elif industry == 'Manufacturing':
            if is_high and is_upward:
                ind_risk, ind_action = 'High', 'Energy cost surge risk; review energy hedging'
            elif is_high:
                ind_risk, ind_action = 'Medium', 'Elevated input cost volatility'
            elif is_medium:
                ind_risk, ind_action = 'Medium', 'Monitor energy procurement costs'

        elif industry == 'Upstream_OG':
            if is_downward and is_high:
                ind_risk, ind_action = 'High', 'Revenue decline risk; review covenant compliance & liquidity'
            elif is_downward:
                ind_risk, ind_action = 'Medium-High', 'Downside tail expanding; monitor cash flow coverage'
            elif is_upward:
                ind_risk, ind_action = 'Low', 'Revenue tailwind; capex commitment review advised'
            else:
                ind_risk, ind_action = 'Low-Medium', 'Balanced outlook'

        rules[f'{industry}_risk'] = ind_risk
        rules[f'{industry}_action'] = ind_action
    return rules


# ═══════════════════════════════════════════════════════════
# REGIME ECONOMIC NARRATIVES
# ═══════════════════════════════════════════════════════════

REGIME_NARRATIVES = {
    '2008 金融危机': {
        'history': '2008年全球金融危机期间,油价从$147暴跌至$32,降幅78%。需求端崩塌是主因——全球GDP收缩、贸易锐减、制造业PMI普遍跌破40。',
        'implication': '需求崩塌格局下,下游成本端企业短期受益于低油价,但总需求萎缩拖累整体营收。上游企业面临最大冲击。',
        'hedge_advice': '上游企业应立即锁定远期销售价格;下游企业可逢低建仓,锁定低价原料。',
    },
    '2014 页岩油冲击': {
        'history': '2014-16年美国页岩油产量爆发(+400万桶/日),叠加OPEC拒绝减产,油价从$110跌至$26。供给过剩主导。',
        'implication': '供给过剩格局持续时间长(18个月以上),下游企业成本优势可持续;上游企业需要重组债务、缩减资本开支。',
        'hedge_advice': '重点关注远期曲线结构(contango加深),利用期货锁价窗口。下游企业延长采购合约期限。',
    },
    '2020 COVID': {
        'history': '2020年COVID-19导致全球需求暴减2000万桶/日,WTI期货历史性跌至负值。需求冲击+仓储危机双重打击。',
        'implication': '极端需求冲击下,航空业客运量降90%+,物流链中断。但复苏速度可能超预期——V型反弹是历史常态。',
        'hedge_advice': '短期:保持现金流弹性,避免过度套保。中期:关注OPEC+协调减产信号,逢低建立多头头寸。',
    },
    '2022 俄乌冲突': {
        'history': '2022年俄乌冲突导致俄罗斯原油出口受制裁,供给缺口+地缘溢价推升布伦特至$130+。地缘政治+供给双重冲击。',
        'implication': '地缘驱动的价格飙升通常突然但短暂(3-6个月),随后制裁适应和替代供给逐步消化溢价。',
        'hedge_advice': '事件驱动行情中,期权策略优于期货——买入看涨期权锁定上限成本,保留价格回落的收益空间。',
    },
    '2023 OPEC减产': {
        'history': '2023年OPEC+主动减产200万桶/日,托底油价在$70-90区间。供给管理型市场,价格波动率较低。',
        'implication': 'OPEC减产格局下,价格区间可预测性较高,但下行风险来自减产执行率下滑和非OPEC增产。',
        'hedge_advice': '低波动环境适合使用零成本领(collar)策略,锁定窄价格带。',
    },
    '常态/低波动': {
        'history': '油价处于常态波动区间,无明显单一因子主导。市场处于供需基本平衡状态。',
        'implication': '常态下关注结构性变化信号——OPEC会议决策、美国钻井数趋势、中国PMI走向。',
        'hedge_advice': '常态下对冲比例可适当降低(25-40%),使用低成本期货锁价即可。',
    },
}


# ═══════════════════════════════════════════════════════════
# NLG REPORT GENERATION (ENHANCED)
# ═══════════════════════════════════════════════════════════

def generate_nlg_report(row):
    """生成单月深度风险研判报告,含经济学叙事和对冲建议。"""
    date = pd.Timestamp(row['test_date']).strftime('%Y年%m月')
    rl = row['risk_level']
    rb = row['risk_bias']
    top = row.get('top_factor', 'Unknown')
    q10 = row['pred_q10_1m'] * 100
    q50 = row['pred_q50_1m'] * 100
    q90 = row['pred_q90_1m'] * 100
    vol = row['pred_vol'] * 100

    rl_zh = {'Low': '低', 'Medium': '中等', 'High': '高'}.get(rl, rl)
    rb_zh = {'Upward': '偏上行', 'Downward': '偏下行', 'Balanced': '均衡'}.get(rb, rb)
    factor_zh = {
        'Price': '价格联动', 'Supply': '供给端', 'Demand': '需求端',
        'Risk_Geo': '地缘政治/风险', 'Technical': '技术面',
    }.get(top, top)

    # ── Part 1: 核心判断 ──
    summary = (
        f"【{date}油价风险研判】\n"
        f"■ 核心判断:风险等级{rl_zh},方向{rb_zh},由{factor_zh}因子主导。\n"
        f"■ 1M预测区间:[{q10:+.1f}%, {q90:+.1f}%],中枢{q50:+.1f}%,波动率{vol:.1f}%。\n"
    )

    # 3M
    if pd.notna(row.get('pred_q10_3m')):
        summary += f"■ 3M预测区间:[{row['pred_q10_3m']*100:+.1f}%, {row['pred_q90_3m']*100:+.1f}%],中枢{row['pred_q50_3m']*100:+.1f}%。\n"

    # CQR
    cqr_lo = row.get('cqr_q10_1m', None)
    if cqr_lo is not None and pd.notna(cqr_lo):
        summary += f"■ CQR校准区间:[{cqr_lo*100:+.1f}%, {row['cqr_q90_1m']*100:+.1f}%](分布自由覆盖保证)。\n"

    # ── Part 2: Regime 经济学叙事 ──
    regime = row.get('regime_match', '')
    regime_sim = row.get('regime_similarity', 0)
    if regime and regime != 'Unknown':
        summary += f"\n▶ 格局识别:当前最接近「{regime}」(相似度{regime_sim*100:.0f}%)\n"
        narr = REGIME_NARRATIVES.get(regime, {})
        if narr:
            summary += f"  历史参照:{narr['history']}\n"
            summary += f"  当前启示:{narr['implication']}\n"
            summary += f"  对冲建议:{narr['hedge_advice']}\n"

    # ── Part 3: 行业影响 ──
    high_risk = []
    med_risk = []
    for ind in INDUSTRIES:
        risk = str(row.get(f'{ind}_risk', 'Low'))
        if 'High' in risk:
            high_risk.append(INDUSTRY_ZH.get(ind, ind))
        elif 'Medium' in risk:
            med_risk.append(INDUSTRY_ZH.get(ind, ind))
    if high_risk:
        summary += f"\n▶ 高风险行业:{'、'.join(high_risk)}——建议提升套保覆盖率至60-80%。\n"
    if med_risk:
        summary += f"▶ 中风险行业:{'、'.join(med_risk)}——建议维持25-50%套保覆盖。\n"
    if not high_risk and not med_risk:
        summary += f"\n▶ 各行业风险均处于可控水平,建议维持常规套保比例。\n"

    # ── Part 4: 压力测试 ──
    base = row.get('scenario_base', 0) * 100
    vix = row.get('scenario_vix_shock', 0) * 100
    supply = row.get('scenario_supply_cut', 0) * 100
    demand = row.get('scenario_demand_crash', 0) * 100
    worst = min(supply, demand)
    summary += (
        f"\n▶ 压力测试:基准{base:+.1f}% | VIX翻倍{vix:+.1f}% | "
        f"供给中断{supply:+.1f}% | 需求崩塌{demand:+.1f}%\n"
        f"  最大下行风险:{worst:+.1f}%,建议预留相应流动性缓冲。\n"
    )

    return summary


def generate_all_reports(results):
    """为所有月份生成 NLG 报告。"""
    reports = {}
    for _, row in results.iterrows():
        dt = pd.Timestamp(row['test_date']).strftime('%Y-%m')
        reports[dt] = generate_nlg_report(row)
    return reports


# ═══════════════════════════════════════════════════════════
# EVALUATION METRICS
# ═══════════════════════════════════════════════════════════

def evaluate_results(results):
    """计算全面的评估指标并打印。"""
    # 只评估有实际值的月份(排除 live forecast)
    eval_mask = results['actual_ret_1m'].notna()
    results = results[eval_mask].copy()
    ar = results['actual_ret_1m'].values
    av = results['actual_vol'].values
    n = len(results)

    print(f"\n{'='*65}")
    print("V2 CHAMPIONSHIP — EVALUATION")
    print("=" * 65)

    # 1M Interval
    print(f"\n--- 1M INTERVAL ---")
    models = [('QR (vol-adapt)', 'pred'), ('LightGBM', 'lgb')]
    if 'cqr_q10_1m' in results.columns:
        models.append(('Conformal QR', 'cqr'))
    for model_name, prefix in models:
        q10 = results[f'{prefix}_q10_1m'].values
        q90 = results[f'{prefix}_q90_1m'].values
        cov = ((ar >= q10) & (ar <= q90)).mean()
        wis = ((q90-q10) + (2/0.2)*np.maximum(q10-ar, 0) + (2/0.2)*np.maximum(ar-q90, 0)).mean()
        vm = np.median(av)
        hi = av > vm
        cov_hi = ((ar[hi] >= q10[hi]) & (ar[hi] <= q90[hi])).mean() if hi.sum() > 0 else 0
        print(f"  {model_name:<18} Cov={cov:.1%} HiCov={cov_hi:.1%} WIS={wis:.4f}")

    nq10 = np.full(n, np.quantile(ar, 0.10))
    nq90 = np.full(n, np.quantile(ar, 0.90))
    naive_wis = ((nq90-nq10) + (2/0.2)*np.maximum(nq10-ar, 0) + (2/0.2)*np.maximum(ar-nq90, 0)).mean()
    print(f"  {'Naive':<18} WIS={naive_wis:.4f}")

    # 3M
    print(f"\n--- 3M INTERVAL ---")
    m3 = results['actual_ret_3m'].notna()
    if m3.sum() > 10:
        ar3 = results.loc[m3, 'actual_ret_3m'].values
        q10_3 = results.loc[m3, 'pred_q10_3m'].values
        q90_3 = results.loc[m3, 'pred_q90_3m'].values
        cov3 = ((ar3 >= q10_3) & (ar3 <= q90_3)).mean()
        wis3 = ((q90_3-q10_3) + (2/0.2)*np.maximum(q10_3-ar3, 0) + (2/0.2)*np.maximum(ar3-q90_3, 0)).mean()
        print(f"  QR 3M (vol-adapt)  Cov={cov3:.1%} WIS={wis3:.4f} n={m3.sum()}")

    # Vol
    print(f"\n--- VOL SCORE ---")
    pv = results['pred_vol'].values
    bl = results['baseline_ewma'].values
    for nm, prd in [('V2 BL+Resid', pv), ('EWMA', bl)]:
        from sklearn.metrics import mean_squared_error
        rmse = np.sqrt(mean_squared_error(av, prd))
        corr = np.corrcoef(av, prd)[0, 1]
        print(f"  {nm:<18} RMSE={rmse:.4f} corr={corr:+.3f}")

    # Risk levels
    print(f"\n--- RISK LEVELS ---")
    for lvl in ['Low', 'Medium', 'High']:
        mask = results['risk_level'] == lvl
        if mask.sum() > 0:
            print(f"  {lvl:<8}: vol={av[mask].mean():.4f} n={mask.sum()}")

    # Factor frequency
    print(f"\n--- FACTOR FREQ ---")
    if 'top_factor' in results.columns:
        for fac, cnt in results['top_factor'].value_counts().items():
            print(f"  {fac:<12}: {cnt} ({cnt/n:.1%})")

    # SHAP
    print(f"\n--- SHAP (avg) ---")
    for g in FACTOR_GROUPS:
        col = f'shap_{g}'
        if col in results.columns:
            print(f"  {g:<12}: {results[col].abs().mean():.4f}")

    # Scenario
    print(f"\n--- SCENARIO (latest) ---")
    lat = results.iloc[-1]
    print(f"  Base:         {lat['scenario_base']*100:+.1f}%")
    print(f"  VIX shock:    {lat['scenario_vix_shock']*100:+.1f}%")
    print(f"  Supply cut:   {lat['scenario_supply_cut']*100:+.1f}%")
    print(f"  Demand crash: {lat['scenario_demand_crash']*100:+.1f}%")

    # NLG sample
    print(f"\n--- NLG REPORT (latest) ---")
    print(generate_nlg_report(results.iloc[-1]))


# ═══════════════════════════════════════════════════════════
# ABLATION EXPERIMENTS
# ═══════════════════════════════════════════════════════════

def _run_qr_eval(panel, feat_list, train_window=120):
    """内部辅助:对给定特征子集跑 walk-forward QR 并返回 (cov, wis, n)。"""
    hits, totals, wis_list = 0, 0, []
    for i in range(train_window, len(panel) - 1):
        train_df = panel.iloc[max(0, i - train_window):i]
        test_df = panel.iloc[i:i + 1]
        avail = [f for f in feat_list if f in train_df.columns and train_df[f].notna().mean() > 0.8]
        if len(avail) < 2:
            continue
        X_tr = train_df[avail].fillna(train_df[avail].median())
        X_te = test_df[avail].fillna(train_df[avail].median())
        sc = StandardScaler()
        X_tr_s = sc.fit_transform(X_tr)
        X_te_s = sc.transform(X_te)
        y = train_df['target_ret_1m'].dropna()
        mask = y.index.isin(X_tr.index)
        y = y[mask]
        X_tr_s = X_tr_s[:len(y)]
        actual = panel['target_ret_1m'].iloc[i]
        if np.isnan(actual):
            continue
        try:
            qr10 = QuantileRegressor(quantile=0.10, alpha=0.1, solver='highs')
            qr90 = QuantileRegressor(quantile=0.90, alpha=0.1, solver='highs')
            qr10.fit(X_tr_s, y)
            qr90.fit(X_tr_s, y)
            p10, p90 = qr10.predict(X_te_s)[0], qr90.predict(X_te_s)[0]
            if p10 > p90: p10, p90 = p90, p10
            hit = 1 if p10 <= actual <= p90 else 0
            hits += hit
            totals += 1
            wis_list.append((p90-p10) + (2/0.2)*max(p10-actual, 0) + (2/0.2)*max(actual-p90, 0))
        except:
            continue
    cov = hits / totals if totals > 0 else 0
    wis = np.mean(wis_list) if wis_list else 999
    return cov, wis, totals


def run_ablation(panel, features):
    """运行消融实验:训练窗口 + 因子组 leave-one-out。"""
    print(f"\n--- ABLATION EXPERIMENTS ---")
    ablation_results = []

    # ── Part 1: Window ablation ──
    print("  [窗口消融]")
    for w in [84, 120, 180]:
        cov, wis, n_ab = _run_qr_eval(panel, features, train_window=w)
        ablation_results.append({
            'type': 'window', 'param': w, 'param_label': f'{w}月',
            'cov': round(cov, 3), 'wis': round(wis, 4), 'n': n_ab
        })
        print(f"    Window={w:>3}: Cov={cov:.1%} WIS={wis:.4f} n={n_ab}")

    # ── Part 2: Factor-group leave-one-out ──
    print("  [因子组消融 — Leave-One-Out]")
    # Baseline: all factors
    base_cov, base_wis, base_n = _run_qr_eval(panel, features)
    ablation_results.append({
        'type': 'factor_group', 'param': 'ALL', 'param_label': '全部因子',
        'cov': round(base_cov, 3), 'wis': round(base_wis, 4), 'n': base_n
    })
    print(f"    ALL (baseline): Cov={base_cov:.1%} WIS={base_wis:.4f}")

    group_zh = {'Price': '价格联动', 'Supply': '供给端', 'Demand': '需求端',
                'Risk_Geo': '地缘/风险', 'Technical': '技术面'}

    for group, members in FACTOR_GROUPS.items():
        # Remove this group's features
        reduced = [f for f in features if f not in members]
        if len(reduced) < 2:
            continue
        cov, wis, n_ab = _run_qr_eval(panel, reduced)
        delta_cov = cov - base_cov
        delta_wis = wis - base_wis
        ablation_results.append({
            'type': 'factor_group', 'param': group,
            'param_label': f'去除{group_zh.get(group, group)}',
            'cov': round(cov, 3), 'wis': round(wis, 4), 'n': n_ab,
            'delta_cov': round(delta_cov, 3), 'delta_wis': round(delta_wis, 4),
        })
        print(f"    -{group:<12}: Cov={cov:.1%}{delta_cov:+.1%}) "
              f"WIS={wis:.4f}{delta_wis:+.4f})")

    return ablation_results