Skill: yonghaozhao722/fund-news-summary (version 1.0.4)

--- name: fund-news-summary description: Automatically collects and summarizes the latest core news for US, Europe, Japan stock markets, gold, and prediction markets. Use when user asks about fund news, market updates, investment research, or daily financial summaries. Also triggered automatically via cron job at 11:00 Beijing Time daily. --- # Fund News Summary Skill ## Purpose Automatically collects and summarizes the latest core news for specified funds across multiple markets. ## Supported Markets - **US Markets**: NASDAQ, S&P 500 - **Europe**: European equity markets - **Japan**: Japanese stock market - **Commodities**: Gold - **Prediction Markets**: Polymarket and prediction market news ## Execution ### Automatic (Cron Job) This skill is bound to the `DailyFundNews` cron job, executing automatically at **11:00 Beijing Time** daily. ### Manual Trigger User can ask: - "Get today's fund news" - "Show market updates" - "What's the latest on my funds?" - "Run fund news summary" When triggered, the Agent should: 1. **Run the script**: `python3 /root/clawd/skills/fund-news-summary/fund_news.py` 2. **Read output**: The script automatically generates a report and outputs to stdout 3. **Send to Telegram**: Send the script output directly to Telegram ## Script Features - ✅ **Multi-market coverage**: US, Europe, Japan, Gold, Polymarket - ✅ **Rate limiting**: Maximum 2 concurrent searches, 1.5 second request interval - ✅ **Retry mechanism**: Automatic retry on rate limit - ✅ **Error handling**: Individual fund failures don't affect others - ✅ **Formatted output**: Bold list format - ✅ **Obsidian sync**: Saves Chinese version to Obsidian - ✅ **Auto GitHub push**: Automatically pushes to GitHub after generation ## Fund Configuration The script has built-in keywords for: - 华宝纳斯达克精选股票 (QDII)C - 摩根欧洲动力策略股票 (QDII)A - 摩根日本精选股票 (QDI)A - 易方达黄金 ETF 联接 C - 标普500 (S&P 500 Index) - Polymarket / 预测市场 ## Error Handling ### Common Issues **Issue**: Script fails with "Rate limit exceeded" - **Cause**: Too many requests to search API - **Solution**: Script has built-in retry with exponential backoff. Wait 5 minutes and retry. **Issue**: Empty or partial report - **Cause**: Some fund APIs may be temporarily unavailable - **Solution**: Check individual fund sources. The script continues even if some funds fail. **Issue**: GitHub push fails - **Cause**: Network issue or authentication expired - **Solution**: Report success/failure in output. User can manually push if needed. ## Output Format The script outputs a formatted report with: - Market name and fund name (bold) - Key news summary with bullet points - Last updated timestamp ## Data Storage - **Obsidian save path**: `/root/clawd/obsidian-vault/reports/fund/YYYY-MM-DD.md` - **Filename format**: `YYYY-MM-DD.md` - **Language**: Chinese (translated from English API results) - **GitHub repo**: `https://github.com/YonghaoZhao722/yonghao-notes` - **Branch**: `master` ## Manual Execution (Debug) ```bash cd /root/clawd/skills/fund-news-summary python3 fund_news.py ```

path: fund_news.py
content: #!/usr/bin/env python3
"""
基金新闻摘要生成器 - 多市场版本
支持：纳斯达克100、标普500、欧洲股市、日经225、黄金/贵金属
使用 QVeris API 和 web_search 获取新闻，生成中文摘要报告
"""

import asyncio
import json
import os
import sys
import time
import subprocess
from datetime import datetime
from typing import Dict, List, Optional, Any
import logging

# 添加 qveris skill 到路径
QVERIS_PATH = os.path.join(os.path.dirname(__file__), "../qveris")
sys.path.insert(0, QVERIS_PATH)

# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

# 市场配置
MARKETS = {
    "us": {
        "name": "美股市场 (纳斯达克/标普500)",
        "emoji": "🇺🇸",
        "symbols": ["QQQ", "SPY", "AAPL", "MSFT", "NVDA", "GOOGL", "META", "AMZN"],
        "keywords": ["NASDAQ", "S&P 500", "tech", "AI", "芯片"],
        "tool": "finnhub.news.retrieve.v1",
        "tool_params": {"category": "general"}
    },
    "europe": {
        "name": "欧洲股市",
        "emoji": "🇪🇺",
        "symbols": ["ASML", "SAP", "MC.PA", "AIR.PA", "NESN.SW"],  # LVMH用MC.PA代替
        "keywords": ["Europe", "ECB", "欧股", "ASML", "SAP", "LVMH"],
        "tool": "yahoo_finance.finance_news.v1",
        "tool_params": {"max_results": 5}
    },
    "japan": {
        "name": "日本股市 (日经225)",
        "emoji": "🇯🇵",
        "symbols": ["TM", "SONY", "NTDOY", "HMC", "6758.T"],  # 6758.T是索尼东京代码
        "keywords": ["Nikkei", "Japan", "日经", "丰田", "索尼", "任天堂"],
        "tool": "yahoo_finance.finance_news.v1",
        "tool_params": {"max_results": 5}
    },
    "gold": {
        "name": "黄金市场",
        "emoji": "🥇",
        "symbols": ["GC=F", "GLD", "XAUUSD"],
        "keywords": ["gold", "XAU", "COMEX", "贵金属", "现货黄金"],
        "tool": "finnhub.news.retrieve.v1",
        "tool_params": {"category": "general"}  # gold新闻在general中
    },
    "polymarket": {
        "name": "Polymarket / 预测市场",
        "emoji": "🔮",
        "keywords": ["Polymarket", "prediction market", "预测市场", "去中心化预测"],
        "tool": "web_search"
    }
}


def safe_parse_json_array(json_str: str) -> List[Dict]:
    """
    安全解析可能被截断的JSON数组
    处理 truncated_content 被截断的情况
    """
    if not json_str or not json_str.strip():
        return []
    
    json_str = json_str.strip()
    
    # 尝试直接解析
    try:
        return json.loads(json_str)
    except json.JSONDecodeError:
        pass
    
    # 如果被截断，尝试修复
    # 找到最后一个完整的对象
    if json_str.startswith('['):
        # 是一个数组，尝试找到最后一个完整的对象
        objects = []
        current_pos = 1  # 跳过开头的 [
        
        while current_pos < len(json_str):
            # 跳过空白和逗号
            while current_pos < len(json_str) and json_str[current_pos] in ' \t\n\r,':
                current_pos += 1
            
            if current_pos >= len(json_str) or json_str[current_pos] == ']':
                break
            
            # 尝试找到下一个完整的对象
            if json_str[current_pos] == '{':
                brace_count = 1
                in_string = False
                escape_next = False
                obj_start = current_pos
                current_pos += 1
                
                while current_pos < len(json_str) and brace_count > 0:
                    char = json_str[current_pos]
                    
                    if escape_next:
                        escape_next = False
                        current_pos += 1
                        continue
                    
                    if char == '\\':
                        escape_next = True
                        current_pos += 1
                        continue
                    
                    if char == '"' and not escape_next:
                        in_string = not in_string
                        current_pos += 1
                        continue
                    
                    if not in_string:
                        if char == '{':
                            brace_count += 1
                        elif char == '}':
                            brace_count -= 1
                    
                    current_pos += 1
                
                if brace_count == 0:
                    # 找到了一个完整的对象
                    try:
                        obj = json.loads(json_str[obj_start:current_pos])
                        objects.append(obj)
                    except:
                        pass
            else:
                current_pos += 1
        
        return objects
    
    return []


