File size: 18,814 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
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
"""
agent/chat.py — Oil Risk Analyst Agent (混合架构: 本地模板 + LLM 增强)
========================================================================
- 常见问题: 直接从平台数据生成专业回答 (即时响应)
- 复杂分析: 调用 SiliconFlow Qwen2.5-7B-Instruct (带重试)
"""
import json, os, re, time
import pandas as pd
import numpy as np
from config import (
    OUTPUT_DIR, SILICONFLOW_API_KEY, SILICONFLOW_BASE_URL, SILICONFLOW_MODEL
)

# ═══════════════════════════════════════════════════════════
# 数据层
# ═══════════════════════════════════════════════════════════

_cache = {}

def _results():
    if 'results' not in _cache:
        fp = os.path.join(OUTPUT_DIR, 'v2_championship_results.csv')
        _cache['results'] = pd.read_csv(fp) if os.path.exists(fp) else None
    return _cache['results']

def _reports():
    if 'reports' not in _cache:
        fp = os.path.join(OUTPUT_DIR, 'v2_nlg_reports.json')
        if os.path.exists(fp):
            with open(fp, 'r', encoding='utf-8') as f:
                _cache['reports'] = json.load(f)
        else:
            _cache['reports'] = {}
    return _cache['reports']

def _hedge():
    if 'hedge' not in _cache:
        fp = os.path.join(OUTPUT_DIR, 'v2_hedge_backtest.json')
        if os.path.exists(fp):
            with open(fp, 'r', encoding='utf-8') as f:
                _cache['hedge'] = json.load(f)
        else:
            _cache['hedge'] = {}
    return _cache['hedge']

def _events():
    if 'events' not in _cache:
        fp = os.path.join(OUTPUT_DIR, 'event_timeline.json')
        if os.path.exists(fp):
            with open(fp, 'r', encoding='utf-8') as f:
                evts = json.load(f)
            evts.sort(key=lambda e: e.get('date', ''), reverse=True)
            _cache['events'] = evts
        else:
            _cache['events'] = []
    return _cache['events']

def _latest():
    """获取最新预测行。"""
    r = _results()
    if r is None:
        return None
    return r.iloc[-1]

def _latest_report(benchmark='WTI'):
    """获取最新NLG报告。"""
    rp = _reports()
    if not rp:
        return None
    # Try specific benchmark first, then any
    keys = sorted(rp.keys())
    bm_keys = [k for k in keys if benchmark in k]
    key = bm_keys[-1] if bm_keys else keys[-1]
    entry = rp[key]
    return entry if isinstance(entry, str) else entry.get('report', str(entry)[:500])

# ═══════════════════════════════════════════════════════════
# 本地回答引擎 — 常见问题秒回
# ═══════════════════════════════════════════════════════════

IND_MAP = {
    '航空': 'aviation', 'aviation': 'aviation',
    '物流': 'logistics', 'logistics': 'logistics',
    '化工': 'chemical', 'chemical': 'chemical', 'chemicals': 'chemical',
    '制造': 'manufacturing', 'manufacturing': 'manufacturing',
    '上游': 'upstream', '油气': 'upstream', 'upstream': 'upstream',
}

IND_ZH = {'aviation': '航空', 'logistics': '物流', 'chemical': '化工',
           'manufacturing': '制造', 'upstream': '上游油气'}

IND_PROFILE = {
    'aviation': '航空燃油占运营成本30-40%,油价波动10%影响利润5-8%,敏感度最高',
    'logistics': '柴油占物流成本25-35%,可通过燃油附加费部分传导,但存在时滞',
    'chemical': '原油作为石化原料占成本40-60%,裂解价差直接影响利润率',
    'manufacturing': '能源成本占制造成本10-20%,主要通过电价和天然气间接传导',
    'upstream': '油价上涨是收入利好,但需防范暴跌风险保护资本开支',
}


