""" 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'', '', 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}")