class QVerisToolRunner:
    """
    通过命令行调用 QVeris 工具
    """
    
    QVERIS_DIR = "/root/clawd/skills/qveris"
    
    async def search_tools(self, query: str, limit: int = 5) -> tuple:
        """
        搜索可用工具，返回 (search_id, tools)
        """
        try:
            cmd = [
                "python3", "scripts/qveris_tool.py", "search", query,
                "--limit", str(limit)
            ]
            result = subprocess.run(
                cmd, cwd=self.QVERIS_DIR,
                capture_output=True, text=True, timeout=30
            )
            
            if result.returncode != 0:
                logger.error(f"搜索工具失败: {result.stderr}")
                return None, []
            
            # 解析输出
            output = result.stdout
            search_id = None
            tools = []
            
            for line in output.split('\n'):
                if line.startswith("Search ID:"):
                    search_id = line.split(":")[1].strip()
                elif line.startswith("[") and "]" in line:
                    # 解析工具信息
                    tool_id = None
                    tool_name = ""
                    
            logger.info(f"搜索到工具，search_id: {search_id}")
            return search_id, tools
            
        except Exception as e:
            logger.error(f"搜索工具异常: {e}")
            return None, []
    
    async def execute_tool(self, tool_id: str, search_id: str, params: Dict) -> Dict:
        """
        执行指定工具
        """
        try:
            cmd = [
                "python3", "scripts/qveris_tool.py", "execute", tool_id,
                "--search-id", search_id,
                "--params", json.dumps(params)
            ]
            
            result = subprocess.run(
                cmd, cwd=self.QVERIS_DIR,
                capture_output=True, text=True, timeout=60
            )
            
            if result.returncode != 0:
                logger.error(f"执行工具失败: {result.stderr}")
                return {"success": False, "error": result.stderr}
            
            # 解析输出中的 JSON
            output = result.stdout
            logger.debug(f"工具输出: {output[:500]}...")
            
            # 找到 JSON 开始的位置 (在 "Result:" 之后)
            if "Result:" in output:
                json_start = output.find("Result:") + len("Result:")
                json_str = output[json_start:].strip()
                
                # 尝试找到 JSON 的结束位置（查找第一个闭合括号后的换行）
                try:
                    data = json.loads(json_str)
                    return {"success": True, "data": data}
                except json.JSONDecodeError as je:
                    # 可能是部分 JSON，尝试提取有效部分
                    logger.warning(f"JSON解析失败，尝试提取有效部分: {je}")
                    # 尝试找到第一个完整的 JSON 对象
                    brace_count = 0
                    in_string = False
                    escape_next = False
                    end_pos = 0
                    
                    for i, char in enumerate(json_str):
                        if escape_next:
                            escape_next = False
                            continue
                        if char == '\\':
                            escape_next = True
                            continue
                        if char == '"' and not escape_next:
                            in_string = not in_string
                            continue
                        if not in_string:
                            if char in '{[':
                                brace_count += 1
                            elif char in '}]':
                                brace_count -= 1
                                if brace_count == 0:
                                    end_pos = i + 1
                                    break
                    
                    if end_pos > 0:
                        try:
                            data = json.loads(json_str[:end_pos])
                            return {"success": True, "data": data}
                        except:
                            pass
                    
                    return {"success": True, "raw": output}
            
            return {"success": True, "raw": output}
            
        except Exception as e:
            logger.error(f"执行工具异常: {e}")
            return {"success": False, "error": str(e)}