def _try_local_answer(msg):
    """尝试本地回答,返回 (reply, confidence)。"""
    m = msg.lower()
    last = _latest()
    if last is None:
        return None, 0

    # ── 1. 风险等级/研判 ──
    if any(w in m for w in ['风险等级', '风险研判', '当前风险', '油价风险']):
        report = _latest_report()
        if report:
            return report, 0.95

    # ── 2. 完整报告/月度报告 ──
    if any(w in m for w in ['完整报告', '月度报告', '详细分析', '报告']):
        report = _latest_report()
        if report:
            return report, 0.9

    # ── 3. 预测区间 ──
    if any(w in m for w in ['预测区间', '分位数', 'q10', 'q50', 'q90', '下个月']):
        q10 = last['pred_q10_1m']
        q50 = last['pred_q50_1m']
        q90 = last['pred_q90_1m']
        vol = last['pred_vol']
        date = str(last['test_date'])[:7]
        reply = (f"**{date} 油价预测区间:**\n\n"
                 f"- **Q10 (悲观):** {q10:.1%}  ← 有10%概率跌幅超此\n"
                 f"- **Q50 (中枢):** {q50:.1%}  ← 最可能的变动\n"
                 f"- **Q90 (乐观):** {q90:.1%}  ← 有10%概率涨幅超此\n"
                 f"- **波动率:** {vol:.1%}\n\n"
                 f"**解读:** 区间跨度{q90-q10:.1%},"
                 f"{'偏上行' if q50 > 0 else '偏下行'},"
                 f"波动率{vol:.1%}{'较高,建议增加对冲' if vol > 0.05 else '可控'}。")
        return reply, 0.9

    # ── 4. 风险趋势 ──
    if any(w in m for w in ['趋势', '走势', '变化', '最近', '历史', '几个月']):
        r = _results()
        tail = r.tail(6)
        lines = ["**近6个月风险趋势:**\n"]
        for _, row in tail.iterrows():
            date = str(row['test_date'])[:7]
            lvl = row['risk_level']
            bias = row['risk_bias']
            top = row['top_factor']
            q50 = row['pred_q50_1m']
            emoji = {'High': '🔴', 'Medium-High': '🟠', 'Medium': '🟡',
                     'Low-Medium': '🔵', 'Low': '🟢'}.get(lvl, '⚪')
            lines.append(f"  {emoji} **{date}**: {lvl} | {bias} | 中枢{q50:+.1%} | 主导: {top}")

        # 趋势判断
        levels = tail['risk_level'].tolist()
        level_map = {'Low': 0, 'Low-Medium': 1, 'Medium': 2, 'Medium-High': 3, 'High': 4}
        nums = [level_map.get(l, 2) for l in levels]
        if nums[-1] > nums[0]:
            trend = "📈 总体趋势:风险**上升**"
        elif nums[-1] < nums[0]:
            trend = "📉 总体趋势:风险**下降**"
        else:
            trend = "➡️ 总体趋势:风险**持平**"
        lines.append(f"\n{trend}")

        return '\n'.join(lines), 0.9

    # ── 5. 行业分析/对冲建议 ──
    detected_ind = None
    for kw, ind in IND_MAP.items():
        if kw in m:
            detected_ind = ind
            break

    if detected_ind or any(w in m for w in ['对冲', '套保', 'cfo', '行业']):
        ind = detected_ind or 'aviation'
        hedge = _hedge()
        h = hedge.get(ind, {})
        zh = IND_ZH.get(ind, ind)
        profile = IND_PROFILE.get(ind, '')

        risk_level = last.get(f"risk_level", "Medium")
        q50 = last['pred_q50_1m']
        vol = last['pred_vol']

        ratio = h.get('recommended_ratio_pct', '50%')
        tool = {'futures': '期货锁价', 'put': '看跌期权', 'collar': '零成本领口'}.get(
            h.get('recommended_tool', 'futures'), '期货锁价')
        rationale = h.get('rationale', '')
        saving = h.get('total_saving', 0)
        vol_red = h.get('vol_reduction', 0)

        reply = (f"**{zh}行业专项分析报告**\n\n"
                 f"**一、行业画像**\n{profile}\n\n"
                 f"**二、当前油价环境**\n"
                 f"- 风险等级: **{risk_level}**\n"
                 f"- 1M预测中枢: **{q50:+.1%}**,波动率: **{vol:.1%}**\n"
                 f"- 主导因子: **{last.get('top_factor', 'N/A')}**\n\n"
                 f"**三、对冲建议**\n"
                 f"- 推荐对冲比例: **{ratio}**\n"
                 f"- 推荐工具: **{tool}**\n"
                 f"- 理由: {rationale}\n\n"
                 f"**四、历史回测**\n"
                 f"- 按推荐比例累计节省: **${saving:.1f}M**\n"
                 f"- 波动率降低: **{vol_red}%**\n\n"
                 f"**五、银行行动建议**\n")

        if risk_level in ('High', 'Medium-High'):
            reply += (f"1. 立即联络{zh}客户,提示油价上行风险\n"
                      f"2. 推荐对冲方案: {ratio} {tool},锁定未来3-6个月成本\n"
                      f"3. 建议预留流动性缓冲以应对波动\n")
        else:
            reply += (f"1. 常规跟进{zh}客户,当前风险可控\n"
                      f"2. 建议维持基础对冲({ratio}),无需过度套保\n"
                      f"3. 关注下一轮OPEC+会议可能的政策变化\n")

        return reply, 0.95

    # ── 6. 压力测试 ──
    if any(w in m for w in ['压力', '如果', '假设', '中东', '冲突', '崩塌', '减产', '战争']):
        vol = last['pred_vol']
        q50 = last['pred_q50_1m']

        # 识别冲击场景
        supply_shock = -15 if any(w in m for w in ['供给', '减产', '中断', '中东', '冲突']) else 0
        demand_shock = -20 if any(w in m for w in ['需求', '崩塌', '衰退']) else 0
        geo_spike = 3 if any(w in m for w in ['地缘', '冲突', '中东', '战争']) else 1

        shock = abs(supply_shock)/100 + abs(demand_shock)/100
        stressed_vol = vol * (1 + shock) * (max(1, geo_spike) ** 0.5)
        stress_level = 'High' if stressed_vol > 0.12 else ('Medium' if stressed_vol > 0.06 else 'Low')

        scenario_name = []
        if supply_shock: scenario_name.append(f'供给冲击{supply_shock}%')
        if demand_shock: scenario_name.append(f'需求冲击{demand_shock}%')
        if geo_spike > 1: scenario_name.append(f'地缘风险×{geo_spike}')
        scenario = '、'.join(scenario_name) or '基准情景'

        reply = (f"**压力测试结果 — {scenario}**\n\n"
                 f"- 基准波动率: **{vol:.1%}**\n"
                 f"- 冲击后波动率: **{stressed_vol:.1%}** ({stressed_vol/vol:.0%})\n"
                 f"- 压力风险等级: **{stress_level}**\n\n")
        if stress_level == 'High':
            reply += ("**⚠️ 高风险预警:**\n"
                      "1. 立即提升对冲比例至 **50%以上**\n"
                      "2. 启动紧急风控预案,增加保证金缓冲\n"
                      "3. 重点关注航空、化工等高敏感行业客户\n")
        elif stress_level == 'Medium':
            reply += ("**⚡ 中等风险:**\n"
                      "1. 建议维持 **30%** 对冲并密切关注\n"
                      "2. 做好应急方案预案\n"
                      "3. 适度增加库存\n")
        else:
            reply += ("**✅ 风险可控:**\n"
                      "1. 当前策略无需调整\n"
                      "2. 维持常规对冲即可\n")
        return reply, 0.9

    # ── 7. 模型验证 ──
    if any(w in m for w in ['准确', '验证', '可靠', '覆盖率', 'wis', '模型']):
        r = _results()
        # Drop rows with NaN
        valid = r.dropna(subset=['actual_ret_1m', 'pred_q10_1m', 'pred_q90_1m', 'pred_vol', 'actual_vol'])
        ar = valid['actual_ret_1m'].values
        q10 = valid['pred_q10_1m'].values
        q90 = valid['pred_q90_1m'].values
        pv = valid['pred_vol'].values
        av = valid['actual_vol'].values
        n = len(valid)

        cov = ((ar >= q10) & (ar <= q90)).mean()
        wis_val = ((q90-q10)+(2/0.2)*np.maximum(q10-ar,0)+(2/0.2)*np.maximum(ar-q90,0)).mean()
        nq10 = np.quantile(ar, 0.10); nq90 = 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()
        corr = np.corrcoef(av, pv)[0,1] if len(av) > 1 else 0
        wis_pct = (1-wis_val/naive_wis)*100 if naive_wis != 0 else 0

        reply = (f"**模型验证报告 (共 {n} 个月)**\n\n"
                 f"**核心指标:**\n"
                 f"- 80%区间覆盖率: **{cov:.1%}** (目标≥80%)\n"
                 f"- WIS得分: **{wis_val:.4f}** (优于基准 {wis_pct:+.1f}%)\n"
                 f"- 波动率相关性: **{corr:.3f}**\n\n"
                 f"**评估:** "
                 f"{'✅ 模型表现优异' if cov >= 0.75 and wis_pct > 0 else '⚠️ 模型有改进空间'}。"
                 f"覆盖率{cov:.1%}{'达标' if cov >= 0.75 else '偏低'},"
                 f"WIS{'优于' if wis_pct > 0 else '劣于'}朴素基准{abs(wis_pct):.1f}%。")
        return reply, 0.9

    # ── 无法本地回答 ──
    return None, 0


