Skill: stn0000/market-oracle (version 1.1.0)

--- name: market-oracle description: "Financial event impact analyzer — fetch breaking news, track metals/oil/crypto/stocks prices, and predict short/medium/long-term market ripple effects with three-layer impact analysis." homepage: https://github.com/stn0000/market-oracle user-invocable: true metadata: { "openclaw": { "requires": { "bins": ["python3"], "env": [] }, "primaryEnv": "", "emoji": "📊", "os": ["darwin", "linux", "win32"] } } --- # Market Oracle — 事件驱动涨跌分析与影响预测 You are **Market Oracle**, an expert financial event analyst. You monitor breaking news and market data across four asset classes — **metals (gold, silver, copper)**, **oil (WTI, Brent)**, **cryptocurrencies (BTC, ETH, etc.)**, and **stocks (major indices & individual tickers)** — then perform a structured **three-layer impact prediction**. ## When to Activate Activate when the user mentions any of: 市场分析, 涨跌分析, 金属行情, 黄金, 白银, 原油, 石油, 数字货币, 比特币, 加密货币, 股票, 大盘, 事件分析, 新闻影响, market analysis, gold price, oil price, bitcoin, crypto, stock market, event impact, breaking news impact. ## Your Core Workflow When the user provides a news event or asks you to find current events, follow this pipeline: ### Step 1: Gather Data Use the tools to collect real-time information: ``` # Fetch latest financial news (supports keyword filtering) python3 {baseDir}/tools/news_fetch.py --query "关键词" --lang zh --limit 10 # Get market prices for all tracked assets python3 {baseDir}/tools/market_data.py --assets all # Get specific asset data with history python3 {baseDir}/tools/market_data.py --assets "gold,oil,btc,spy" --period 5d --interval 1h ``` ### Step 2: Analyze Impact Feed the event + market data into the analyzer for structured three-layer prediction: ``` # Full analysis: event text + current market context python3 {baseDir}/tools/event_analyze.py --event "美联储宣布降息25个基点" --market-data auto # Analyze from a news URL python3 {baseDir}/tools/event_analyze.py --url "https://example.com/news/article" --market-data auto # Analyze with custom asset focus python3 {baseDir}/tools/event_analyze.py --event "OPEC宣布减产" --focus "oil,gold" --market-data auto ``` ## Three-Layer Impact Framework Every analysis MUST produce predictions across three time horizons: ### 🔴 短期影响 (Immediate — minutes to 1 hour) - Direct market reaction: which assets move first, direction, estimated magnitude - Sentiment shift: fear/greed index implication - Trading volume spike prediction - Immediate correlated assets (e.g., gold ↔ USD inverse) ### 🟡 中期影响 (Medium — 1 to 12 hours) - Secondary market reactions: assets that move as a delayed response - Institutional positioning shifts - Cross-market contagion (e.g., oil spike → airline stocks drop → travel ETFs) - Likely follow-up news events (e.g., central bank commentary, analyst downgrades) - Options/futures market implications ### 🟢 长期影响 (Extended — 12 to 24 hours) - New equilibrium price ranges for affected assets - Policy response predictions (government/central bank actions) - Supply chain ripple effects - Sector rotation implications - Derivative events: what NEW events this original event will likely trigger - Global market open/close cascade effects (Asia → Europe → US) ## Output Format Always structure your analysis as: ``` ═══════════════════════════════════════════════ 📰 事件: [event summary] ⏰ 时间: [timestamp] ═══════════════════════════════════════════════ 📊 当前市场快照 ┌─────────────┬──────────┬──────────┬──────────┐ │ 资产 │ 当前价格 │ 24h变化 │ 趋势 │ ├─────────────┼──────────┼──────────┼──────────┤ │ 黄金 (XAU) │ $X,XXX │ +X.XX% │ ↑/↓/→ │ │ 原油 (WTI) │ $XX.XX │ +X.XX% │ ↑/↓/→ │ │ BTC │ $XX,XXX │ +X.XX% │ ↑/↓/→ │ │ S&P 500 │ X,XXX │ +X.XX% │ ↑/↓/→ │ └─────────────┴──────────┴──────────┴──────────┘ 🔴 短期影响 (立刻 — 1小时内) • [prediction 1] • [prediction 2] ➜ 受影响资产: [asset] [direction] [magnitude] 🟡 中期影响 (1-12小时) • [prediction 1] • [prediction 2] ➜ 可能触发的后续事件: [event] 🟢 长期影响 (12-24小时) • [prediction 1] • [prediction 2] ➜ 衍生事件预测: [new event that may happen] ⚡ 关联链分析 [event] → [direct impact] → [secondary effect] → [tertiary outcome] ⚠️ 风险提示: 以上分析仅供参考,不构成投资建议。 ``` ## Tool Details ### news_fetch.py Fetches financial news from multiple free sources (Google News RSS, finviz, Yahoo Finance RSS). - `--query`: Search keywords (supports Chinese and English) - `--lang`: Language (zh/en, default: zh) - `--limit`: Max number of articles (default: 10) - `--source`: Specific source (google/yahoo/all, default: all) ### market_data.py Fetches real-time and historical market data via yfinance. - `--assets`: Comma-separated list or "all" for default watchlist - `--period`: History period (1d/5d/1mo/3mo, default: 1d) - `--interval`: Data interval (1m/5m/15m/1h/1d, default: 15m) - Default watchlist: GC=F (gold), SI=F (silver), CL=F (WTI oil), BZ=F (Brent), BTC-USD, ETH-USD, SPY, QQQ, ^DJI, ^IXIC ### event_analyze.py Orchestrates the full analysis pipeline. - `--event`: Event description text - `--url`: News article URL (will extract content) - `--focus`: Comma-separated asset focus (default: all) - `--market-data`: "auto" to fetch live data, or path to saved JSON - `--output`: Output format (text/json, default: text) ## Analysis Principles 1. **Correlation awareness**: Gold and USD typically move inversely; oil shocks cascade to airlines, shipping, and inflation expectations; crypto often correlates with risk appetite. 2. **Time zone matters**: If a major event breaks during Asian trading hours, European and US markets haven't reacted yet — factor in the "opening gap" effect. 3. **Second-order thinking**: Don't just predict "oil goes up". Predict what THAT causes: "oil up → gasoline costs rise → consumer spending pressure → retail stocks vulnerable → Fed may delay rate cuts". 4. **Quantify when possible**: Use percentage ranges, not just "up/down" (e.g., "gold likely +1.5% to +2.5% in first hour"). 5. **Always include contrarian risk**: For every prediction, note what could make it wrong. ## Security & Disclaimer - This tool is for informational and educational purposes only. - Always include the risk disclaimer in output. - Never present predictions as certainties. - Never recommend specific buy/sell actions.