class MarketNewsFetcher:
    """
    多市场新闻获取器
    """
    
    # 已知可用的工具ID（基于搜索结果）
    FINNHUB_NEWS_TOOL = "finnhub.news.retrieve.v1.51b2567e"
    YAHOO_NEWS_TOOL = "yahoo_finance.finance_news.v1"
    
    def __init__(self):
        self.qveris = QVerisToolRunner()
        self.search_cache = {}  # 缓存搜索结果
    
    async def get_us_news(self) -> List[Dict]:
        """
        获取美股新闻 (纳斯达克/标普500)
        使用 finnhub.news.retrieve.v1
        """
        logger.info("获取美股新闻...")
        try:
            # 搜索工具获取 search_id
            search_id, _ = await self.qveris.search_tools("US stock market news")
            if not search_id:
                logger.warning("无法获取 search_id，使用备用搜索ID")
                search_id = "0c006ed7-e7cb-4939-93aa-a3fb2cf69d7b"
            
            result = await self.qveris.execute_tool(
                self.FINNHUB_NEWS_TOOL,
                search_id,
                {"category": "general"}
            )
            
            if not result.get("success"):
                logger.error(f"获取美股新闻失败: {result.get('error')}")
                return []
            
            data = result.get("data", {})
            
            # 解析新闻数据
            if data.get("status_code") == 200:
                news_list = data.get("data", [])
                if not news_list:
                    # 尝试解析 truncated_content (它是一个JSON字符串)
                    content = data.get("truncated_content", "")
                    if content:
                        news_list = safe_parse_json_array(content)
                        logger.info(f"从 truncated_content 解析到 {len(news_list)} 条新闻")
                else:
                    logger.info(f"从 data 字段获取到 {len(news_list)} 条新闻")
                
                # 确保 news_list 是列表
                if not isinstance(news_list, list):
                    logger.warning(f"news_list 不是列表类型: {type(news_list)}")
                    return []
                
                # 过滤与科技/纳指相关的新闻
                tech_keywords = ["tech", "AI", "artificial", "chip", "semiconductor", 
                                "nvidia", "apple", "microsoft", "google", "meta", "amazon",
                                "半导体", "芯片", "人工智能", "科技", "纳斯达克", "nasdaq",
                                "cisco", "micron", "memory", "data center"]
                
                filtered_news = []
                for news in news_list[:20]:  # 取前20条
                    if not isinstance(news, dict):
                        continue
                    headline = news.get("headline", "").lower()
                    summary = news.get("summary", "").lower()
                    text = headline + " " + summary
                    
                    # 检查是否与科技股相关
                    is_tech = any(kw.lower() in text for kw in tech_keywords)
                    
                    news_item = {
                        "title": news.get("headline", ""),
                        "summary": news.get("summary", ""),
                        "source": news.get("source", "Unknown"),
                        "datetime": news.get("datetime", 0),
                        "url": news.get("url", ""),
                        "is_tech": is_tech
                    }
                    filtered_news.append(news_item)
                
                logger.info(f"美股新闻筛选完成，共 {len(filtered_news)} 条")
                
                # 优先返回科技股相关新闻
                tech_news = [n for n in filtered_news if n["is_tech"]]
                if tech_news:
                    return tech_news[:5]
                return filtered_news[:5]
            else:
                logger.warning(f"API返回非200状态码: {data.get('status_code')}")
            
            return []
            
        except Exception as e:
            logger.error(f"获取美股新闻异常: {e}")
            return []
    
    async def get_europe_news(self) -> List[Dict]:
        """
        获取欧洲股市新闻
        使用 yahoo_finance 获取 ASML, SAP, LVMH 等股票新闻
        """
        logger.info("获取欧洲股市新闻...")
        try:
            # 搜索工具获取 search_id
            search_id, _ = await self.qveris.search_tools("yahoo finance news ASML")
            if not search_id:
                search_id = "a0cdc077-ed28-47a4-88b1-b656ed1b41a6"
            
            all_news = []
            symbols = ["ASML", "SAP"]  # 主要关注 ASML 和 SAP
            
            for symbol in symbols:
                try:
                    result = await self.qveris.execute_tool(
                        self.YAHOO_NEWS_TOOL,
                        search_id,
                        {"symbol": symbol, "max_results": 3}
                    )
                    
                    if result.get("success"):
                        data = result.get("data", {})
                        articles = data.get("data", {}).get("articles", [])
                        
                        for article in articles[:3]:
                            news_item = {
                                "title": article.get("title", ""),
                                "summary": "",  # Yahoo新闻可能没有摘要
                                "source": article.get("publisher", "Yahoo Finance"),
                                "datetime": article.get("pubDate", ""),
                                "url": article.get("link", ""),
                                "symbol": symbol,
                                "is_key_stock": symbol in ["ASML", "SAP"]
                            }
                            all_news.append(news_item)
                            
                except Exception as e:
                    logger.warning(f"获取 {symbol} 新闻失败: {e}")
                    continue
            
            return all_news[:5]
            
        except Exception as e:
            logger.error(f"获取欧洲新闻异常: {e}")
            return []
    
    async def get_japan_news(self) -> List[Dict]:
        """
        获取日本股市新闻
        使用 yahoo_finance 获取丰田、索尼、任天堂等股票新闻
        """
        logger.info("获取日本股市新闻...")
        try:
            search_id, _ = await self.qveris.search_tools("yahoo finance news SONY")
            if not search_id:
                search_id = "a0cdc077-ed28-47a4-88b1-b656ed1b41a6"
            
            all_news = []
            symbols = ["SONY", "NTDOY", "TM"]  # 索尼、任天堂、丰田
            
            for symbol in symbols:
                try:
                    result = await self.qveris.execute_tool(
                        self.YAHOO_NEWS_TOOL,
                        search_id,
                        {"symbol": symbol, "max_results": 3}
                    )
                    
                    if result.get("success"):
                        data = result.get("data", {})
                        articles = data.get("data", {}).get("articles", [])
                        
                        for article in articles[:2]:
                            news_item = {
                                "title": article.get("title", ""),
                                "summary": "",
                                "source": article.get("publisher", "Yahoo Finance"),
                                "datetime": article.get("pubDate", ""),
                                "url": article.get("link", ""),
                                "symbol": symbol
                            }
                            all_news.append(news_item)
                            
                except Exception as e:
                    logger.warning(f"获取 {symbol} 新闻失败: {e}")
                    continue
            
            return all_news[:5]
            
        except Exception as e:
            logger.error(f"获取日本新闻异常: {e}")
            return []
    
    async def get_gold_news(self) -> List[Dict]:
        """
        获取黄金市场新闻
        从 Finnhub general 新闻中筛选黄金相关内容，或搜索
        """
        logger.info("获取黄金市场新闻...")
        try:
            # 尝试从 Finnhub 获取新闻并筛选
            search_id, _ = await self.qveris.search_tools("gold commodity news")
            if not search_id:
                search_id = "0c006ed7-e7cb-4939-93aa-a3fb2cf69d7b"
            
            result = await self.qveris.execute_tool(
                self.FINNHUB_NEWS_TOOL,
                search_id,
                {"category": "general"}
            )
            
            gold_news = []
            
            if result.get("success"):
                data = result.get("data", {})
                
                if data.get("status_code") == 200:
                    news_list = data.get("data", [])
                    if not news_list:
                        content = data.get("truncated_content", "")
                        if content:
                            news_list = safe_parse_json_array(content)
                            logger.info(f"黄金新闻: 从 truncated_content 解析到 {len(news_list)} 条新闻")
                    else:
                        logger.info(f"黄金新闻: 从 data 字段获取到 {len(news_list)} 条新闻")
                    
                    # 确保 news_list 是列表
                    if not isinstance(news_list, list):
                        logger.warning(f"黄金新闻 news_list 不是列表类型: {type(news_list)}")
                        return []
                    
                    # 筛选黄金相关新闻
                    gold_keywords = ["gold", "xau", "precious", "metal", "bullion", 
                                     "黄金", "贵金属", "comex"]
                    
                    for news in news_list:
                        if not isinstance(news, dict):
                            continue
                        headline = news.get("headline", "").lower()
                        summary = news.get("summary", "").lower()
                        text = headline + " " + summary
                        
                        if any(kw.lower() in text for kw in gold_keywords):
                            news_item = {
                                "title": news.get("headline", ""),
                                "summary": news.get("summary", ""),
                                "source": news.get("source", "Unknown"),
                                "datetime": news.get("datetime", 0),
                                "url": news.get("url", "")
                            }
                            gold_news.append(news_item)
                            
                            if len(gold_news) >= 5:
                                break
                    
                    logger.info(f"黄金新闻筛选完成，共 {len(gold_news)} 条")
                else:
                    logger.warning(f"黄金新闻 API返回非200状态码: {data.get('status_code')}")
            
            return gold_news[:5]
            
        except Exception as e:
            logger.error(f"获取黄金新闻异常: {e}")
            return []

    async def get_polymarket_news(self) -> List[Dict]:
        """
        获取 Polymarket / 预测市场相关新闻
        使用 brave_search.web.search.list.v1 工具搜索最新新闻
        """
        logger.info("获取 Polymarket / 预测市场新闻...")
        try:
            # 搜索 brave 工具
            search_id, _ = await self.qveris.search_tools("brave web search")
            if not search_id:
                logger.warning("无法获取搜索ID，使用默认ID")
                search_id = "pm-search-default"
            
            pm_news = []
            brave_tool = "brave_search.web.search.list.v1"
            
            # 搜索1: Polymarket 最新新闻
            try:
                result = await self.qveris.execute_tool(
                    brave_tool,
                    search_id,
                    {"q": "Polymarket news latest today", "count": 5}
                )
                
                if result.get("success"):
                    data = result.get("data", {})
                    results = self._parse_search_results(data)
                    
                    logger.info(f"Polymarket 新闻搜索原始结果数: {len(results)}")
                    
                    for item in results[:5]:
                        if isinstance(item, dict):
                            title = item.get("title", item.get("name", ""))
                            # 过滤掉介绍性/非新闻内容
                            if self._is_news_content(title, item.get("url", "")):
                                desc = item.get("snippet", item.get("description", item.get("content", "")))
                                url = item.get("url", item.get("link", ""))
                                source = self._extract_source(item, url)
                                
                                news_item = {
                                    "title": title,
                                    "summary": desc[:150] if len(desc) > 150 else desc,
                                    "source": source,
                                    "url": url,
                                    "category": "news"
                                }
                                pm_news.append(news_item)
                    
                    logger.info(f"Polymarket 新闻筛选完成，共 {len(pm_news)} 条")
                else:
                    logger.warning(f"Polymarket 搜索未成功: {result.get('error', 'unknown')}")
            except Exception as e:
                logger.warning(f"Polymarket 搜索失败: {e}")
            
            # 搜索2: 预测市场行业动态
            if len(pm_news) < 3:
                try:
                    result = await self.qveris.execute_tool(
                        brave_tool,
                        search_id,
                        {"q": "prediction market crypto betting news 2025 2026", "count": 4}
                    )
                    
                    if result.get("success"):
                        data = result.get("data", {})
                        results = self._parse_search_results(data)
                        
                        for item in results[:4]:
                            if isinstance(item, dict):
                                title = item.get("title", item.get("name", ""))
                                # 避免重复且过滤非新闻内容
                                if not any(n["title"] == title for n in pm_news) and self._is_news_content(title, item.get("url", "")):
                                    desc = item.get("snippet", item.get("description", ""))
                                    url = item.get("url", item.get("link", ""))
                                    source = self._extract_source(item, url)
                                    
                                    news_item = {
                                        "title": title,
                                        "summary": desc[:150] if len(desc) > 150 else desc,
                                        "source": source,
                                        "url": url,
                                        "category": "industry"
                                    }
                                    pm_news.append(news_item)
                        
                        logger.info(f"预测市场行业新闻补充完成，共 {len(pm_news)} 条")
                except Exception as e:
                    logger.warning(f"预测市场行业新闻搜索失败: {e}")
            
            # 搜索3: 预测市场特定事件新闻
            if len(pm_news) < 3:
                try:
                    result = await self.qveris.execute_tool(
                        brave_tool,
                        search_id,
                        {"q": "Polymarket election crypto price prediction results", "count": 3}
                    )
                    
                    if result.get("success"):
                        data = result.get("data", {})
                        results = self._parse_search_results(data)
                        
                        for item in results[:3]:
                            if isinstance(item, dict):
                                title = item.get("title", item.get("name", ""))
                                if not any(n["title"] == title for n in pm_news) and self._is_news_content(title, item.get("url", "")):
                                    desc = item.get("snippet", item.get("description", ""))
                                    url = item.get("url", item.get("link", ""))
                                    source = self._extract_source(item, url)
                                    
                                    news_item = {
                                        "title": title,
                                        "summary": desc[:150] if len(desc) > 150 else desc,
                                        "source": source,
                                        "url": url,
                                        "category": "events"
                                    }
                                    pm_news.append(news_item)
                        
                        logger.info(f"预测市场事件新闻补充完成，共 {len(pm_news)} 条")
                except Exception as e:
                    logger.warning(f"预测市场事件新闻搜索失败: {e}")
            
            return pm_news[:6]
            
        except Exception as e:
            logger.error(f"获取 Polymarket 新闻异常: {e}")
            return []
    
    def _parse_search_results(self, data: dict) -> list:
        """解析搜索结果"""
        results = []
        if isinstance(data, dict):
            if "data" in data and isinstance(data["data"], dict):
                results = data["data"].get("results", data["data"].get("web", {}).get("results", []))
            elif "results" in data:
                results = data["results"]
            elif "web" in data and isinstance(data["web"], dict):
                results = data["web"].get("results", [])
            elif "organic" in data:
                results = data["organic"]
        return results
    
    def _is_news_content(self, title: str, url: str) -> bool:
        """判断是否为新闻内容而非介绍页面"""
        if not title:
            return False
        
        title_lower = title.lower()
        url_lower = url.lower() if url else ""
        
        # 排除介绍性/主页内容
        exclude_patterns = [
            "| the world's largest prediction market",
            "| polymarket",
            "- wikipedia",
            "home | ",
            "official site",
            "about us",
            "what is",
            "how to",
            "guide to",
            "tutorial",
            "wiki",
            "definition",
            "meaning of"
        ]
        
        for pattern in exclude_patterns:
            if pattern in title_lower:
                return False
        
        # 排除官网主页和维基百科
        if "polymarket.com" in url_lower and ("blog" not in url_lower and "news" not in url_lower):
            return False
        if "wikipedia.org" in url_lower:
            return False
        
        # 包含新闻特征
        news_indicators = [
            "news", "report", "says", "announces", "launches", "raises", 
            "acquires", "partners", "expands", "grows", "surges", "drops",
            "regulators", "sec", "cftc", "lawsuit", "legal", "ban", "approval",
            "trading", "volume", "market", "prediction", "betting", "election",
            "crypto", "blockchain", "defi", "web3"
        ]
        
        return any(ind in title_lower for ind in news_indicators)
    
    def _extract_source(self, item: dict, url: str) -> str:
        """提取新闻来源"""
        source = item.get("source", item.get("domain", ""))
        if not source and url:
            try:
                from urllib.parse import urlparse
                parsed = urlparse(url)
                source = parsed.netloc.replace("www.", "")
            except:
                source = "News"
        if not source:
            source = "News"
        return source