# ═══════════════════════════════════════════════════════════
# LLM 增强 — 仅用于复杂/自定义分析
# ═══════════════════════════════════════════════════════════

SYSTEM_PROMPT = """你是「油刃有余 OilVerse」平台的AI助手「Oil Risk Agent」。

你拥有实时的平台预测数据和事件时间线,你的回答必须:
1. 先给结论(一句话加粗),再给支撑(3-5条要点),最后给行动建议
2. 用 **加粗** 标记关键数字和结论
3. 每次回答控制在 200 字以内
4. 绝对不要输出工具名、函数名、JSON等技术内容
5. 如果是闲聊,简短回答身份即可
6. 引用最近事件作为分析支撑,说明「事件→因子异动→风险信号→对冲建议」的因果链"""


def _build_data_context(msg):
    """为LLM构建精炼的数据上下文。"""
    last = _latest()
    if last is None:
        return ""

    ctx = [f"分析日期: {str(last['test_date'])[:7]}",
           f"风险等级: {last['risk_level']}",
           f"方向偏置: {last['risk_bias']}",
           f"1M区间: [{last['pred_q10_1m']:.1%}, {last['pred_q90_1m']:.1%}]",
           f"波动率: {last['pred_vol']:.1%}",
           f"主导因子: {last['top_factor']}",
           f"Regime匹配: {last.get('regime_match', 'N/A')} ({last.get('regime_similarity', 0):.0%})"]

    # Add recent events as causal context
    evts = _events()
    if evts:
        ctx.append('\n[近期关键事件]')
        for ev in evts[:3]:
            impact_zh = {'bullish': '利多', 'bearish': '利空', 'neutral': '中性'}.get(ev.get('impact', ''), '')
            ctx.append(f"- {ev['date']} {ev['title']} ({impact_zh}): {ev.get('risk_signal', '')}")

    return '\n'.join(ctx)


