Finance News Skill AI-powered market news briefings with configurable language output and automated delivery. First-Time Setup Run the interactive setup wizard to configure your sources, delivery channels, and schedule: The wizard will guide you through: - 📰 RSS Feeds: Enable/disable WSJ, Barron's, CNBC, Yahoo, etc. - 📊 Markets: Choose regions (US, Europe, Japan, Asia) - 📤 Delivery: Configure WhatsApp/Telegram group - 🌐 Language: Set default language (English/German) - ⏰ Schedule: Configure morning/evening cron times You can also configure specific sections: Quick Start Features 📊 Market…

, line)\n if match:\n headlines.append(match.group(1))\n else:\n # No reference number\n headlines.append(line[1:].strip())\n return headlines\n\n\ndef translate_headlines(headlines: list[str], lang: str = \"de\") -> list[str]:\n \"\"\"Translate headlines using openclaw agent.\"\"\"\n if not headlines:\n return []\n\n prompt = f\"\"\"Translate these English headlines to German.\nReturn ONLY a JSON array of strings in the same order.\nExample: [\"Übersetzung 1\", \"Übersetzung 2\"]\nDo not add commentary.\n\nHeadlines:\n\"\"\"\n for idx, title in enumerate(headlines, start=1):\n prompt += f\"{idx}. {title}\\n\"\n\n try:\n result = subprocess.run(\n [\n 'openclaw', 'agent',\n '--session-id', 'finance-news-translate-portfolio',\n '--message', prompt,\n '--json',\n '--timeout', '60'\n ],\n capture_output=True,\n text=True,\n timeout=90\n )\n except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:\n print(f\"⚠️ Translation failed: {e}\", file=sys.stderr)\n return headlines\n\n if result.returncode != 0:\n print(f\"⚠️ openclaw error: {result.stderr}\", file=sys.stderr)\n return headlines\n\n # Extract reply from openclaw JSON output\n # Format: {\"result\": {\"payloads\": [{\"text\": \"...\"}]}}\n # Note: openclaw may print plugin loading messages before JSON, so find the JSON start\n stdout = result.stdout\n json_start = stdout.find('{')\n if json_start > 0:\n stdout = stdout[json_start:]\n\n try:\n output = json.loads(stdout)\n payloads = output.get('result', {}).get('payloads', [])\n if payloads and payloads[0].get('text'):\n reply = payloads[0]['text']\n else:\n reply = output.get('reply', '') or output.get('message', '') or stdout\n except json.JSONDecodeError:\n reply = stdout\n\n # Parse JSON array from reply\n json_text = reply.strip()\n if \"```\" in json_text:\n match = re.search(r'```(?:json)?\\s*(.*?)```', json_text, re.DOTALL)\n if match:\n json_text = match.group(1).strip()\n\n try:\n translated = json.loads(json_text)\n if isinstance(translated, list) and len(translated) == len(headlines):\n print(f\"✅ Translated {len(headlines)} portfolio headlines\", file=sys.stderr)\n return translated\n except json.JSONDecodeError as e:\n print(f\"⚠️ JSON parse error: {e}\", file=sys.stderr)\n\n print(f\"⚠️ Translation failed, using original headlines\", file=sys.stderr)\n return headlines\n\n\ndef replace_headlines(portfolio_message: str, original: list[str], translated: list[str]) -> str:\n \"\"\"Replace original headlines with translated ones in portfolio message.\"\"\"\n result = portfolio_message\n for orig, trans in zip(original, translated):\n if orig != trans:\n # Replace the headline text, preserving bullet and reference\n result = result.replace(f\"• {orig}\", f\"• {trans}\")\n return result\n\n\ndef main():\n parser = argparse.ArgumentParser(description='Translate portfolio headlines')\n parser.add_argument('json_file', help='Path to briefing JSON file')\n parser.add_argument('--lang', default='de', help='Target language (default: de)')\n args = parser.parse_args()\n\n # Read JSON\n try:\n with open(args.json_file, 'r') as f:\n data = json.load(f)\n except (FileNotFoundError, json.JSONDecodeError) as e:\n print(f\"❌ Error reading {args.json_file}: {e}\", file=sys.stderr)\n sys.exit(1)\n\n portfolio_message = data.get('portfolio_message', '')\n if not portfolio_message:\n print(\"No portfolio_message to translate\", file=sys.stderr)\n print(json.dumps(data, ensure_ascii=False, indent=2))\n return\n\n # Extract, translate, replace\n headlines = extract_headlines(portfolio_message)\n if not headlines:\n print(\"No headlines found in portfolio_message\", file=sys.stderr)\n print(json.dumps(data, ensure_ascii=False, indent=2))\n return\n\n print(f\"📝 Found {len(headlines)} headlines to translate\", file=sys.stderr)\n translated = translate_headlines(headlines, args.lang)\n\n # Update portfolio message\n data['portfolio_message'] = replace_headlines(portfolio_message, headlines, translated)\n\n # Write back\n with open(args.json_file, 'w') as f:\n json.dump(data, f, ensure_ascii=False, indent=2)\n\n print(f\"✅ Updated {args.json_file}\", file=sys.stderr)\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5436,"content_sha256":"1cd16d8980c2d0ed653ed5c5f58ed6013b0bb23e1149610d55ce74d447849dc9"},{"filename":"scripts/utils.py","content":"\"\"\"Shared helpers.\"\"\"\n\nimport os\nimport sys\nimport time\nfrom pathlib import Path\n\n\ndef ensure_venv() -> None:\n \"\"\"Re-exec inside local venv if available and not already active.\"\"\"\n if os.environ.get(\"FINANCE_NEWS_VENV_BOOTSTRAPPED\") == \"1\":\n return\n if sys.prefix != sys.base_prefix:\n return\n venv_python = Path(__file__).resolve().parent.parent / \"venv\" / \"bin\" / \"python3\"\n if not venv_python.exists():\n print(\"⚠️ finance-news venv missing; run scripts from the repo venv to avoid dependency errors.\", file=sys.stderr)\n return\n env = os.environ.copy()\n env[\"FINANCE_NEWS_VENV_BOOTSTRAPPED\"] = \"1\"\n os.execvpe(str(venv_python), [str(venv_python)] + sys.argv, env)\n\n\ndef compute_deadline(deadline_sec: int | None) -> float | None:\n if deadline_sec is None:\n return None\n if deadline_sec \u003c= 0:\n return None\n return time.monotonic() + deadline_sec\n\n\ndef time_left(deadline: float | None) -> int | None:\n if deadline is None:\n return None\n remaining = int(deadline - time.monotonic())\n return remaining\n\n\ndef clamp_timeout(default_timeout: int, deadline: float | None, minimum: int = 1) -> int:\n remaining = time_left(deadline)\n if remaining is None:\n return default_timeout\n if remaining \u003c= 0:\n raise TimeoutError(\"Deadline exceeded\")\n return max(min(default_timeout, remaining), minimum)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":1408,"content_sha256":"a350574b8f8338eb9d3aa912feab64aa81a1d6d0f7cd7712b6d15935c93aff33"},{"filename":"scripts/venv-setup.sh","content":"#!/usr/bin/env bash\n# Finance News - venv Setup Script\n# Creates or rebuilds the Python virtual environment\n# Handles NixOS libstdc++ issues automatically\n\nset -e\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nBASE_DIR=\"$(cd \"${SCRIPT_DIR}/..\" && pwd)\"\nVENV_DIR=\"${BASE_DIR}/venv\"\n\necho \"📦 Finance News - venv Setup\"\necho \"============================\"\necho \"\"\n\n# Check Python version\nPYTHON_BIN=\"${PYTHON_BIN:-python3}\"\nPYTHON_VERSION=$(\"$PYTHON_BIN\" --version 2>&1)\necho \"Using: $PYTHON_VERSION\"\necho \"Path: $(command -v \"$PYTHON_BIN\" 2>/dev/null || echo \"$PYTHON_BIN\")\"\necho \"\"\n\n# Remove existing venv if --force flag\nif [[ \"$1\" == \"--force\" || \"$1\" == \"-f\" ]]; then\n if [[ -d \"$VENV_DIR\" ]]; then\n echo \"🗑️ Removing existing venv...\"\n rm -rf \"$VENV_DIR\"\n fi\nfi\n\n# Check if venv exists\nif [[ -d \"$VENV_DIR\" ]]; then\n echo \"⚠️ venv already exists at $VENV_DIR\"\n echo \" Use --force to rebuild\"\n exit 0\nfi\n\n# Create venv\necho \"📁 Creating virtual environment...\"\n\"$PYTHON_BIN\" -m venv \"$VENV_DIR\"\n\n# Activate venv\nsource \"$VENV_DIR/bin/activate\"\n\n# Upgrade pip\necho \"⬆️ Upgrading pip...\"\npip install --upgrade pip --quiet\n\n# Install requirements\necho \"📥 Installing dependencies...\"\npip install -r \"$BASE_DIR/requirements.txt\" --quiet\n\n# NixOS-specific: Add LD_LIBRARY_PATH to activate script\nif [[ -d \"/nix/store\" ]]; then\n echo \"🐧 NixOS detected - configuring libstdc++ path...\"\n\n ACTIVATE_SCRIPT=\"$VENV_DIR/bin/activate\"\n\n # Find libstdc++ path\n LIBSTDCXX_PATH=\"\"\n if [[ -d \"/home/linuxbrew/.linuxbrew/lib\" ]]; then\n LIBSTDCXX_PATH=\"/home/linuxbrew/.linuxbrew/lib\"\n elif [[ -d \"$HOME/.linuxbrew/lib\" ]]; then\n LIBSTDCXX_PATH=\"$HOME/.linuxbrew/lib\"\n else\n # Try nix store - only set if find returns a result\n GCC_LIB_DIR=$(find /nix/store -maxdepth 2 -name \"*-gcc-*-lib\" -print -quit 2>/dev/null)\n if [[ -n \"$GCC_LIB_DIR\" && -d \"$GCC_LIB_DIR/lib\" ]]; then\n LIBSTDCXX_PATH=\"$GCC_LIB_DIR/lib\"\n fi\n fi\n\n if [[ -n \"$LIBSTDCXX_PATH\" && -d \"$LIBSTDCXX_PATH\" ]]; then\n # Add to activate script if not already there\n if ! grep -q \"FINANCE_NEWS_LD_LIBRARY_PATH\" \"$ACTIVATE_SCRIPT\"; then\n cat >> \"$ACTIVATE_SCRIPT\" \u003c\u003c EOF\n\n# NixOS libstdc++ fix for numpy/yfinance (added by venv-setup.sh)\nif [[ -z \"\\${FINANCE_NEWS_LD_LIBRARY_PATH:-}\" ]]; then\n export FINANCE_NEWS_LD_LIBRARY_PATH=1\n if [[ -z \"\\${LD_LIBRARY_PATH:-}\" ]]; then\n export LD_LIBRARY_PATH=\"$LIBSTDCXX_PATH\"\n else\n export LD_LIBRARY_PATH=\"$LIBSTDCXX_PATH:\\$LD_LIBRARY_PATH\"\n fi\nfi\nEOF\n echo \" Added LD_LIBRARY_PATH=$LIBSTDCXX_PATH to activate script\"\n fi\n else\n echo \" ⚠️ Could not find libstdc++.so.6 path\"\n echo \" Install Linuxbrew: /bin/bash -c \\\"\\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\\\"\"\n fi\nfi\n\n# Verify installation\necho \"\"\necho \"✅ venv created successfully!\"\necho \"\"\necho \"Verifying installation...\"\n\"$VENV_DIR/bin/python3\" -c \"import feedparser; print(' ✓ feedparser')\"\n\"$VENV_DIR/bin/python3\" -c \"import yfinance; print(' ✓ yfinance')\" 2>/dev/null || echo \" ⚠️ yfinance import failed (may need LD_LIBRARY_PATH)\"\n\necho \"\"\necho \"To activate manually:\"\necho \" source $VENV_DIR/bin/activate\"\necho \"\"\necho \"Or just use the CLI:\"\necho \" ./scripts/finance-news briefing --morning\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":3444,"content_sha256":"74f5ad95cd4581a8f156ff90972ea39a06e1c4e52fdbcfafc21fba8bf1f92093"},{"filename":"tests/fixtures/sample_portfolio.csv","content":"symbol,name,category,notes\nAAPL,Apple Inc,Tech,Core holding\nTSLA,Tesla Inc,Auto,Growth play\nMSFT,Microsoft,Tech,Dividend stock\n","content_type":"text/csv; charset=utf-8","language":"csv","size":127,"content_sha256":"3805c7319156ec10b811ab3fcc2bb68fcf75884d5b6857e6c45c9458347428a6"},{"filename":"tests/fixtures/sample_rss.xml","content":"\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003crss version=\"2.0\">\n \u003cchannel>\n \u003ctitle>Test Market News\u003c/title>\n \u003clink>https://example.com\u003c/link>\n \u003cdescription>Sample RSS feed for testing\u003c/description>\n \u003citem>\n \u003ctitle>Apple Stock Rises 5%\u003c/title>\n \u003clink>https://example.com/apple-rises\u003c/link>\n \u003cdescription>Apple Inc. shares rose 5% today on strong earnings.\u003c/description>\n \u003cpubDate>Mon, 20 Jan 2025 10:00:00 GMT\u003c/pubDate>\n \u003c/item>\n \u003citem>\n \u003ctitle>Tesla Announces New Model\u003c/title>\n \u003clink>https://example.com/tesla-model\u003c/link>\n \u003cdescription>Tesla unveils new electric vehicle model.\u003c/description>\n \u003cpubDate>Mon, 20 Jan 2025 11:30:00 GMT\u003c/pubDate>\n \u003c/item>\n \u003c/channel>\n\u003c/rss>\n","content_type":"application/xml","language":"xml","size":730,"content_sha256":"ca4127503bbe8051c63fd172c7be4ee6e71bae64a1cf8b6427d5b9ce5c9316a6"},{"filename":"tests/README.md","content":"# Unit Tests\n\n## Setup\n\n```bash\n# Install test dependencies\npip install -r requirements-test.txt\n\n# Run tests\npytest\n\n# Run with coverage\npytest --cov=scripts --cov-report=html\n\n# Run specific test file\npytest tests/test_portfolio.py\n```\n\n## Test Structure\n\n- `test_portfolio.py` - Portfolio CRUD operations\n- `test_fetch_news.py` - RSS feed parsing with mocked responses\n- `test_setup.py` - Setup wizard validation\n- `fixtures/` - Sample RSS and portfolio data\n\n## Coverage Target\n\n60%+ coverage for core functions (portfolio, fetch_news, setup).\n\n## Notes\n\n- Tests use `tmp_path` for file isolation\n- Network calls are mocked with `unittest.mock`\n- `pytest-mock` provides `mocker` fixture for advanced mocking\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":712,"content_sha256":"b4cf4eaeb871cfc199db2ba76a54caf6bf03438cbca876b1821160c87f34cf6f"},{"filename":"tests/test_alerts.py","content":"import sys\nfrom pathlib import Path\nimport json\nimport pytest\nfrom unittest.mock import Mock, patch\nfrom datetime import datetime, timedelta\n\n# Add scripts to path\nsys.path.insert(0, str(Path(__file__).parent.parent / \"scripts\"))\n\nfrom alerts import check_alerts, load_alerts, save_alerts\n\[email protected]\ndef mock_alerts_data():\n return {\n \"_meta\": {\"version\": 1, \"supported_currencies\": [\"USD\", \"EUR\"]},\n \"alerts\": [\n {\n \"ticker\": \"AAPL\",\n \"target_price\": 150.0,\n \"currency\": \"USD\",\n \"note\": \"Buy Apple\",\n \"triggered_count\": 0,\n \"last_triggered\": None\n },\n {\n \"ticker\": \"TSLA\",\n \"target_price\": 200.0,\n \"currency\": \"USD\",\n \"note\": \"Buy Tesla\",\n \"triggered_count\": 5,\n \"last_triggered\": \"2026-01-26T10:00:00\"\n }\n ]\n }\n\ndef test_check_alerts_trigger(mock_alerts_data, monkeypatch, tmp_path):\n # Setup mock alerts file\n alerts_file = tmp_path / \"alerts.json\"\n monkeypatch.setattr(\"alerts.ALERTS_FILE\", alerts_file)\n alerts_file.write_text(json.dumps(mock_alerts_data))\n \n # Mock market data: AAPL is under target, TSLA is over\n mock_quotes = {\n \"AAPL\": {\"price\": 145.0},\n \"TSLA\": {\"price\": 210.0}\n }\n \n with patch(\"alerts.get_fetch_market_data\") as mock_fmd_getter:\n mock_fmd = Mock(return_value=mock_quotes)\n mock_fmd_getter.return_value = mock_fmd\n \n results = check_alerts()\n \n assert len(results[\"triggered\"]) == 1\n assert results[\"triggered\"][0][\"ticker\"] == \"AAPL\"\n assert results[\"triggered\"][0][\"current_price\"] == 145.0\n \n assert len(results[\"watching\"]) == 1\n assert results[\"watching\"][0][\"ticker\"] == \"TSLA\"\n \n # Verify triggered count incremented for AAPL\n updated_data = json.loads(alerts_file.read_text())\n aapl_alert = next(a for a in updated_data[\"alerts\"] if a[\"ticker\"] == \"AAPL\")\n assert aapl_alert[\"triggered_count\"] == 1\n assert aapl_alert[\"last_triggered\"] is not None\n\ndef test_check_alerts_deduplication(mock_alerts_data, monkeypatch, tmp_path):\n # If already triggered today, triggered_count should NOT increment\n now = datetime.now()\n mock_alerts_data[\"alerts\"][0][\"last_triggered\"] = now.isoformat()\n mock_alerts_data[\"alerts\"][0][\"triggered_count\"] = 1\n \n alerts_file = tmp_path / \"alerts.json\"\n monkeypatch.setattr(\"alerts.ALERTS_FILE\", alerts_file)\n alerts_file.write_text(json.dumps(mock_alerts_data))\n \n mock_quotes = {\"AAPL\": {\"price\": 140.0}, \"TSLA\": {\"price\": 250.0}}\n \n with patch(\"alerts.get_fetch_market_data\") as mock_fmd_getter:\n mock_fmd = Mock(return_value=mock_quotes)\n mock_fmd_getter.return_value = mock_fmd\n \n check_alerts()\n \n updated_data = json.loads(alerts_file.read_text())\n aapl_alert = next(a for a in updated_data[\"alerts\"] if a[\"ticker\"] == \"AAPL\")\n assert aapl_alert[\"triggered_count\"] == 1 # Still 1, didn't increment because same day\n\ndef test_check_alerts_snooze(mock_alerts_data, monkeypatch, tmp_path):\n # Snoozed alert should be ignored\n future_date = datetime.now() + timedelta(days=1)\n mock_alerts_data[\"alerts\"][0][\"snooze_until\"] = future_date.isoformat()\n \n alerts_file = tmp_path / \"alerts.json\"\n monkeypatch.setattr(\"alerts.ALERTS_FILE\", alerts_file)\n alerts_file.write_text(json.dumps(mock_alerts_data))\n \n mock_quotes = {\"AAPL\": {\"price\": 140.0}, \"TSLA\": {\"price\": 190.0}}\n \n with patch(\"alerts.get_fetch_market_data\") as mock_fmd_getter:\n mock_fmd = Mock(return_value=mock_quotes)\n mock_fmd_getter.return_value = mock_fmd\n \n results = check_alerts()\n \n # AAPL is snoozed, so only TSLA should be in triggered\n assert len(results[\"triggered\"]) == 1\n assert results[\"triggered\"][0][\"ticker\"] == \"TSLA\"\n assert all(t[\"ticker\"] != \"AAPL\" for t in results[\"triggered\"])\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4138,"content_sha256":"0a7070657ab2d42e413c50f0123970d4eb076c8f9cd72854b447e0957e4dc617"},{"filename":"tests/test_briefing.py","content":"import sys\nfrom pathlib import Path\nimport json\nimport pytest\nfrom unittest.mock import Mock, patch\nimport subprocess\n\n# Add scripts to path\nsys.path.insert(0, str(Path(__file__).parent.parent / \"scripts\"))\n\nfrom briefing import generate_and_send\n\ndef test_generate_and_send_success():\n # Mock subprocess.run for summarize.py\n mock_briefing_data = {\n \"macro_message\": \"Macro Summary\",\n \"portfolio_message\": \"Portfolio Summary\",\n \"summary\": \"Full Summary\"\n }\n \n with patch(\"briefing.subprocess.run\") as mock_run:\n mock_result = Mock()\n mock_result.returncode = 0\n mock_result.stdout = json.dumps(mock_briefing_data)\n mock_run.return_value = mock_result\n \n args = Mock()\n args.time = \"morning\"\n args.style = \"briefing\"\n args.lang = \"en\"\n args.deadline = 300\n args.fast = False\n args.llm = False\n args.debug = False\n args.json = True\n args.send = False\n \n result = generate_and_send(args)\n \n assert result == \"Macro Summary\"\n assert mock_run.called\n # Check if summarize.py was called with correct args\n call_args = mock_run.call_args[0][0]\n assert \"summarize.py\" in str(call_args[1])\n assert \"--time\" in call_args\n assert \"morning\" in call_args\n\ndef test_generate_and_send_with_whatsapp():\n mock_briefing_data = {\n \"macro_message\": \"Macro Summary\",\n \"portfolio_message\": \"Portfolio Summary\"\n }\n \n with patch(\"briefing.subprocess.run\") as mock_run, \\\n patch(\"briefing.send_to_whatsapp\") as mock_send:\n \n # First call is summarize.py\n mock_result = Mock()\n mock_result.returncode = 0\n mock_result.stdout = json.dumps(mock_briefing_data)\n mock_run.return_value = mock_result\n \n args = Mock()\n args.time = \"evening\"\n args.style = \"briefing\"\n args.lang = \"en\"\n args.deadline = None\n args.fast = True\n args.llm = False\n args.json = False\n args.send = True\n args.group = \"Test Group\"\n args.debug = False\n \n generate_and_send(args)\n \n # Check if send_to_whatsapp was called for both messages\n assert mock_send.call_count == 2\n mock_send.assert_any_call(\"Macro Summary\", \"Test Group\")\n mock_send.assert_any_call(\"Portfolio Summary\", \"Test Group\")\n\ndef test_generate_and_send_failure():\n with patch(\"briefing.subprocess.run\") as mock_run:\n mock_result = Mock()\n mock_result.returncode = 1\n mock_result.stderr = \"Error occurred\"\n mock_run.return_value = mock_result\n \n args = Mock()\n args.time = \"morning\"\n args.style = \"briefing\"\n args.lang = \"en\"\n args.deadline = None\n args.fast = False\n args.llm = False\n args.json = False\n args.send = False\n args.debug = False\n \n with pytest.raises(SystemExit):\n generate_and_send(args)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":3066,"content_sha256":"dabad77787f009eaab4d5c77433f923c543ed1897ecd275f567654027a990480"},{"filename":"tests/test_earnings.py","content":"import sys\nfrom pathlib import Path\nimport json\nimport pytest\nfrom unittest.mock import Mock, patch, MagicMock\nfrom datetime import datetime, timedelta\n\n# Add scripts to path\nsys.path.insert(0, str(Path(__file__).parent.parent / \"scripts\"))\n\nfrom earnings import (\n fetch_all_earnings_finnhub,\n get_briefing_section,\n load_earnings_cache,\n save_earnings_cache,\n refresh_earnings\n)\n\[email protected]\ndef mock_finnhub_response():\n return {\n \"earningsCalendar\": [\n {\n \"symbol\": \"AAPL\",\n \"date\": \"2026-02-01\",\n \"hour\": \"amc\",\n \"epsEstimate\": 1.5,\n \"revenueEstimate\": 100000000,\n \"quarter\": 1,\n \"year\": 2026\n },\n {\n \"symbol\": \"TSLA\",\n \"date\": \"2026-01-27\",\n \"hour\": \"bmo\",\n \"epsEstimate\": 0.8,\n \"revenueEstimate\": 25000000,\n \"quarter\": 4,\n \"year\": 2025\n }\n ]\n }\n\ndef test_fetch_earnings_finnhub_success(mock_finnhub_response):\n with patch(\"earnings.urlopen\") as mock_urlopen:\n mock_resp = MagicMock()\n mock_resp.read.return_value = json.dumps(mock_finnhub_response).encode(\"utf-8\")\n mock_resp.__enter__.return_value = mock_resp\n mock_urlopen.return_value = mock_resp\n \n with patch(\"earnings.get_finnhub_key\", return_value=\"fake_key\"):\n result = fetch_all_earnings_finnhub(days_ahead=30)\n \n assert \"AAPL\" in result\n assert result[\"AAPL\"][\"date\"] == \"2026-02-01\"\n assert result[\"AAPL\"][\"time\"] == \"amc\"\n assert \"TSLA\" in result\n assert result[\"TSLA\"][\"date\"] == \"2026-01-27\"\n\ndef test_cache_logic(tmp_path, monkeypatch):\n cache_file = tmp_path / \"earnings_calendar.json\"\n monkeypatch.setattr(\"earnings.EARNINGS_CACHE\", cache_file)\n monkeypatch.setattr(\"earnings.CACHE_DIR\", tmp_path)\n \n test_data = {\n \"last_updated\": \"2026-01-27T08:00:00\",\n \"earnings\": {\"AAPL\": {\"date\": \"2026-02-01\"}}\n }\n \n save_earnings_cache(test_data)\n assert cache_file.exists()\n \n loaded_data = load_earnings_cache()\n assert loaded_data[\"earnings\"][\"AAPL\"][\"date\"] == \"2026-02-01\"\n\ndef test_get_briefing_section_output():\n # Mock portfolio and cache to return specific earnings\n mock_portfolio = [{\"symbol\": \"AAPL\", \"name\": \"Apple\", \"category\": \"Tech\"}]\n mock_cache = {\n \"last_updated\": datetime.now().isoformat(),\n \"earnings\": {\n \"AAPL\": {\n \"date\": datetime.now().strftime(\"%Y-%m-%d\"),\n \"time\": \"amc\",\n \"eps_estimate\": 1.5\n }\n }\n }\n \n with patch(\"earnings.load_portfolio\", return_value=mock_portfolio), \\\n patch(\"earnings.load_earnings_cache\", return_value=mock_cache), \\\n patch(\"earnings.refresh_earnings\", return_value=mock_cache):\n \n section = get_briefing_section()\n assert \"## 🔴 EARNINGS TODAY\" in section\n assert \"**AAPL**\" in section\n assert \"Apple\" in section\n assert \"after-close\" in section\n assert \"Est: $1.50\" in section\n\ndef test_refresh_earnings_force(mock_finnhub_response):\n mock_portfolio = [{\"symbol\": \"AAPL\", \"name\": \"Apple\"}]\n \n with patch(\"earnings.get_finnhub_key\", return_value=\"fake_key\"), \\\n patch(\"earnings.fetch_all_earnings_finnhub\", return_value={\"AAPL\": mock_finnhub_response[\"earningsCalendar\"][0]}), \\\n patch(\"earnings.save_earnings_cache\") as mock_save:\n \n refresh_earnings(mock_portfolio, force=True)\n assert mock_save.called\n args, _ = mock_save.call_args\n assert \"AAPL\" in args[0][\"earnings\"]\n","content_type":"text/x-python; charset=utf-8","language":"python","size":3791,"content_sha256":"fdd80f5b511bc6bc3485d01d958dea0598ceca72f93fb371120ecfe3f5f6f409"},{"filename":"tests/test_fetch_news.py","content":"\"\"\"Tests for RSS feed fetching and parsing.\"\"\"\nimport sys\nfrom pathlib import Path\n\nsys.path.insert(0, str(Path(__file__).parent.parent / \"scripts\"))\n\nimport json\nimport pytest\nfrom unittest.mock import Mock, patch, MagicMock\nfrom fetch_news import fetch_market_data, fetch_rss, _get_best_feed_url\nfrom utils import clamp_timeout, compute_deadline\n\n\[email protected]\ndef sample_rss_content():\n \"\"\"Load sample RSS fixture.\"\"\"\n fixture_path = Path(__file__).parent / \"fixtures\" / \"sample_rss.xml\"\n return fixture_path.read_bytes()\n\n\ndef test_fetch_rss_success(sample_rss_content):\n \"\"\"Test successful RSS fetch and parse.\"\"\"\n with patch(\"urllib.request.urlopen\") as mock_urlopen:\n mock_response = MagicMock()\n mock_response.read.return_value = sample_rss_content\n mock_response.__enter__.return_value = mock_response\n mock_urlopen.return_value = mock_response\n \n articles = fetch_rss(\"https://example.com/feed.xml\", timeout=7)\n \n assert len(articles) == 2\n assert articles[0][\"title\"] == \"Apple Stock Rises 5%\"\n assert articles[1][\"title\"] == \"Tesla Announces New Model\"\n assert \"apple-rises\" in articles[0][\"link\"]\n assert mock_urlopen.call_args.kwargs[\"timeout\"] == 7\n\n\ndef test_fetch_rss_network_error():\n \"\"\"Test RSS fetch handles network errors.\"\"\"\n with patch(\"urllib.request.urlopen\", side_effect=Exception(\"Network error\")):\n articles = fetch_rss(\"https://example.com/feed.xml\")\n assert articles == []\n\n\ndef test_get_best_feed_url_priority():\n \"\"\"Test feed URL selection prioritizes 'top' key.\"\"\"\n source = {\n \"name\": \"Test Source\",\n \"homepage\": \"https://example.com\",\n \"top\": \"https://example.com/top.xml\",\n \"markets\": \"https://example.com/markets.xml\"\n }\n \n url = _get_best_feed_url(source)\n assert url == \"https://example.com/top.xml\"\n\n\ndef test_get_best_feed_url_fallback():\n \"\"\"Test feed URL falls back to other http URLs when priority keys missing.\"\"\"\n source = {\n \"name\": \"Test Source\",\n \"feed\": \"https://example.com/feed.xml\"\n }\n \n url = _get_best_feed_url(source)\n assert url == \"https://example.com/feed.xml\"\n\n\ndef test_get_best_feed_url_none_if_no_urls():\n \"\"\"Test returns None when no valid URLs found.\"\"\"\n source = {\n \"name\": \"Test Source\",\n \"enabled\": True,\n \"note\": \"No URLs here\"\n }\n \n url = _get_best_feed_url(source)\n assert url is None\n\n\ndef test_get_best_feed_url_skips_non_urls():\n \"\"\"Test skips non-URL values.\"\"\"\n source = {\n \"name\": \"Test Source\",\n \"enabled\": True,\n \"count\": 5,\n \"rss\": \"https://example.com/rss.xml\"\n }\n \n url = _get_best_feed_url(source)\n assert url == \"https://example.com/rss.xml\"\n\n\ndef test_clamp_timeout_respects_deadline(monkeypatch):\n start = 100.0\n monkeypatch.setattr(\"utils.time.monotonic\", lambda: start)\n deadline = compute_deadline(5)\n monkeypatch.setattr(\"utils.time.monotonic\", lambda: 103.0)\n\n assert clamp_timeout(30, deadline) == 2\n\n\ndef test_clamp_timeout_deadline_exceeded(monkeypatch):\n start = 200.0\n monkeypatch.setattr(\"utils.time.monotonic\", lambda: start)\n deadline = compute_deadline(1)\n monkeypatch.setattr(\"utils.time.monotonic\", lambda: 205.0)\n\n with pytest.raises(TimeoutError):\n clamp_timeout(30, deadline)\n\n\ndef test_fetch_market_data_price_fallback(monkeypatch):\n sample = {\n \"price\": None,\n \"open\": 100,\n \"prev_close\": 105,\n \"change_percent\": None,\n }\n\n def fake_run(*_args, **_kwargs):\n class Result:\n returncode = 0\n stdout = json.dumps(sample)\n stderr = \"\"\n\n return Result()\n\n monkeypatch.setattr(\"fetch_news.OPENBB_BINARY\", \"/bin/openbb-quote\")\n monkeypatch.setattr(\"fetch_news.subprocess.run\", fake_run)\n\n no_fallback = fetch_market_data([\"^GSPC\"], allow_price_fallback=False)\n assert no_fallback[\"^GSPC\"][\"price\"] is None\n\n with_fallback = fetch_market_data([\"^GSPC\"], allow_price_fallback=True)\n assert with_fallback[\"^GSPC\"][\"price\"] == 100\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4151,"content_sha256":"bf5d7495982ad8fa28d397881d6814bba2f7a9f2cc74c2a5d10b98b0b3db7fa6"},{"filename":"tests/test_portfolio.py","content":"\"\"\"Tests for portfolio operations.\"\"\"\nimport sys\nfrom pathlib import Path\n\n# Add scripts to path for imports\nsys.path.insert(0, str(Path(__file__).parent.parent / \"scripts\"))\n\nimport pytest\nfrom portfolio import load_portfolio, save_portfolio\n\n\ndef test_load_portfolio_success(tmp_path, monkeypatch):\n \"\"\"Test loading valid portfolio CSV.\"\"\"\n portfolio_file = tmp_path / \"portfolio.csv\"\n portfolio_file.write_text(\"symbol,name,category,notes,type\\nAAPL,Apple,Tech,,\\nTSLA,Tesla,Auto,,\\n\")\n \n monkeypatch.setattr(\"portfolio.PORTFOLIO_FILE\", portfolio_file)\n positions = load_portfolio()\n \n assert len(positions) == 2\n assert positions[0][\"symbol\"] == \"AAPL\"\n assert positions[0][\"name\"] == \"Apple\"\n assert positions[1][\"symbol\"] == \"TSLA\"\n\n\ndef test_load_portfolio_missing_file(tmp_path, monkeypatch):\n \"\"\"Test loading non-existent portfolio returns empty list.\"\"\"\n portfolio_file = tmp_path / \"nonexistent.csv\"\n monkeypatch.setattr(\"portfolio.PORTFOLIO_FILE\", portfolio_file)\n \n positions = load_portfolio()\n assert positions == []\n\n\ndef test_save_portfolio(tmp_path, monkeypatch):\n \"\"\"Test saving portfolio to CSV.\"\"\"\n portfolio_file = tmp_path / \"portfolio.csv\"\n monkeypatch.setattr(\"portfolio.PORTFOLIO_FILE\", portfolio_file)\n \n positions = [\n {\"symbol\": \"AAPL\", \"name\": \"Apple\", \"category\": \"Tech\", \"notes\": \"\", \"type\": \"stock\"},\n {\"symbol\": \"MSFT\", \"name\": \"Microsoft\", \"category\": \"Tech\", \"notes\": \"\", \"type\": \"stock\"}\n ]\n save_portfolio(positions)\n \n content = portfolio_file.read_text()\n assert \"symbol,name,category,notes,type\" in content\n assert \"AAPL\" in content\n assert \"MSFT\" in content\n\n\ndef test_save_empty_portfolio(tmp_path, monkeypatch):\n \"\"\"Test saving empty portfolio creates header.\"\"\"\n portfolio_file = tmp_path / \"portfolio.csv\"\n monkeypatch.setattr(\"portfolio.PORTFOLIO_FILE\", portfolio_file)\n \n save_portfolio([])\n \n content = portfolio_file.read_text()\n assert content == \"symbol,name,category,notes,type\\n\"\n\n\ndef test_load_portfolio_preserves_fields(tmp_path, monkeypatch):\n \"\"\"Test loading portfolio preserves all fields.\"\"\"\n portfolio_file = tmp_path / \"portfolio.csv\"\n portfolio_file.write_text(\"symbol,name,category,notes,type\\nAAPL,Apple Inc,Tech,Core holding,stock\\n\")\n monkeypatch.setattr(\"portfolio.PORTFOLIO_FILE\", portfolio_file)\n \n positions = load_portfolio()\n \n assert len(positions) == 1\n assert positions[0][\"symbol\"] == \"AAPL\"\n assert positions[0][\"name\"] == \"Apple Inc\"\n assert positions[0][\"category\"] == \"Tech\"\n assert positions[0][\"notes\"] == \"Core holding\"\n assert positions[0][\"type\"] == \"stock\"\n","content_type":"text/x-python; charset=utf-8","language":"python","size":2709,"content_sha256":"7acadc86506cfc58bb90c90fae8002d7c1d1797253dafc13ed0bcc3a7c075f5b"},{"filename":"tests/test_ranking.py","content":"import sys\nfrom pathlib import Path\nimport pytest\nfrom datetime import datetime, timedelta\n\n# Add scripts to path\nsys.path.insert(0, str(Path(__file__).parent.parent / \"scripts\"))\n\nfrom ranking import calculate_score, rank_headlines, classify_category\n\ndef test_classify_category():\n assert \"macro\" in classify_category(\"Fed signals rate cut\")\n assert \"equities\" in classify_category(\"Apple earnings beat\")\n assert \"energy\" in classify_category(\"Oil prices surge\")\n assert \"tech\" in classify_category(\"AI chip demand remains high\")\n assert \"geopolitics\" in classify_category(\"US imposes new sanctions on Russia\")\n assert classify_category(\"Weather is nice\") == [\"general\"]\n\ndef test_calculate_score_impact():\n weights = {\"market_impact\": 0.4, \"novelty\": 0.2, \"breadth\": 0.2, \"credibility\": 0.1, \"diversity\": 0.1}\n category_counts = {}\n \n high_impact = {\"title\": \"Fed announces emergency rate cut\", \"source\": \"Reuters\", \"published_at\": datetime.now().isoformat()}\n low_impact = {\"title\": \"Local coffee shop opens\", \"source\": \"Blog\", \"published_at\": datetime.now().isoformat()}\n \n score_high = calculate_score(high_impact, weights, category_counts)\n score_low = calculate_score(low_impact, weights, category_counts)\n \n assert score_high > score_low\n\ndef test_rank_headlines_deduplication():\n headlines = [\n {\"title\": \"Fed signals rate cut in March\", \"source\": \"WSJ\"},\n {\"title\": \"FED SIGNALS RATE CUT IN MARCH!!!\", \"source\": \"Reuters\"}, # Dupe\n {\"title\": \"Apple earnings are out\", \"source\": \"CNBC\"}\n ]\n \n result = rank_headlines(headlines)\n \n # After dedupe, we should have 2 unique headlines\n assert result[\"after_dedupe\"] == 2\n # must_read should contain the best ones\n assert len(result[\"must_read\"]) \u003c= 2\n\ndef test_rank_headlines_sorting():\n headlines = [\n {\"title\": \"Local news\", \"source\": \"SmallBlog\", \"description\": \"Nothing much\"},\n {\"title\": \"FED EMERGENCY RATE CUT\", \"source\": \"Bloomberg\", \"description\": \"Huge market impact\"},\n {\"title\": \"Nvidia Earnings Surprise\", \"source\": \"Reuters\", \"description\": \"AI demand surges\"}\n ]\n \n result = rank_headlines(headlines)\n \n # FED should be first due to macro impact + credibility\n assert \"FED\" in result[\"must_read\"][0][\"title\"]\n assert \"Nvidia\" in result[\"must_read\"][1][\"title\"]\n\ndef test_source_cap():\n # Test that we don't have too many items from the same source\n headlines = [\n {\"title\": f\"Story {i}\", \"source\": \"Reuters\"} for i in range(10)\n ]\n \n # Default source cap is 2\n result = rank_headlines(headlines)\n \n reuters_in_must_read = [h for h in result[\"must_read\"] if h[\"source\"] == \"Reuters\"]\n reuters_in_scan = [h for h in result[\"scan\"] if h[\"source\"] == \"Reuters\"]\n \n assert len(reuters_in_must_read) + len(reuters_in_scan) \u003c= 2\n","content_type":"text/x-python; charset=utf-8","language":"python","size":2874,"content_sha256":"d0a1a8ee986eafc64c81a6eb76bc99aa1ef59d6e284dc62878491e853b324452"},{"filename":"tests/test_setup.py","content":"\"\"\"Tests for setup wizard functionality.\"\"\"\nimport sys\nfrom pathlib import Path\n\nsys.path.insert(0, str(Path(__file__).parent.parent / \"scripts\"))\n\nimport pytest\nimport json\nfrom unittest.mock import patch\nfrom setup import load_sources, save_sources, get_default_sources, setup_language, setup_markets\n\n\ndef test_load_sources_missing_file(tmp_path, monkeypatch):\n \"\"\"Test loading non-existent sources returns defaults.\"\"\"\n sources_file = tmp_path / \"sources.json\"\n \n # Patch both path constants to use temp file\n monkeypatch.setattr(\"setup.SOURCES_FILE\", sources_file)\n \n # File doesn't exist, so load_sources should call get_default_sources\n sources = load_sources()\n \n assert isinstance(sources, dict)\n assert \"rss_feeds\" in sources # Default structure has rss_feeds\n\n\ndef test_save_sources(tmp_path, monkeypatch):\n \"\"\"Test saving sources to JSON.\"\"\"\n sources_file = tmp_path / \"sources.json\"\n monkeypatch.setattr(\"setup.SOURCES_FILE\", sources_file)\n \n sources = {\n \"rss_feeds\": {\n \"test_source\": {\n \"name\": \"Test\",\n \"enabled\": True,\n \"top\": \"https://example.com/rss\"\n }\n }\n }\n \n save_sources(sources)\n \n assert sources_file.exists()\n with open(sources_file) as f:\n saved = json.load(f)\n \n assert saved[\"rss_feeds\"][\"test_source\"][\"enabled\"] is True\n\n\ndef test_get_default_sources():\n \"\"\"Test default sources structure.\"\"\"\n sources = get_default_sources()\n \n assert isinstance(sources, dict)\n assert \"rss_feeds\" in sources\n # Should have common sources like wsj, barrons, cnbc\n feeds = sources[\"rss_feeds\"]\n assert any(\"wsj\" in k.lower() or \"barrons\" in k.lower() or \"cnbc\" in k.lower()\n for k in feeds.keys())\n\n\n@patch(\"setup.prompt\", side_effect=[\"en\"])\n@patch(\"setup.save_sources\")\ndef test_setup_language(mock_save, mock_prompt):\n \"\"\"Test language setup function.\"\"\"\n sources = {\"language\": {\"supported\": [\"en\", \"de\"], \"default\": \"de\"}}\n setup_language(sources)\n \n # Should have called prompt\n mock_prompt.assert_called()\n # Language should be updated\n assert sources[\"language\"][\"default\"] == \"en\"\n\n\n@patch(\"setup.prompt_bool\", side_effect=[True, False])\n@patch(\"setup.save_sources\")\ndef test_setup_markets(mock_save, mock_prompt):\n \"\"\"Test markets setup function.\"\"\"\n sources = {\"markets\": {\"us\": {\"enabled\": False}, \"eu\": {\"enabled\": False}}}\n setup_markets(sources)\n \n # Should have prompted (at least once for US)\n assert mock_prompt.called\n","content_type":"text/x-python; charset=utf-8","language":"python","size":2594,"content_sha256":"7408aecab67e156f1b43e2b3da6f90b9a0dc26811f2f47558158af881b7e7f9e"},{"filename":"tests/test_summarize.py","content":"\"\"\"Tests for summarize helpers.\"\"\"\nimport sys\nfrom pathlib import Path\n\nsys.path.insert(0, str(Path(__file__).parent.parent / \"scripts\"))\n\nfrom datetime import datetime\n\nimport summarize\nfrom summarize import (\n MoverContext,\n SectorCluster,\n WatchpointsData,\n build_watchpoints_data,\n classify_move_type,\n detect_sector_clusters,\n format_watchpoints,\n get_index_change,\n match_headline_to_symbol,\n)\n\n\nclass FixedDateTime(datetime):\n @classmethod\n def now(cls, tz=None):\n return cls(2026, 1, 1, 15, 0)\n\n\ndef test_generate_briefing_auto_time_evening(capsys, monkeypatch):\n def fake_market_news(*_args, **_kwargs):\n return {\n \"headlines\": [\n {\"source\": \"CNBC\", \"title\": \"Headline one\", \"link\": \"https://example.com/1\"},\n {\"source\": \"Yahoo\", \"title\": \"Headline two\", \"link\": \"https://example.com/2\"},\n {\"source\": \"CNBC\", \"title\": \"Headline three\", \"link\": \"https://example.com/3\"},\n ],\n \"markets\": {\n \"us\": {\n \"name\": \"US Markets\",\n \"indices\": {\n \"^GSPC\": {\"name\": \"S&P 500\", \"data\": {\"price\": 100, \"change_percent\": 1.0}},\n },\n }\n },\n }\n\n def fake_summary(*_args, **_kwargs):\n return \"OK\"\n\n monkeypatch.setattr(summarize, \"get_market_news\", fake_market_news)\n monkeypatch.setattr(summarize, \"get_portfolio_news\", lambda *_a, **_k: None)\n monkeypatch.setattr(summarize, \"summarize_with_claude\", fake_summary)\n monkeypatch.setattr(summarize, \"datetime\", FixedDateTime)\n\n args = type(\n \"Args\",\n (),\n {\n \"lang\": \"de\",\n \"style\": \"briefing\",\n \"time\": None,\n \"model\": \"claude\",\n \"json\": False,\n \"research\": False,\n \"deadline\": None,\n \"fast\": False,\n \"llm\": False,\n \"debug\": False,\n },\n )()\n\n summarize.generate_briefing(args)\n stdout = capsys.readouterr().out\n assert \"Börsen Abend-Briefing\" in stdout\n\n\n# --- Tests for watchpoints feature (Issue #92) ---\n\n\nclass TestGetIndexChange:\n def test_extracts_sp500_change(self):\n market_data = {\n \"markets\": {\n \"us\": {\n \"indices\": {\n \"^GSPC\": {\"data\": {\"change_percent\": -1.5}}\n }\n }\n }\n }\n assert get_index_change(market_data) == -1.5\n\n def test_returns_zero_on_missing_data(self):\n assert get_index_change({}) == 0.0\n assert get_index_change({\"markets\": {}}) == 0.0\n assert get_index_change({\"markets\": {\"us\": {}}}) == 0.0\n\n\nclass TestMatchHeadlineToSymbol:\n def test_exact_symbol_match_dollar(self):\n headlines = [{\"title\": \"Breaking: $NVDA surges on AI demand\"}]\n result = match_headline_to_symbol(\"NVDA\", \"NVIDIA Corporation\", headlines)\n assert result is not None\n assert \"NVDA\" in result[\"title\"]\n\n def test_exact_symbol_match_parens(self):\n headlines = [{\"title\": \"Tesla (TSLA) reports record deliveries\"}]\n result = match_headline_to_symbol(\"TSLA\", \"Tesla Inc\", headlines)\n assert result is not None\n\n def test_exact_symbol_match_word_boundary(self):\n headlines = [{\"title\": \"AAPL announces new product line\"}]\n result = match_headline_to_symbol(\"AAPL\", \"Apple Inc\", headlines)\n assert result is not None\n\n def test_company_name_match(self):\n headlines = [{\"title\": \"Apple announces record iPhone sales\"}]\n result = match_headline_to_symbol(\"AAPL\", \"Apple Inc\", headlines)\n assert result is not None\n\n def test_no_match_returns_none(self):\n headlines = [{\"title\": \"Fed raises interest rates\"}]\n result = match_headline_to_symbol(\"NVDA\", \"NVIDIA Corporation\", headlines)\n assert result is None\n\n def test_avoids_partial_symbol_match(self):\n # \"APP\" should not match \"application\"\n headlines = [{\"title\": \"New application launches today\"}]\n result = match_headline_to_symbol(\"APP\", \"AppLovin Corp\", headlines)\n assert result is None\n\n def test_empty_headlines(self):\n result = match_headline_to_symbol(\"NVDA\", \"NVIDIA\", [])\n assert result is None\n\n\nclass TestDetectSectorClusters:\n def test_detects_cluster_three_stocks_same_direction(self):\n movers = [\n {\"symbol\": \"NVDA\", \"change_pct\": -5.0},\n {\"symbol\": \"AMD\", \"change_pct\": -4.0},\n {\"symbol\": \"INTC\", \"change_pct\": -3.0},\n ]\n portfolio_meta = {\n \"NVDA\": {\"category\": \"Tech\"},\n \"AMD\": {\"category\": \"Tech\"},\n \"INTC\": {\"category\": \"Tech\"},\n }\n clusters = detect_sector_clusters(movers, portfolio_meta)\n assert len(clusters) == 1\n assert clusters[0].category == \"Tech\"\n assert clusters[0].direction == \"down\"\n assert len(clusters[0].stocks) == 3\n\n def test_no_cluster_if_less_than_three(self):\n movers = [\n {\"symbol\": \"NVDA\", \"change_pct\": -5.0},\n {\"symbol\": \"AMD\", \"change_pct\": -4.0},\n ]\n portfolio_meta = {\n \"NVDA\": {\"category\": \"Tech\"},\n \"AMD\": {\"category\": \"Tech\"},\n }\n clusters = detect_sector_clusters(movers, portfolio_meta)\n assert len(clusters) == 0\n\n def test_no_cluster_if_mixed_direction(self):\n movers = [\n {\"symbol\": \"NVDA\", \"change_pct\": 5.0},\n {\"symbol\": \"AMD\", \"change_pct\": -4.0},\n {\"symbol\": \"INTC\", \"change_pct\": 3.0},\n ]\n portfolio_meta = {\n \"NVDA\": {\"category\": \"Tech\"},\n \"AMD\": {\"category\": \"Tech\"},\n \"INTC\": {\"category\": \"Tech\"},\n }\n clusters = detect_sector_clusters(movers, portfolio_meta)\n assert len(clusters) == 0\n\n\nclass TestClassifyMoveType:\n def test_earnings_with_keyword(self):\n headline = {\"title\": \"Company beats Q3 earnings expectations\"}\n result = classify_move_type(headline, False, 5.0, 0.1)\n assert result == \"earnings\"\n\n def test_sector_cluster(self):\n result = classify_move_type(None, True, -3.0, -0.5)\n assert result == \"sector\"\n\n def test_market_wide(self):\n result = classify_move_type(None, False, -2.0, -2.0)\n assert result == \"market_wide\"\n\n def test_company_specific_with_headline(self):\n headline = {\"title\": \"Company announces acquisition\"}\n result = classify_move_type(headline, False, 3.0, 0.1)\n assert result == \"company_specific\"\n\n def test_company_specific_large_move_no_headline(self):\n result = classify_move_type(None, False, 8.0, 0.1)\n assert result == \"company_specific\"\n\n def test_unknown_small_move_no_context(self):\n result = classify_move_type(None, False, 1.5, 0.2)\n assert result == \"unknown\"\n\n\nclass TestFormatWatchpoints:\n def test_formats_sector_cluster(self):\n cluster = SectorCluster(\n category=\"Tech\",\n stocks=[\n MoverContext(\"NVDA\", -5.0, 100.0, \"Tech\", None, \"sector\", None, None),\n MoverContext(\"AMD\", -4.0, 80.0, \"Tech\", None, \"sector\", None, None),\n MoverContext(\"INTC\", -3.0, 30.0, \"Tech\", None, \"sector\", None, None),\n ],\n avg_change=-4.0,\n direction=\"down\",\n vs_index=-3.5,\n )\n data = WatchpointsData(\n movers=[],\n sector_clusters=[cluster],\n index_change=-0.5,\n market_wide=False,\n )\n result = format_watchpoints(data, \"en\", {})\n assert \"Tech\" in result\n assert \"-4.0%\" in result\n assert \"vs Index\" in result\n\n def test_formats_individual_mover_with_headline(self):\n mover = MoverContext(\n symbol=\"NVDA\",\n change_pct=5.0,\n price=100.0,\n category=\"Tech\",\n matched_headline={\"title\": \"NVIDIA reports record revenue\"},\n move_type=\"company_specific\",\n vs_index=4.5,\n )\n data = WatchpointsData(\n movers=[mover],\n sector_clusters=[],\n index_change=0.5,\n market_wide=False,\n )\n result = format_watchpoints(data, \"en\", {})\n assert \"NVDA\" in result\n assert \"+5.0%\" in result\n assert \"record revenue\" in result\n\n def test_formats_market_wide_move_english(self):\n data = WatchpointsData(\n movers=[],\n sector_clusters=[],\n index_change=-2.0,\n market_wide=True,\n )\n result = format_watchpoints(data, \"en\", {})\n assert \"Market-wide move\" in result\n assert \"S&P 500 fell 2.0%\" in result\n\n def test_formats_market_wide_move_german(self):\n data = WatchpointsData(\n movers=[],\n sector_clusters=[],\n index_change=2.5,\n market_wide=True,\n )\n result = format_watchpoints(data, \"de\", {})\n assert \"Breite Marktbewegung\" in result\n assert \"stieg 2.5%\" in result\n\n def test_uses_label_fallbacks(self):\n mover = MoverContext(\n symbol=\"XYZ\",\n change_pct=1.5,\n price=50.0,\n category=\"Other\",\n matched_headline=None,\n move_type=\"unknown\",\n vs_index=1.0,\n )\n data = WatchpointsData(\n movers=[mover],\n sector_clusters=[],\n index_change=0.5,\n market_wide=False,\n )\n labels = {\"no_catalyst\": \" -- no news\"}\n result = format_watchpoints(data, \"en\", labels)\n assert \"XYZ\" in result\n assert \"no news\" in result\n\n\nclass TestBuildWatchpointsData:\n def test_builds_complete_data_structure(self):\n movers = [\n {\"symbol\": \"NVDA\", \"change_pct\": -5.0, \"price\": 100.0},\n {\"symbol\": \"AMD\", \"change_pct\": -4.0, \"price\": 80.0},\n {\"symbol\": \"INTC\", \"change_pct\": -3.0, \"price\": 30.0},\n {\"symbol\": \"AAPL\", \"change_pct\": 2.0, \"price\": 150.0},\n ]\n headlines = [\n {\"title\": \"NVIDIA reports weak guidance\"},\n {\"title\": \"Apple announces new product\"},\n ]\n portfolio_meta = {\n \"NVDA\": {\"category\": \"Tech\", \"name\": \"NVIDIA Corporation\"},\n \"AMD\": {\"category\": \"Tech\", \"name\": \"Advanced Micro Devices\"},\n \"INTC\": {\"category\": \"Tech\", \"name\": \"Intel Corporation\"},\n \"AAPL\": {\"category\": \"Tech\", \"name\": \"Apple Inc\"},\n }\n index_change = -0.5\n\n result = build_watchpoints_data(movers, headlines, portfolio_meta, index_change)\n\n # Should detect Tech sector cluster (3 losers)\n assert len(result.sector_clusters) == 1\n assert result.sector_clusters[0].category == \"Tech\"\n assert result.sector_clusters[0].direction == \"down\"\n\n # All movers should be present\n assert len(result.movers) == 4\n\n # NVDA should have matched headline\n nvda_mover = next(m for m in result.movers if m.symbol == \"NVDA\")\n assert nvda_mover.matched_headline is not None\n assert \"guidance\" in nvda_mover.matched_headline[\"title\"]\n\n # vs_index should be calculated\n assert nvda_mover.vs_index == -5.0 - (-0.5) # -4.5\n\n def test_handles_empty_movers(self):\n result = build_watchpoints_data([], [], {}, 0.0)\n assert result.movers == []\n assert result.sector_clusters == []\n assert result.market_wide is False\n\n def test_detects_market_wide_move(self):\n result = build_watchpoints_data([], [], {}, -2.0)\n assert result.market_wide is True\n","content_type":"text/x-python; charset=utf-8","language":"python","size":11777,"content_sha256":"eb917d6ad31efa574bdf7c1139035b36dc240ce12860d9bfce0de68aa485a8a9"},{"filename":"workflows/alerts-cron.yaml","content":"# Price Alerts Workflow for Cron (No Approval Gate)\n# Usage: lobster run --file workflows/alerts-cron.yaml --args-json '{\"lang\":\"en\"}'\n#\n# Schedule: 2:00 PM PT / 5:00 PM ET (1 hour after market close)\n# Checks price alerts against current prices (including after-hours)\n\nname: finance.alerts.cron\ndescription: Check price alerts and send triggered alerts to WhatsApp/Telegram\n\nargs:\n lang:\n default: en\n description: \"Language: en or de\"\n channel:\n default: \"${FINANCE_NEWS_CHANNEL:-whatsapp}\"\n description: \"Delivery channel: whatsapp or telegram\"\n target:\n default: \"${FINANCE_NEWS_TARGET}\"\n description: \"Target: group name, JID, or chat ID\"\n\nsteps:\n # Check alerts against current prices\n - id: check_alerts\n command: |\n SKILL_DIR=\"${SKILL_DIR:-$HOME/projects/skills/personal/finance-news}\"\n python3 \"$SKILL_DIR/scripts/alerts.py\" check --lang \"${lang}\"\n description: Check price alerts against current prices\n\n # Send alert message if there's content\n - id: send_alerts\n command: |\n MSG=$(cat)\n MSG=$(echo \"$MSG\" | tr -d '\\r')\n # Only send if message has actual content (not just \"No price data\" message)\n if echo \"$MSG\" | grep -q \"IN BUY ZONE\\|IN KAUFZONE\\|WATCHING\\|BEOBACHTUNG\"; then\n openclaw message send \\\n --channel \"${channel}\" \\\n --target \"${target}\" \\\n --message \"$MSG\"\n echo \"Sent price alerts to ${channel}\"\n else\n echo \"No triggered alerts or watchlist items to send\"\n fi\n stdin: $check_alerts.stdout\n description: Send price alerts to channel\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":1590,"content_sha256":"823cd766f6f63292c7e06707d16d4f445a42d8023853062f0327b0988e27b365"},{"filename":"workflows/briefing-cron.yaml","content":"# Finance Briefing Workflow for Cron (No Approval Gate)\n# Usage: lobster run --file workflows/briefing-cron.yaml --args-json '{\"time\":\"morning\",\"lang\":\"de\"}'\n#\n# This workflow:\n# 1. Generates a market briefing via Docker\n# 2. Translates portfolio headlines (German)\n# 3. Sends directly to messaging channel (no approval)\n\nname: finance.briefing.cron\ndescription: Generate market briefing and send to WhatsApp/Telegram (auto-approve for cron)\n\nargs:\n time:\n default: morning\n description: \"Briefing type: morning or evening\"\n lang:\n default: de\n description: \"Language: en or de\"\n channel:\n default: \"${FINANCE_NEWS_CHANNEL:-whatsapp}\"\n description: \"Delivery channel: whatsapp or telegram\"\n target:\n default: \"${FINANCE_NEWS_TARGET}\"\n description: \"Target: group name, JID, phone number, or Telegram chat ID (requires FINANCE_NEWS_TARGET env var if not specified)\"\n fast:\n default: \"false\"\n description: \"Use fast mode: true or false\"\n\nsteps:\n # Generate briefing and save to temp file\n - id: generate\n command: |\n SKILL_DIR=\"${SKILL_DIR:-$HOME/projects/skills/personal/finance-news}\"\n FAST_FLAG=\"\"\n if [ \"${fast}\" = \"true\" ]; then FAST_FLAG=\"--fast\"; fi\n OUTFILE=\"/tmp/lobster-briefing-$.json\"\n # Resolve openbb-quote symlink for Docker mount\n OPENBB_BIN=$(realpath \"$HOME/.local/bin/openbb-quote\" 2>/dev/null || echo \"\")\n OPENBB_MOUNT=\"\"\n if [ -f \"$OPENBB_BIN\" ]; then\n OPENBB_MOUNT=\"-v $OPENBB_BIN:/usr/local/bin/openbb-quote:ro\"\n fi\n docker run --rm \\\n -v \"$SKILL_DIR/config:/app/config:ro\" \\\n -v \"$SKILL_DIR/scripts:/app/scripts:ro\" \\\n $OPENBB_MOUNT \\\n finance-news-briefing python3 scripts/briefing.py \\\n --time \"${time}\" \\\n --lang \"${lang}\" \\\n --json \\\n $FAST_FLAG > \"$OUTFILE\"\n # Output the file path for subsequent steps\n echo \"$OUTFILE\"\n description: Generate briefing via Docker\n\n # Translate portfolio headlines (if German)\n - id: translate\n command: |\n OUTFILE=$(cat)\n OUTFILE=$(echo \"$OUTFILE\" | tr -d '\\n')\n SKILL_DIR=\"${SKILL_DIR:-$HOME/projects/skills/personal/finance-news}\"\n if [ \"${lang}\" = \"de\" ]; then\n python3 \"$SKILL_DIR/scripts/translate_portfolio.py\" \"$OUTFILE\" --lang de || true\n fi\n echo \"$OUTFILE\"\n stdin: $generate.stdout\n description: Translate portfolio headlines via openclaw\n\n # Send macro briefing (market overview) - NO APPROVAL GATE\n - id: send_macro\n command: |\n OUTFILE=$(cat)\n OUTFILE=$(echo \"$OUTFILE\" | tr -d '\\n')\n MSG=$(jq -r '.macro_message // empty' \"$OUTFILE\")\n if [ -n \"$MSG\" ]; then\n openclaw message send \\\n --channel \"${channel}\" \\\n --target \"${target}\" \\\n --message \"$MSG\"\n else\n echo \"No macro message to send\"\n fi\n stdin: $translate.stdout\n description: Send macro briefing\n\n # Send portfolio briefing (stock movers)\n - id: send_portfolio\n command: |\n OUTFILE=$(cat)\n OUTFILE=$(echo \"$OUTFILE\" | tr -d '\\n')\n MSG=$(jq -r '.portfolio_message // empty' \"$OUTFILE\")\n if [ -n \"$MSG\" ]; then\n openclaw message send \\\n --channel \"${channel}\" \\\n --target \"${target}\" \\\n --message \"$MSG\"\n else\n echo \"No portfolio message to send\"\n fi\n stdin: $translate.stdout\n description: Send portfolio briefing\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3432,"content_sha256":"a5e6971cb7bf6b1211041a4b5a1ca48738d46b20f8dddaf908bee06db035b741"},{"filename":"workflows/briefing.yaml","content":"# Finance Briefing Workflow for Lobster\n# Usage: lobster \"workflows.run --file workflows/briefing.yaml --args-json '{\\\"time\\\":\\\"morning\\\",\\\"lang\\\":\\\"de\\\"}'\"\n#\n# This workflow:\n# 1. Generates a market briefing via Docker\n# 2. Halts for approval before sending\n# 3. Sends to messaging channel after approval\n\nname: finance.briefing\ndescription: Generate market briefing and send to WhatsApp/Telegram with approval gate\n\nargs:\n time:\n default: morning\n description: \"Briefing type: morning or evening\"\n lang:\n default: de\n description: \"Language: en or de\"\n channel:\n default: \"${FINANCE_NEWS_CHANNEL:-whatsapp}\"\n description: \"Delivery channel: whatsapp or telegram\"\n target:\n default: \"${FINANCE_NEWS_TARGET}\"\n description: \"Target: group name, JID, phone number, or Telegram chat ID (requires FINANCE_NEWS_TARGET env var if not specified)\"\n fast:\n default: \"false\"\n description: \"Use fast mode: true or false\"\n\nsteps:\n # Generate briefing and save to temp file\n - id: generate\n command: |\n SKILL_DIR=\"${SKILL_DIR:-$HOME/projects/skills/personal/finance-news}\"\n FAST_FLAG=\"\"\n if [ \"${fast}\" = \"true\" ]; then FAST_FLAG=\"--fast\"; fi\n OUTFILE=\"/tmp/lobster-briefing-$.json\"\n # Resolve openbb-quote symlink for Docker mount\n OPENBB_BIN=$(realpath \"$HOME/.local/bin/openbb-quote\" 2>/dev/null || echo \"\")\n OPENBB_MOUNT=\"\"\n if [ -f \"$OPENBB_BIN\" ]; then\n OPENBB_MOUNT=\"-v $OPENBB_BIN:/usr/local/bin/openbb-quote:ro\"\n fi\n docker run --rm \\\n -v \"$SKILL_DIR/config:/app/config:ro\" \\\n -v \"$SKILL_DIR/scripts:/app/scripts:ro\" \\\n $OPENBB_MOUNT \\\n finance-news-briefing python3 scripts/briefing.py \\\n --time \"${time}\" \\\n --lang \"${lang}\" \\\n --json \\\n $FAST_FLAG > \"$OUTFILE\"\n # Output the file path for subsequent steps\n echo \"$OUTFILE\"\n description: Generate briefing via Docker\n\n # Translate portfolio headlines (if German)\n - id: translate\n command: |\n OUTFILE=$(cat)\n OUTFILE=$(echo \"$OUTFILE\" | tr -d '\\n')\n SKILL_DIR=\"${SKILL_DIR:-$HOME/projects/skills/personal/finance-news}\"\n if [ \"${lang}\" = \"de\" ]; then\n python3 \"$SKILL_DIR/scripts/translate_portfolio.py\" \"$OUTFILE\" --lang de || true\n fi\n echo \"$OUTFILE\"\n stdin: $generate.stdout\n description: Translate portfolio headlines via openclaw\n\n # Approval gate - workflow halts here until user approves\n - id: approve\n approval: required\n command: |\n OUTFILE=$(cat)\n echo \"Briefing saved to: $OUTFILE\"\n echo \"Target: ${target}\"\n echo \"Channel: ${channel}\"\n cat \"$OUTFILE\" | jq -r '.macro_message' | head -20\n echo \"...\"\n echo \"Review above. Approve to send.\"\n stdin: $translate.stdout\n description: Approval gate before message delivery\n\n # Send macro briefing (market overview)\n - id: send_macro\n command: |\n OUTFILE=$(cat)\n OUTFILE=$(echo \"$OUTFILE\" | tr -d '\\n')\n MSG=$(jq -r '.macro_message // empty' \"$OUTFILE\")\n if [ -n \"$MSG\" ]; then\n openclaw message send \\\n --channel \"${channel}\" \\\n --target \"${target}\" \\\n --message \"$MSG\"\n else\n echo \"No macro message to send\"\n fi\n stdin: $translate.stdout\n description: Send macro briefing\n\n # Send portfolio briefing (stock movers)\n - id: send_portfolio\n command: |\n OUTFILE=$(cat)\n OUTFILE=$(echo \"$OUTFILE\" | tr -d '\\n')\n MSG=$(jq -r '.portfolio_message // empty' \"$OUTFILE\")\n if [ -n \"$MSG\" ]; then\n openclaw message send \\\n --channel \"${channel}\" \\\n --target \"${target}\" \\\n --message \"$MSG\"\n else\n echo \"No portfolio message to send\"\n fi\n stdin: $translate.stdout\n description: Send portfolio briefing\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3831,"content_sha256":"c8db23e0ac4e0978c5d21ba26d751843623e5ab3a4b3a638cd3092297c510c34"},{"filename":"workflows/earnings-cron.yaml","content":"# Earnings Alert Workflow for Cron (No Approval Gate)\n# Usage: lobster run --file workflows/earnings-cron.yaml --args-json '{\"lang\":\"en\"}'\n#\n# Schedule: 6:00 AM PT / 9:00 AM ET (30 min before market open)\n# Sends today's earnings calendar to WhatsApp/Telegram\n\nname: finance.earnings.cron\ndescription: Send earnings alerts for today's reports\n\nargs:\n lang:\n default: en\n description: \"Language: en or de\"\n channel:\n default: \"${FINANCE_NEWS_CHANNEL:-whatsapp}\"\n description: \"Delivery channel: whatsapp or telegram\"\n target:\n default: \"${FINANCE_NEWS_TARGET}\"\n description: \"Target: group name, JID, or chat ID\"\n\nsteps:\n # Check earnings calendar for today and this week\n - id: check_earnings\n command: |\n SKILL_DIR=\"${SKILL_DIR:-$HOME/projects/skills/personal/finance-news}\"\n python3 \"$SKILL_DIR/scripts/earnings.py\" check --lang \"${lang}\"\n description: Get today's earnings calendar\n\n # Send earnings alert if there's content\n - id: send_earnings\n command: |\n MSG=$(cat)\n MSG=$(echo \"$MSG\" | tr -d '\\r')\n # Only send if there are actual earnings today\n if echo \"$MSG\" | grep -q \"EARNINGS TODAY\\|EARNINGS HEUTE\"; then\n openclaw message send \\\n --channel \"${channel}\" \\\n --target \"${target}\" \\\n --message \"$MSG\"\n echo \"Sent earnings alert to ${channel}\"\n else\n echo \"No earnings today - skipping message\"\n fi\n stdin: $check_earnings.stdout\n description: Send earnings alert to channel\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":1513,"content_sha256":"383f04ef8a2e1e1ec569e9132ba3dfd026ad4c95c79a4981e2f70fbc30f75c3a"},{"filename":"workflows/earnings-weekly-cron.yaml","content":"# Weekly Earnings Alert Workflow for Cron (No Approval Gate)\n# Usage: lobster run --file workflows/earnings-weekly-cron.yaml --args-json '{\"lang\":\"en\"}'\n#\n# Schedule: Sunday 7:00 AM PT (before market week starts)\n# Sends upcoming week's earnings calendar to WhatsApp/Telegram\n\nname: finance.earnings.weekly.cron\ndescription: Send weekly earnings preview for portfolio stocks\n\nargs:\n lang:\n default: en\n description: \"Language: en or de\"\n channel:\n default: \"${FINANCE_NEWS_CHANNEL:-whatsapp}\"\n description: \"Delivery channel: whatsapp or telegram\"\n target:\n default: \"${FINANCE_NEWS_TARGET}\"\n description: \"Target: group name, JID, or chat ID\"\n\nsteps:\n # Check earnings calendar for upcoming week\n - id: check_earnings\n command: |\n SKILL_DIR=\"${SKILL_DIR:-$HOME/projects/skills/personal/finance-news}\"\n python3 \"$SKILL_DIR/scripts/earnings.py\" check --week --lang \"${lang}\"\n description: Get upcoming week's earnings calendar\n\n # Send earnings alert if there's content\n - id: send_earnings\n command: |\n MSG=$(cat)\n MSG=$(echo \"$MSG\" | tr -d '\\r')\n # Only send if there are actual earnings next week\n if echo \"$MSG\" | grep -qE \"EARNINGS (NEXT WEEK|NÄCHSTE WOCHE)\"; then\n openclaw message send \\\n --channel \"${channel}\" \\\n --target \"${target}\" \\\n --message \"$MSG\"\n echo \"Sent weekly earnings preview to ${channel}\"\n else\n echo \"No earnings next week - skipping message\"\n fi\n stdin: $check_earnings.stdout\n description: Send weekly earnings alert to channel\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":1584,"content_sha256":"9115be2b90a277c71834feebc584b9b7aa468df0d0d877cefd9eb5417508906f"},{"filename":"workflows/README.md","content":"# Lobster Workflows\n\nThis directory contains [Lobster](https://github.com/openclaw/lobster) workflow definitions for the finance-news skill.\n\n## Available Workflows\n\n### `briefing.yaml` - Market Briefing with Approval\n\nGenerates a market briefing and sends to WhatsApp with an approval gate.\n\n**Usage:**\n```bash\n# Run via Lobster CLI\nlobster \"workflows.run --file ~/projects/finance-news-openclaw-skill/workflows/briefing.yaml\"\n\n# With custom args\nlobster \"workflows.run --file workflows/briefing.yaml --args-json '{\\\"time\\\":\\\"evening\\\",\\\"lang\\\":\\\"en\\\"}'\"\n```\n\n**Arguments:**\n| Arg | Default | Description |\n|-----|---------|-------------|\n| `time` | `morning` | Briefing type: `morning` or `evening` |\n| `lang` | `de` | Language: `en` or `de` |\n| `channel` | `whatsapp` | Delivery channel: `whatsapp` or `telegram` |\n| `target` | env var | Group name, JID, phone number, or Telegram chat ID |\n| `fast` | `false` | Use fast mode (shorter timeouts) |\n\n**Environment Variables:**\n| Variable | Description |\n|----------|-------------|\n| `FINANCE_NEWS_CHANNEL` | Default channel: `whatsapp` or `telegram` |\n| `FINANCE_NEWS_TARGET` | Default target (group name, phone, chat ID) |\n\n**Examples:**\n```bash\n# WhatsApp group (default)\nlobster \"workflows.run --file workflows/briefing.yaml\"\n\n# Telegram group\nlobster \"workflows.run --file workflows/briefing.yaml --args-json '{\\\"channel\\\":\\\"telegram\\\",\\\"target\\\":\\\"-1001234567890\\\"}'\"\n\n# WhatsApp DM to phone number\nlobster \"workflows.run --file workflows/briefing.yaml --args-json '{\\\"target\\\":\\\"+15551234567\\\"}'\"\n\n# Telegram DM to user\nlobster \"workflows.run --file workflows/briefing.yaml --args-json '{\\\"channel\\\":\\\"telegram\\\",\\\"target\\\":\\\"@username\\\"}'\"\n```\n\n**Flow:**\n1. **Generate** - Runs Docker container to produce briefing JSON\n2. **Approve** - Halts for human review (shows briefing preview)\n3. **Send** - Delivers to channel (WhatsApp/Telegram) after approval\n\n**Requirements:**\n- Docker with `finance-news-briefing` image built\n- `jq` for JSON parsing\n- `openclaw` CLI for message delivery\n\n## Adding to Lobster Registry\n\nTo make these workflows available as named workflows in Lobster:\n\n```typescript\n// In lobster/src/workflows/registry.ts\nexport const workflowRegistry = {\n // ... existing workflows\n 'finance.briefing': {\n name: 'finance.briefing',\n description: 'Generate market briefing with approval gate for WhatsApp/Telegram',\n argsSchema: {\n type: 'object',\n properties: {\n time: { type: 'string', enum: ['morning', 'evening'], default: 'morning' },\n lang: { type: 'string', enum: ['en', 'de'], default: 'de' },\n channel: { type: 'string', enum: ['whatsapp', 'telegram'], default: 'whatsapp' },\n target: { type: 'string', description: 'Group name, JID, phone, or chat ID' },\n fast: { type: 'boolean', default: false },\n },\n },\n examples: [\n { args: { time: 'morning', lang: 'de' }, description: 'German morning briefing to WhatsApp' },\n { args: { channel: 'telegram', target: '-1001234567890' }, description: 'Send to Telegram group' },\n ],\n sideEffects: ['message.send'],\n },\n};\n```\n\n## Why Lobster?\n\nUsing Lobster instead of direct cron execution provides:\n\n- **Approval gates** - Review briefing before it's sent\n- **Resumability** - If interrupted, continue from last step\n- **Token efficiency** - One workflow call vs. multiple LLM tool calls\n- **Determinism** - Same inputs = same outputs\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":3442,"content_sha256":"dd62e0e7f8acdc3c3281e727eaeb1e09ad43a4e3bc7368b9b4c978eb5973b827"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Finance News Skill","type":"text"}]},{"type":"paragraph","content":[{"text":"AI-powered market news briefings with configurable language output and automated delivery.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"First-Time Setup","type":"text"}]},{"type":"paragraph","content":[{"text":"Run the interactive setup wizard to configure your sources, delivery channels, and schedule:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"finance-news setup","type":"text"}]},{"type":"paragraph","content":[{"text":"The wizard will guide you through:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"📰 ","type":"text"},{"text":"RSS Feeds:","type":"text","marks":[{"type":"strong"}]},{"text":" Enable/disable WSJ, Barron's, CNBC, Yahoo, etc.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"📊 ","type":"text"},{"text":"Markets:","type":"text","marks":[{"type":"strong"}]},{"text":" Choose regions (US, Europe, Japan, Asia)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"📤 ","type":"text"},{"text":"Delivery:","type":"text","marks":[{"type":"strong"}]},{"text":" Configure WhatsApp/Telegram group","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"🌐 ","type":"text"},{"text":"Language:","type":"text","marks":[{"type":"strong"}]},{"text":" Set default language (English/German)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"⏰ ","type":"text"},{"text":"Schedule:","type":"text","marks":[{"type":"strong"}]},{"text":" Configure morning/evening cron times","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"You can also configure specific sections:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"finance-news setup --section feeds # Just RSS feeds\nfinance-news setup --section delivery # Just delivery channels\nfinance-news setup --section schedule # Just cron schedule\nfinance-news setup --reset # Reset to defaults\nfinance-news config # Show current config","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick Start","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Generate morning briefing\nfinance-news briefing --morning\n\n# View market overview\nfinance-news market\n\n# Get news for your portfolio\nfinance-news portfolio\n\n# Get news for specific stock\nfinance-news news AAPL","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Features","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"📊 Market Coverage","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"US Markets:","type":"text","marks":[{"type":"strong"}]},{"text":" S&P 500, Dow Jones, NASDAQ","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Europe:","type":"text","marks":[{"type":"strong"}]},{"text":" DAX, STOXX 50, FTSE 100","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Japan:","type":"text","marks":[{"type":"strong"}]},{"text":" Nikkei 225","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"📰 News Sources","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Premium:","type":"text","marks":[{"type":"strong"}]},{"text":" WSJ, Barron's (RSS feeds)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Free:","type":"text","marks":[{"type":"strong"}]},{"text":" CNBC, Yahoo Finance, Finnhub","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Portfolio:","type":"text","marks":[{"type":"strong"}]},{"text":" Ticker-specific news from Yahoo","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"🤖 AI Summaries","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Gemini-powered analysis","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Configurable language (English/German)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Briefing styles: summary, analysis, headlines","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"📅 Automated Briefings","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Morning:","type":"text","marks":[{"type":"strong"}]},{"text":" 6:30 AM PT (US market open)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Evening:","type":"text","marks":[{"type":"strong"}]},{"text":" 1:00 PM PT (US market close)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Delivery:","type":"text","marks":[{"type":"strong"}]},{"text":" WhatsApp (configure group in cron scripts)","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Commands","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Briefing Generation","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Morning briefing (English is default)\nfinance-news briefing --morning\n\n# Evening briefing with WhatsApp delivery\nfinance-news briefing --evening --send --group \"Market Briefing\"\n\n# German language option\nfinance-news briefing --morning --lang de\n\n# Analysis style (more detailed)\nfinance-news briefing --style analysis","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Market Data","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Market overview (indices + top headlines)\nfinance-news market\n\n# JSON output for processing\nfinance-news market --json","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Portfolio Management","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# List portfolio\nfinance-news portfolio-list\n\n# Add stock\nfinance-news portfolio-add NVDA --name \"NVIDIA Corporation\" --category Tech\n\n# Remove stock\nfinance-news portfolio-remove TSLA\n\n# Import from CSV\nfinance-news portfolio-import ~/my_stocks.csv\n\n# Interactive portfolio creation\nfinance-news portfolio-create","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Ticker News","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# News for specific stock\nfinance-news news AAPL\nfinance-news news TSLA","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Configuration","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Portfolio CSV Format","type":"text"}]},{"type":"paragraph","content":[{"text":"Location: ","type":"text"},{"text":"~/clawd/skills/finance-news/config/portfolio.csv","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"csv"},"content":[{"text":"symbol,name,category,notes\nAAPL,Apple Inc.,Tech,Core holding\nNVDA,NVIDIA Corporation,Tech,AI play\nMSFT,Microsoft Corporation,Tech,","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Sources Configuration","type":"text"}]},{"type":"paragraph","content":[{"text":"Location: ","type":"text"},{"text":"~/clawd/skills/finance-news/config/config.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (legacy fallback: ","type":"text"},{"text":"config/sources.json","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"RSS feeds for WSJ, Barron's, CNBC, Yahoo","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Market indices by region","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Language settings","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Cron Jobs","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Setup via OpenClaw","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Add morning briefing cron job\nopenclaw cron add --schedule \"30 6 * * 1-5\" \\\n --timezone \"America/Los_Angeles\" \\\n --command \"bash ~/clawd/skills/finance-news/cron/morning.sh\"\n\n# Add evening briefing cron job\nopenclaw cron add --schedule \"0 13 * * 1-5\" \\\n --timezone \"America/Los_Angeles\" \\\n --command \"bash ~/clawd/skills/finance-news/cron/evening.sh\"","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Manual Cron (crontab)","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"cron"},"content":[{"text":"# Morning briefing (6:30 AM PT, weekdays)\n30 6 * * 1-5 bash ~/clawd/skills/finance-news/cron/morning.sh\n\n# Evening briefing (1:00 PM PT, weekdays)\n0 13 * * 1-5 bash ~/clawd/skills/finance-news/cron/evening.sh","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Sample Output","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"markdown"},"content":[{"text":"🌅 **Börsen-Morgen-Briefing**\nDienstag, 21. Januar 2026 | 06:30 Uhr\n\n📊 **Märkte**\n• S&P 500: 5.234 (+0,3%)\n• DAX: 16.890 (-0,1%)\n• Nikkei: 35.678 (+0,5%)\n\n📈 **Dein Portfolio**\n• AAPL $256 (+1,2%) — iPhone-Verkäufe übertreffen Erwartungen\n• NVDA $512 (+3,4%) — KI-Chip-Nachfrage steigt\n\n🔥 **Top Stories**\n• [WSJ] Fed signalisiert mögliche Zinssenkung im März\n• [CNBC] Tech-Sektor führt Rally an\n\n🤖 **Analyse**\nDer S&P zeigt Stärke. Dein Portfolio profitiert von NVDA's \nMomentum. Fed-Kommentare könnten Volatilität auslösen.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Integration","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"With OpenBB (existing skill)","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Get detailed quote, then news\nopenbb-quote AAPL && finance-news news AAPL","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"With OpenClaw Agent","type":"text"}]},{"type":"paragraph","content":[{"text":"The agent will automatically use this skill when asked about:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"What's the market doing?\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"News for my portfolio\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Generate morning briefing\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"What's happening with AAPL?\"","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"With Lobster (Workflow Engine)","type":"text"}]},{"type":"paragraph","content":[{"text":"Run briefings via ","type":"text"},{"text":"Lobster","type":"text","marks":[{"type":"link","attrs":{"href":"https://github.com/openclaw/lobster","title":null}}]},{"text":" for approval gates and resumability:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Run with approval before WhatsApp send\nlobster \"workflows.run --file workflows/briefing.yaml\"\n\n# With custom args\nlobster \"workflows.run --file workflows/briefing.yaml --args-json '{\\\"time\\\":\\\"evening\\\",\\\"lang\\\":\\\"en\\\"}'\"","type":"text"}]},{"type":"paragraph","content":[{"text":"See ","type":"text"},{"text":"workflows/README.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for full documentation.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Files","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"skills/finance-news/\n├── SKILL.md # This documentation\n├── Dockerfile # NixOS-compatible container\n├── config/\n│ ├── portfolio.csv # Your watchlist\n│ ├── config.json # RSS/API/language configuration\n│ ├── alerts.json # Price target alerts\n│ └── manual_earnings.json # Earnings calendar overrides\n├── scripts/\n│ ├── finance-news # Main CLI\n│ ├── briefing.py # Briefing generator\n│ ├── fetch_news.py # News aggregator\n│ ├── portfolio.py # Portfolio CRUD\n│ ├── summarize.py # AI summarization\n│ ├── alerts.py # Price alert management\n│ ├── earnings.py # Earnings calendar\n│ ├── ranking.py # Headline ranking\n│ └── stocks.py # Stock management\n├── workflows/\n│ ├── briefing.yaml # Lobster workflow with approval gate\n│ └── README.md # Workflow documentation\n├── cron/\n│ ├── morning.sh # Morning cron (Docker-based)\n│ └── evening.sh # Evening cron (Docker-based)\n└── cache/ # 15-minute news cache","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Dependencies","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Python 3.10+","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"feedparser","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"pip install feedparser","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Gemini CLI (","type":"text"},{"text":"brew install gemini-cli","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"OpenBB (existing ","type":"text"},{"text":"openbb-quote","type":"text","marks":[{"type":"code_inline"}]},{"text":" wrapper)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"OpenClaw message tool (for WhatsApp delivery)","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Troubleshooting","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Gemini not working","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Authenticate Gemini\ngemini # Follow login flow","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"RSS feeds timing out","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Check network connectivity","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"WSJ/Barron's may require subscription cookies for some content","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Free feeds (CNBC, Yahoo) should always work","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"WhatsApp delivery failing","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Verify WhatsApp group exists and bot has access","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Check ","type":"text"},{"text":"openclaw doctor","type":"text","marks":[{"type":"code_inline"}]},{"text":" for WhatsApp status","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"finance-news","author":"@skillopedia","source":{"stars":609,"repo_name":"awesome-openclaw-skills","origin_url":"https://github.com/sundial-org/awesome-openclaw-skills/blob/HEAD/skills/finance-news/SKILL.md","repo_owner":"sundial-org","body_sha256":"a11d8971f620253709f73e9888a0933198f0a90a66a63848a70296d80b5e03d7","cluster_key":"d478ed1574d2efdd667354246c1bdccace76331ecba4050118c7608aac7fe01b","clean_bundle":{"format":"clean-skill-bundle-v1","source":"sundial-org/awesome-openclaw-skills/skills/finance-news/SKILL.md","attachments":[{"id":"92607cd1-1fb5-56d1-aa8a-2026f120bc8e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/92607cd1-1fb5-56d1-aa8a-2026f120bc8e/attachment.md","path":"README.md","size":4573,"sha256":"0a796d5e40cbc784488f6e94d9576f114fec249cd2a83bd506374060befd8c74","contentType":"text/markdown; charset=utf-8"},{"id":"5ca1453a-53f6-530e-813d-3ad5772a0745","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5ca1453a-53f6-530e-813d-3ad5772a0745/attachment.json","path":"config/config.json","size":7899,"sha256":"51392864a3de2bc26abf5a61ec3503e6c2faceda19138bdc36c70505d08c2468","contentType":"application/json; charset=utf-8"},{"id":"1064a605-58e1-56c9-8fae-ced680c45e36","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1064a605-58e1-56c9-8fae-ced680c45e36/attachment.json","path":"config/manual_earnings.json","size":5914,"sha256":"3cfc48d18531193ed9076f41fc5a1f7c4619e1a91bdeb94f321acc5c0772be40","contentType":"application/json; charset=utf-8"},{"id":"84f8403b-6894-5eec-85e7-1018dd14c2da","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/84f8403b-6894-5eec-85e7-1018dd14c2da/attachment.sh","path":"cron/alerts.sh","size":677,"sha256":"1ca24d22a49943ff7ec20264e4b6e8836e2d29cbaacaf20fd1cb29715ccc57fe","contentType":"application/x-sh; charset=utf-8"},{"id":"0181b77c-5666-5557-b87f-d7fa7d71a8aa","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0181b77c-5666-5557-b87f-d7fa7d71a8aa/attachment.sh","path":"cron/earnings-weekly.sh","size":676,"sha256":"3cc4a2de14fa83f7373bf41138aa9094e6d73590cbea794003501d4f6ed95951","contentType":"application/x-sh; charset=utf-8"},{"id":"56b3cc09-968f-590d-8d0c-f1d7c0233d0c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/56b3cc09-968f-590d-8d0c-f1d7c0233d0c/attachment.sh","path":"cron/earnings.sh","size":656,"sha256":"469202e28cd43c92d3ee0db8ec7df0023fab4570b197019a49cd78e5839ac8b6","contentType":"application/x-sh; charset=utf-8"},{"id":"e34e063d-09b8-5455-92bc-3d835efd6ef6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e34e063d-09b8-5455-92bc-3d835efd6ef6/attachment.sh","path":"cron/evening.sh","size":679,"sha256":"51e0c88bb884ff6ed48bc27cc179f1d5c67c9012a97744f21c81d9d30aee8a17","contentType":"application/x-sh; charset=utf-8"},{"id":"dbbcad02-4c4f-50b2-8f4d-dc437b82a639","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/dbbcad02-4c4f-50b2-8f4d-dc437b82a639/attachment.sh","path":"cron/morning.sh","size":678,"sha256":"359f2e348f54e62a14a8108c1511681c551a00d320fccbf81bb8d654c6eeb63e","contentType":"application/x-sh; charset=utf-8"},{"id":"5bfedf27-be61-535d-9673-2f0a26db9e8d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5bfedf27-be61-535d-9673-2f0a26db9e8d/attachment.md","path":"docs/EQUITY_SHEET_FIXES.md","size":4888,"sha256":"3ecadbb2d46b1ee3ae7850f6083178a9b05d7c1abe15ca975d32cfd5be0be911","contentType":"text/markdown; charset=utf-8"},{"id":"be79417d-c8b0-5f14-adae-eece549a5971","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/be79417d-c8b0-5f14-adae-eece549a5971/attachment.md","path":"docs/PREMIUM_SOURCES.md","size":6310,"sha256":"c74f74630d96aa97e1994b64b1321bdd1f154d27b053c8cd6357e0821b8b962b","contentType":"text/markdown; charset=utf-8"},{"id":"bb6f01cc-6bfe-56c3-9580-f7f13c2eda16","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bb6f01cc-6bfe-56c3-9580-f7f13c2eda16/attachment.ini","path":"pytest.ini","size":225,"sha256":"6e2f42aa12eb236456a785480d4ae12ab52c5874d66a4b1f9e9d96e01613e600","contentType":"text/plain; charset=utf-8"},{"id":"56510fc0-da18-5b41-baac-fc0d6f1d0191","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/56510fc0-da18-5b41-baac-fc0d6f1d0191/attachment.txt","path":"requirements-test.txt","size":72,"sha256":"b3cea0ce14fdb65f9173d87dbbd392c395ffad86aba4147d378c25594bb2878f","contentType":"text/plain; charset=utf-8"},{"id":"b35dc59a-049c-55d7-9815-568ad8482edc","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b35dc59a-049c-55d7-9815-568ad8482edc/attachment.txt","path":"requirements.txt","size":28,"sha256":"feb113cedfce019008461a33c9bc38913e30e89a7ecfdfac8f5c0c1fc90ea782","contentType":"text/plain; charset=utf-8"},{"id":"ae013a3d-b544-5839-8b3c-8e9900ec6a19","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ae013a3d-b544-5839-8b3c-8e9900ec6a19/attachment.py","path":"scripts/alerts.py","size":16265,"sha256":"711d75a5577c8e6f55f537db3de6575faca002f74d3214d5a61a1164729ef6d1","contentType":"text/x-python; charset=utf-8"},{"id":"f0022dfe-02f6-5661-8ce0-2346eec16047","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f0022dfe-02f6-5661-8ce0-2346eec16047/attachment.py","path":"scripts/briefing.py","size":5593,"sha256":"d6a53eab6a1c68726b9bf91045f720ad98ee4d2916d89e93b64297e5dbf15c5f","contentType":"text/x-python; charset=utf-8"},{"id":"712000d4-c636-501a-bbc2-0c2efb45a0c4","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/712000d4-c636-501a-bbc2-0c2efb45a0c4/attachment.py","path":"scripts/earnings.py","size":20899,"sha256":"82d3af9d4bc4d0278675f21bc4160e2b9d577545e513089a0e568a97f1b09033","contentType":"text/x-python; charset=utf-8"},{"id":"c5ff0cc9-5452-5d76-9267-c26b8e3fc322","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c5ff0cc9-5452-5d76-9267-c26b8e3fc322/attachment.py","path":"scripts/fetch_news.py","size":38799,"sha256":"e27bf70696c3a600593ce33409491ee3f4a19dd1e50d81cbcfe827e6f628b971","contentType":"text/x-python; charset=utf-8"},{"id":"455cfcd7-eada-55a5-b920-286648730c7e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/455cfcd7-eada-55a5-b920-286648730c7e/attachment.py","path":"scripts/portfolio.py","size":10024,"sha256":"44ee37067c623b0cd4eb1a12f22aea430486438c1448c1ff396e4db3b7e8723f","contentType":"text/x-python; charset=utf-8"},{"id":"905eeffb-4e23-5f6a-b56f-7a0f07927322","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/905eeffb-4e23-5f6a-b56f-7a0f07927322/attachment.py","path":"scripts/ranking.py","size":10834,"sha256":"f641b58b8e385d07c73a9ca6f680cf10a9d740754ee2265bb51571e0f3b6b7ba","contentType":"text/x-python; charset=utf-8"},{"id":"c709975e-feb4-5b2a-b923-82a41ba9c83e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c709975e-feb4-5b2a-b923-82a41ba9c83e/attachment.py","path":"scripts/research.py","size":8629,"sha256":"6de55b1c733c32d326b2f9d0433336bfa25bc9a457b535d8993549db05b6b852","contentType":"text/x-python; charset=utf-8"},{"id":"42caefb9-eea7-5a4f-a4c6-1b82fe609aa8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/42caefb9-eea7-5a4f-a4c6-1b82fe609aa8/attachment.py","path":"scripts/setup.py","size":10113,"sha256":"eb7df7e32827f1e1a8c8df70ef86707f2e99405d36614e9c69fc714b1f4e4814","contentType":"text/x-python; charset=utf-8"},{"id":"b8202e7a-b45e-5ad0-8e5d-8283c1492402","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b8202e7a-b45e-5ad0-8e5d-8283c1492402/attachment.py","path":"scripts/stocks.py","size":11307,"sha256":"cfead270eab034d68eaa8e75be64421ad8f469905fa6f229d18bb82269dbd4b0","contentType":"text/x-python; charset=utf-8"},{"id":"08de4132-9486-5a48-ab9a-1cddb5ce9863","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/08de4132-9486-5a48-ab9a-1cddb5ce9863/attachment.py","path":"scripts/summarize.py","size":63474,"sha256":"0914a2e4c03f71cec58ef03bcdf9d3c79f1457f477ae8f0e177a03143fe93b61","contentType":"text/x-python; charset=utf-8"},{"id":"f237798b-7d81-58e4-92c6-cdaeec57ad31","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f237798b-7d81-58e4-92c6-cdaeec57ad31/attachment.py","path":"scripts/translate_portfolio.py","size":5436,"sha256":"1cd16d8980c2d0ed653ed5c5f58ed6013b0bb23e1149610d55ce74d447849dc9","contentType":"text/x-python; charset=utf-8"},{"id":"58f3b29b-a8a0-5234-b77b-205607126b4d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/58f3b29b-a8a0-5234-b77b-205607126b4d/attachment.py","path":"scripts/utils.py","size":1408,"sha256":"a350574b8f8338eb9d3aa912feab64aa81a1d6d0f7cd7712b6d15935c93aff33","contentType":"text/x-python; charset=utf-8"},{"id":"7231b5dd-a11e-5c3c-892e-0dbe07cdcf15","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7231b5dd-a11e-5c3c-892e-0dbe07cdcf15/attachment.sh","path":"scripts/venv-setup.sh","size":3444,"sha256":"74f5ad95cd4581a8f156ff90972ea39a06e1c4e52fdbcfafc21fba8bf1f92093","contentType":"application/x-sh; charset=utf-8"},{"id":"f348acd9-2194-5452-8939-7a86a132a338","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f348acd9-2194-5452-8939-7a86a132a338/attachment.md","path":"tests/README.md","size":712,"sha256":"b4cf4eaeb871cfc199db2ba76a54caf6bf03438cbca876b1821160c87f34cf6f","contentType":"text/markdown; charset=utf-8"},{"id":"99df1bf9-c00b-5b60-a8c1-0f5551e0f3b6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/99df1bf9-c00b-5b60-a8c1-0f5551e0f3b6/attachment.csv","path":"tests/fixtures/sample_portfolio.csv","size":127,"sha256":"3805c7319156ec10b811ab3fcc2bb68fcf75884d5b6857e6c45c9458347428a6","contentType":"text/csv; charset=utf-8"},{"id":"3b790924-e3af-5c4c-bdca-f2c0e4dbbeff","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3b790924-e3af-5c4c-bdca-f2c0e4dbbeff/attachment.xml","path":"tests/fixtures/sample_rss.xml","size":730,"sha256":"ca4127503bbe8051c63fd172c7be4ee6e71bae64a1cf8b6427d5b9ce5c9316a6","contentType":"application/xml"},{"id":"05afff45-fda8-51cd-9186-3518b8858231","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/05afff45-fda8-51cd-9186-3518b8858231/attachment.py","path":"tests/test_alerts.py","size":4138,"sha256":"0a7070657ab2d42e413c50f0123970d4eb076c8f9cd72854b447e0957e4dc617","contentType":"text/x-python; charset=utf-8"},{"id":"184abcff-b87f-5a7d-8b94-5fbe69d7e74f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/184abcff-b87f-5a7d-8b94-5fbe69d7e74f/attachment.py","path":"tests/test_briefing.py","size":3066,"sha256":"dabad77787f009eaab4d5c77433f923c543ed1897ecd275f567654027a990480","contentType":"text/x-python; charset=utf-8"},{"id":"9748fab0-2876-556f-9b0d-8c0f7863a3ce","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9748fab0-2876-556f-9b0d-8c0f7863a3ce/attachment.py","path":"tests/test_earnings.py","size":3791,"sha256":"fdd80f5b511bc6bc3485d01d958dea0598ceca72f93fb371120ecfe3f5f6f409","contentType":"text/x-python; charset=utf-8"},{"id":"8f982766-08f7-5a96-9785-a75faed286e0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8f982766-08f7-5a96-9785-a75faed286e0/attachment.py","path":"tests/test_fetch_news.py","size":4151,"sha256":"bf5d7495982ad8fa28d397881d6814bba2f7a9f2cc74c2a5d10b98b0b3db7fa6","contentType":"text/x-python; charset=utf-8"},{"id":"9922dc6a-e0a1-5476-9b42-7f73824c6f20","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9922dc6a-e0a1-5476-9b42-7f73824c6f20/attachment.py","path":"tests/test_portfolio.py","size":2709,"sha256":"7acadc86506cfc58bb90c90fae8002d7c1d1797253dafc13ed0bcc3a7c075f5b","contentType":"text/x-python; charset=utf-8"},{"id":"ca603f0a-ed07-5a24-a72a-9ff118363c5f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ca603f0a-ed07-5a24-a72a-9ff118363c5f/attachment.py","path":"tests/test_ranking.py","size":2874,"sha256":"d0a1a8ee986eafc64c81a6eb76bc99aa1ef59d6e284dc62878491e853b324452","contentType":"text/x-python; charset=utf-8"},{"id":"b37043d1-e6c5-5176-b749-7362253c35a1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b37043d1-e6c5-5176-b749-7362253c35a1/attachment.py","path":"tests/test_setup.py","size":2594,"sha256":"7408aecab67e156f1b43e2b3da6f90b9a0dc26811f2f47558158af881b7e7f9e","contentType":"text/x-python; charset=utf-8"},{"id":"af9d98c0-0007-5f42-be8c-89ec658f65fd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/af9d98c0-0007-5f42-be8c-89ec658f65fd/attachment.py","path":"tests/test_summarize.py","size":11777,"sha256":"eb917d6ad31efa574bdf7c1139035b36dc240ce12860d9bfce0de68aa485a8a9","contentType":"text/x-python; charset=utf-8"},{"id":"d560ed5c-4ead-5456-9cb0-bc0b970f5849","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d560ed5c-4ead-5456-9cb0-bc0b970f5849/attachment.md","path":"workflows/README.md","size":3442,"sha256":"dd62e0e7f8acdc3c3281e727eaeb1e09ad43a4e3bc7368b9b4c978eb5973b827","contentType":"text/markdown; charset=utf-8"},{"id":"39bf2731-5517-5a54-af77-40fe08217882","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/39bf2731-5517-5a54-af77-40fe08217882/attachment.yaml","path":"workflows/alerts-cron.yaml","size":1590,"sha256":"823cd766f6f63292c7e06707d16d4f445a42d8023853062f0327b0988e27b365","contentType":"application/yaml; charset=utf-8"},{"id":"f20bf56e-02d7-55a2-b3a6-f1d2664a2f45","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f20bf56e-02d7-55a2-b3a6-f1d2664a2f45/attachment.yaml","path":"workflows/briefing-cron.yaml","size":3432,"sha256":"a5e6971cb7bf6b1211041a4b5a1ca48738d46b20f8dddaf908bee06db035b741","contentType":"application/yaml; charset=utf-8"},{"id":"47e24880-f599-5a61-9392-ebdc78c826e5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/47e24880-f599-5a61-9392-ebdc78c826e5/attachment.yaml","path":"workflows/briefing.yaml","size":3831,"sha256":"c8db23e0ac4e0978c5d21ba26d751843623e5ab3a4b3a638cd3092297c510c34","contentType":"application/yaml; charset=utf-8"},{"id":"a57d8a12-a86b-5c9c-9158-32cdec9091d5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a57d8a12-a86b-5c9c-9158-32cdec9091d5/attachment.yaml","path":"workflows/earnings-cron.yaml","size":1513,"sha256":"383f04ef8a2e1e1ec569e9132ba3dfd026ad4c95c79a4981e2f70fbc30f75c3a","contentType":"application/yaml; charset=utf-8"},{"id":"6196f50b-ce3e-5b27-b175-9e7001ea9159","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6196f50b-ce3e-5b27-b175-9e7001ea9159/attachment.yaml","path":"workflows/earnings-weekly-cron.yaml","size":1584,"sha256":"9115be2b90a277c71834feebc584b9b7aa468df0d0d877cefd9eb5417508906f","contentType":"application/yaml; charset=utf-8"}],"bundle_sha256":"fd1b7cde8583e8c0af362ee42d8478e610e990fa4629465ece50970a4e52b9e9","attachment_count":43,"text_attachments":43,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":0,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"skills/finance-news/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"finance-legal-compliance","category_label":"Finance"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"finance-legal-compliance","import_tag":"clean-skills-v1","description":"Market news briefings with AI summaries. Use when asked about stock news, market updates, portfolio performance, morning/evening briefings, financial headlines, or price alerts. Supports US/Europe/Japan markets, WhatsApp delivery, and English/German output."}},"renderedAt":1782981539387}

Finance News Skill AI-powered market news briefings with configurable language output and automated delivery. First-Time Setup Run the interactive setup wizard to configure your sources, delivery channels, and schedule: The wizard will guide you through: - 📰 RSS Feeds: Enable/disable WSJ, Barron's, CNBC, Yahoo, etc. - 📊 Markets: Choose regions (US, Europe, Japan, Asia) - 📤 Delivery: Configure WhatsApp/Telegram group - 🌐 Language: Set default language (English/German) - ⏰ Schedule: Configure morning/evening cron times You can also configure specific sections: Quick Start Features 📊 Market…