class FundNewsGenerator:
    """基金新闻生成器 - 多市场版本"""
    
    def __init__(self):
        self.fetcher = MarketNewsFetcher()
    
    def translate_to_chinese(self, text: str) -> str:
        """
        简单的英文新闻标题/摘要翻译逻辑
        这里可以接入翻译API，暂时返回原文并做简单处理
        """
        if not text:
            return "暂无摘要"
        
        # 如果已经是中文，直接返回
        if any('\u4e00' <= char <= '\u9fff' for char in text):
            return text
        
        # 返回原文（截断）
        return text[:120] + "..." if len(text) > 120 else text
    
    async def generate_market_report(self) -> str:
        """
        生成完整的市场新闻摘要报告
        """
        logger.info("开始收集各市场新闻...")
        start_time = time.monotonic()
        
        # 并行获取所有市场新闻
        results = await asyncio.gather(
            self.fetcher.get_us_news(),
            self.fetcher.get_europe_news(),
            self.fetcher.get_japan_news(),
            self.fetcher.get_gold_news(),
            self.fetcher.get_polymarket_news(),
            return_exceptions=True
        )
        
        us_news = results[0] if not isinstance(results[0], Exception) else []
        europe_news = results[1] if not isinstance(results[1], Exception) else []
        japan_news = results[2] if not isinstance(results[2], Exception) else []
        gold_news = results[3] if not isinstance(results[3], Exception) else []
        polymarket_news = results[4] if not isinstance(results[4], Exception) else []
        
        elapsed = time.monotonic() - start_time
        logger.info(f"新闻获取完成，耗时: {elapsed:.2f} 秒")
        
        # 构建报告
        today_str = datetime.now().strftime("%Y-%m-%d")
        report_lines = [
            f"📊 **基金新闻摘要** ({today_str})",
            ""
        ]
        
        # 🇺🇸 美股市场
        report_lines.extend([
            "🇺🇸 **美股市场 (纳斯达克/标普500)**",
            ""
        ])
        if us_news:
            for news in us_news[:4]:
                title = news.get("title", "无标题")
                summary = self.translate_to_chinese(news.get("summary", ""))
                source = news.get("source", "未知")
                report_lines.append(f"• **{title}**")
                report_lines.append(f"  └ {summary} (*{source}*)")
                report_lines.append("")
        else:
            report_lines.append("• 暂无最新美股新闻")
            report_lines.append("")
        
        # 🇪🇺 欧洲股市
        report_lines.extend([
            "🇪🇺 **欧洲股市**",
            ""
        ])
        if europe_news:
            for news in europe_news[:4]:
                title = news.get("title", "无标题")
                source = news.get("source", "未知")
                symbol = news.get("symbol", "")
                symbol_tag = f" [{symbol}]" if symbol else ""
                report_lines.append(f"• **{title}**{symbol_tag}")
                report_lines.append(f"  └ (*{source}*)")
                report_lines.append("")
        else:
            report_lines.append("• 暂无最新欧洲股市新闻")
            report_lines.append("")
        
        # 🇯🇵 日本股市
        report_lines.extend([
            "🇯🇵 **日本股市 (日经225)**",
            ""
        ])
        if japan_news:
            for news in japan_news[:4]:
                title = news.get("title", "无标题")
                source = news.get("source", "未知")
                symbol = news.get("symbol", "")
                symbol_tag = f" [{symbol}]" if symbol else ""
                report_lines.append(f"• **{title}**{symbol_tag}")
                report_lines.append(f"  └ (*{source}*)")
                report_lines.append("")
        else:
            report_lines.append("• 暂无最新日本股市新闻")
            report_lines.append("")
        
        # 🥇 黄金市场
        report_lines.extend([
            "🥇 **黄金市场**",
            ""
        ])
        if gold_news:
            for news in gold_news[:4]:
                title = news.get("title", "无标题")
                summary = self.translate_to_chinese(news.get("summary", ""))
                source = news.get("source", "未知")
                report_lines.append(f"• **{title}**")
                report_lines.append(f"  └ {summary} (*{source}*)")
                report_lines.append("")
        else:
            report_lines.append("• 暂无最新黄金新闻")
            report_lines.append("")
        
        # 🔮 Polymarket / 预测市场
        report_lines.extend([
            "🔮 **Polymarket / 预测市场**",
            ""
        ])
        if polymarket_news:
            for news in polymarket_news[:4]:
                title = news.get("title", "无标题")
                summary = news.get("summary", "")[:100]
                if summary and len(news.get("summary", "")) > 100:
                    summary += "..."
                source = news.get("source", "未知")
                category = news.get("category", "")
                cat_tag = f" [{category}]" if category else ""
                report_lines.append(f"• **{title}**{cat_tag}")
                if summary:
                    report_lines.append(f"  └ {summary} (*{source}*)")
                else:
                    report_lines.append(f"  └ (*{source}*)")
                report_lines.append("")
        else:
            report_lines.append("• 暂无最新预测市场新闻")
            report_lines.append("")
        
        # 🎯 基金相关要点
        report_lines.extend([
            "🎯 **基金相关要点**",
            ""
        ])
        
        # 根据获取到的新闻生成要点
        if us_news:
            report_lines.append("• 纳斯达克100: AI板块持续受关注，科技股波动需留意")
        else:
            report_lines.append("• 纳斯达克100: 关注AI和芯片板块动态")
            
        report_lines.append("• 标普500: 关注美联储政策和企业财报季表现")
        
        if europe_news:
            report_lines.append("• 欧洲动力: ASML、SAP等龙头企业订单及业绩表现")
        else:
            report_lines.append("• 欧洲动力: 关注欧洲央行货币政策及制造业数据")
            
        if japan_news:
            report_lines.append("• 日本精选: 丰田、索尼、任天堂等核心持仓动态")
        else:
            report_lines.append("• 日本精选: 关注日元汇率和日本央行政策动向")
            
        if gold_news:
            report_lines.append("• 黄金ETF: 避险需求推动金价波动")
        else:
            report_lines.append("• 黄金ETF: 关注地缘政治风险和美元指数走势")
        
        # Polymarket / 预测市场要点
        if polymarket_news:
            report_lines.append("• 预测市场: Polymarket 平台活跃度及热门事件值得跟踪")
            report_lines.append("• 加密预测: 去中心化预测市场与加密货币联动性增强")
        else:
            report_lines.append("• 预测市场: 关注 Polymarket 等平台热门预测事件")
            report_lines.append("• 自动化交易: 预测市场量化策略及跟单机会")
        
        report_lines.append("")
        
        # ⚠️ 风险提示
        report_lines.extend([
            "⚠️ **风险提示**",
            ""
        ])
        report_lines.append("• 美股市场波动可能受美联储政策及科技股表现影响")
        report_lines.append("• 欧洲股市受地缘政治及欧洲央行货币政策影响")
        report_lines.append("• 日本股市受日元汇率波动及日本央行政策影响")
        report_lines.append("• 黄金价格受地缘政治风险及美元指数走势影响")
        report_lines.append("• 预测市场存在流动性风险及监管不确定性")
        report_lines.append("• 自动化交易策略需严格风控，避免过度杠杆")
        report_lines.append("• QDII基金受汇率波动影响，投资需谨慎")
        report_lines.append("")
        
        # 数据来源
        report_lines.append(f"⏱️ 获取耗时: {elapsed:.1f}秒 | 📰 数据来源: QVeris API / Finnhub / Yahoo Finance")
        
        return "\n".join(report_lines)
    
    async def generate_simple_report(self) -> str:
        """
        生成简化版报告（当API不可用时）
        """
        today_str = datetime.now().strftime("%Y-%m-%d")
        report_lines = [
            f"📊 **基金新闻摘要** ({today_str})",
            "",
            "🇺🇸 **美股市场 (纳斯达克/标普500)**",
            "• AI板块持续受关注，科技股波动需留意",
            "• 美联储政策预期影响市场情绪",
            "",
            "🇪🇺 **欧洲股市**",
            "• 关注ASML、SAP等龙头企业动态",
            "• 欧洲央行货币政策维持宽松预期",
            "",
            "🇯🇵 **日本股市 (日经225)**",
            "• 关注日元汇率和日本央行政策动向",
            "• 出口企业受益于汇率变化",
            "",
            "🥇 **黄金市场**",
            "• 地缘政治风险推升避险需求",
            "• 关注美元指数走势",
            "",
            "🔮 **Polymarket / 预测市场**",
            "• 关注平台热门预测事件及交易量变化",
            "• 去中心化预测市场与加密货币联动",
            "",
            "⚠️ **风险提示**",
            "• 预测市场存在监管不确定性和流动性风险",
            "• 自动化交易需严格风控",
            "• QDII基金受汇率波动影响，投资需谨慎",
            "",
            "📊 数据来源: 备用数据"
        ]
        return "\n".join(report_lines)


