portfolio_metrics.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. """
  2. 账户级 + 人群包级 ROI 基线汇总 — auto_put_ad_mini (Step 4.2)
  3. 职责:
  4. - 读取 metrics CSV(calculate_roi_metrics 输出)
  5. - 按 account_id / audience_tier / global 三层聚合
  6. - 输出 p25/p50/p75 分位数作为决策基线
  7. 使用定位:
  8. - LLM 在判断广告 ROI "偏低" 时,应先和【同人群包 p25】比,而非全体均值 × 0.5
  9. - 跑量放大候选:同人群包 p75 是最低门槛
  10. - 大盘下滑信号:账户 roi_mean 比历史显著低 → 行情差,不应过度降价触发死亡螺旋
  11. """
  12. import json
  13. import logging
  14. import math
  15. import sys
  16. from datetime import datetime, timedelta
  17. from pathlib import Path
  18. from typing import Any, Dict, List, Optional, Tuple
  19. import numpy as np
  20. import pandas as pd
  21. from agent.tools import tool
  22. from agent.tools.models import ToolContext, ToolResult
  23. logger = logging.getLogger(__name__)
  24. _MINI_DIR = Path(__file__).resolve().parent.parent
  25. # metrics CSV 保存在 outputs/metrics_{date}.csv(roi_calculator.py line 769)
  26. _OUTPUTS_DIR = _MINI_DIR / "outputs"
  27. _PORTFOLIO_DIR = _MINI_DIR / "outputs" / "portfolio_summary"
  28. # 引入 config 中的市场信号阈值
  29. sys.path.insert(0, str(_MINI_DIR))
  30. try:
  31. from config import MARKET_VOLATILE_PCT, MARKET_TRENDING_DOWN_PCT # noqa: E402
  32. except ImportError:
  33. MARKET_VOLATILE_PCT = 0.15
  34. MARKET_TRENDING_DOWN_PCT = -0.10
  35. def _safe_float(v) -> Optional[float]:
  36. """安全转换为 float,NaN / inf / 转换失败时返回 None。"""
  37. try:
  38. f = float(v)
  39. if math.isnan(f) or math.isinf(f):
  40. return None
  41. return f
  42. except (TypeError, ValueError):
  43. return None
  44. def _round_or_none(v: Optional[float], digits: int = 4) -> Optional[float]:
  45. """四舍五入到指定位数,None 时原样返回 None。"""
  46. return round(v, digits) if v is not None else None
  47. def _describe_group(df: pd.DataFrame) -> Dict[str, Any]:
  48. """对一个分组 DataFrame 计算统计指标。"""
  49. if df.empty:
  50. return {
  51. "ad_count": 0,
  52. "active_ads": 0,
  53. "daily_cost_avg": 0.0,
  54. "daily_revenue_avg": 0.0,
  55. "roi_mean": None,
  56. "roi_p25": None,
  57. "roi_p50": None,
  58. "roi_p75": None,
  59. "stable_ads_pct": 0.0,
  60. }
  61. ad_count = len(df)
  62. # active_ads: 7 日有消耗
  63. if "cost_7d_avg" in df.columns:
  64. active = int((pd.to_numeric(df["cost_7d_avg"], errors="coerce").fillna(0) > 0).sum())
  65. else:
  66. active = 0
  67. # 日消耗 / 日收入均值(跨广告的日均)
  68. if "cost_7d_avg" in df.columns:
  69. daily_cost_avg = float(pd.to_numeric(df["cost_7d_avg"], errors="coerce").fillna(0).sum())
  70. else:
  71. daily_cost_avg = 0.0
  72. # 日收入 = 7日总收入 / 7(若有 revenue_7d_total),否则 fallback 为空
  73. daily_revenue_avg = 0.0
  74. if "revenue_7d_total" in df.columns:
  75. total_rev = pd.to_numeric(df["revenue_7d_total"], errors="coerce").fillna(0).sum()
  76. daily_revenue_avg = float(total_rev) / 7.0
  77. # ROI 分位数(使用 动态ROI_7日均值,若缺则尝试 f_7日动态ROI)
  78. roi_col = None
  79. for candidate in ("动态ROI_7日均值", "f_7日动态ROI"):
  80. if candidate in df.columns:
  81. roi_col = candidate
  82. break
  83. roi_stats = {"roi_mean": None, "roi_p25": None, "roi_p50": None, "roi_p75": None}
  84. if roi_col:
  85. roi_series = pd.to_numeric(df[roi_col], errors="coerce").dropna()
  86. # 过滤掉 ROI = 0 的记录(通常代表无数据)
  87. roi_series = roi_series[roi_series > 0]
  88. if not roi_series.empty:
  89. roi_stats = {
  90. "roi_mean": _round_or_none(float(roi_series.mean())),
  91. "roi_p25": _round_or_none(float(roi_series.quantile(0.25))),
  92. "roi_p50": _round_or_none(float(roi_series.quantile(0.50))),
  93. "roi_p75": _round_or_none(float(roi_series.quantile(0.75))),
  94. }
  95. # ===== 新增:裂变率统计 =====
  96. fission_stats = {"fission_mean": None, "fission_p50": None}
  97. # 假设metrics CSV中有 "T0裂变系数_7日均值" 字段
  98. fission_col = None
  99. for candidate in ("T0裂变系数_7日均值", "T0裂变系数", "fission_ratio"):
  100. if candidate in df.columns:
  101. fission_col = candidate
  102. break
  103. if fission_col:
  104. fission_series = pd.to_numeric(df[fission_col], errors="coerce").dropna()
  105. fission_series = fission_series[fission_series > 0] # 过滤无效值
  106. if not fission_series.empty:
  107. fission_stats = {
  108. "fission_mean": _round_or_none(float(fission_series.mean())),
  109. "fission_p50": _round_or_none(float(fission_series.quantile(0.50))),
  110. }
  111. # ===== 新增:CTR 统计 =====
  112. ctr_stats = {"ctr_mean": None, "ctr_p50": None}
  113. if "valid_click_count" in df.columns and "view_count" in df.columns:
  114. click = pd.to_numeric(df["valid_click_count"], errors="coerce").fillna(0)
  115. view = pd.to_numeric(df["view_count"], errors="coerce").fillna(0)
  116. ctr_series = (click / view).replace([np.inf, -np.inf], np.nan).dropna()
  117. ctr_series = ctr_series[ctr_series > 0]
  118. if not ctr_series.empty:
  119. ctr_stats = {
  120. "ctr_mean": round(float(ctr_series.mean()), 6),
  121. "ctr_p50": round(float(ctr_series.quantile(0.50)), 6),
  122. }
  123. # ===== 新增:出价统计 =====
  124. bid_stats = {"bid_mean": None, "bid_p50": None}
  125. if "bid_amount" in df.columns:
  126. bid_series = pd.to_numeric(df["bid_amount"], errors="coerce").dropna()
  127. bid_series = bid_series[bid_series > 0]
  128. if not bid_series.empty:
  129. bid_stats = {
  130. "bid_mean": round(float(bid_series.mean()), 4),
  131. "bid_p50": round(float(bid_series.quantile(0.50)), 4),
  132. }
  133. # 稳定消耗比例:stable_spend_days_30d >= 7 视为稳定
  134. stable_pct = 0.0
  135. if "stable_spend_days_30d" in df.columns and ad_count > 0:
  136. stable_mask = pd.to_numeric(df["stable_spend_days_30d"], errors="coerce").fillna(0) >= 7
  137. stable_pct = float(stable_mask.sum()) / ad_count
  138. return {
  139. "ad_count": ad_count,
  140. "active_ads": active,
  141. "daily_cost_avg": round(daily_cost_avg, 2),
  142. "daily_revenue_avg": round(daily_revenue_avg, 2),
  143. **roi_stats,
  144. **fission_stats,
  145. **ctr_stats,
  146. **bid_stats,
  147. "stable_ads_pct": round(stable_pct, 4),
  148. }
  149. def _resolve_metrics_csv(metrics_csv: str, end_date: str) -> Optional[Path]:
  150. """解析 metrics CSV 路径(优先用传入的路径,否则根据 end_date 找)。"""
  151. if metrics_csv:
  152. p = Path(metrics_csv)
  153. if not p.is_absolute():
  154. p = _MINI_DIR / p
  155. if p.exists():
  156. return p
  157. # Fallback:根据 end_date
  158. if end_date == "yesterday":
  159. end_dt = datetime.now() - timedelta(days=1)
  160. else:
  161. try:
  162. end_dt = datetime.strptime(end_date.replace("-", ""), "%Y%m%d")
  163. except ValueError:
  164. return None
  165. end_date_str = end_dt.strftime("%Y%m%d")
  166. # 按常见命名找
  167. candidates = [
  168. _OUTPUTS_DIR / f"metrics_{end_date_str}.csv",
  169. _OUTPUTS_DIR / "metrics_temp.csv",
  170. ]
  171. for c in candidates:
  172. if c.exists():
  173. return c
  174. # 最后尝试目录下最新的 metrics_*.csv
  175. if _OUTPUTS_DIR.exists():
  176. csvs = sorted(_OUTPUTS_DIR.glob("metrics_*.csv"), key=lambda p: p.stat().st_mtime, reverse=True)
  177. if csvs:
  178. return csvs[0]
  179. return None
  180. def _compute_daily_tier_snapshot(
  181. end_dt: datetime, days: int = 7
  182. ) -> Dict[str, Dict[str, Any]]:
  183. """读取最近 N 天的 metrics_{date}.csv,按 (date, audience_tier) 计算 p25/p50/p75。
  184. 结构:
  185. {
  186. "YYYYMMDD": {
  187. "global": {roi_p25, roi_p50, roi_p75, ad_count},
  188. "by_audience_tier": {
  189. "R500": {roi_p25, roi_p50, roi_p75, ad_count},
  190. ...
  191. }
  192. },
  193. ...
  194. }
  195. 用途:给 LLM 看"同 tier 基线的日级波动",辅助判断大盘行情;
  196. 也是 relative_trend_pct 的审计来源(虽然 roi_calculator 内部已自算)。
  197. 对历史 metrics CSV 中无 audience_tier 列的情况 → 该天只输出 global。
  198. """
  199. by_date: Dict[str, Dict[str, Any]] = {}
  200. for i in range(days):
  201. date_str = (end_dt - timedelta(days=i)).strftime("%Y%m%d")
  202. csv_path = _OUTPUTS_DIR / f"metrics_{date_str}.csv"
  203. if not csv_path.exists():
  204. continue
  205. try:
  206. dfi = pd.read_csv(csv_path, dtype={"ad_id": str, "account_id": str})
  207. except Exception as e:
  208. logger.warning("读取 %s 失败:%s", csv_path.name, e)
  209. continue
  210. if dfi.empty:
  211. continue
  212. day_entry: Dict[str, Any] = {
  213. "global": _describe_group(dfi),
  214. }
  215. if "audience_tier" in dfi.columns:
  216. tier_map: Dict[str, Any] = {}
  217. for tier, group in dfi.groupby("audience_tier"):
  218. tier_map[str(tier)] = _describe_group(group)
  219. day_entry["by_audience_tier"] = tier_map
  220. else:
  221. day_entry["by_audience_tier"] = {}
  222. by_date[date_str] = day_entry
  223. return by_date
  224. def _compute_market_signal(by_date: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
  225. """
  226. 根据最近 N 天的全局 p50 序列,自动判定大盘行情(不让 LLM 看 by_date 自己猜)。
  227. 判定规则(优先级从高到低):
  228. 1. 数据 < 4 天 → unknown(无法判断)
  229. 2. 最近 3 日 p50 均值 / 之前 N-3 日均值 - 1 ≤ MARKET_TRENDING_DOWN_PCT (-10%) → trending_down
  230. 3. (max - min) / min > MARKET_VOLATILE_PCT (15%) → volatile
  231. 4. 否则 → stable
  232. 输出:
  233. {
  234. "label": "stable" / "volatile" / "trending_down" / "unknown",
  235. "p50_volatility_pct": 0.12,
  236. "recent_vs_baseline_pct": -0.08,
  237. "p50_series": [{"date": "20260410", "p50": 1.78}, ...],
  238. "ad_count_today": 1234,
  239. "reason": "..."
  240. }
  241. """
  242. p50_pairs: List[Tuple[str, float]] = sorted(
  243. [
  244. (d, entry["global"].get("roi_p50"))
  245. for d, entry in by_date.items()
  246. if entry.get("global", {}).get("roi_p50") is not None
  247. ]
  248. )
  249. if len(p50_pairs) < 4:
  250. return {
  251. "label": "unknown",
  252. "p50_volatility_pct": None,
  253. "recent_vs_baseline_pct": None,
  254. "p50_series": [{"date": d, "p50": p} for d, p in p50_pairs],
  255. "ad_count_today": None,
  256. "reason": f"数据不足(只有 {len(p50_pairs)} 天 p50),无法判断行情",
  257. }
  258. p50_values = [p for _, p in p50_pairs]
  259. p50_min = min(p50_values)
  260. p50_max = max(p50_values)
  261. volatility_pct = (p50_max - p50_min) / p50_min if p50_min > 0 else 0.0
  262. # 最近 3 天 vs 之前若干天
  263. recent = p50_values[-3:]
  264. baseline = p50_values[:-3]
  265. recent_mean = sum(recent) / len(recent)
  266. baseline_mean = sum(baseline) / len(baseline) if baseline else recent_mean
  267. recent_vs_baseline_pct = (
  268. (recent_mean - baseline_mean) / baseline_mean if baseline_mean > 0 else 0.0
  269. )
  270. # 判定
  271. if recent_vs_baseline_pct <= MARKET_TRENDING_DOWN_PCT:
  272. label = "trending_down"
  273. reason = (
  274. f"最近 3 日 p50 均值 {recent_mean:.3f} 比之前 {len(baseline)} 日均值 "
  275. f"{baseline_mean:.3f} 低 {-recent_vs_baseline_pct * 100:.1f}%(阈值 "
  276. f"{-MARKET_TRENDING_DOWN_PCT * 100:.0f}%),大盘下行"
  277. )
  278. elif volatility_pct > MARKET_VOLATILE_PCT:
  279. label = "volatile"
  280. reason = (
  281. f"全局 p50 跨日波幅 {volatility_pct * 100:.1f}%(阈值 "
  282. f"{MARKET_VOLATILE_PCT * 100:.0f}%),ARPU 共模噪声大,单广告决策应保守"
  283. )
  284. else:
  285. label = "stable"
  286. reason = (
  287. f"全局 p50 跨日波幅 {volatility_pct * 100:.1f}% < "
  288. f"{MARKET_VOLATILE_PCT * 100:.0f}% 且无显著趋势,大盘平稳"
  289. )
  290. # 当日广告数量(取最后一天)
  291. ad_count_today = None
  292. if p50_pairs:
  293. last_date = p50_pairs[-1][0]
  294. ad_count_today = by_date.get(last_date, {}).get("global", {}).get("ad_count")
  295. return {
  296. "label": label,
  297. "p50_volatility_pct": round(volatility_pct, 4),
  298. "recent_vs_baseline_pct": round(recent_vs_baseline_pct, 4),
  299. "p50_series": [{"date": d, "p50": round(p, 4)} for d, p in p50_pairs],
  300. "ad_count_today": ad_count_today,
  301. "reason": reason,
  302. }
  303. @tool(description="账户级 + 人群包级 ROI 基线汇总(p25/p50/p75 + 最近 7 天日级基线快照 + 大盘行情判定),供 LLM 对标用")
  304. async def calculate_portfolio_summary(
  305. ctx: ToolContext = None,
  306. metrics_csv: str = "",
  307. end_date: str = "yesterday",
  308. ) -> ToolResult:
  309. """
  310. 读取 metrics CSV,按账户 / 人群包 / 全局三层汇总;
  311. 同时读取最近 7 天 metrics CSV 生成日级基线快照(供 LLM 审计大盘波动)。
  312. 输出:outputs/portfolio_summary/portfolio_summary_{end_date}.json
  313. 结构:
  314. {
  315. "end_date": "YYYYMMDD",
  316. "by_account": { "<account_id>": { ... } },
  317. "by_audience_tier": { "<tier>": { ... } }, # 基于 end_date CSV
  318. "global": { ... },
  319. "by_date": {
  320. "YYYYMMDD": {
  321. "global": { roi_p25, roi_p50, roi_p75, ad_count, ... },
  322. "by_audience_tier": { "R500": {...}, "R50": {...} }
  323. },
  324. ...(最近 7 天)
  325. }
  326. }
  327. Args:
  328. metrics_csv: metrics CSV 路径(可选,默认根据 end_date 自动解析)
  329. end_date: 结束日期(YYYYMMDD 或 "yesterday")
  330. Returns:
  331. ToolResult 含 JSON 路径 + 关键摘要
  332. """
  333. try:
  334. if end_date == "yesterday":
  335. end_dt = datetime.now() - timedelta(days=1)
  336. else:
  337. end_dt = datetime.strptime(end_date.replace("-", ""), "%Y%m%d")
  338. end_date_str = end_dt.strftime("%Y%m%d")
  339. csv_path = _resolve_metrics_csv(metrics_csv, end_date)
  340. if csv_path is None or not csv_path.exists():
  341. return ToolResult(
  342. title="账户基线计算失败",
  343. output=f"未找到 metrics CSV(指定:{metrics_csv or '自动'},日期:{end_date})",
  344. )
  345. df = pd.read_csv(csv_path, dtype={"ad_id": str, "account_id": str})
  346. if df.empty:
  347. return ToolResult(
  348. title="账户基线计算失败",
  349. output=f"metrics CSV 为空:{csv_path}",
  350. )
  351. # 按 account_id 分组
  352. by_account: Dict[str, Any] = {}
  353. if "account_id" in df.columns:
  354. for acc_id, group in df.groupby("account_id"):
  355. by_account[str(acc_id)] = _describe_group(group)
  356. # 按 audience_tier 分组
  357. by_tier: Dict[str, Any] = {}
  358. if "audience_tier" in df.columns:
  359. for tier, group in df.groupby("audience_tier"):
  360. by_tier[str(tier)] = _describe_group(group)
  361. # 按 (audience_tier, 广告优化目标) 双键分组 — 用于同类+同转化目标的均值出价
  362. by_tier_goal: Dict[str, Any] = {}
  363. goal_col = None
  364. for candidate in ("广告优化目标", "optimization_goal"):
  365. if candidate in df.columns:
  366. goal_col = candidate
  367. break
  368. if "audience_tier" in df.columns and goal_col:
  369. for (tier, goal), group in df.groupby(["audience_tier", goal_col]):
  370. key = f"{tier}_{goal}"
  371. by_tier_goal[key] = _describe_group(group)
  372. # 全局
  373. global_stats = _describe_group(df)
  374. # 日级基线快照(最近 7 天)
  375. by_date = _compute_daily_tier_snapshot(end_dt, days=7)
  376. # 大盘行情判定(V4:由代码出,不让 LLM 看 by_date 自己猜)
  377. market_signal = _compute_market_signal(by_date)
  378. summary = {
  379. "end_date": end_date_str,
  380. "source_csv": str(csv_path),
  381. "by_account": by_account,
  382. "by_audience_tier": by_tier,
  383. "by_tier_goal": by_tier_goal,
  384. "global": global_stats,
  385. "by_date": by_date,
  386. "market_signal": market_signal,
  387. }
  388. _PORTFOLIO_DIR.mkdir(parents=True, exist_ok=True)
  389. out_path = _PORTFOLIO_DIR / f"portfolio_summary_{end_date_str}.json"
  390. out_path.write_text(
  391. json.dumps(summary, ensure_ascii=False, indent=2),
  392. encoding="utf-8",
  393. )
  394. # 生成可读摘要(只显示 top 层)
  395. lines = [
  396. f"✅ 账户 / 人群包基线汇总完成",
  397. f" 数据源:{csv_path.name}",
  398. f" 输出文件:{out_path}",
  399. "",
  400. f"【全局】ad_count={global_stats['ad_count']}, "
  401. f"active={global_stats['active_ads']}, "
  402. f"roi_mean={global_stats['roi_mean']}, "
  403. f"p25/p50/p75={global_stats['roi_p25']}/{global_stats['roi_p50']}/{global_stats['roi_p75']}",
  404. ]
  405. if by_tier:
  406. lines.append("")
  407. lines.append("【人群包(end_date 截面)】")
  408. # 按 R 值典型顺序展示
  409. tier_order = ["R500", "R330+", "R330", "R180", "R100", "R50", "R10", "R2", "default"]
  410. for tier in tier_order:
  411. if tier in by_tier:
  412. s = by_tier[tier]
  413. lines.append(
  414. f" {tier}: n={s['ad_count']}, "
  415. f"roi_p25/p50/p75={s['roi_p25']}/{s['roi_p50']}/{s['roi_p75']}, "
  416. f"stable_pct={s['stable_ads_pct']*100:.0f}%"
  417. )
  418. # 其他未列出的
  419. for tier, s in by_tier.items():
  420. if tier not in tier_order:
  421. lines.append(
  422. f" {tier}: n={s['ad_count']}, "
  423. f"roi_p25/p50/p75={s['roi_p25']}/{s['roi_p50']}/{s['roi_p75']}"
  424. )
  425. if by_date:
  426. lines.append("")
  427. lines.append(f"【日级基线快照(最近 {len(by_date)} 天)】")
  428. # 全局 p50 波动范围
  429. p50_series = [
  430. (d, entry["global"].get("roi_p50"))
  431. for d, entry in sorted(by_date.items())
  432. if entry.get("global", {}).get("roi_p50") is not None
  433. ]
  434. if p50_series:
  435. p50_values = [v for _, v in p50_series]
  436. lines.append(
  437. f" 全局 p50 波动:min={min(p50_values):.3f}, "
  438. f"max={max(p50_values):.3f}, "
  439. f"波幅={(max(p50_values) - min(p50_values)) / min(p50_values) * 100:.1f}% "
  440. f"(大盘行情参考,波动大说明 ARPU 共模噪声大)"
  441. )
  442. # 显示每天每 tier 的 p50
  443. lines.append(" 每日 tier p50 快照:")
  444. for d, entry in sorted(by_date.items()):
  445. tier_map = entry.get("by_audience_tier", {})
  446. tier_p50s = []
  447. for t in tier_order:
  448. if t in tier_map and tier_map[t].get("roi_p50") is not None:
  449. tier_p50s.append(f"{t}={tier_map[t]['roi_p50']:.2f}")
  450. if tier_p50s:
  451. lines.append(f" {d}: {' | '.join(tier_p50s)}")
  452. else:
  453. g = entry.get("global", {})
  454. lines.append(f" {d}: global_p50={g.get('roi_p50')}(无 tier 数据)")
  455. # 大盘行情可读输出
  456. lines.append("")
  457. lines.append(f"【大盘行情】label={market_signal['label']}")
  458. if market_signal.get("p50_volatility_pct") is not None:
  459. lines.append(
  460. f" 波幅={market_signal['p50_volatility_pct'] * 100:.1f}%, "
  461. f"近 3 日 vs 之前={market_signal['recent_vs_baseline_pct'] * 100:+.1f}%"
  462. )
  463. lines.append(f" 原因:{market_signal['reason']}")
  464. return ToolResult(
  465. title="账户 / 人群包基线汇总",
  466. output="\n".join(lines),
  467. metadata={
  468. "json_path": str(out_path),
  469. "by_account_count": len(by_account),
  470. "by_audience_tier_count": len(by_tier),
  471. "by_date_count": len(by_date),
  472. "global_ad_count": global_stats["ad_count"],
  473. "market_signal": market_signal["label"],
  474. },
  475. )
  476. except Exception as e:
  477. logger.exception("calculate_portfolio_summary 失败")
  478. return ToolResult(title="账户基线计算异常", output=f"错误:{e}")