Spaces:
Build error
Build error
| """ | |
| 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}") | |