async def main():
    """主函数"""
    generator = FundNewsGenerator()
    
    try:
        # 尝试生成完整报告
        report = await generator.generate_market_report()
        
        print(report)
        
        # 保存到 Obsidian vault (中文版本)
        today_str = datetime.now().strftime("%Y-%m-%d")
        obsidian_dir = "/root/clawd/obsidian-vault/reports/fund"
        os.makedirs(obsidian_dir, exist_ok=True)
        output_path = os.path.join(obsidian_dir, f"{today_str}.md")
        
        with open(output_path, "w", encoding="utf-8") as f:
            f.write(report)
        
        logger.info(f"报告已保存到 {output_path}")
        
        # 自动同步到 GitHub (先提交本地更改，再 pull，再 push)
        try:
            import subprocess
            os.chdir("/root/clawd/obsidian-vault")
            
            # 先提交本地更改
            logger.info("正在提交本地更改...")
            subprocess.run(["git", "add", "-A"], check=False)
            commit_result = subprocess.run(
                ["git", "commit", "-m", f"Update fund report {today_str}"],
                capture_output=True, text=True
            )
            if commit_result.returncode == 0:
                logger.info("本地更改已提交")
            
            # 获取远程更新 (使用 merge 策略)
            logger.info("正在同步 GitHub 仓库...")
            pull_result = subprocess.run(
                ["git", "pull", "origin", "master", "--no-rebase"],
                capture_output=True, text=True
            )
            if pull_result.returncode == 0:
                logger.info("成功拉取远程更新")
            else:
                logger.warning(f"拉取远程更新失败: {pull_result.stderr}")
            
            # 推送
            push_result = subprocess.run(
                ["git", "push", "origin", "master"],
                capture_output=True, text=True
            )
            
            if push_result.returncode == 0:
                logger.info("✅ 已自动同步到 GitHub")
            else:
                logger.warning(f"GitHub 推送失败: {push_result.stderr}")
                
        except Exception as e:
            logger.warning(f"GitHub 同步失败: {e}")
        
        return report
        
    except Exception as e:
        logger.exception("生成报告失败，使用简化版")
        # 降级到简化报告
        try:
            report = await generator.generate_simple_report()
            print(report)
            return report
        except Exception as e2:
            logger.error(f"简化报告也失败: {e2}")
            raise