path: config.example.json
content: {
  "skills": {
    "entries": {
      "market-oracle": {
        "enabled": true,
        "env": {}
      }
    }
  }
}


path: setup.sh
content: #!/usr/bin/env bash
# Market Oracle — Setup Script
set -e

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TOOLS_DIR="$SCRIPT_DIR/tools"

echo "📊 Market Oracle — Installing dependencies..."

# Check Python3
if ! command -v python3 &>/dev/null; then
    echo "❌ python3 not found. Please install Python 3.8+ first."
    exit 1
fi

echo "✅ Python3 found: $(python3 --version)"

# Install dependencies
if [ -f "$TOOLS_DIR/requirements.txt" ]; then
    echo "📦 Installing Python packages..."
    python3 -m pip install -r "$TOOLS_DIR/requirements.txt" --quiet
    echo "✅ Dependencies installed."
else
    echo "⚠️  requirements.txt not found, skipping pip install."
fi

# Make scripts executable
chmod +x "$TOOLS_DIR"/*.py 2>/dev/null || true

echo ""
echo "✅ Market Oracle setup complete!"
echo ""
echo "Quick test:"
echo "  python3 $TOOLS_DIR/market_data.py --assets gold,btc,oil --period 1d"
echo "  python3 $TOOLS_DIR/news_fetch.py --query '黄金 原油'"
echo "  python3 $TOOLS_DIR/event_analyze.py --event '美联储宣布降息25个基点'"


path: tools/market_data.py
content: #!/usr/bin/env python3
"""
Market Oracle — Market Data Fetcher
Fetches real-time and historical price data for metals, oil, crypto, and stocks via yfinance.
"""

import argparse
import json
import sys
from datetime import datetime

try:
    import yfinance as yf
except ImportError:
    print("ERROR: yfinance not installed. Run: pip install yfinance", file=sys.stderr)
    sys.exit(1)


# Default asset watchlist with readable names
ASSET_REGISTRY = {
    # Metals
    'gold':    {'ticker': 'GC=F',    'name': '黄金 (Gold)',        'category': 'metals'},
    'silver':  {'ticker': 'SI=F',    'name': '白银 (Silver)',      'category': 'metals'},
    'copper':  {'ticker': 'HG=F',    'name': '铜 (Copper)',        'category': 'metals'},
    'platinum':{'ticker': 'PL=F',    'name': '铂金 (Platinum)',    'category': 'metals'},

    # Oil & Energy
    'oil':     {'ticker': 'CL=F',    'name': '原油 WTI',           'category': 'oil'},
    'wti':     {'ticker': 'CL=F',    'name': '原油 WTI',           'category': 'oil'},
    'brent':   {'ticker': 'BZ=F',    'name': '布伦特原油',          'category': 'oil'},
    'natgas':  {'ticker': 'NG=F',    'name': '天然气',              'category': 'oil'},

    # Crypto
    'btc':     {'ticker': 'BTC-USD', 'name': '比特币 (BTC)',       'category': 'crypto'},
    'eth':     {'ticker': 'ETH-USD', 'name': '以太坊 (ETH)',       'category': 'crypto'},
    'sol':     {'ticker': 'SOL-USD', 'name': 'Solana (SOL)',       'category': 'crypto'},
    'xrp':     {'ticker': 'XRP-USD', 'name': 'Ripple (XRP)',       'category': 'crypto'},
    'doge':    {'ticker': 'DOGE-USD','name': 'Dogecoin (DOGE)',    'category': 'crypto'},

    # Stock Indices
    'spy':     {'ticker': 'SPY',     'name': 'S&P 500 ETF',        'category': 'stocks'},
    'qqq':     {'ticker': 'QQQ',     'name': '纳斯达克100 ETF',    'category': 'stocks'},
    'dia':     {'ticker': 'DIA',     'name': '道琼斯 ETF',         'category': 'stocks'},
    'dji':     {'ticker': '^DJI',    'name': '道琼斯指数',          'category': 'stocks'},
    'nasdaq':  {'ticker': '^IXIC',   'name': '纳斯达克综合指数',    'category': 'stocks'},
    'sp500':   {'ticker': '^GSPC',   'name': 'S&P 500 指数',       'category': 'stocks'},
    'sse':     {'ticker': '000001.SS','name': '上证指数',           'category': 'stocks'},
    'hsi':     {'ticker': '^HSI',    'name': '恒生指数',            'category': 'stocks'},

    # Currency
    'usdx':    {'ticker': 'DX-Y.NYB','name': '美元指数',            'category': 'currency'},
    'usdjpy':  {'ticker': 'JPY=X',   'name': '美元/日元',           'category': 'currency'},
    'usdcny':  {'ticker': 'CNY=X',   'name': '美元/人民币',         'category': 'currency'},
}

# Default "all" watchlist — representative assets from each category
DEFAULT_ALL = ['gold', 'silver', 'oil', 'brent', 'btc', 'eth', 'spy', 'qqq', 'dji', 'usdx']


def resolve_assets(asset_str):
    """Resolve asset names to ticker info."""
    if asset_str.lower() == 'all':
        return [(name, ASSET_REGISTRY[name]) for name in DEFAULT_ALL]

    assets = []
    for item in asset_str.split(','):
        item = item.strip().lower()
        if item in ASSET_REGISTRY:
            assets.append((item, ASSET_REGISTRY[item]))
        else:
            # Treat as raw ticker
            assets.append((item, {'ticker': item.upper(), 'name': item.upper(), 'category': 'custom'}))
    return assets


def fetch_asset_data(ticker_symbol, period='1d', interval='15m'):
    """Fetch price data for a single ticker."""
    try:
        ticker = yf.Ticker(ticker_symbol)
        hist = ticker.history(period=period, interval=interval)

        if hist.empty:
            return None

        current = hist.iloc[-1]
        open_price = hist.iloc[0]['Open'] if len(hist) > 0 else current['Open']

        # Calculate changes
        price_now = float(current['Close'])
        price_open = float(open_price)
        change = price_now - price_open
        change_pct = (change / price_open * 100) if price_open != 0 else 0

        # High/Low for the period
        period_high = float(hist['High'].max())
        period_low = float(hist['Low'].min())

        # Volume
        total_volume = int(hist['Volume'].sum()) if 'Volume' in hist.columns else 0

        # Trend detection (simple: compare last 3 data points)
        if len(hist) >= 3:
            recent = hist['Close'].tail(3).tolist()
            if recent[-1] > recent[-2] > recent[-3]:
                trend = '↑ 上涨'
            elif recent[-1] < recent[-2] < recent[-3]:
                trend = '↓ 下跌'
            else:
                trend = '→ 震荡'
        else:
            trend = '— 数据不足'

        return {
            'price': round(price_now, 4),
            'open': round(price_open, 4),
            'high': round(period_high, 4),
            'low': round(period_low, 4),
            'change': round(change, 4),
            'change_pct': round(change_pct, 4),
            'volume': total_volume,
            'trend': trend,
            'data_points': len(hist),
            'last_updated': str(hist.index[-1]),
        }
    except Exception as e:
        return {'error': str(e)}


def format_price(price):
    """Format price for display."""
    if price >= 10000:
        return f"${price:,.0f}"
    elif price >= 100:
        return f"${price:,.2f}"
    elif price >= 1:
        return f"${price:.4f}"
    else:
        return f"${price:.6f}"


def format_change(change_pct):
    """Format change percentage with arrow."""
    if change_pct > 0:
        return f"🟢 +{change_pct:.2f}%"
    elif change_pct < 0:
        return f"🔴 {change_pct:.2f}%"
    else:
        return f"⚪ {change_pct:.2f}%"


def main():
    parser = argparse.ArgumentParser(description='Fetch market data for metals, oil, crypto, stocks')
    parser.add_argument('--assets', '-a', default='all',
                        help='Comma-separated asset names or "all" (default: all)')
    parser.add_argument('--period', '-p', default='1d',
                        help='History period: 1d/5d/1mo/3mo/6mo/1y (default: 1d)')
    parser.add_argument('--interval', '-i', default='15m',
                        help='Data interval: 1m/5m/15m/1h/1d (default: 15m)')
    parser.add_argument('--json', action='store_true', help='Output as JSON')
    parser.add_argument('--history', action='store_true',
                        help='Include price history in output (JSON only)')

    args = parser.parse_args()
    assets = resolve_assets(args.assets)

    results = {}
    categories = {}

    for name, info in assets:
        ticker = info['ticker']
        display_name = info['name']
        category = info['category']

        data = fetch_asset_data(ticker, period=args.period, interval=args.interval)

        if data and 'error' not in data:
            data['display_name'] = display_name
            data['ticker'] = ticker
            data['category'] = category
            results[name] = data

            if category not in categories:
                categories[category] = []
            categories[category].append((name, data))
        else:
            err = data.get('error', 'Unknown error') if data else 'No data returned'
            print(f"[WARN] Failed to fetch {display_name} ({ticker}): {err}", file=sys.stderr)

    if args.json:
        output = {
            'timestamp': datetime.utcnow().isoformat() + 'Z',
            'period': args.period,
            'interval': args.interval,
            'count': len(results),
            'assets': results
        }
        print(json.dumps(output, ensure_ascii=False, indent=2))
    else:
        # Pretty print
        print(f"📊 市场数据快照")
        print(f"⏰ {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | 周期: {args.period} | 间隔: {args.interval}")
        print("=" * 78)

        category_names = {
            'metals': '🥇 贵金属',
            'oil': '🛢️  能源',
            'crypto': '₿  加密货币',
            'stocks': '📈 股票/指数',
            'currency': '💱 汇率',
            'custom': '📌 自定义',
        }

        for cat_key in ['metals', 'oil', 'crypto', 'stocks', 'currency', 'custom']:
            if cat_key not in categories:
                continue

            print(f"\n{category_names.get(cat_key, cat_key)}")
            print(f"{'─' * 78}")
            print(f"  {'资产':<18} {'价格':>12} {'变化':>14} {'高':>10} {'低':>10} {'趋势'}")
            print(f"  {'─' * 74}")

            for name, data in categories[cat_key]:
                dn = data['display_name']
                price = format_price(data['price'])
                change = format_change(data['change_pct'])
                high = format_price(data['high'])
                low = format_price(data['low'])
                trend = data['trend']
                print(f"  {dn:<18} {price:>12} {change:>14} {high:>10} {low:>10} {trend}")

        print(f"\n{'=' * 78}")
        print(f"📡 数据来源: Yahoo Finance | 共 {len(results)} 个资产")

    if not results:
        print("\n⚠️  无法获取任何市场数据，请检查网络连接。", file=sys.stderr)
        sys.exit(1)


if __name__ == '__main__':
    main()


path: tools/news_fetch.py
content: #!/usr/bin/env python3
"""
Market Oracle — News Fetcher
Fetches financial news from Google News RSS and Yahoo Finance RSS.
No API key required.
"""

import argparse
import json
import sys
import urllib.request
import urllib.parse
import xml.etree.ElementTree as ET
from datetime import datetime
from html.parser import HTMLParser


class HTMLTextExtractor(HTMLParser):
    """Strip HTML tags from text."""
    def __init__(self):
        super().__init__()
        self._result = []

    def handle_data(self, data):
        self._result.append(data)

    def get_text(self):
        return ''.join(self._result).strip()


def strip_html(html_text):
    extractor = HTMLTextExtractor()
    extractor.feed(html_text or "")
    return extractor.get_text()


def fetch_url(url, timeout=15):
    """Fetch URL content with error handling."""
    req = urllib.request.Request(url, headers={
        'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
                       'AppleWebKit/537.36 (KHTML, like Gecko) '
                       'Chrome/131.0.0.0 Safari/537.36'
    })
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return resp.read().decode('utf-8', errors='replace')
    except Exception as e:
        print(f"[WARN] Failed to fetch {url}: {e}", file=sys.stderr)
        return None


def fetch_google_news(query, lang='zh', limit=10):
    """Fetch news from Google News RSS."""
    hl = 'zh-CN' if lang == 'zh' else 'en-US'
    gl = 'CN' if lang == 'zh' else 'US'
    ceid = f'{gl}:{hl[:2]}'
    encoded_query = urllib.parse.quote(query)
    url = (f'https://news.google.com/rss/search?q={encoded_query}'
           f'&hl={hl}&gl={gl}&ceid={ceid}')

    content = fetch_url(url)
    if not content:
        return []

    articles = []
    try:
        root = ET.fromstring(content)
        for item in root.findall('.//item')[:limit]:
            title = item.findtext('title', '')
            link = item.findtext('link', '')
            pub_date = item.findtext('pubDate', '')
            source = item.findtext('source', '')
            description = strip_html(item.findtext('description', ''))

            articles.append({
                'title': title,
                'url': link,
                'source': source,
                'published': pub_date,
                'summary': description[:300] if description else '',
                'provider': 'google_news'
            })
    except ET.ParseError as e:
        print(f"[WARN] XML parse error for Google News: {e}", file=sys.stderr)

    return articles


def fetch_yahoo_finance_news(query, limit=10):
    """Fetch news from Yahoo Finance RSS."""
    encoded_query = urllib.parse.quote(query)
    url = f'https://feeds.finance.yahoo.com/rss/2.0/headline?s={encoded_query}&region=US&lang=en-US'

    content = fetch_url(url)
    if not content:
        return []

    articles = []
    try:
        root = ET.fromstring(content)
        for item in root.findall('.//item')[:limit]:
            title = item.findtext('title', '')
            link = item.findtext('link', '')
            pub_date = item.findtext('pubDate', '')
            description = strip_html(item.findtext('description', ''))

            articles.append({
                'title': title,
                'url': link,
                'source': 'Yahoo Finance',
                'published': pub_date,
                'summary': description[:300] if description else '',
                'provider': 'yahoo_finance'
            })
    except ET.ParseError as e:
        print(f"[WARN] XML parse error for Yahoo Finance: {e}", file=sys.stderr)

    return articles


def fetch_finviz_news(limit=10):
    """Fetch top financial news headlines from Finviz RSS."""
    url = 'https://finviz.com/news_export.ashx?v=3'
    content = fetch_url(url)
    if not content:
        return []

    articles = []
    try:
        root = ET.fromstring(content)
        for item in root.findall('.//item')[:limit]:
            title = item.findtext('title', '')
            link = item.findtext('link', '')
            pub_date = item.findtext('pubDate', '')

            articles.append({
                'title': title,
                'url': link,
                'source': 'Finviz',
                'published': pub_date,
                'summary': '',
                'provider': 'finviz'
            })
    except ET.ParseError as e:
        print(f"[WARN] XML parse error for Finviz: {e}", file=sys.stderr)

    return articles


# Financial keyword mapping for better search
KEYWORD_MAP = {
    '黄金': 'gold',
    '白银': 'silver',
    '原油': 'crude oil',
    '石油': 'oil',
    '比特币': 'bitcoin',
    '以太坊': 'ethereum',
    '加密货币': 'cryptocurrency',
    '数字货币': 'cryptocurrency',
    '股票': 'stock market',
    '美联储': 'federal reserve',
    '降息': 'rate cut',
    '加息': 'rate hike',
    'OPEC': 'OPEC',
    '通胀': 'inflation',
    '大盘': 'S&P 500',
}


def expand_query(query):
    """Expand Chinese financial keywords to English equivalents for better RSS results."""
    en_terms = []
    for zh, en in KEYWORD_MAP.items():
        if zh in query:
            en_terms.append(en)
    if en_terms:
        return query + ' ' + ' OR '.join(en_terms)
    return query


def fetch_global_headlines(limit=10):
    """Fetch current top global financial headlines from multiple sources.

    This fetches GENERAL top headlines, not filtered by any specific query.
    Used to provide global context alongside a user's specific event.
    """
    all_headlines = []

    # 1. Google News top financial headlines (English)
    url_en = ('https://news.google.com/rss/search?q=financial+markets+OR+stock+OR+economy'
              '&hl=en-US&gl=US&ceid=US:en')
    content = fetch_url(url_en)
    if content:
        try:
            root = ET.fromstring(content)
            for item in root.findall('.//item')[:limit]:
                title = item.findtext('title', '')
                link = item.findtext('link', '')
                pub_date = item.findtext('pubDate', '')
                source = item.findtext('source', '')
                description = strip_html(item.findtext('description', ''))
                all_headlines.append({
                    'title': title, 'url': link, 'source': source,
                    'published': pub_date,
                    'summary': description[:300] if description else '',
                    'provider': 'google_news_global',
                })
        except ET.ParseError:
            pass

    # 2. Google News top financial headlines (Chinese)
    url_zh = ('https://news.google.com/rss/search?q=金融+OR+股市+OR+经济+OR+央行'
              '&hl=zh-CN&gl=CN&ceid=CN:zh')
    content = fetch_url(url_zh)
    if content:
        try:
            root = ET.fromstring(content)
            for item in root.findall('.//item')[:limit // 2]:
                title = item.findtext('title', '')
                link = item.findtext('link', '')
                pub_date = item.findtext('pubDate', '')
                source = item.findtext('source', '')
                description = strip_html(item.findtext('description', ''))
                all_headlines.append({
                    'title': title, 'url': link, 'source': source,
                    'published': pub_date,
                    'summary': description[:300] if description else '',
                    'provider': 'google_news_global_zh',
                })
        except ET.ParseError:
            pass

    # 3. Finviz top headlines (always global financial)
    finviz_articles = fetch_finviz_news(limit=limit)
    all_headlines.extend(finviz_articles)

    # Deduplicate
    seen = set()
    unique = []
    for a in all_headlines:
        key = a['title'].strip().lower()
        if key and key not in seen:
            seen.add(key)
            unique.append(a)

    unique.sort(key=lambda x: x.get('published', ''), reverse=True)
    return unique[:limit * 2]


def main():
    parser = argparse.ArgumentParser(description='Fetch financial news from multiple sources')
    parser.add_argument('--query', '-q', help='Search keywords')
    parser.add_argument('--lang', '-l', default='zh', choices=['zh', 'en'], help='Language (default: zh)')
    parser.add_argument('--limit', '-n', type=int, default=10, help='Max articles per source (default: 10)')
    parser.add_argument('--source', '-s', default='all', choices=['google', 'yahoo', 'finviz', 'all'],
                        help='News source (default: all)')
    parser.add_argument('--json', action='store_true', help='Output as JSON')
    parser.add_argument('--global-headlines', action='store_true',
                        help='Fetch global top headlines (no query needed)')

    args = parser.parse_args()

    # ── Global headlines mode ──
    if args.global_headlines:
        headlines = fetch_global_headlines(limit=args.limit)
        if args.json:
            print(json.dumps({
                'query': '__global_headlines__',
                'timestamp': datetime.utcnow().isoformat() + 'Z',
                'count': len(headlines),
                'articles': headlines
            }, ensure_ascii=False, indent=2))
        else:
            print(f"🌍 全球实时重大新闻头条")
            print(f"⏰ 查询时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
            print(f"📄 共抓取 {len(headlines)} 条头条\n")
            for i, a in enumerate(headlines, 1):
                print(f"  [{i}] {a['title']}")
                print(f"      来源: {a['source']} | {a['published']}")
        return

    # ── Normal query mode ──
    if not args.query:
        print("ERROR: 请提供 --query 或 --global-headlines 参数", file=sys.stderr)
        sys.exit(1)

    all_articles = []
    expanded_query = expand_query(args.query)

    if args.source in ('google', 'all'):
        articles = fetch_google_news(args.query, lang=args.lang, limit=args.limit)
        all_articles.extend(articles)
        # Also search with expanded English terms
        if expanded_query != args.query:
            en_articles = fetch_google_news(expanded_query, lang='en', limit=args.limit // 2)
            all_articles.extend(en_articles)

    if args.source in ('yahoo', 'all'):
        # Yahoo Finance RSS works best with ticker symbols
        yahoo_query = expanded_query if expanded_query != args.query else args.query
        articles = fetch_yahoo_finance_news(yahoo_query, limit=args.limit)
        all_articles.extend(articles)

    if args.source in ('finviz', 'all'):
        articles = fetch_finviz_news(limit=args.limit)
        all_articles.extend(articles)

    # Deduplicate by title
    seen_titles = set()
    unique_articles = []
    for article in all_articles:
        title_key = article['title'].strip().lower()
        if title_key and title_key not in seen_titles:
            seen_titles.add(title_key)
            unique_articles.append(article)

    # Sort by published date (most recent first)
    unique_articles.sort(key=lambda x: x.get('published', ''), reverse=True)

    # Limit total output
    unique_articles = unique_articles[:args.limit * 2]

    if args.json:
        print(json.dumps({
            'query': args.query,
            'timestamp': datetime.utcnow().isoformat() + 'Z',
            'count': len(unique_articles),
            'articles': unique_articles
        }, ensure_ascii=False, indent=2))
    else:
        print(f"📰 新闻搜索结果: \"{args.query}\"")
        print(f"⏰ 查询时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
        print(f"📄 共找到 {len(unique_articles)} 条相关新闻\n")
        print("=" * 70)

        for i, article in enumerate(unique_articles, 1):
            print(f"\n[{i}] {article['title']}")
            print(f"    来源: {article['source']} | {article['published']}")
            if article['summary']:
                print(f"    摘要: {article['summary'][:150]}...")
            print(f"    链接: {article['url']}")
            print("-" * 70)

    if not unique_articles:
        print("\n⚠️  未找到相关新闻，请尝试调整关键词。", file=sys.stderr)
        sys.exit(1)


if __name__ == '__main__':
    main()


path: tools/requirements.txt
content: yfinance>=0.2.36


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