def _call_llm_enhanced(user_message, history):
    """调用 LLM,带精炼上下文。"""
    import requests

    data_ctx = _build_data_context(user_message)
    enriched = f"{user_message}\n\n[平台数据]\n{data_ctx}" if data_ctx else user_message

    messages = [{'role': 'system', 'content': SYSTEM_PROMPT}]
    for h in history[-4:]:  # 只保留最近2轮对话
        messages.append(h)
    messages.append({'role': 'user', 'content': enriched})

    headers = {
        'Authorization': f'Bearer {SILICONFLOW_API_KEY}',
        'Content-Type': 'application/json',
    }
    payload = {
        'model': SILICONFLOW_MODEL,
        'messages': messages,
        'temperature': 0.3,
        'max_tokens': 500,
        'stream': False,
    }

    last_err = None
    for attempt in range(2):
        try:
            resp = requests.post(
                f'{SILICONFLOW_BASE_URL}/chat/completions',
                headers=headers, json=payload, timeout=45
            )
            resp.raise_for_status()
            data = resp.json()
            reply = data['choices'][0]['message']['content']
            # 清理残留
            reply = re.sub(r'</?tool_call>', '', reply)
            reply = re.sub(r'\b(query_\w+|run_\w+)\(.*?\)', '', reply)
            return reply.strip()
        except requests.exceptions.Timeout:
            last_err = "LLM响应超时"
            time.sleep(2)
        except requests.exceptions.ConnectionError:
            last_err = "无法连接LLM服务"
            time.sleep(2)
        except Exception as e:
            return f"LLM调用失败: {e}"

    return f"⚠️ {last_err},请稍后重试。\n\n💡 你可以尝试更具体的问题,如「航空行业对冲建议」「当前风险等级」等,这些可以即时响应。"


# ═══════════════════════════════════════════════════════════
# 主入口
# ═══════════════════════════════════════════════════════════

def chat_with_agent(user_message, history=None):
    """
    混合架构对话入口:
    1. 先尝试本地回答(即时)
    2. 无法本地回答时调用 LLM
    """
    if history is None:
        history = []

    # 闲聊快速回复
    greets = ['你好', '你是谁', 'hello', 'hi', '嗨', '在吗']
    if any(user_message.strip().lower() == g for g in greets):
        reply = "👋 你好!我是油价风险分析 Agent,基于平台实时数据为你提供专业分析。\n\n你可以问我:\n• 当前风险等级和预测区间\n• 行业专项分析(航空/物流/化工/制造/上游)\n• 对冲策略和工具推荐\n• 压力测试模拟\n• 模型验证指标"
        history.append({'role': 'user', 'content': user_message})
        history.append({'role': 'assistant', 'content': reply})
        return reply, history

    # 尝试本地回答
    local_reply, confidence = _try_local_answer(user_message)
    if local_reply and confidence >= 0.85:
        history.append({'role': 'user', 'content': user_message})
        history.append({'role': 'assistant', 'content': local_reply})
        return local_reply, history

    # LLM 增强回答
    reply = _call_llm_enhanced(user_message, history)
    history.append({'role': 'user', 'content': user_message})
    history.append({'role': 'assistant', 'content': reply})
    return reply, history


if __name__ == '__main__':
    print("油价风险分析 Agent(输入 quit 退出)")
    print("=" * 50)
    h = []
    while True:
        q = input("\n你: ").strip()
        if q.lower() in ('quit', 'exit', 'q'):
            break
        reply, h = chat_with_agent(q, h)
        print(f"\nAgent: {reply}")