if __name__ == "__main__":
    asyncio.run(main())


path: fund_news_cursor.py
content: #!/usr/bin/env python3
"""
Fund News Summary Generator - Cursor Version
Production-ready with asyncio rate limiting, exponential backoff, and proper error handling.
"""

import asyncio
import json
import time
import os
import sys
from datetime import datetime
from typing import Dict, List, Optional, Tuple
import random
import logging

# Configure logging
logging.basicConfig(
    level=logging.DEBUG,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Set INFO level for less verbose output in production
# logger.setLevel(logging.INFO)

# Fund configuration - 5 funds
FUNDS = {
    "nasdaq": {
        "name": "华宝纳斯达克精选股票 (QDII)C",
        "keywords": ["纳斯达克100", "Nvidia", "Microsoft", "美股半导体", "Nasdaq 100"],
        "impact_factors": ["AI板块表现", "美联储政策", "科技股财报"]
    },
    "europe": {
        "name": "摩根欧洲动力策略股票 (QDII)A",
        "keywords": ["欧洲股市", "ASML", "诺华", "欧股走势", "欧洲股市"],
        "impact_factors": ["ASML业绩", "欧股整体走势", "欧元汇率"]
    },
    "japan": {
        "name": "摩根日本精选股票 (QDI)A",
        "keywords": ["日经225", "日元汇率", "丰田", "日本股市", "Nikkei 225"],
        "impact_factors": ["日经指数变化", "日元汇率波动", "日本央行政策"]
    },
    "gold": {
        "name": "易方达黄金 ETF 联接 C",
        "keywords": ["现货黄金", "COMEX黄金", "避险情绪", "gold price", "COMEX"],
        "impact_factors": ["国际金价", "避险情绪", "美元指数"]
    },
    "sp500": {
        "name": "标普500 (S&P 500 Index)",
        "keywords": ["标普500", "美股大盘", "联储政策", "S&P 500", "Fed"],
        "impact_factors": ["美联储政策", "美股大盘走势", "经济数据"]
    }
}


class RateLimiter:
    """
    Async rate limiter that allows true concurrency while maintaining minimum interval between request starts.
    
    Key design:
    - All tasks start concurrently
    - Uses a token bucket approach: tracks last request time and ensures minimum interval
    - Multiple HTTP requests can run concurrently (network I/O)
    - Only controls the spacing between when requests START, not their execution duration
    - This allows HTTP requests to overlap while respecting rate limits
    """
    
    def __init__(self, min_interval: float = 1.5):
        self.min_interval = min_interval
        self.lock = asyncio.Lock()
        self._last_request_time = 0
    
    async def acquire(self):
        """
        Acquire execution permit with automatic interval handling.
        Ensures minimum interval since last request start, then allows immediate execution.
        Multiple calls can happen concurrently - they'll queue and execute with proper spacing.
        """
        async with self.lock:
            now = time.time()
            
            # Calculate delay needed to maintain interval
            if self._last_request_time > 0:
                elapsed = now - self._last_request_time
                if elapsed < self.min_interval:
                    # Need to wait to maintain interval
                    delay = self.min_interval - elapsed
                    # Update last request time BEFORE sleeping (so next task knows when to start)
                    self._last_request_time = now + delay
                    # Release lock before sleeping to allow other tasks to check their timing
                    await asyncio.sleep(delay)
                else:
                    # Can start immediately
                    self._last_request_time = now
            else:
                # First request starts immediately
                self._last_request_time = now
        
        # Now we can start HTTP request - it will run concurrently with others
        # The lock is released, so other tasks can proceed
    
    def release(self):
        """Release execution permit (no-op for new design, kept for compatibility)."""
        pass
    
    async def __aenter__(self):
        await self.acquire()
        return self
    
    async def __aexit__(self, exc_type, exc_val, exc_tb):
        self.release()


class ExponentialBackoff:
    """
    Enhanced exponential backoff retry handler with comprehensive error handling.
    Handles rate limits, network errors, timeouts, and transient failures.
    """
    
    def __init__(self, base_delay: float = 1.0, max_delay: float = 60.0, max_retries: int = 5):
        self.base_delay = base_delay
        self.max_delay = max_delay
        self.max_retries = max_retries
    
    def _is_retryable_error(self, error: Exception) -> bool:
        """Check if error is retryable."""
        error_str = str(error).lower()
        error_type = type(error).__name__.lower()
        
        # Rate limit errors
        is_rate_limit = (
            "429" in error_str or
            "rate limit" in error_str or
            "too many requests" in error_str or
            "quota exceeded" in error_str
        )
        
        # Network errors
        is_network_error = (
            "timeout" in error_str or
            "connection" in error_str or
            "network" in error_str or
            "dns" in error_str or
            error_type in ["timeouterror", "connectionerror", "connecttimeouterror"]
        )
        
        # Server errors (5xx)
        is_server_error = (
            "500" in error_str or
            "502" in error_str or
            "503" in error_str or
            "504" in error_str or
            "service unavailable" in error_str
        )
        
        # Transient errors
        is_transient = (
            is_rate_limit or
            is_network_error or
            is_server_error
        )
        
        return is_transient
    
    async def retry(self, func, *args, **kwargs):
        """Execute function with exponential backoff retry on retryable errors."""
        last_exception = None
        
        for attempt in range(self.max_retries):
            try:
                return await func(*args, **kwargs)
            except Exception as e:
                last_exception = e
                
                # Check if error is retryable
                if not self._is_retryable_error(e):
                    logger.error(f"Non-retryable error: {str(e)}")
                    raise e
                
                # If max retries reached, raise the exception
                if attempt >= self.max_retries - 1:
                    logger.error(
                        f"Max retries ({self.max_retries}) reached. "
                        f"Last error: {str(e)}"
                    )
                    raise e
                
                # Calculate delay with exponential backoff
                delay = min(self.base_delay * (2 ** attempt), self.max_delay)
                # Add jitter to prevent thundering herd
                jitter = random.uniform(0, delay * 0.2)
                total_delay = delay + jitter
                
                error_type = "rate limit" if "429" in str(e).lower() else "network/server error"
                logger.warning(
                    f"{error_type} encountered (attempt {attempt + 1}/{self.max_retries}). "
                    f"Retrying in {total_delay:.2f}s... Error: {str(e)[:100]}"
                )
                
                await asyncio.sleep(total_delay)
                continue
        
        # Should not reach here, but just in case
        if last_exception:
            raise last_exception
        raise Exception("Retry logic failed unexpectedly")


class WebSearchClient:
    """
    Web search client wrapper.
    Supports both direct API calls and tool-based calls.
    """
    
    def __init__(self, use_api: bool = True):
        self.use_api = use_api
        self.[REDACTED_SECRET]"BRAVE_API_KEY")
        
    async def search(self, query: str, search_lang: str = "zh-hans") -> List[Dict]:
        """
        Perform web search.
        
        Args:
            query: Search query string
            search_lang: Language code (default: zh-hans for Simplified Chinese)
            
        Returns:
            List of search results with title, url, snippet, etc.
        """
        if self.use_api and self.[REDACTED_SECRET] await self._brave_search_api(query, search_lang)
        else:
            # Fallback: return structured mock data for testing
            logger.warning("No API key found, using mock data")
            return await self._mock_search(query)
    
    async def _brave_search_api(self, query: str, search_lang: str) -> List[Dict]:
        """Call Brave Search API with timeout and better error handling."""
        import aiohttp
        
        url = "https://api.search.brave.com/res/v1/web/search"
        headers = {
            "Accept": "application/json",
            "Accept-Encoding": "gzip",
            "X-Subscription-Token": self.api_key
        }
        params = {
            "q": query,
            "count": 5,
            "search_lang": search_lang,
            "country": "CN",
            "safesearch": "moderate"
        }
        
        # Configure timeout: 10s connect, 30s total
        timeout = aiohttp.ClientTimeout(total=30, connect=10)
        
        try:
            async with aiohttp.ClientSession(timeout=timeout) as session:
                async with session.get(url, headers=headers, params=params) as response:
                    # Handle rate limit explicitly
                    if response.status == 429:
                        error_text = await response.text()
                        raise Exception(f"429 Rate Limit: {error_text}")
                    
                    # Handle other HTTP errors
                    if response.status >= 500:
                        error_text = await response.text()
                        raise Exception(f"Server Error {response.status}: {error_text}")
                    
                    response.raise_for_status()
                    
                    # Parse JSON response
                    try:
                        data = await response.json()
                    except Exception as e:
                        raise Exception(f"Failed to parse JSON response: {str(e)}")
                    
                    results = []
                    web_results = data.get("web", {}).get("results", [])
                    
                    for item in web_results[:5]:
                        results.append({
                            "title": item.get("title", ""),
                            "url": item.get("url", ""),
                            "snippet": item.get("description", ""),
                            "source": self._extract_domain(item.get("url", "")),
                            "time": datetime.now().strftime("%Y-%m-%d")
                        })
                    
                    return results
                    
        except asyncio.TimeoutError:
            raise Exception("Request timeout: API did not respond within 30 seconds")
        except aiohttp.ClientError as e:
            raise Exception(f"Network error: {str(e)}")
        except Exception as e:
            # Re-raise if already formatted
            if "Rate Limit" in str(e) or "Server Error" in str(e) or "timeout" in str(e).lower():
                raise
            # Wrap unexpected errors
            raise Exception(f"Unexpected error: {str(e)}")
    
    async def _mock_search(self, query: str) -> List[Dict]:
        """Mock search for testing without API."""
        await asyncio.sleep(random.uniform(0.5, 1.0))
        
        return [
            {
                "title": f"{query} 相关新闻标题",
                "url": "https://example.com/news1",
                "snippet": f"关于 {query} 的最新动态和分析...",
                "source": "Reuters",
                "time": datetime.now().strftime("%Y-%m-%d")
            },
            {
                "title": f"{query} 市场分析",
                "url": "https://example.com/news2",
                "snippet": f"{query} 的市场表现和未来展望...",
                "source": "Yahoo Finance",
                "time": datetime.now().strftime("%Y-%m-%d")
            }
        ]
    
    @staticmethod
    def _extract_domain(url: str) -> str:
        """Extract domain name from URL."""
        try:
            from urllib.parse import urlparse
            parsed = urlparse(url)
            domain = parsed.netloc.replace("www.", "")
            return domain.split(".")[-2] if "." in domain else domain
        except:
            return "Unknown"


