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