class FundNewsGenerator:
    """Fund news generator with rate limiting and retry logic."""
    
    def __init__(self, use_api: bool = True, min_interval: float = 1.5):
        # Rate limiter now only controls interval, not concurrency
        self.rate_limiter = RateLimiter(min_interval=min_interval)
        self.backoff = ExponentialBackoff(base_delay=1.0, max_delay=60.0, max_retries=5)
        self.search_client = WebSearchClient(use_api=use_api)
        self.results = {}
    
    async def search_fund_news(self, fund_id: str, fund_config: Dict) -> Dict:
        """
        Search news for a single fund with rate limiting and retry logic.
        
        True concurrency design:
        1. All tasks start concurrently
        2. Rate limiter ensures requests start with minimum interval
        3. Once a request gets its turn, HTTP request executes immediately
        4. Multiple HTTP requests can run concurrently (network I/O)
        
        Args:
            fund_id: Fund identifier
            fund_config: Fund configuration dict
            
        Returns:
            Dict with fund_name, fund_id, query, news, status, and error (if any)
        """
        fund_name = fund_config["name"]
        keywords = fund_config["keywords"]
        
        # Build search query using first 3 keywords
        query = " ".join(keywords[:3])
        
        # Acquire rate limiter slot (waits for turn, then immediately proceeds)
        # The key: once we exit this context, HTTP request starts immediately
        # and can run concurrently with other HTTP requests
        request_start_time = time.time()
        async with self.rate_limiter:
            pass  # Rate limiter ensures spacing, then we proceed
        
        wait_time = time.time() - request_start_time
        logger.info(f"[{fund_name}] Got rate limit slot, waited {wait_time:.3f}s, starting HTTP request now")
        
        # Now execute HTTP request - this can run concurrently with others
        try:
            # Use exponential backoff for retries
            search_results = await self.backoff.retry(
                self.search_client.search,
                query,
                search_lang="zh-hans"
            )
            
            return {
                "fund_name": fund_name,
                "fund_id": fund_id,
                "query": query,
                "news": search_results,
                "status": "success"
            }
            
        except Exception as e:
            logger.error(f"Failed to search news for {fund_name}: {str(e)}")
            return {
                "fund_name": fund_name,
                "fund_id": fund_id,
                "query": query,
                "news": [],
                "status": "error",
                "error": str(e)
            }
    
    async def generate_summary(self) -> str:
        """
        Generate complete news summary report for all funds.
        Now uses true concurrency - all HTTP requests can run simultaneously,
        but rate limiter ensures minimum interval between request starts.
        
        Returns:
            Formatted report string in bold list format
        """
        logger.info("Starting fund news collection with concurrent requests...")
        
        # Create tasks for all funds - they will run concurrently
        # Rate limiter ensures spacing between request starts, not blocking concurrency
        tasks = [
            self.search_fund_news(fund_id, config)
            for fund_id, config in FUNDS.items()
        ]
        
        # Execute all tasks concurrently
        results = await asyncio.gather(*tasks, return_exceptions=True)
        
        logger.info(f"Completed {len(results)} fund news searches")
        
        # Build report
        report_lines = [
            f"**📊 基金新闻摘要** — {datetime.now().strftime('%Y年%m月%d日')}",
            "",
            "---",
            ""
        ]
        
        # Process results for each fund
        for i, ((fund_id, config), result) in enumerate(zip(FUNDS.items(), results), 1):
            if isinstance(result, Exception):
                report_lines.append(f"**{i}. {config['name']}**")
                report_lines.append(f"• 获取新闻失败: {str(result)}")
                report_lines.append("")
                continue
            
            if result and result.get("status") == "success":
                report_lines.append(f"**{i}. {config['name']}**")
                
                news_list = result.get("news", [])
                if news_list:
                    for news in news_list[:3]:  # Show max 3 items
                        title = news.get("title", "无标题")
                        source = news.get("source", "未知来源")
                        snippet = news.get("snippet", "暂无摘要")
                        
                        report_lines.append(f"• **{title}**")
                        report_lines.append(f"  - 来源: {source}")
                        report_lines.append(f"  - 摘要: {snippet[:100]}..." if len(snippet) > 100 else f"  - 摘要: {snippet}")
                else:
                    report_lines.append("• 今日暂无重大新闻")
                
                report_lines.append("")
            else:
                report_lines.append(f"**{i}. {config['name']}**")
                error_msg = result.get("error", "未知错误") if result else "获取失败"
                report_lines.append(f"• 获取新闻失败: {error_msg}")
                report_lines.append("")
        
        # Add impact prediction section
        report_lines.extend([
            "---",
            "",
            "**💡 今日净值影响预测**",
            ""
        ])
        
        for fund_id, config in FUNDS.items():
            impact_factors = config.get("impact_factors", [])
            factors_str = "、".join(impact_factors)
            report_lines.append(f"• **{config['name']}**: {factors_str}")
        
        report_lines.extend([
            "",
            "*数据来源: Yahoo Finance, Reuters, MarketWatch, Brave Search*"
        ])
        
        return "\n".join(report_lines)


async def main():
    """Main entry point."""
    # Check if API key is available
    use_api = bool(os.getenv("BRAVE_API_KEY"))
    
    if not use_api:
        logger.warning("BRAVE_API_KEY not found. Using mock data for testing.")
    
    generator = FundNewsGenerator(use_api=use_api)
    report = await generator.generate_summary()
    
    print(report)
    return report


if __name__ == "__main__":
    try:
        result = asyncio.run(main())
        
        # Optionally save to file
        output_file = "/tmp/fund_news_report.txt"
        with open(output_file, "w", encoding="utf-8") as f:
            f.write(result)
        
        logger.info(f"Report saved to {output_file}")
        
    except KeyboardInterrupt:
        logger.info("Interrupted by user")
        sys.exit(1)
    except Exception as e:
        logger.error(f"Fatal error: {str(e)}", exc_info=True)
        sys.exit(1)


path: optimize_task.md
content: # 优化任务：基金新闻并发性能

## 当前问题
1. _mock_search 是模拟方法，需要改成真正调用搜索 API
2. 需要用 aiohttp 异步调用 Brave Search API
3. 保持现有的 RateLimiter 并发控制

## 优化目标
1. 真正实现 Brave Search API 调用
2. 添加更智能的错误处理和指数退避重试
3. 使用 asyncio.gather 并发处理 5 个基金，但受限于 RateLimiter

## Brave Search API 格式
- URL: https://api.search.brave.com/res/v1/web/search
- Headers: X-Subscription-[REDACTED_SECRET]
- 参数: q={query}, count=5, search_lang=zh

## 输出格式
保持现有的 Telegram 报告格式不变

请编辑 fund_news.py 实现以上优化


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)