
Finance News
- 1 installs
- 15 repo stars
- Updated July 29, 2026
- kesslerio/finance-news-openclaw-skill
Get market news briefings with AI summaries and price alerts from US/Europe/Japan markets.
About
Aggregates market headlines and generates AI-powered briefings with configurable language output. Supports WhatsApp/Telegram delivery and scheduled cron jobs for morning/evening briefings.
- Aggregates RSS feeds from multiple markets (US/Europe/Japan/Asia) with configurable sources
- Delivers automated briefings via WhatsApp/Telegram with customizable language (English/German)
Finance News by the numbers
- 1 all-time installs (skills.sh)
- Ranked #14,102 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 30, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kesslerio/finance-news-openclaw-skill --skill finance-newsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 15 |
| Last updated | July 29, 2026 |
| Repository | kesslerio/finance-news-openclaw-skill ↗ |
What it does
Get market news briefings with AI summaries and price alerts from US/Europe/Japan markets.
Files
Finance News Skill
AI-powered market news briefings with configurable language output and automated delivery via WhatsApp/Telegram.
First-Time Setup
Run the interactive setup wizard:
finance-news setupThe wizard configures RSS feeds, markets (US/Europe/Japan/Asia), delivery channels (WhatsApp/Telegram), language (English/German), and cron schedule.
Configure individual sections:
finance-news setup --section feeds # Just RSS feeds
finance-news setup --section delivery # Just delivery channels
finance-news setup --section schedule # Just cron schedule
finance-news setup --reset # Reset to defaultsVerify setup completed correctly:
finance-news config # Show current config
finance-news briefing --morning # Test a dry-run briefingQuick Start
finance-news briefing --morning # Morning briefing
finance-news briefing --evening --send --group "Market Briefing" # Evening + WhatsApp
finance-news market # Market overview
finance-news portfolio # Portfolio news
finance-news news AAPL # Ticker-specific newsSee COMMANDS.md for the full CLI reference including portfolio management, cron setup, and configuration details.
Cron Jobs
# Add morning briefing (6:30 AM PT, weekdays)
openclaw cron add --schedule "30 6 * * 1-5" \
--timezone "America/Los_Angeles" \
--command "bash ~/clawd/skills/finance-news/cron/morning.sh"
# Add evening briefing (1:00 PM PT, weekdays)
openclaw cron add --schedule "0 13 * * 1-5" \
--timezone "America/Los_Angeles" \
--command "bash ~/clawd/skills/finance-news/cron/evening.sh"Verify cron jobs are active:
openclaw cron list
bash ~/clawd/skills/finance-news/cron/morning.sh # Manual test runIntegration
Combine with OpenBB for detailed quotes before news:
openbb-quote AAPL && finance-news news AAPLRun briefings via Lobster for approval gates and resumability — see workflows/README.md for full documentation:
lobster "workflows.run --file workflows/briefing.yaml"Troubleshooting
| Issue | Fix |
|---|---|
| Gemini not working | Run gemini and follow the login flow to authenticate |
| RSS feeds timing out | Check network; WSJ/Barron's may need subscription cookies; CNBC/Yahoo always work |
| WhatsApp delivery failing | Verify group exists and bot has access; run openclaw doctor |
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Set up Python
run: uv python install 3.10
- name: Install dependencies
run: uv sync --all-extras
- name: Run tests with coverage
run: |
uv run pytest tests/ \
--cov=scripts \
--cov-report=term-missing \
--cov-report=xml \
--cov-fail-under=30
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: coverage.xml
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Set up Python
run: uv python install 3.10
- name: Install dependencies
run: uv sync --all-extras
- name: Run ruff check
run: uv run ruff check scripts/ tests/
continue-on-error: true
# Cache files
cache/
.coverage
*.pyc
__pycache__/
# Environment
.env
.venv/
venv/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Sensitive credentials
config/cookies.json
*.cookie
config/portfolio.csv
config/sources.json
config/translations.json
# Personal data
config/stocks.json
config/alerts.json
config/moltbot-cron-backup-*.json
Finance News — Full Command Reference
Briefing Generation
# Morning briefing (English is default)
finance-news briefing --morning
# Evening briefing with WhatsApp delivery
finance-news briefing --evening --send --group "Market Briefing"
# German language option
finance-news briefing --morning --lang de
# Analysis style (more detailed)
finance-news briefing --style analysisMarket Data
# Market overview (indices + top headlines)
finance-news market
# JSON output for processing
finance-news market --jsonPortfolio Management
finance-news portfolio-list # List portfolio
finance-news portfolio-add NVDA --name "NVIDIA Corporation" --category Tech
finance-news portfolio-remove TSLA # Remove stock
finance-news portfolio-import ~/my_stocks.csv # Import from CSV
finance-news portfolio-create # Interactive setupPortfolio CSV Format
Location: ~/clawd/skills/finance-news/config/portfolio.csv
symbol,name,category,notes
AAPL,Apple Inc.,Tech,Core holding
NVDA,NVIDIA Corporation,Tech,AI play
MSFT,Microsoft Corporation,Tech,Ticker News
finance-news news AAPL
finance-news news TSLAConfiguration
Sources: ~/clawd/skills/finance-news/config/config.json (legacy fallback: config/sources.json) — RSS feeds, market indices by region, and language settings.
Cron Jobs
Setup via OpenClaw
# Add morning briefing cron job
openclaw cron add --schedule "30 6 * * 1-5" \
--timezone "America/Los_Angeles" \
--command "bash ~/clawd/skills/finance-news/cron/morning.sh"
# Add evening briefing cron job
openclaw cron add --schedule "0 13 * * 1-5" \
--timezone "America/Los_Angeles" \
--command "bash ~/clawd/skills/finance-news/cron/evening.sh"Verify cron jobs are registered and test a dry-run:
openclaw cron list
bash ~/clawd/skills/finance-news/cron/morning.sh # Manual test runManual Cron (crontab)
# Morning briefing (6:30 AM PT, weekdays)
30 6 * * 1-5 bash ~/clawd/skills/finance-news/cron/morning.sh
# Evening briefing (1:00 PM PT, weekdays)
0 13 * * 1-5 bash ~/clawd/skills/finance-news/cron/evening.sh{
"_meta": {
"version": 1,
"updated_at": "2026-01-01T00:00:00.000000",
"supported_currencies": ["USD", "EUR", "JPY", "SGD", "MXN"]
},
"alerts": [
{
"ticker": "AAPL",
"target_price": 200.0,
"currency": "USD",
"note": "Buy zone after correction",
"set_by": "User",
"set_date": "2026-01-01",
"status": "active",
"snooze_until": null,
"triggered_count": 0,
"last_triggered": null
},
{
"ticker": "NVDA",
"target_price": 120.0,
"currency": "USD",
"note": "Strong support level",
"set_by": "User",
"set_date": "2026-01-01",
"status": "active",
"snooze_until": null,
"triggered_count": 0,
"last_triggered": null
}
]
}
{
"rss_feeds": {
"wsj": {
"name": "Wall Street Journal",
"enabled": true,
"markets": "https://feeds.content.dowjones.io/public/rss/RSSMarketsMain",
"daily": "https://feeds.content.dowjones.io/public/rss/RSSWSJD"
},
"tagesschau": {
"name": "Tagesschau",
"enabled": true,
"wirtschaft": "https://www.tagesschau.de/wirtschaft/weltwirtschaft/index~rss2.xml"
},
"finanzen_net": {
"name": "Finanzen.net",
"enabled": false,
"news": "https://www.finanzen.net/rss/news",
"note": "Disabled - Akamai WAF blocks all requests with 403"
},
"wallstreet_online": {
"name": "Wallstreet Online",
"enabled": true,
"news": "https://www.wallstreet-online.de/rss/nachrichten-alle.xml",
"note": "Germany's largest finance community - good stock-specific coverage"
},
"handelsblatt": {
"name": "Handelsblatt",
"enabled": true,
"finanzen": "https://feeds.cms.handelsblatt.com/finanzen"
},
"zeit": {
"name": "ZEIT Wirtschaft",
"enabled": true,
"wirtschaft": "https://newsfeed.zeit.de/wirtschaft/index"
},
"marketwatch": {
"name": "MarketWatch",
"enabled": true,
"topstories": "https://feeds.content.dowjones.io/public/rss/mw_topstories"
},
"reuters": {
"name": "Reuters",
"enabled": true,
"markets": "https://news.google.com/rss/search?q=site%3Areuters.com+markets+OR+stocks+OR+economy+OR+fed+OR+earnings&hl=en-US&gl=US&ceid=US%3Aen",
"note": "Google News RSS wrapper for Reuters - filtered for finance/markets."
},
"ft": {
"name": "Financial Times",
"enabled": true,
"markets": "https://www.ft.com/markets?format=rss"
},
"bloomberg": {
"name": "Bloomberg",
"enabled": true,
"markets": "https://feeds.bloomberg.com/markets/news.rss"
},
"barrons": {
"name": "Barron's",
"enabled": false,
"main": "https://www.barrons.com/market-data/rss/articles",
"note": "Requires subscription - enable after adding credentials"
},
"cnbc": {
"name": "CNBC",
"enabled": true,
"top": "https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=10001147",
"business": "https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=15839069",
"markets": "https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=20910258",
"world": "https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=10000664",
"tech": "https://www.cnbc.com/id/19854910/device/rss/rss.html"
},
"yahoo": {
"name": "Yahoo Finance",
"enabled": true,
"top": "https://finance.yahoo.com/rss/topstories"
}
},
"headline_sources": [
"reuters",
"wsj",
"ft",
"bloomberg",
"marketwatch",
"cnbc",
"yahoo"
],
"headline_sources_by_lang": {
"de": [
"wallstreet_online",
"tagesschau",
"handelsblatt",
"zeit",
"reuters",
"wsj",
"ft",
"bloomberg",
"marketwatch",
"cnbc",
"yahoo"
],
"en": [
"reuters",
"wsj",
"ft",
"bloomberg",
"marketwatch",
"cnbc",
"yahoo"
]
},
"headline_exclude": [],
"source_weights": {
"reuters": 5,
"wsj": 4,
"ft": 4,
"bloomberg": 3,
"marketwatch": 3,
"cnbc": 2,
"tagesschau": 4,
"handelsblatt": 4,
"zeit": 4,
"wallstreet_online": 5,
"yahoo": 1
},
"source_tiers": {
"paid": [
"wsj",
"ft",
"barrons"
],
"free": [
"bloomberg",
"marketwatch",
"yahoo",
"cnbc",
"tagesschau",
"handelsblatt",
"zeit",
"wallstreet_online"
]
},
"headline_shortlist_size_by_lang": {
"de": 30,
"en": 20
},
"portfolio_deadline_sec": 360,
"portfolio": {
"briefing_limit": 10,
"prioritization_enabled": true,
"prioritization_weights": {
"type": 0.4,
"volatility": 0.35,
"news_volume": 0.25
}
},
"markets": {
"us": {
"name": "US Markets",
"enabled": true,
"indices": [
"^GSPC",
"^DJI",
"^IXIC"
],
"index_names": {
"^GSPC": "S&P 500",
"^DJI": "Dow Jones",
"^IXIC": "NASDAQ"
}
},
"europe": {
"name": "Europe",
"enabled": true,
"indices": [
"^GDAXI",
"^STOXX50E",
"^FTSE"
],
"index_names": {
"^GDAXI": "DAX",
"^STOXX50E": "STOXX 50",
"^FTSE": "FTSE 100"
}
},
"japan": {
"name": "Japan",
"enabled": true,
"indices": [
"^N225"
],
"index_names": {
"^N225": "Nikkei 225"
}
}
},
"language": {
"default": "en",
"supported": [
"en",
"de"
]
},
"delivery": {
"whatsapp": {
"enabled": true,
"group": ""
},
"telegram": {
"enabled": false,
"group": ""
}
},
"schedule": {
"morning": {
"enabled": true,
"cron": "30 6 * * 1-5",
"timezone": "America/Los_Angeles",
"description": "US Market Open (9:30 AM ET = 6:30 AM PT)"
},
"evening": {
"enabled": true,
"cron": "0 13 * * 1-5",
"timezone": "America/Los_Angeles",
"description": "US Market Close (4:00 PM ET = 1:00 PM PT)"
}
},
"llm": {
"headline_model_order": [
"kimi",
"gemini"
],
"summary_model_order": [
"kimi",
"gemini"
],
"translation_model_order": [
"kimi",
"gemini"
]
},
"translations": {
"en": {
"title_morning": "Morning Briefing",
"title_evening": "Evening Briefing",
"title_prefix": "Market",
"time_suffix": "",
"heading_briefing": "Market Briefing",
"heading_markets": "Markets",
"heading_sentiment": "Sentiment",
"heading_top_headlines": "Top 5 Headlines",
"heading_portfolio_impact": "Portfolio Impact",
"heading_portfolio_movers": "Portfolio Movers",
"heading_portfolio_unresolved": "Unresolved Large Moves",
"heading_watchpoints": "Watchpoints",
"watchpoints_legend": "_Legend: `vs Index` = move minus S&P 500 move._",
"watchpoints_section_clusters": "Sector Rotation",
"watchpoints_section_single_names": "Single-Name Moves",
"watchpoints_section_market_context": "Market Context",
"no_data": "No data available",
"no_movers": "No significant moves (±1%)",
"follows_market": " -- follows market",
"no_catalyst": " -- no specific catalyst",
"likely_sector_contagion": " -- likely sector contagion",
"portfolio_attr_benchmark": "Attribution",
"portfolio_attr_residual": "residual",
"portfolio_attr_no_catalyst": "No confirmed catalyst",
"portfolio_attr_no_visible_movers": "No high-confidence explained movers",
"portfolio_attr_unresolved_exception": "large move; no high-confidence direct catalyst found",
"portfolio_attr_mapping_uncertain": "benchmark mapping uncertain",
"portfolio_attr_confidence": "confidence",
"portfolio_attr_na": "n/a",
"portfolio_attr_confidence_map": {
"HIGH": "HIGH",
"MEDIUM": "MEDIUM",
"LOW": "LOW"
},
"portfolio_benchmark_label_map": {
"Global equities": "Global equities",
"Japan equities": "Japan equities",
"Europe equities": "Europe equities",
"China equities": "China equities",
"Taiwan equities": "Taiwan equities",
"Canada equities": "Canada equities",
"Switzerland equities": "Switzerland equities",
"US technology": "US technology",
"US health care": "US health care",
"US financials": "US financials",
"US industrials": "US industrials",
"US consumer discretionary": "US consumer discretionary",
"US consumer staples": "US consumer staples",
"US energy": "US energy",
"US materials": "US materials",
"US real estate": "US real estate",
"US utilities": "US utilities",
"US communication services": "US communication services",
"Semiconductors": "Semiconductors",
"Semiconductor equipment": "Semiconductor equipment",
"Semiconductor design/software": "Semiconductor design/software",
"S&P 500": "S&P 500"
},
"portfolio_classification_map": {
"market": "market-driven",
"sector_theme": "sector/theme-driven",
"fx": "FX-driven",
"idiosyncratic": "idiosyncratic",
"mixed": "mixed",
"unexplained": "unexplained"
},
"rec_bullish": "Selective opportunities, keep risk management tight.",
"rec_bearish": "Reduce risk and prioritize liquidity.",
"rec_neutral": "Wait-and-see, focus on quality names.",
"rec_unknown": "No clear recommendation without reliable data.",
"sources_header": "Sources",
"sentiment_map": {
"Bullish": "Bullish",
"Bearish": "Bearish",
"Neutral": "Neutral",
"No data available": "No data available"
}
},
"de": {
"title_morning": "Morgen-Briefing",
"title_evening": "Abend-Briefing",
"title_prefix": "Börsen",
"time_suffix": "Uhr",
"heading_briefing": "Marktbriefing",
"heading_markets": "Märkte",
"heading_sentiment": "Stimmung",
"heading_top_headlines": "Top 5 Schlagzeilen",
"heading_portfolio_impact": "Portfolio-Auswirkung",
"heading_portfolio_movers": "Portfolio-Bewegungen",
"heading_portfolio_unresolved": "Ungeklärte große Bewegungen",
"heading_watchpoints": "Beobachtungspunkte",
"watchpoints_legend": "_Legende: `vs Index` = Kursbewegung minus S&P-500-Bewegung._",
"watchpoints_section_clusters": "Sektorrotation",
"watchpoints_section_single_names": "Einzeltitel",
"watchpoints_section_market_context": "Marktkontext",
"no_data": "Keine Daten verfügbar",
"no_movers": "Keine deutlichen Bewegungen (±1%)",
"follows_market": " -- folgt dem Markt",
"no_catalyst": " -- kein spezifischer Katalysator",
"likely_sector_contagion": " -- wahrscheinlich Sektor-Ansteckung",
"portfolio_attr_benchmark": "Einordnung",
"portfolio_attr_residual": "Restbewegung",
"portfolio_attr_no_catalyst": "kein bestätigter Auslöser",
"portfolio_attr_no_visible_movers": "Keine Bewegungen mit bestätigtem Auslöser",
"portfolio_attr_unresolved_exception": "große Bewegung; kein belastbarer direkter Auslöser gefunden",
"portfolio_attr_mapping_uncertain": "Benchmark-Zuordnung unsicher",
"portfolio_attr_confidence": "Konfidenz",
"portfolio_attr_na": "k. A.",
"portfolio_attr_confidence_map": {
"HIGH": "HOCH",
"MEDIUM": "MITTEL",
"LOW": "NIEDRIG"
},
"portfolio_benchmark_label_map": {
"Global equities": "Globale Aktien",
"Japan equities": "Japan-Aktien",
"Europe equities": "Europa-Aktien",
"China equities": "China-Aktien",
"Taiwan equities": "Taiwan-Aktien",
"Canada equities": "Kanada-Aktien",
"Switzerland equities": "Schweiz-Aktien",
"US technology": "US-Technologie",
"US health care": "US-Gesundheitswesen",
"US financials": "US-Finanzwerte",
"US industrials": "US-Industriewerte",
"US consumer discretionary": "US-Konsum (zyklisch)",
"US consumer staples": "US-Konsum (defensiv)",
"US energy": "US-Energie",
"US materials": "US-Grundstoffe",
"US real estate": "US-Immobilien",
"US utilities": "US-Versorger",
"US communication services": "US-Kommunikationsdienste",
"Semiconductors": "Halbleiter",
"Semiconductor equipment": "Halbleiterausrüstung",
"Semiconductor design/software": "Halbleiterdesign/-software",
"S&P 500": "S&P 500"
},
"portfolio_classification_map": {
"market": "marktgetrieben",
"sector_theme": "sektor-/themengetrieben",
"fx": "FX-getrieben",
"idiosyncratic": "einzeltitelspezifisch",
"mixed": "gemischt",
"unexplained": "ungeklärt"
},
"rec_bullish": "Chancen selektiv nutzen, aber Risikomanagement beibehalten.",
"rec_bearish": "Risiken reduzieren und Liquidität priorisieren.",
"rec_neutral": "Abwarten und Fokus auf Qualitätstitel.",
"rec_unknown": "Keine klare Empfehlung ohne belastbare Daten.",
"sources_header": "Quellen",
"sentiment_map": {
"Bullish": "Bullisch",
"Bearish": "Bärisch",
"Neutral": "Neutral",
"No data available": "Keine Daten verfügbar"
},
"months": {
"January": "Januar",
"February": "Februar",
"March": "März",
"April": "April",
"May": "Mai",
"June": "Juni",
"July": "Juli",
"August": "August",
"September": "September",
"October": "Oktober",
"November": "November",
"December": "Dezember"
},
"days": {
"Monday": "Montag",
"Tuesday": "Dienstag",
"Wednesday": "Mittwoch",
"Thursday": "Donnerstag",
"Friday": "Freitag",
"Saturday": "Samstag",
"Sunday": "Sonntag"
}
}
}
}
{
"_comment": "Manual earnings dates for stocks not covered by Finnhub API",
"_updated": "2026-01-27",
"6857.T": {
"date": "2026-01-27",
"time": "amc",
"note": "Q3 FY2025 - Advantest",
"source": "marketscreener.com"
},
"6920.T": {
"date": "2026-02-02",
"time": "amc",
"note": "Q3 FY2025 - Lasertec",
"source": "tipranks.com"
},
"8035.T": {
"date": "2026-02-05",
"time": "amc",
"note": "Q3 FY2025 - Tokyo Electron",
"source": "tipranks.com"
},
"6146.T": {
"date": "2026-02-06",
"time": "amc",
"note": "Q3 FY2025 - Disco Corp",
"source": "estimate"
},
"7741.T": {
"date": "2026-01-30",
"time": "amc",
"note": "Q3 FY2025 - Hoya",
"source": "estimate"
},
"7735.T": {
"date": "2026-01-30",
"time": "amc",
"note": "Q3 FY2025 - Screen Holdings",
"source": "estimate"
},
"4063.T": {
"date": "2026-01-31",
"time": "amc",
"note": "Q3 FY2025 - Shin-Etsu Chemical",
"source": "estimate"
},
"6861.T": {
"date": "2026-01-29",
"time": "amc",
"note": "Q3 FY2025 - Keyence",
"source": "estimate"
},
"9984.T": {
"date": "2026-02-07",
"time": "amc",
"note": "Q3 FY2025 - SoftBank Group",
"source": "estimate"
},
"9983.T": {
"date": "2026-01-09",
"time": "amc",
"note": "Q1 FY2026 - Fast Retailing (Uniqlo)",
"source": "estimate"
},
"D05.SI": {
"date": "2026-02-10",
"time": "bmo",
"note": "Q4 2025 - DBS Group",
"source": "estimate"
},
"O39.SI": {
"date": "2026-02-21",
"time": "bmo",
"note": "Q4 2025 - OCBC Bank",
"source": "estimate"
},
"S68.SI": {
"date": "2026-01-23",
"time": "bmo",
"note": "H1 FY2026 - Singapore Exchange",
"source": "estimate"
},
"AAPL": {
"date": "2026-01-30",
"time": "amc",
"note": "Q1 FY2026"
},
"MSFT": {
"date": "2026-01-29",
"time": "amc",
"note": "Q2 FY2026"
},
"META": {
"date": "2026-01-29",
"time": "amc",
"note": "Q4 2025"
},
"TSLA": {
"date": "2026-01-29",
"time": "amc",
"note": "Q4 2025"
},
"NVDA": {
"date": "2026-02-25",
"time": "amc",
"note": "Q4 FY2026"
},
"GOOGL": {
"date": "2026-02-04",
"time": "amc",
"note": "Q4 2025"
},
"AMZN": {
"date": "2026-02-06",
"time": "amc",
"note": "Q4 2025"
},
"NFLX": {
"date": "2026-01-21",
"time": "amc",
"note": "Q4 2025"
},
"V": {
"date": "2026-01-30",
"time": "amc",
"note": "Q1 FY2026"
},
"MA": {
"date": "2026-01-30",
"time": "bmo",
"note": "Q4 2025"
},
"ASML": {
"date": "2026-01-29",
"time": "bmo",
"note": "Q4 2025"
},
"NOW": {
"date": "2026-01-29",
"time": "amc",
"note": "Q4 2025"
},
"UBER": {
"date": "2026-02-05",
"time": "bmo",
"note": "Q4 2025"
},
"SHOP": {
"date": "2026-02-11",
"time": "bmo",
"note": "Q4 2025"
},
"SPOT": {
"date": "2026-02-04",
"time": "bmo",
"note": "Q4 2025"
},
"NET": {
"date": "2026-02-06",
"time": "amc",
"note": "Q4 2025"
},
"SNOW": {
"date": "2026-02-26",
"time": "amc",
"note": "Q4 FY2026"
},
"DKNG": {
"date": "2026-02-13",
"time": "bmo",
"note": "Q4 2025"
},
"SQ": {
"date": "2026-02-20",
"time": "amc",
"note": "Q4 2025"
},
"ABNB": {
"date": "2026-02-13",
"time": "amc",
"note": "Q4 2025"
},
"TEAM": {
"date": "2026-01-30",
"time": "amc",
"note": "Q2 FY2026"
},
"ZS": {
"date": "2026-02-25",
"time": "amc",
"note": "Q2 FY2026"
},
"FTNT": {
"date": "2026-02-06",
"time": "amc",
"note": "Q4 2025"
},
"WDAY": {
"date": "2026-02-27",
"time": "amc",
"note": "Q4 FY2026"
},
"TTD": {
"date": "2026-02-13",
"time": "amc",
"note": "Q4 2025"
},
"WMT": {
"date": "2026-02-19",
"time": "bmo",
"note": "Q4 FY2026"
},
"EA": {
"date": "2026-02-03",
"time": "amc",
"note": "Q3 FY2026"
},
"ADSK": {
"date": "2026-02-26",
"time": "amc",
"note": "Q4 FY2026"
},
"ROKU": {
"date": "2026-02-13",
"time": "amc",
"note": "Q4 2025"
},
"SNAP": {
"date": "2026-02-04",
"time": "amc",
"note": "Q4 2025"
},
"ETSY": {
"date": "2026-02-19",
"time": "amc",
"note": "Q4 2025"
},
"KO": {
"date": "2026-02-11",
"time": "bmo",
"note": "Q4 2025"
},
"BLK": {
"date": "2026-01-15",
"time": "bmo",
"note": "Q4 2025"
},
"PH": {
"date": "2026-01-30",
"time": "bmo",
"note": "Q2 FY2026"
},
"SYK": {
"date": "2026-01-28",
"time": "bmo",
"note": "Q4 2025"
},
"TJX": {
"date": "2026-02-26",
"time": "bmo",
"note": "Q4 FY2026"
},
"ROST": {
"date": "2026-03-04",
"time": "amc",
"note": "Q4 FY2026"
},
"ORLY": {
"date": "2026-02-05",
"time": "amc",
"note": "Q4 2025"
},
"SHW": {
"date": "2026-01-30",
"time": "bmo",
"note": "Q4 2025"
},
"FISV": {
"date": "2026-02-04",
"time": "bmo",
"note": "Q4 2025"
},
"MSI": {
"date": "2026-02-06",
"time": "bmo",
"note": "Q4 2025"
},
"APH": {
"date": "2026-01-22",
"time": "bmo",
"note": "Q4 2025"
},
"AXON": {
"date": "2026-02-25",
"time": "amc",
"note": "Q4 2025"
},
"ROP": {
"date": "2026-01-30",
"time": "bmo",
"note": "Q4 2025"
},
"RACE": {
"date": "2026-02-04",
"time": "bmo",
"note": "Q4 2025"
},
"TWLO": {
"date": "2026-02-12",
"time": "amc",
"note": "Q4 2025"
},
"ZM": {
"date": "2026-02-24",
"time": "amc",
"note": "Q4 FY2026"
},
"U": {
"date": "2026-02-20",
"time": "amc",
"note": "Q4 2025"
},
"ZI": {
"date": "2026-02-10",
"time": "amc",
"note": "Q4 2025"
}
}{
"broad_markets": {
"default": {"ticker": "ACWI", "label": "Global equities"},
"US": {"ticker": "SPY", "label": "S&P 500"},
"Japan": {"ticker": "EWJ", "label": "Japan equities"},
"Europe": {"ticker": "VGK", "label": "Europe equities"},
"China": {"ticker": "MCHI", "label": "China equities"},
"Taiwan": {"ticker": "EWT", "label": "Taiwan equities"},
"Canada": {"ticker": "EWC", "label": "Canada equities"},
"Switzerland": {"ticker": "EWL", "label": "Switzerland equities"}
},
"categories": {
"Technology": {"ticker": "XLK", "label": "US technology"},
"Tech": {"ticker": "XLK", "label": "US technology"},
"Semiconductors": {"ticker": "SOXX", "label": "Semiconductors"},
"Semiconductor Equipment": {"ticker": "SOXX", "label": "Semiconductors"},
"KI-Infrastructure": {"ticker": "SOXX", "label": "Semiconductors"},
"AI/Data": {"ticker": "XLK", "label": "US technology"},
"Healthcare": {"ticker": "XLV", "label": "US health care"},
"Finance": {"ticker": "XLF", "label": "US financials"},
"Financials": {"ticker": "XLF", "label": "US financials"},
"Industrials": {"ticker": "XLI", "label": "US industrials"},
"Consumer": {"ticker": "XLY", "label": "US consumer discretionary"},
"Consumer Discretionary": {"ticker": "XLY", "label": "US consumer discretionary"},
"Consumer Staples": {"ticker": "XLP", "label": "US consumer staples"},
"Energy": {"ticker": "XLE", "label": "US energy"},
"Materials": {"ticker": "XLB", "label": "US materials"},
"Real Estate": {"ticker": "XLRE", "label": "US real estate"},
"Utilities": {"ticker": "XLU", "label": "US utilities"},
"Communication Services": {"ticker": "XLC", "label": "US communication services"}
},
"themes": {
"AI Infrastructure": {"ticker": "SOXX", "label": "Semiconductors"},
"Advanced Manufacturing": {"ticker": "SOXX", "label": "Semiconductors"},
"Cloud": {"ticker": "XLK", "label": "US technology"},
"Cybersecurity": {"ticker": "XLK", "label": "US technology"},
"Digital Payments": {"ticker": "XLF", "label": "US financials"},
"Japan Reform": {"ticker": "EWJ", "label": "Japan equities"},
"GLP-1 Ecosystem": {"ticker": "XLV", "label": "US health care"}
},
"ticker_overrides": {
"NVDA": {"ticker": "SOXX", "label": "Semiconductors"},
"AMD": {"ticker": "SOXX", "label": "Semiconductors"},
"AVGO": {"ticker": "SOXX", "label": "Semiconductors"},
"AMAT": {"ticker": "SOXX", "label": "Semiconductor equipment"},
"ASML": {"ticker": "SOXX", "label": "Semiconductor equipment"},
"KLAC": {"ticker": "SOXX", "label": "Semiconductor equipment"},
"LRCX": {"ticker": "SOXX", "label": "Semiconductor equipment"},
"CDNS": {"ticker": "SOXX", "label": "Semiconductor design/software"},
"SNPS": {"ticker": "SOXX", "label": "Semiconductor design/software"},
"5803.T": {"ticker": "EWJ", "label": "Japan equities"},
"6861.T": {"ticker": "EWJ", "label": "Japan equities"},
"7741.T": {"ticker": "EWJ", "label": "Japan equities"},
"8001.T": {"ticker": "EWJ", "label": "Japan equities"},
"8058.T": {"ticker": "EWJ", "label": "Japan equities"}
},
"ticker_suffix_regions": {
".A": "US",
".B": "US",
".T": "Japan",
".TW": "Taiwan",
".DE": "Europe",
".PA": "Europe",
".AS": "Europe",
".SW": "Switzerland",
".TO": "Canada",
".V": "Canada"
},
"currency_proxies": {
".T": {"ticker": "JPY=X", "label": "USD/JPY"},
".TW": {"ticker": "TWD=X", "label": "USD/TWD"},
".DE": {"ticker": "EURUSD=X", "label": "EUR/USD"},
".PA": {"ticker": "EURUSD=X", "label": "EUR/USD"},
".AS": {"ticker": "EURUSD=X", "label": "EUR/USD"},
".SW": {"ticker": "CHF=X", "label": "USD/CHF"},
".TO": {"ticker": "CAD=X", "label": "USD/CAD"},
".V": {"ticker": "CAD=X", "label": "USD/CAD"}
},
"source_quality": {
"allowed_sources": [
"Reuters",
"Wall Street Journal",
"WSJ",
"Financial Times",
"FT",
"Bloomberg",
"CNBC",
"MarketWatch",
"Yahoo Finance",
"Handelsblatt",
"Tagesschau",
"ZEIT"
],
"allowed_domains": [
"reuters.com",
"wsj.com",
"ft.com",
"bloomberg.com",
"cnbc.com",
"marketwatch.com",
"finance.yahoo.com",
"handelsblatt.com",
"tagesschau.de",
"zeit.de"
],
"high_confidence_sources": [
"Reuters",
"Wall Street Journal",
"WSJ",
"Financial Times",
"FT",
"Bloomberg",
"CNBC",
"MarketWatch",
"Handelsblatt"
],
"high_confidence_domains": [
"reuters.com",
"wsj.com",
"ft.com",
"bloomberg.com",
"cnbc.com",
"marketwatch.com",
"handelsblatt.com"
],
"blocked_domains": [
"fool.com",
"benzinga.com",
"zacks.com",
"investorplace.com",
"seekingalpha.com"
],
"quality_scoring": {
"minimum_score": 1.1,
"allowed_source_bonus": 0.5,
"catalyst_bonus": 1.2,
"recency_max_bonus": 0.4,
"recency_half_life_hours": 24,
"low_value_template_penalty": 1.5,
"low_value_template_patterns": [
"which is the better value stock",
"which stock is the better value option",
"which stock is better value",
"vs\\.",
"\\bvs\\b",
"outpacing .* peers",
"here'?s\\s+what\\s+you\\s+should\\s+know"
]
}
}
}
symbol,name,category,notes
AAPL,,Magnificent 7,
ABNB,,Travel,
ADBE,,Creative,
ADSK,,Tech,
AMPL,,Tech,
AMZN,,Magnificent 7,
APP,,Tech,
APPF,,Tech,
ASAN,,Productivity,
AVLR,,Tech,
AXON,,Tech,
BILL,,Tech,
BKNG,,Travel,
BMBL,,Tech,
CARG,,Tech,
CHGG,,Tech,
CHWY,,E-commerce,
COUP,,Procurement,
CRM,,SaaS Leaders,
CRNC,,Tech,
CRWD,,SaaS Leaders,
CVNA,,Tech,
DDOG,,SaaS Leaders,
DKNG,,Tech,
DLO,,Tech,
DM,,Tech,
DOCN,,Tech,
DOCU,,Productivity,
DOMO,,Tech,
DT,,Tech,
EA,,Gaming,
ESTC,,Data,
ETSY,,E-commerce,
EXPE,,Travel,
FIVN,,Data,
FROG,,Tech,
FSLY,,Tech,
FTCH,,Tech,
FTNT,,Security,
FVRR,,Tech,
GDOT,,Tech,
GLBE,,Tech,
GLOB,,Tech,
GME,,Tech,
GOOGL,,Magnificent 7,
GTLB,,Tech,
HUBS,,SaaS Leaders,
IAC,,Tech,
INTU,,SaaS Leaders,
LC,,Tech,
LMND,,Tech,
LOGI,,Tech,
LSPD,,Tech,
LTCH,,Tech,
LYFT,,Rideshare,
MDB,,SaaS Leaders,
META,,Magnificent 7,
MGNI,,Tech,
MITK,,Tech,
MNDY,,Tech,
MQ,,Tech,
MSFT,,Magnificent 7,
MTCH,,Tech,
NET,,SaaS Leaders,
NFLX,,Entertainment,
NOW,,SaaS Leaders,
NVDA,,Magnificent 7,
OKTA,,Security,
OSTK,,Tech,
PAYC,,Tech,
PD,,Tech,
PINS,,Social,
PLAN,,Productivity,
PLTR,,AI/Data,
POSH,,Tech,
PTON,,Tech,
PUBM,,AdTech,
PYPL,,FinTech,
RBLX,,Gaming,
RDFN,,Tech,
REAL,,Tech,
RNG,,Tech,
ROKU,,Entertainment,
ROOT,,Tech,
RPD,,Tech,
S,,Tech,
SEMR,,Tech,
SHOP,,E-commerce,
SMAR,,Tech,
SNAP,,Social,
SNOW,,AI/Data,
SOFI,,FinTech,
SPOT,,Entertainment,
SPT,,Tech,
SQ,,FinTech,
SUMO,,Tech,
TDUP,,Tech,
TEAM,,SaaS Leaders,
TSM,,Tech,
TTD,,AdTech,
TTWO,,Gaming,
TWLO,,Communication,
TWTR,,Tech,
U,,Tech,
UBER,,Rideshare,
UPST,,Tech,
UPWK,,Tech,
VLD,,Tech,
VMEO,,Marketing,
VRM,,Tech,
W,,Tech,
WDAY,,SaaS Leaders,
WIX,,Tech,
ZEN,,Tech,
ZI,,Tech,
ZM,,Communication,
ZS,,Security,#!/usr/bin/env bash
# Price Alerts Cron Job (Lobster Workflow)
# Schedule: 2:00 PM PT / 5:00 PM ET (1 hour after market close)
#
# Checks price alerts against current prices including after-hours.
# Sends triggered alerts and watchlist status to WhatsApp/Telegram.
set -e
export SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export FINANCE_NEWS_TARGET="${FINANCE_NEWS_TARGET:?FINANCE_NEWS_TARGET must be set}"
export FINANCE_NEWS_CHANNEL="${FINANCE_NEWS_CHANNEL:?FINANCE_NEWS_CHANNEL must be set}"
echo "[$(date)] Checking price alerts via Lobster..."
lobster run --file "$SKILL_DIR/workflows/alerts-cron.yaml" \
--args-json '{"lang":"en"}'
echo "[$(date)] Price alerts check complete."
#!/usr/bin/env bash
# Weekly Earnings Alert Cron Job (Lobster Workflow)
# Schedule: Sunday 7:00 AM PT (before market week starts)
#
# Sends upcoming week's earnings calendar to WhatsApp/Telegram.
# Shows all portfolio stocks reporting Mon-Fri.
set -e
export SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export FINANCE_NEWS_TARGET="${FINANCE_NEWS_TARGET:?FINANCE_NEWS_TARGET must be set}"
export FINANCE_NEWS_CHANNEL="${FINANCE_NEWS_CHANNEL:?FINANCE_NEWS_CHANNEL must be set}"
echo "[$(date)] Checking next week's earnings via Lobster..."
lobster run --file "$SKILL_DIR/workflows/earnings-weekly-cron.yaml" \
--args-json '{"lang":"en"}'
echo "[$(date)] Weekly earnings alert complete."
#!/usr/bin/env bash
# Earnings Alert Cron Job (Lobster Workflow)
# Schedule: 6:00 AM PT / 9:00 AM ET (30 min before market open)
#
# Sends today's earnings calendar to WhatsApp/Telegram.
# Alerts users about portfolio stocks reporting today.
set -e
export SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export FINANCE_NEWS_TARGET="${FINANCE_NEWS_TARGET:?FINANCE_NEWS_TARGET must be set}"
export FINANCE_NEWS_CHANNEL="${FINANCE_NEWS_CHANNEL:?FINANCE_NEWS_CHANNEL must be set}"
echo "[$(date)] Checking today's earnings via Lobster..."
lobster run --file "$SKILL_DIR/workflows/earnings-cron.yaml" \
--args-json '{"lang":"en"}'
echo "[$(date)] Earnings alert complete."
#!/usr/bin/env bash
# Evening Briefing Cron Job (Lobster Workflow)
# Schedule: 1:00 PM PT (US Market Close at 4:00 PM ET)
#
# Uses Lobster workflow to generate and send briefing directly,
# bypassing LLM agent reformatting that truncates output.
set -e
export SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export FINANCE_NEWS_TARGET="${FINANCE_NEWS_TARGET:?FINANCE_NEWS_TARGET must be set}"
export FINANCE_NEWS_CHANNEL="${FINANCE_NEWS_CHANNEL:?FINANCE_NEWS_CHANNEL must be set}"
echo "[$(date)] Starting evening briefing via Lobster..."
lobster run --file "$SKILL_DIR/workflows/briefing-cron.yaml" \
--args-json '{"time":"evening","lang":"de"}'
echo "[$(date)] Evening briefing complete."
#!/usr/bin/env bash
# Morning Briefing Cron Job (Lobster Workflow)
# Schedule: 6:30 AM PT (US Market Open at 9:30 AM ET)
#
# Uses Lobster workflow to generate and send briefing directly,
# bypassing LLM agent reformatting that truncates output.
set -e
export SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export FINANCE_NEWS_TARGET="${FINANCE_NEWS_TARGET:?FINANCE_NEWS_TARGET must be set}"
export FINANCE_NEWS_CHANNEL="${FINANCE_NEWS_CHANNEL:?FINANCE_NEWS_CHANNEL must be set}"
echo "[$(date)] Starting morning briefing via Lobster..."
lobster run --file "$SKILL_DIR/workflows/briefing-cron.yaml" \
--args-json '{"time":"morning","lang":"de"}'
echo "[$(date)] Morning briefing complete."
FROM python:3.13-slim
WORKDIR /app
# Install build dependencies and libstdc++ for numpy
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc libstdc++6 && \
rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir openbb openbb-yfinance
# Copy application code
COPY . .
ENV KIMI_API_KEY=
ENV KIMI_API_BASE_URL=https://api.kimi.com/coding/
ENV FINANCE_NEWS_KIMI_MODEL=k2p5
ENV MINIMAX_CODING_PLAN_API_KEY=
ENV FINANCE_NEWS_SUPPRESS_VENV_WARNING=1
ENV OPENBB_QUOTE_BIN=/app/scripts/openbb-quote
# Default command (override via docker run args)
CMD ["python3", "scripts/briefing.py"]
Equity Sheet Fixes
Contents
NRR Column (Column Q) - Range Values Fix
Problem: Values like "115-120%", "125%+", "N/A" in NRR column cause #VALUE! errors in MSS Score formula (columns Y/Z).
Root cause: Excel/Sheets formulas cannot perform math operations on text ranges.
Solution: Convert all NRR values to single numeric percentages.
Conversion Rules
Standard formats:
| Original | Fixed | Calculation | Rationale |
|---|---|---|---|
| 115-120% | 117.5% | (115+120)/2 | Midpoint (conservative estimate) |
| 120-125% | 122.5% | (120+125)/2 | Midpoint |
| 125%+ | 125% | Use lower bound | Conservative (actual may be higher) |
| N/A | [blank] | Leave empty | MSS formula uses IFERROR to handle blanks |
| 110% | 110% | Already valid | No change needed |
Edge cases (normalize before converting):
| Variant | Normalized | Notes |
|---|---|---|
| 115–120% (en-dash) | 115-120% | Replace en-dash with hyphen |
| 115 - 120% (spaces) | 115-120% | Remove spaces around hyphen |
| >=125% | 125%+ | Convert to standard "+" format |
| 125%+ or higher | 125%+ | Strip extra text |
Fix Procedure
Option A: Manual fix via browser 1. Open sheet: https://docs.google.com/spreadsheets/d/1lTpdbDjqW40qe4YUvk_1vBzKYLUNrmLZYyQN-7HmFJg/edit#gid=0 2. IMPORTANT: Select column Q header → Format → Number → Percent
- This ensures values are stored as numbers, not text
- If column is set to "Plain text", entering "117.5%" stores as text → still causes errors
3. Navigate to column Q (NRR) 4. For each range value:
- Calculate midpoint (e.g., (115+120)/2 = 117.5)
- Replace with single percentage:
117.5% - Sheets auto-converts to numeric percentage when column is formatted correctly
5. For "N/A" → delete content (leave blank) 6. For "125%+" → replace with 125% 7. Verify: After editing, click cell → formula bar should show 1.175 (not "117.5%" with quotes)
Option B: Sheets API fix (requires Sheets API enabled)
Prerequisites: 1. Enable Sheets API: https://console.developers.google.com/apis/api/sheets.googleapis.com/overview?project=831892255935 2. Ensure column Q is formatted as Percent (do once before any API writes):
- Via browser: Select column Q → Format → Number → Percent
- Via API: Use
batchUpdatewithrepeatCell+numberFormat(see below)
Using gog CLI:
# gog CLI uses USER_ENTERED by default (parses "117.5%" as numeric)
gog-shapescale --account martin@shapescale.com sheets update \
1lTpdbDjqW40qe4YUvk_1vBzKYLUNrmLZYyQN-7HmFJg \
'Equity!Q5' '117.5%'Using Sheets API directly (curl/Python):
# CRITICAL: Specify valueInputOption=USER_ENTERED explicitly
curl -X PUT \
"https://sheets.googleapis.com/v4/spreadsheets/SHEET_ID/values/Equity!Q5?valueInputOption=USER_ENTERED" \
-H "Authorization: Bearer $TOKEN" \
-d '{"values": [["117.5%"]]}'
# Python example:
service.spreadsheets().values().update(
spreadsheetId=SHEET_ID,
range='Equity!Q5',
valueInputOption='USER_ENTERED', # Parse as Sheets would
body={'values': [['117.5%']]}
).execute()Verify after writing:
- Click cell → formula bar should show
1.175(numeric) - If formula bar shows
"117.5%"with quotes → stored as text, still causes errors
Impact
Fixing NRR ranges will:
- ✅ Eliminate #VALUE! errors in MSS Score column (Y)
- ✅ Eliminate #VALUE! errors in MSS Rating column (Z)
- ✅ Allow proper numerical analysis and sorting
- ✅ Make formulas copyable to new rows without errors
How MSS Formula Handles Blank NRR Values
The MSS Score formula (column Y) includes IFERROR() wrapper to handle missing data:
- Blank NRR cell → Formula treats as missing data, uses available metrics only
- Not treated as 0% → Blank is excluded from calculation (doesn't penalize score)
- Better than text "N/A" → Text causes #VALUE! error, blank is handled gracefully
Example: If NRR is blank but other metrics exist (Rev Growth, Rule of 40, etc.), MSS Score calculates using remaining metrics without error.
Related Columns
Other columns that need single numeric values (not ranges):
- Column M (Rule of 40 Ops): Should be calculated value (Ops Margin + Rev Growth)
- Column O (Rule of 40 FCF): Should be calculated value (FCF Margin + Rev Growth)
- Both can be negative for pre-profitable/turnaround companies
Prevention
When adding new companies: 1. Always use single percentage values in NRR column 2. Test MSS Score formula immediately after adding row 3. If #VALUE! error appears → check Q column for ranges/text
feat: Add morning portfolio attribution brief
Summary
Implement a deterministic attribution-first portfolio message in finance-news: notable movers are explained against market, sector/theme ETF, FX proxy, and residual context before any article evidence is shown. Seed a repo-owned global benchmark map and replace the current article-link portfolio output with compact German/English attribution rows.
---
Problem Frame
The morning WhatsApp portfolio section currently behaves like a ticker-news digest: it ranks movers, prints article bullets, and can degrade into generic fallback text. The useful product shape is portfolio-manager attribution: "what moved, what benchmark explains it, what residual remains, and whether a credible catalyst exists."
---
Requirements
- R1. The morning portfolio output must be an attribution brief, not an article digest.
- R2. Movers must rank by portfolio relevance and absolute move, not article count.
- R3. Each included mover must show move, price, and a compact classification: market-driven, sector/theme-driven, FX-driven, idiosyncratic, mixed, or unexplained.
- R4. Absence of credible evidence must render as "no confirmed catalyst" or localized equivalent, never as filler article text.
- R5. Generic "new message/article for portfolio position" bullets must not appear.
- R6. Routine source appendices and long source link dumps must be removed from portfolio messages.
- R7. Article evidence must pass source quality and catalyst gates before inclusion.
- R8. Yahoo-style aggregators, listicles, recycled price-action recaps, and generic opinion articles must not create catalyst bullets.
- R9. Catalyst evidence must explain why the move matters now.
- R10. Catalyst bullets must include a confidence or uncertainty signal.
- R11. Weak, conflicted, or missing evidence must be stated plainly.
- R12. Small and mid-cap positions must work with quantitative attribution alone.
- R13. Each notable mover must compare against broad market, sector/theme, and applicable currency context when data exists.
- R14. Attribution must expose residual/idiosyncratic movement.
- R15. Sector/theme benchmarks must come from a repo-owned global taxonomy map.
- R16. The taxonomy must support broad regions, currencies, sectors, industries, and themes such as technology, semiconductors, AI infrastructure, industrials, financials, Japan, and China.
- R17. Ticker and portfolio metadata must be able to override generic category mapping.
- R18. Missing or ambiguous benchmark mappings must be visible as review-worthy uncertainty, not hidden precision.
- R19. WhatsApp copy must stay compact.
- R20. Output must avoid investment advice, price targets, unsupported sentiment, and recommendations.
Origin actors: Portfolio owner, morning scheduler, attribution engine, evidence gate, taxonomy maintainer. Origin flows: Morning attribution brief, credible catalyst inclusion, benchmark taxonomy maintenance. Origin acceptance examples: Japanese industrial sector move without evidence; semiconductor residual move with credible catalyst; Yahoo/listicle suppression; small-cap residual with unknown catalyst; ticker-specific benchmark override.
---
Scope Boundaries
- Do not add buy/sell/hold advice, price targets, fair value, or trade recommendations.
- Do not require credible article evidence for every mover.
- Do not build a perfect taxonomy on day one; seed a maintainable map and make unknowns visible.
- Do not preserve generic fallback article bullets for compatibility.
- Do not add real-time newswire integrations.
- Do not rewrite the macro market briefing.
- Do not summarize every source. Sources are evidence only.
Deferred to Follow-Up Work
- Premium-source authentication and full-text article extraction: separate research/source-quality track.
- Moving taxonomy ownership into
equity-research: later shared-data consolidation once the finance-news behavior proves useful. - Live FX decomposition with base-currency P&L weighting: later iteration if quote-level FX proxies are insufficient.
---
Context & Research
Relevant Code and Patterns
scripts/summarize.pycurrently builds the portfolio message inbuild_portfolio_message(), hardcodes$prices, translates all article titles, appends source references, and ranks by raw daily change.scripts/summarize.pyalready has watchpoint data classes and helper tests for mover context, headline matching, sector clusters, and localized watchpoint formatting. The new attribution code should follow this deterministic helper style rather than adding LLM summarization.scripts/fetch_news.pyowns quote fetching viafetch_market_data()and ticker news via Yahoo RSS. The attribution path should reuse quote fetching but treat Yahoo ticker articles as low-quality evidence by default.config/config.jsonalready stores market index tickers, source weights, and localized labels. It is the natural home for brief copy labels; a separate benchmark map keeps ETF/taxonomy data out of translation config.tests/test_summarize.pyalready covers portfolio message formatting and watchpoints. Add focused unit coverage there, and add a small dedicated test file if attribution helpers outgrow the current module.- Existing LLQD taxonomy data in the wider workspace has broad sector, specific sector, and investment-theme concepts but no ETF benchmark map. Use it as conceptual input only; keep this repo self-contained for the first implementation.
Institutional Learnings
- Portfolio state should have one clear owner. Keep this change in
finance-newsbecause the current WhatsApp portfolio message is generated here; do not introduce a second scheduler or duplicate portfolio source. - Shared LLQD configuration remains canonical in the portfolio repo, but this first slice can work from the CSV already mounted into
finance-news.
External References
- State Street's Select Sector SPDR pages provide a stable U.S. sector ETF family: XLC, XLY, XLP, XLE, XLF, XLV, XLI, XLB, XLRE, XLK, and XLU.
- iShares official fund pages provide broad global technology and semiconductor ETFs such as IXN and SOXX.
- Japan and regional mappings should start conservative; if local sector ETFs are hard to fetch reliably, use broad market ETFs/indexes plus explicit mapping uncertainty rather than false precision.
---
Key Technical Decisions
- Add a small attribution module instead of growing
summarize.pyfurther: this keeps new benchmark, evidence, and classification logic testable. - Seed benchmarks in a repo-owned JSON config: this satisfies the global taxonomy requirement without coupling this repo to the portfolio repo's internal CSV layout.
- Prefer deterministic evidence gates over article summarization: low-quality feeds are the problem, so the first line of defense should be source/catalyst filtering.
- Use quote
change_percentfrom existing quote fetchers for both stocks and benchmarks: the first pass can compute relative/residual movement without storing time series. - Keep output formatter deterministic and localized through config labels: no LLM should be needed to produce the morning portfolio rows.
---
Open Questions
Resolved During Planning
- Should the first implementation be full article analysis or attribution-first without article dependence? Resolved: attribution-first, with optional gated evidence only.
- Should the benchmark map live only in LLQD/equity-research? Resolved for this PR: no. Seed a repo-owned map in
finance-news; later consolidation can move it once behavior is proven. - Should Yahoo ticker RSS remain a catalyst source? Resolved: not by default. It can supply raw candidates, but Yahoo-style domains are blocked unless a later allowlist decision changes that.
Deferred to Implementation
- Exact threshold constants for "mixed" versus "idiosyncratic": start conservative in code and tune against tests.
- Exact label phrasing in German: implement terse config labels and adjust after snapshot output review.
- Which benchmark mappings are fetchable in the runtime container: seed conservative defaults and treat missing quote data as explicit uncertainty.
---
Implementation Units
- U1. Seed benchmark and evidence configuration
- Goal: Add repo-owned data for benchmark attribution and source-quality gates.
- Requirements: R7, R8, R13, R15, R16, R17, R18.
- Dependencies: None.
- Files:
- Create:
config/portfolio_benchmarks.json - Modify:
config/config.json - Test:
tests/test_portfolio_attribution.py - Approach: Add a compact JSON map with broad market defaults, region defaults, category/sector mappings, theme mappings, ticker overrides, source allowlist/blocklist, and uncertainty defaults. Start with highly liquid U.S. sector ETFs, broad global/region ETFs, semiconductor/technology theme ETFs, and explicit unknown handling.
- Patterns to follow: Existing
config/config.jsonstructure for readable operator-owned config. - Test scenarios:
- Loading config returns broad market, sector/theme, and source-quality defaults.
- A ticker override wins over a generic category mapping.
- Missing category mapping returns an explicit unknown mapping instead of a fabricated benchmark.
- Blocked domains include Yahoo-style aggregator/listicle sources by default.
- Verification: The map can be loaded without network access and exposes uncertainty metadata for unmapped tickers.
- U2. Add attribution model helpers
- Goal: Compute benchmark-relative explanations for portfolio movers.
- Requirements: R2, R3, R11, R12, R13, R14, R18.
- Dependencies: U1.
- Files:
- Create:
scripts/portfolio_attribution.py - Test:
tests/test_portfolio_attribution.py - Approach: Define small data classes for benchmark mapping, quote change, evidence, and attribution result. Given portfolio rows, mover quotes, benchmark quotes, and optional articles, classify moves as market-driven, sector/theme-driven, FX-driven, idiosyncratic, mixed, or unexplained. Compute residual as ticker change minus the best available sector/theme benchmark, with market fallback when no specific benchmark exists.
- Execution note: Implement behavior test-first because threshold mistakes will directly affect user-facing classification.
- Patterns to follow:
MoverContext,SectorCluster, andWatchpointsDatainscripts/summarize.py. - Test scenarios:
- Covers AE1. A Japan industrial mover close to its benchmark renders sector/theme-driven with no confirmed catalyst.
- Covers AE2. A semiconductor mover far away from its benchmark renders idiosyncratic and preserves credible catalyst evidence.
- Covers AE4. A small-cap or unmapped ticker renders quantitative attribution with mapping uncertainty.
- Market benchmark explains a move when sector data is unavailable.
- Residual magnitude sorts ahead of article volume when selecting notable rows.
- Verification: Attribution helpers are pure and unit-testable with injected quote/article fixtures.
- U3. Gate article evidence
- Goal: Only include source evidence when it is credible and catalyst-bearing.
- Requirements: R4, R5, R7, R8, R9, R10, R11, R12.
- Dependencies: U1, U2.
- Files:
- Create or modify:
scripts/portfolio_attribution.py - Test:
tests/test_portfolio_attribution.py - Approach: Implement deterministic source gate helpers using source/domain blocklists, allowlists, and catalyst keywords for earnings, guidance, M&A, regulation, product, capex, supply chain, analyst action, and demand/pricing events. Block generic price-action/listicle language. Return a confidence label for included evidence and "no confirmed catalyst" for everything else.
- Patterns to follow:
is_generic_headline()inscripts/fetch_news.pyand headline normalization helpers inscripts/summarize.py. - Test scenarios:
- Covers AE3. Yahoo/listicle/price-action candidates are blocked and do not create bullets.
- Reuters/WSJ/FT/Bloomberg/CNBC-style candidates with guidance or earnings catalysts pass with confidence.
- A credible source without a concrete catalyst is still suppressed.
- Blocked evidence never appears in formatted source lists.
- Verification: Evidence output is deterministic and does not require live feeds or LLM calls.
- U4. Replace portfolio message rendering
- Goal: Make
build_portfolio_message()render attribution rows instead of article digest rows. - Requirements: R1, R2, R3, R4, R5, R6, R10, R11, R19, R20.
- Dependencies: U1, U2, U3.
- Files:
- Modify:
scripts/summarize.py - Modify:
config/config.json - Test:
tests/test_summarize.py - Approach: Load benchmark config, fetch benchmark quotes for unique mapping tickers, call attribution helpers, and format compact localized rows. Replace source appendix behavior with inline evidence only when evidence is included. Fix price formatting so non-USD tickers do not always print
$. - Patterns to follow: Existing deterministic
build_briefing_summary()and localized label lookups. - Test scenarios:
- Existing portfolio message test updates from article links/source appendix to attribution text.
- German output includes attribution labels and "kein bestätigter Auslöser" for weak/no evidence.
- Source appendix is omitted when no gated evidence exists.
- A credible catalyst appears inline with confidence and one source reference only.
- Japanese ticker price does not render as
$27830.00. - Verification:
build_portfolio_message()can render from fixture data without network when benchmark quote fetcher is injected or monkeypatched.
- U5. Wire benchmark fetching into briefing generation
- Goal: Provide benchmark quote changes to the portfolio formatter during normal briefing runs.
- Requirements: R1, R3, R13, R14, R19.
- Dependencies: U1, U2, U4.
- Files:
- Modify:
scripts/summarize.py - Test:
tests/test_summarize.py - Approach: Keep
get_portfolio_news()as the portfolio stock quote source, but compute the benchmark ticker set from portfolio metadata and benchmark config, fetch those quotes via existingfetch_market_data(), and pass them into the formatter. If fetching fails or times out, render with explicit benchmark uncertainty rather than failing the briefing. - Patterns to follow: Existing fail-soft handling around
get_portfolio_news()andget_portfolio_movers(). - Test scenarios:
- Benchmark fetch failure still renders portfolio rows with uncertainty.
- Unique benchmark ticker set is deduplicated before fetch.
- Fast mode remains bounded and does not fetch unrelated benchmark families.
- Verification: The deterministic briefing path remains fail-open for portfolio attribution.
- U6. Add regression coverage and smoke checks
- Goal: Lock in the new product contract and prevent fallback/link-dump regression.
- Requirements: All requirements, especially R4-R8 and R19-R20.
- Dependencies: U1-U5.
- Files:
- Modify:
tests/test_summarize.py - Modify:
tests/test_portfolio_attribution.py - Optional:
README.md - Approach: Add focused unit tests for the examples above and one end-to-end deterministic JSON generation smoke with mocked market/portfolio/benchmark data. Update README only if existing user-facing docs describe the old source-list portfolio digest.
- Test scenarios:
- End-to-end morning JSON contains attribution-first portfolio text and no
## Sourcesblock for weak evidence. - No test expects generic fallback article bullets.
- No output contains buy/sell/hold advice or price targets.
- Verification: Targeted tests pass, full summarize test file passes, and lint/diff checks are clean.
---
Verification Plan
python3 -m pytest -q tests/test_portfolio_attribution.py tests/test_summarize.pypython3 -m pytest -qgit diff --check
---
Risk Analysis & Mitigation
- Benchmark false precision: A bad sector ETF is worse than no ETF. Mitigate by explicit unknown/mapping-uncertain output and conservative seed mappings.
- Runtime cost: Fetching many benchmark tickers can slow the morning cron. Mitigate through deduplication, broad defaults, and fail-soft behavior.
- Source overblocking: Blocking Yahoo-style feeds may remove some useful articles. Accept this in v1 because the current failure mode is noisy evidence; credible source expansion can be added later.
- Cross-repo taxonomy drift: This repo's seeded map may diverge from portfolio taxonomy. Keep the map small and operator-owned for now; consolidate later only after the attribution product shape proves useful.
fix: Improve portfolio headline quality ranking
Summary
Reduce low-value portfolio article picks (for example, repetitive "X vs Y value stock" templates) by adding deterministic quality scoring and configurable pattern penalties while keeping Yahoo enabled as a source.
---
Problem Frame
Morning portfolio output can surface weak templated articles even when they add little catalyst value. Current portfolio evidence selection stops at the first passing item instead of ranking all candidates by quality.
---
Requirements
- R1. Keep Yahoo available as a source; do not globally block the domain.
- R2. Demote templated low-information headlines (for example, "which stock is better value", "X vs Y") deterministically.
- R3. Select the best available per-ticker article using ranked quality signals instead of first-match behavior.
- R4. Keep behavior configurable via JSON, not hard-coded-only thresholds.
- R5. Add regression tests proving low-value templates are filtered/demoted and higher-value catalyst stories win.
---
Scope Boundaries
- Do not redesign macro headline ranking architecture.
- Do not change cron scheduling or delivery transport behavior in this plan.
- Do not remove Yahoo from feed configuration.
---
Context & Research
Relevant Code and Patterns
scripts/portfolio_attribution.pycurrently uses_is_generic_article,_has_catalyst, andevidence_for_articles(...)with first-match selection.scripts/ranking.pyalready has deterministic score composition and is suitable as a reference style for weighted scoring.config/portfolio_benchmarks.jsoncontrols source allow/block policy and should hold quality configuration.
Institutional Learnings
- Existing tests already codify quality guardrails for portfolio evidence and should be extended in place.
External References
- None required; repo-local behavior and fixtures are sufficient.
---
Key Technical Decisions
- Add a two-stage deterministic portfolio evidence selector:
1. Eligibility gate (title/source/domain/basic catalyst checks) 2. Candidate scoring/ranking (quality score) and choose highest score
- Move low-value title-template controls into
config/portfolio_benchmarks.jsonso tuning does not require code edits. - Keep Yahoo allowed, but apply the same template penalties and quality threshold rules as other sources.
---
Open Questions
Resolved During Planning
- Should Yahoo be removed to avoid weak content? No; keep Yahoo and demote low-value templates via scoring and thresholds.
Deferred to Implementation
- Exact penalty weights and minimum score cutoff values after test calibration.
---
Implementation Units
- U1. Add Configurable Portfolio Evidence Quality Signals
Goal: Introduce config keys for low-value template patterns, source/domain bonuses, and minimum evidence score.
Requirements: R1, R2, R4
Dependencies: None
Files:
- Modify:
config/portfolio_benchmarks.json - Test:
tests/test_portfolio_attribution.py
Approach:
- Add a
quality_scoringsubsection undersource_qualitywith: - template penalty patterns (
vs,better value stock,outpacing peers, etc.) - optional source/domain score adjustments
- minimum score threshold for acceptance
- Keep
finance.yahoo.comout of blocked domains.
Patterns to follow:
- Existing config-driven quality controls in
source_quality.
Test scenarios:
- Happy path: Yahoo catalyst article with strong signal and no low-value template is accepted by config policy.
- Edge case: Unknown source with strong catalyst title remains eligible but must pass threshold.
- Error path: Missing optional scoring keys falls back to safe defaults.
Verification:
- Config loads without schema/runtime errors and tests pass with new keys.
---
- U2. Implement Ranked Evidence Selection for Portfolio Articles
Goal: Replace first-match evidence selection with deterministic best-candidate ranking.
Requirements: R2, R3, R5
Dependencies: U1
Files:
- Modify:
scripts/portfolio_attribution.py - Test:
tests/test_portfolio_attribution.py
Approach:
- Add candidate scoring helper(s) for portfolio article quality:
- positive signals: catalyst keywords, allowed source/domain, recency
- negative signals: low-value template regex/pattern matches
- Evaluate all eligible articles, rank by score, and return highest-scoring candidate above threshold.
- Preserve fail-safe behavior: if no candidate passes, return
Noneevidence.
Patterns to follow:
- Deterministic scoring composition style in
scripts/ranking.py.
Test scenarios:
- Happy path: Higher-signal Reuters/Bloomberg article beats weaker candidate.
- Happy path: High-quality Yahoo article can win when it outranks alternatives.
- Edge case: Two Yahoo template titles with "X vs Y value stock" are rejected or demoted below threshold.
- Integration: Mixed article list returns exactly one
Evidenceobject from the highest-scoring valid candidate. - Error path: Articles with missing link/source/title do not crash and are skipped deterministically.
Verification:
- Portfolio attribution tests pass and selected evidence matches expected top candidate.
---
- U3. Validate End-to-End Morning Portfolio Output Quality
Goal: Confirm live JSON output no longer elevates low-value template stories after rebuild.
Requirements: R1, R2, R5
Dependencies: U2
Files:
- Modify:
README.md
Approach:
- Rebuild
finance-news-briefingimage after changes. - Run morning JSON generation with portfolio CSV mount and inspect
portfolio_message. - Capture source distribution and representative selected titles for sanity-check notes.
- Document image refresh expectation to prevent stale-image false negatives.
Patterns to follow:
- Existing docker-run based smoke workflow in cron wrappers.
Test scenarios:
- Integration: Morning JSON run completes and emits portfolio section without selected low-value template headlines.
- Integration: Yahoo remains present in candidate universe; only low-value templates are suppressed.
Verification:
- Smoke output and tests show improved headline mix with Yahoo still available.
---
System-Wide Impact
- Interaction graph: Portfolio article selection impacts morning portfolio content quality and downstream localization.
- Error propagation: No change to fail-open behavior; missing evidence continues to produce non-crashing output.
- State lifecycle risks: None; logic is stateless and per-run deterministic.
- API surface parity: JSON output schema remains unchanged.
- Integration coverage: Requires both unit tests and docker-backed smoke checks to avoid stale-image mismatches.
---
Risks & Dependencies
| Risk | Mitigation |
|---|---|
| Over-penalizing valid comparative analysis headlines | Keep penalties configurable and test with mixed fixtures |
| Stale Docker image hides code changes in smoke tests | Force image rebuild before validation |
| Threshold too strict causing no evidence coverage | Add tests for threshold calibration and adjust defaults conservatively |
---
Documentation / Operational Notes
- Add a short note in
README.mdthat morning smoke validation should rebuild image after ranking/evidence changes.
---
Sources & References
- Related code:
scripts/portfolio_attribution.py - Related code:
scripts/ranking.py - Related tests:
tests/test_portfolio_attribution.py - Related tests:
tests/test_ranking.py
Premium Source Authentication
Contents
- Overview
- Option 1: Keep It Simple (Recommended)
- Option 2: Use Premium Sources (Advanced)
- Troubleshooting
- Alternative: Use APIs Instead
- Recommendation
Overview
WSJ and Barron's are premium financial news sources that require subscriptions. This guide explains how to authenticate and use premium sources with the finance-news skill.
Recommendation: For simplicity, we recommend using free sources only (Yahoo Finance, CNBC, MarketWatch). Premium sources add complexity and maintenance burden.
If you have subscriptions and want premium content, follow the steps below.
---
Option 1: Keep It Simple (Recommended)
Use free sources only. They provide 90% of the value without authentication complexity:
- ✅ Yahoo Finance (free, reliable)
- ✅ CNBC (free, real-time news)
- ✅ MarketWatch (free, broad coverage)
- ✅ Reuters (free via Yahoo RSS)
To disable premium sources: 1. Edit config/config.json (legacy: config/sources.json) 2. Set "enabled": false for WSJ/Barron's entries 3. Done - no authentication needed
---
Option 2: Use Premium Sources (Advanced)
Prerequisites
- Active WSJ or Barron's subscription
- Browser with active login session (Chrome/Firefox)
- Option B only: Install
requestslibrary if needed:
pip install requestsStep 1: Export Cookies from Browser
Chrome: 1. Install extension: EditThisCookie 2. Navigate to wsj.com (logged in) 3. Click EditThisCookie icon → Export → Copy JSON
Firefox: 1. Install extension: Cookie Quick Manager 2. Navigate to wsj.com (logged in) 3. Right-click page → Inspect → Storage → Cookies 4. Copy relevant cookies (see format below)
Step 2: Create Cookie File
Create config/cookies.json (this file is gitignored):
{
"feeds.a.dj.com": {
"wsjgeo": "US",
"djcs_session": "YOUR_SESSION_TOKEN_HERE",
"djcs_route": "YOUR_ROUTE_HERE"
},
"www.barrons.com": {
"wsjgeo": "US",
"djcs_session": "YOUR_SESSION_TOKEN_HERE"
}
}Important: Cookie domain must match feed URL domain:
- WSJ feeds use
feeds.a.dj.com(notwsj.com) - Barron's feeds use
www.barrons.com - Check
config/config.jsonfor actual feed URLs
Note: Cookie names/values vary by site. Export from browser to get actual values.
Step 3: Pass Cookies to fetch_news.py
Option A: Modify fetch_news.py (not officially supported)
Add cookie loading to fetch_rss() function (maintains existing signature):
import json
import urllib.request
from pathlib import Path
from urllib.parse import urlparse
def fetch_rss(url: str, limit: int = 10) -> list[dict]:
"""Fetch and parse RSS feed with optional cookie authentication."""
# Load cookies if they exist
cookie_file = Path(__file__).parent.parent / "config" / "cookies.json"
cookies = {}
if cookie_file.exists():
with open(cookie_file) as f:
all_cookies = json.load(f)
# Extract domain from URL (e.g., feeds.a.dj.com)
domain = urlparse(url).netloc
cookies = all_cookies.get(domain, {})
# Fetch with cookies and User-Agent
req = urllib.request.Request(url, headers={'User-Agent': 'OpenClaw/1.0'})
if cookies:
cookie_header = "; ".join([f"{k}={v}" for k, v in cookies.items()])
req.add_header("Cookie", cookie_header)
# ... rest of function (unchanged)Note: This is a doc-only suggestion, not officially supported by the skill.
Option B: Use requests library instead of urllib
Replace urllib with requests for easier cookie handling (maintains API signature):
import requests
def fetch_rss(url: str, limit: int = 10, cookies_dict: dict = None) -> list[dict]:
response = requests.get(url, cookies=cookies_dict, timeout=10)
response.raise_for_status()
# ... parse with feedparserStep 4: Security Considerations
Critical: Do NOT commit cookies to git
1. `.gitignore` already includes cookie files:
config/cookies.json*.cookie- No action needed (already configured)
2. Set restrictive file permissions:
chmod 600 config/cookies.json2. Set restrictive file permissions:
chmod 600 config/cookies.json3. Rotate cookies regularly:
- Browser session cookies expire (usually 7-30 days)
- Re-export cookies when authentication fails
4. Never share cookie files:
- Cookies grant full account access
- Treat like passwords
---
Troubleshooting
"HTTP 403 Forbidden" errors
Cause: Cookies expired or invalid
Fix: 1. Log in to WSJ/Barron's in browser 2. Re-export cookies 3. Update config/cookies.json
"Paywall detected" in articles
Cause: RSS feed doesn't require auth, but full article does
Fix:
- Premium sources often provide headlines/snippets in RSS (no auth needed)
- Full articles require subscription + cookie auth
- If you only need headlines → no cookies needed
Cookies not working
Debug checklist:
- [ ] Correct domain in cookies.json:
- WSJ: Use
feeds.a.dj.com(notwsj.com) - Barron's: Use
www.barrons.com(notbarrons.com) - Check
config/config.jsonfor actual feed URLs - [ ] Cookie values copied completely (no truncation)
- [ ] Browser session still active (test by visiting site)
- [ ] File permissions correct (chmod 600)
---
Alternative: Use APIs Instead
Some premium sources offer APIs:
- WSJ API: Not publicly available
- Barron's API: Part of Dow Jones API (enterprise only)
- Bloomberg API: Enterprise only
Conclusion: Cookie-based auth is the only practical option for individual users.
---
Recommendation
For most users: Stick with free sources. They're reliable, no auth needed, and provide comprehensive market coverage.
For premium subscribers: Follow Option 2, but be prepared to maintain cookie files and handle expiration.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright
owner or by an individual or Legal Entity authorized to submit on behalf
of the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 Martin Kessler
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
[project]
name = "finance-news"
version = "1.0.0"
description = "Finance news aggregation and market briefing skill for OpenClaw"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "Martin Kessler", email = "martin@kessler.io" }]
dependencies = [
"feedparser>=6.0.11",
"yfinance>=0.2.40",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"ruff>=0.4",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["scripts"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v --tb=short"
[tool.coverage.run]
source = ["scripts"]
omit = ["scripts/__pycache__/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if __name__ == .__main__.:",
"raise NotImplementedError",
]
fail_under = 30 # Current coverage is ~39%
[tool.ruff]
line-length = 120
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP"]
ignore = ["E501"]
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-v
--strict-markers
--tb=short
--cov=scripts
--cov-report=term-missing
--cov-report=html
Finance News Skill for OpenClaw
AI-powered market news briefings with configurable language output and automated delivery.
Features
- Multi-source aggregation: Reuters, WSJ, FT, Bloomberg, CNBC, Yahoo Finance, Tagesschau, Handelsblatt
- Global markets: US (S&P, Dow, NASDAQ), Europe (DAX, STOXX, FTSE), Japan (Nikkei)
- AI summaries: LLM-powered analysis in German or English
- Automated briefings: Morning (market open) and evening (market close)
- WhatsApp/Telegram delivery: Send briefings via openclaw
- Portfolio tracking: Personalized news for your stocks with price alerts
- Lobster workflows: Approval gates before sending
Quick Start
Docker (Recommended)
# Build the Docker image
docker build -t finance-news-briefing .
# Generate a briefing
docker run --rm -v "$PWD/config:/app/config:ro" \
finance-news-briefing python3 scripts/briefing.py \
--time morning --lang de --json --fastIf you change ranking, attribution, or source-quality logic, rebuild the image before smoke tests so cron-style runs do not use stale code.
Lobster Workflow
# Set required environment variables
export FINANCE_NEWS_TARGET="your-group-jid@g.us" # WhatsApp JID or Telegram chat ID
export FINANCE_NEWS_CHANNEL="whatsapp" # or "telegram"
# Run workflow (halts for approval before sending)
lobster run workflows/briefing.yaml --args-json '{"time":"morning","lang":"de"}'CLI (Legacy)
# Generate a briefing
finance-news briefing --morning --lang de
# Use fast mode + deadline (recommended)
finance-news briefing --morning --lang de --fast --deadline 300Environment Variables
| Variable | Description | Example |
|---|---|---|
FINANCE_NEWS_TARGET | Delivery target (WhatsApp JID, group name, or Telegram chat ID) | Required |
FINANCE_NEWS_CHANNEL | Delivery channel | whatsapp or telegram |
FINANCE_NEWS_OLLAMA_KIMI_MODEL | Ollama model used for Kimi summaries/research | kimi-k2.6:cloud |
OLLAMA_KIMI_MODEL | Secondary override for the Ollama Kimi model | kimi-k2.6:cloud |
SKILL_DIR | Path to skill directory (for Lobster) | $HOME/projects/finance-news-openclaw-skill |
LLM generation defaults to ollama run kimi-k2.6:cloud <prompt> and falls back to AI_MODEL=gemini-3.5-flash-medium agy -p <prompt>. Deep research uses gemini-3.5-flash-high by default. Override with FINANCE_NEWS_AGY_MODEL or AGY_MODEL.
Installation
Option 1: Docker (Recommended)
git clone https://github.com/kesslerio/finance-news-openclaw-skill.git
cd finance-news-openclaw-skill
docker build -t finance-news-briefing .Option 2: Native Python
# Clone repository
git clone https://github.com/kesslerio/finance-news-openclaw-skill.git \
~/openclaw/skills/finance-news
# Create virtual environment
cd ~/openclaw/skills/finance-news
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# Create CLI symlink
ln -sf ~/openclaw/skills/finance-news/scripts/finance-news ~/.local/bin/finance-newsConfiguration
Configuration is stored in config/config.json:
- RSS Feeds: Enable/disable news sources per region
- Markets: Choose which indices to track
- Delivery: WhatsApp/Telegram settings
- Language: German (
de) or English (en) output - Schedule: Cron times for morning/evening briefings
- LLM: Model order preference for headlines, summaries, translations
Run the setup wizard for interactive configuration:
finance-news setupLobster Workflow
The skill includes a Lobster workflow (workflows/briefing.yaml) that:
1. Generates briefing via Docker 2. Translates portfolio headlines (German only, via openclaw) 3. Halts for approval (shows preview) 4. Sends macro briefing to channel 5. Sends portfolio briefing to channel
Workflow Arguments
| Arg | Default | Description |
|---|---|---|
time | morning | Briefing type: morning or evening |
lang | de | Language: en or de |
channel | env var | whatsapp or telegram |
target | env var | Group JID/name or chat ID |
fast | false | Use fast mode (shorter timeouts) |
Portfolio
Manage your stock watchlist in config/portfolio.csv:
finance-news portfolio-list # View portfolio
finance-news portfolio-add NVDA # Add stock
finance-news portfolio-remove TSLA # Remove stock
finance-news portfolio-import stocks.csv # Import from CSVPortfolio briefings show:
- Top gainers and losers from your holdings
- Relevant news articles with translations
- Shortened hyperlinks for easy access
Dependencies
- Python 3.10+
- Docker (recommended)
- openclaw CLI (for message delivery and LLM)
- Lobster (for workflow automation)
Optional
- OpenBB (
openbb-quote) for enhanced market data
License
Apache 2.0 - See LICENSE file for details.
Related Skills
- [task-tracker](https://github.com/kesslerio/task-tracker-openclaw-skill): Personal task management with daily standups
# Test dependencies
pytest>=7.4.0
pytest-cov>=4.1.0
pytest-mock>=3.12.0
feedparser>=6.0.11
yfinance
#!/usr/bin/env bash
set -euo pipefail
resolve_openbb_python() {
if [[ -n "${OPENBB_PYTHON:-}" ]]; then
if [[ -x "${OPENBB_PYTHON}" ]]; then
printf "%s\n" "${OPENBB_PYTHON}"
return 0
fi
echo "OPENBB_PYTHON is set but not executable: ${OPENBB_PYTHON}" >&2
return 1
fi
local venv_python="${HOME}/.local/venvs/openbb/bin/python3"
if [[ -x "${venv_python}" ]]; then
printf "%s\n" "${venv_python}"
return 0
fi
if command -v python3 >/dev/null 2>&1; then
command -v python3
return 0
fi
echo "No usable python3 found for OpenBB wrappers." >&2
return 1
}
resolve_openbb_provider() {
local explicit="${1:-}"
local fallback="${2:-yfinance}"
if [[ -n "${explicit}" ]]; then
printf "%s\n" "${explicit}"
elif [[ -n "${OPENBB_DEFAULT_PROVIDER:-}" ]]; then
printf "%s\n" "${OPENBB_DEFAULT_PROVIDER}"
else
printf "%s\n" "${fallback}"
fi
}
run_openbb_python() {
local script=""
script="$(cat)"
local py=""
py="$(resolve_openbb_python)" || return 1
"${py}" 2>&1 <<<"${script}"
}
#!/usr/bin/env python3
"""
Price Target Alerts - Track buy zone alerts for stocks.
Features:
- Set price target alerts (buy zone triggers)
- Check alerts against current prices
- Snooze, update, delete alerts
- Multi-currency support (USD, EUR, JPY, SGD, MXN)
Usage:
alerts.py list # Show all alerts
alerts.py set CRWD 400 --note 'Kaufzone' # Set alert
alerts.py check # Check triggered alerts
alerts.py delete CRWD # Delete alert
alerts.py snooze CRWD --days 7 # Snooze for 7 days
alerts.py update CRWD 380 # Update target price
"""
import argparse
import json
import sys
from datetime import datetime, timedelta
from pathlib import Path
from utils import ensure_venv
ensure_venv()
# Lazy import to avoid numpy issues at module load
fetch_market_data = None
def get_fetch_market_data():
global fetch_market_data
if fetch_market_data is None:
from fetch_news import fetch_market_data as fmd
fetch_market_data = fmd
return fetch_market_data
SCRIPT_DIR = Path(__file__).parent
CONFIG_DIR = SCRIPT_DIR.parent / "config"
ALERTS_FILE = CONFIG_DIR / "alerts.json"
SUPPORTED_CURRENCIES = ["USD", "EUR", "JPY", "SGD", "MXN"]
def load_alerts() -> dict:
"""Load alerts from JSON file."""
if not ALERTS_FILE.exists():
return {"_meta": {"version": 1, "supported_currencies": SUPPORTED_CURRENCIES}, "alerts": []}
return json.loads(ALERTS_FILE.read_text())
def save_alerts(data: dict) -> None:
"""Save alerts to JSON file."""
data["_meta"]["updated_at"] = datetime.now().isoformat()
ALERTS_FILE.write_text(json.dumps(data, indent=2))
def get_alert_by_ticker(alerts: list, ticker: str) -> dict | None:
"""Find alert by ticker."""
ticker = ticker.upper()
for alert in alerts:
if alert["ticker"] == ticker:
return alert
return None
def format_price(price: float, currency: str) -> str:
"""Format price with currency symbol."""
symbols = {"USD": "$", "EUR": "€", "JPY": "¥", "SGD": "S$", "MXN": "MX$"}
symbol = symbols.get(currency, currency + " ")
if currency == "JPY":
return f"{symbol}{price:,.0f}"
return f"{symbol}{price:,.2f}"
def cmd_list(args) -> None:
"""List all alerts."""
data = load_alerts()
alerts = data.get("alerts", [])
if not alerts:
print("📭 No price alerts set")
return
print(f"📊 Price Alerts ({len(alerts)} total)\n")
now = datetime.now()
active = []
snoozed = []
for alert in alerts:
snooze_until = alert.get("snooze_until")
if snooze_until and datetime.fromisoformat(snooze_until) > now:
snoozed.append(alert)
else:
active.append(alert)
if active:
print("### Active Alerts")
for a in active:
target = format_price(a["target_price"], a.get("currency", "USD"))
note = f' — "{a["note"]}"' if a.get("note") else ""
user = f" (by {a['set_by']})" if a.get("set_by") else ""
print(f" • {a['ticker']}: {target}{note}{user}")
print()
if snoozed:
print("### Snoozed")
for a in snoozed:
target = format_price(a["target_price"], a.get("currency", "USD"))
until = datetime.fromisoformat(a["snooze_until"]).strftime("%Y-%m-%d")
print(f" • {a['ticker']}: {target} (until {until})")
print()
def cmd_set(args) -> None:
"""Set a new alert."""
data = load_alerts()
alerts = data.get("alerts", [])
ticker = args.ticker.upper()
# Check if alert exists
existing = get_alert_by_ticker(alerts, ticker)
if existing:
print(f"⚠️ Alert for {ticker} already exists. Use 'update' to change target.")
return
# Validate target price
if args.target <= 0:
print(f"❌ Target price must be greater than 0")
return
currency = args.currency.upper() if args.currency else "USD"
if currency not in SUPPORTED_CURRENCIES:
print(f"❌ Currency {currency} not supported. Use: {', '.join(SUPPORTED_CURRENCIES)}")
return
# Warn about currency mismatch based on ticker suffix
ticker_currency_map = {
".T": "JPY", # Tokyo
".SI": "SGD", # Singapore
".MX": "MXN", # Mexico
".DE": "EUR", ".F": "EUR", ".PA": "EUR", # Europe
}
expected_currency = "USD" # Default for US stocks
for suffix, curr in ticker_currency_map.items():
if ticker.endswith(suffix):
expected_currency = curr
break
if currency != expected_currency:
print(f"⚠️ Warning: {ticker} trades in {expected_currency}, but alert set in {currency}")
# Fetch current price (optional - may fail if numpy broken)
current_price = None
try:
quotes = get_fetch_market_data()([ticker], timeout=10)
if ticker in quotes and quotes[ticker].get("price"):
current_price = quotes[ticker]["price"]
except Exception as e:
print(f"⚠️ Could not fetch current price: {e}", file=sys.stderr)
alert = {
"ticker": ticker,
"target_price": args.target,
"currency": currency,
"note": args.note or "",
"set_by": args.user or "",
"set_date": datetime.now().strftime("%Y-%m-%d"),
"status": "active",
"snooze_until": None,
"triggered_count": 0,
"last_triggered": None,
}
alerts.append(alert)
data["alerts"] = alerts
save_alerts(data)
target_str = format_price(args.target, currency)
print(f"✅ Alert set: {ticker} under {target_str}")
if current_price:
pct_diff = ((current_price - args.target) / current_price) * 100
current_str = format_price(current_price, currency)
print(f" Current: {current_str} ({pct_diff:+.1f}% to target)")
def cmd_delete(args) -> None:
"""Delete an alert."""
data = load_alerts()
alerts = data.get("alerts", [])
ticker = args.ticker.upper()
new_alerts = [a for a in alerts if a["ticker"] != ticker]
if len(new_alerts) == len(alerts):
print(f"❌ No alert found for {ticker}")
return
data["alerts"] = new_alerts
save_alerts(data)
print(f"🗑️ Alert deleted: {ticker}")
def cmd_snooze(args) -> None:
"""Snooze an alert."""
data = load_alerts()
alerts = data.get("alerts", [])
ticker = args.ticker.upper()
alert = get_alert_by_ticker(alerts, ticker)
if not alert:
print(f"❌ No alert found for {ticker}")
return
days = args.days or 7
snooze_until = datetime.now() + timedelta(days=days)
alert["snooze_until"] = snooze_until.isoformat()
save_alerts(data)
print(f"😴 Alert snoozed: {ticker} until {snooze_until.strftime('%Y-%m-%d')}")
def cmd_update(args) -> None:
"""Update alert target price."""
data = load_alerts()
alerts = data.get("alerts", [])
ticker = args.ticker.upper()
alert = get_alert_by_ticker(alerts, ticker)
if not alert:
print(f"❌ No alert found for {ticker}")
return
# Validate target price
if args.target <= 0:
print(f"❌ Target price must be greater than 0")
return
old_target = alert["target_price"]
alert["target_price"] = args.target
if args.note:
alert["note"] = args.note
save_alerts(data)
currency = alert.get("currency", "USD")
old_str = format_price(old_target, currency)
new_str = format_price(args.target, currency)
print(f"✏️ Alert updated: {ticker} {old_str} → {new_str}")
def cmd_check(args) -> None:
"""Check alerts against current prices."""
data = load_alerts()
alerts = data.get("alerts", [])
if not alerts:
if args.json:
print(json.dumps({"triggered": [], "watching": []}))
else:
print("📭 No alerts to check")
return
now = datetime.now()
active_alerts = []
for alert in alerts:
snooze_until = alert.get("snooze_until")
if snooze_until and datetime.fromisoformat(snooze_until) > now:
continue
active_alerts.append(alert)
if not active_alerts:
if args.json:
print(json.dumps({"triggered": [], "watching": []}))
else:
print("📭 All alerts snoozed")
return
# Fetch prices for all active alerts
tickers = [a["ticker"] for a in active_alerts]
quotes = get_fetch_market_data()(tickers, timeout=30)
triggered = []
watching = []
for alert in active_alerts:
ticker = alert["ticker"]
target = alert["target_price"]
currency = alert.get("currency", "USD")
quote = quotes.get(ticker, {})
price = quote.get("price")
if price is None:
continue
# Divide-by-zero protection
if target == 0:
pct_diff = 0
else:
pct_diff = ((price - target) / target) * 100
result = {
"ticker": ticker,
"target_price": target,
"current_price": price,
"currency": currency,
"pct_from_target": round(pct_diff, 2),
"note": alert.get("note", ""),
"set_by": alert.get("set_by", ""),
}
if price <= target:
triggered.append(result)
# Update triggered count (only once per day to avoid inflation)
last_triggered = alert.get("last_triggered")
today = now.strftime("%Y-%m-%d")
if not last_triggered or not last_triggered.startswith(today):
alert["triggered_count"] = alert.get("triggered_count", 0) + 1
alert["last_triggered"] = now.isoformat()
else:
watching.append(result)
save_alerts(data)
if args.json:
print(json.dumps({"triggered": triggered, "watching": watching}, indent=2))
return
# Translations
lang = getattr(args, 'lang', 'en')
if lang == "de":
labels = {
"title": "PREISWARNUNGEN",
"in_zone": "IN KAUFZONE",
"buy": "KAUFEN!",
"target": "Ziel",
"watching": "BEOBACHTUNG",
"to_target": "noch",
"no_data": "Keine Preisdaten für Alerts verfügbar",
}
else:
labels = {
"title": "PRICE ALERTS",
"in_zone": "IN BUY ZONE",
"buy": "BUY SIGNAL",
"target": "target",
"watching": "WATCHING",
"to_target": "to target",
"no_data": "No price data available for alerts",
}
# Date header
date_str = datetime.now().strftime("%b %d, %Y") if lang == "en" else datetime.now().strftime("%d. %b %Y")
print(f"📊 {labels['title']} — {date_str}\n")
# Human-readable output
if triggered:
print(f"🟢 {labels['in_zone']}:\n")
for t in triggered:
target_str = format_price(t["target_price"], t["currency"])
current_str = format_price(t["current_price"], t["currency"])
note = f'\n "{t["note"]}"' if t.get("note") else ""
user = f" — {t['set_by']}" if t.get("set_by") else ""
print(f"• {t['ticker']}: {current_str} ({labels['target']}: {target_str}) ← {labels['buy']}{note}{user}")
print()
if watching:
print(f"⏳ {labels['watching']}:\n")
for w in sorted(watching, key=lambda x: x["pct_from_target"]):
target_str = format_price(w["target_price"], w["currency"])
current_str = format_price(w["current_price"], w["currency"])
print(f"• {w['ticker']}: {current_str} ({labels['target']}: {target_str}) — {labels['to_target']} {abs(w['pct_from_target']):.1f}%")
print()
if not triggered and not watching:
print(f"📭 {labels['no_data']}")
def check_alerts() -> dict:
"""
Check alerts and return results for briefing integration.
Returns: {"triggered": [...], "watching": [...]}
"""
data = load_alerts()
alerts = data.get("alerts", [])
if not alerts:
return {"triggered": [], "watching": []}
now = datetime.now()
active_alerts = [
a for a in alerts
if not a.get("snooze_until") or datetime.fromisoformat(a["snooze_until"]) <= now
]
if not active_alerts:
return {"triggered": [], "watching": []}
tickers = [a["ticker"] for a in active_alerts]
quotes = get_fetch_market_data()(tickers, timeout=30)
triggered = []
watching = []
for alert in active_alerts:
ticker = alert["ticker"]
target = alert["target_price"]
currency = alert.get("currency", "USD")
quote = quotes.get(ticker, {})
price = quote.get("price")
if price is None:
continue
# Divide-by-zero protection
if target == 0:
pct_diff = 0
else:
pct_diff = ((price - target) / target) * 100
result = {
"ticker": ticker,
"target_price": target,
"current_price": price,
"currency": currency,
"pct_from_target": round(pct_diff, 2),
"note": alert.get("note", ""),
"set_by": alert.get("set_by", ""),
}
if price <= target:
triggered.append(result)
# Update triggered count (only once per day to avoid inflation)
last_triggered = alert.get("last_triggered")
today = now.strftime("%Y-%m-%d")
if not last_triggered or not last_triggered.startswith(today):
alert["triggered_count"] = alert.get("triggered_count", 0) + 1
alert["last_triggered"] = now.isoformat()
else:
watching.append(result)
save_alerts(data)
return {"triggered": triggered, "watching": watching}
def main():
parser = argparse.ArgumentParser(description="Price target alerts")
subparsers = parser.add_subparsers(dest="command", required=True)
# list
subparsers.add_parser("list", help="List all alerts")
# set
set_parser = subparsers.add_parser("set", help="Set new alert")
set_parser.add_argument("ticker", help="Stock ticker")
set_parser.add_argument("target", type=float, help="Target price")
set_parser.add_argument("--note", help="Note/reason")
set_parser.add_argument("--user", help="Who set the alert")
set_parser.add_argument("--currency", default="USD", help="Currency (USD, EUR, JPY, SGD, MXN)")
# delete
del_parser = subparsers.add_parser("delete", help="Delete alert")
del_parser.add_argument("ticker", help="Stock ticker")
# snooze
snooze_parser = subparsers.add_parser("snooze", help="Snooze alert")
snooze_parser.add_argument("ticker", help="Stock ticker")
snooze_parser.add_argument("--days", type=int, default=7, help="Days to snooze")
# update
update_parser = subparsers.add_parser("update", help="Update alert target")
update_parser.add_argument("ticker", help="Stock ticker")
update_parser.add_argument("target", type=float, help="New target price")
update_parser.add_argument("--note", help="Update note")
# check
check_parser = subparsers.add_parser("check", help="Check alerts against prices")
check_parser.add_argument("--json", action="store_true", help="JSON output")
check_parser.add_argument("--lang", default="en", help="Output language (en, de)")
args = parser.parse_args()
if args.command == "list":
cmd_list(args)
elif args.command == "set":
cmd_set(args)
elif args.command == "delete":
cmd_delete(args)
elif args.command == "snooze":
cmd_snooze(args)
elif args.command == "update":
cmd_update(args)
elif args.command == "check":
cmd_check(args)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Briefing Generator - Main entry point for market briefings.
Generates and optionally sends to WhatsApp group.
"""
import argparse
import json
import os
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from utils import ensure_venv
SCRIPT_DIR = Path(__file__).parent
ensure_venv()
def send_to_whatsapp(message: str, group_name: str | None = None):
"""Send message to WhatsApp group via openclaw message tool."""
if not group_name:
group_name = os.environ.get('FINANCE_NEWS_TARGET', '')
if not group_name:
print("❌ No target specified. Set FINANCE_NEWS_TARGET env var or use --group", file=sys.stderr)
return False
# Use openclaw message tool
try:
result = subprocess.run(
[
'openclaw', 'message', 'send',
'--channel', 'whatsapp',
'--target', group_name,
'--message', message
],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
print(f"✅ Sent to WhatsApp group: {group_name}", file=sys.stderr)
return True
else:
print(f"⚠️ WhatsApp send failed: {result.stderr}", file=sys.stderr)
return False
except Exception as e:
print(f"❌ WhatsApp error: {e}", file=sys.stderr)
return False
def generate_and_send(args):
"""Generate briefing and optionally send to WhatsApp."""
# Determine briefing type based on current time or args
if args.time:
briefing_time = args.time
else:
hour = datetime.now().hour
briefing_time = 'morning' if hour < 12 else 'evening'
# Generate the briefing
cmd = [
sys.executable, SCRIPT_DIR / 'summarize.py',
'--time', briefing_time,
'--style', args.style,
'--lang', args.lang
]
if args.deadline is not None:
cmd.extend(['--deadline', str(args.deadline)])
if args.fast:
cmd.append('--fast')
force_llm = bool(args.llm or args.style == 'briefing')
if force_llm:
cmd.append('--llm')
cmd.extend(['--model', args.model])
if args.debug:
cmd.append('--debug')
# Always use JSON for internal processing to handle splits
cmd.append('--json')
print(f"📊 Generating {briefing_time} briefing...", file=sys.stderr)
timeout = args.deadline if args.deadline is not None else 300
timeout = max(1, int(timeout))
if args.deadline is not None:
timeout = timeout + 5
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=None,
text=True,
stdin=subprocess.DEVNULL,
timeout=timeout
)
if result.returncode != 0:
print("❌ Briefing generation failed", file=sys.stderr)
sys.exit(1)
try:
data = json.loads(result.stdout.strip())
except json.JSONDecodeError:
# Fallback if not JSON (shouldn't happen with --json)
print(f"⚠️ Failed to parse briefing JSON", file=sys.stderr)
print(result.stdout)
return result.stdout
# Output handling
if args.json:
print(json.dumps(data, indent=2))
else:
# Print for humans
if data.get('macro_message'):
print(data['macro_message'])
if data.get('portfolio_message'):
print("\n" + "="*20 + "\n")
print(data['portfolio_message'])
# Send to WhatsApp if requested
if args.send and args.group:
# Message 1: Macro
macro_msg = data.get('macro_message') or data.get('summary', '')
if macro_msg:
send_to_whatsapp(macro_msg, args.group)
# Message 2: Portfolio (if exists)
portfolio_msg = data.get('portfolio_message')
if portfolio_msg:
send_to_whatsapp(portfolio_msg, args.group)
return data.get('macro_message', '')
def main():
parser = argparse.ArgumentParser(description='Briefing Generator')
parser.add_argument('--time', choices=['morning', 'evening'],
help='Briefing type (auto-detected if not specified)')
parser.add_argument('--style', choices=['briefing', 'analysis', 'headlines'],
default='briefing', help='Summary style')
parser.add_argument('--lang', choices=['de', 'en'], default='en',
help='Output language')
parser.add_argument('--send', action='store_true',
help='Send to WhatsApp group')
parser.add_argument('--group', default=os.environ.get('FINANCE_NEWS_TARGET', ''),
help='WhatsApp group name or JID (default: FINANCE_NEWS_TARGET env var)')
parser.add_argument('--json', action='store_true',
help='Output as JSON')
parser.add_argument('--deadline', type=int, default=None,
help='Overall deadline in seconds')
parser.add_argument('--llm', action='store_true', help='Force LLM summary for non-briefing styles')
parser.add_argument('--model', choices=['kimi', 'gemini'],
default='kimi', help='Summary model override')
parser.add_argument('--fast', action='store_true',
help='Use fast mode (shorter timeouts, fewer items)')
parser.add_argument('--debug', action='store_true',
help='Write debug log with sources')
args = parser.parse_args()
generate_and_send(args)
if __name__ == '__main__':
main()
#!/usr/bin/env bash
# Finance News CLI - Main entry point
# Usage: finance-news <command> [options]
set -e
# Resolve symlinks to find actual script directory
SOURCE="${BASH_SOURCE[0]}"
while [ -h "$SOURCE" ]; do
DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
SOURCE="$(readlink "$SOURCE")"
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
done
SCRIPT_DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
BASE_DIR="$( cd -P "${SCRIPT_DIR}/.." && pwd )"
# NixOS Fix: Ensure libstdc++.so.6 is in LD_LIBRARY_PATH for numpy/yfinance
# NixOS/Linuxbrew Fix: Ensure libstdc++.so.6 is available for numpy/yfinance
# Note: Avoid trailing colon in LD_LIBRARY_PATH (security: empty entry = cwd)
prepend_ld_path() {
local new_path="$1"
if [[ -z "${LD_LIBRARY_PATH:-}" ]]; then
export LD_LIBRARY_PATH="$new_path"
else
export LD_LIBRARY_PATH="$new_path:$LD_LIBRARY_PATH"
fi
}
if [[ -d "/nix/store" ]]; then
# Priority 1: Linuxbrew (most reliable)
if [[ -d "/home/linuxbrew/.linuxbrew/lib" ]]; then
prepend_ld_path "/home/linuxbrew/.linuxbrew/lib"
# Priority 2: User Linuxbrew
elif [[ -d "$HOME/.linuxbrew/lib" ]]; then
prepend_ld_path "$HOME/.linuxbrew/lib"
# Priority 3: Nix store (slow fallback)
else
LIBPATH=$(find /nix/store -maxdepth 2 -name "*-gcc-*-lib" -print -quit 2>/dev/null)/lib
if [[ -d "$LIBPATH" ]]; then
prepend_ld_path "$LIBPATH"
fi
fi
fi
# Auto-activate local venv if present (prevents missing deps in cron/gateway runs)
VENV_DIR="${BASE_DIR}/venv"
if [[ -z "${VIRTUAL_ENV:-}" && -x "${VENV_DIR}/bin/python3" ]]; then
export VIRTUAL_ENV="${VENV_DIR}"
export PATH="${VIRTUAL_ENV}/bin:${PATH}"
else
if [[ ! -x "${VENV_DIR}/bin/python3" ]]; then
echo "⚠️ Local venv missing at ${VENV_DIR}; install deps to avoid runtime errors." >&2
fi
fi
PYTHON_BIN="${PYTHON_BIN:-python3}"
usage() {
cat << EOM
📈 Finance News CLI
Usage: finance-news <command> [options]
Commands:
install Install/rebuild Python venv (NixOS-compatible)
setup Interactive setup wizard
config Show current configuration
briefing [--morning|--evening] Generate market briefing
market Market overview (indices + headlines)
portfolio News for portfolio stocks
portfolio-only Top gainers/losers from portfolio with news
news <SYMBOL> News for specific ticker
alerts <subcommand> Price target alerts (list, set, check, delete, snooze, update)
earnings <subcommand> Earnings calendar (list, check, refresh)
portfolio-list List portfolio stocks
portfolio-add <SYMBOL> Add stock to portfolio
portfolio-remove <SYMBOL> Remove stock from portfolio
portfolio-import <file.csv> Import portfolio from CSV
Options:
--lang <de|en> Output language (default: de)
--json Output as JSON
--send Send to WhatsApp group
--deadline <sec> Overall deadline in seconds
--fast Fast mode (shorter timeouts, fewer items)
--help Show this help
Examples:
finance-news briefing --morning
finance-news market --lang en
finance-news news AAPL
finance-news portfolio-add NVDA --name "NVIDIA Corporation" --category Tech
EOM
}
case "${1:-}" in
briefing)
shift
"$PYTHON_BIN" "$SCRIPT_DIR/briefing.py" "$@"
;;
market)
shift
"$PYTHON_BIN" "$SCRIPT_DIR/fetch_news.py" market "$@"
;;
portfolio)
shift
"$PYTHON_BIN" "$SCRIPT_DIR/fetch_news.py" portfolio "$@"
;;
portfolio-only)
shift
"$PYTHON_BIN" "$SCRIPT_DIR/fetch_news.py" portfolio-only "$@"
;;
news)
shift
if [[ -z "$1" ]]; then
echo "❌ Usage: finance-news news <SYMBOL>"
exit 1
fi
# Fetch news for specific ticker
symbol="$1"
shift
"$PYTHON_BIN" -c "
import sys
sys.path.insert(0, '$SCRIPT_DIR')
from fetch_news import fetch_ticker_news
import json
articles = fetch_ticker_news('$symbol', 10)
print(f'\n📰 News for $symbol\n')
for a in articles:
print(f\"• {a['title']}\")
print(f\" {a['link']}\n\")
"
;;
portfolio-list)
"$PYTHON_BIN" "$SCRIPT_DIR/portfolio.py" list
;;
portfolio-add)
shift
"$PYTHON_BIN" "$SCRIPT_DIR/portfolio.py" add "$@"
;;
portfolio-remove)
shift
"$PYTHON_BIN" "$SCRIPT_DIR/portfolio.py" remove "$@"
;;
portfolio-import)
shift
"$PYTHON_BIN" "$SCRIPT_DIR/portfolio.py" import "$@"
;;
portfolio-create)
"$PYTHON_BIN" "$SCRIPT_DIR/portfolio.py" create
;;
alerts)
shift
"$PYTHON_BIN" "$SCRIPT_DIR/alerts.py" "$@"
;;
earnings)
shift
"$PYTHON_BIN" "$SCRIPT_DIR/earnings.py" "$@"
;;
install)
shift
bash "$SCRIPT_DIR/venv-setup.sh" "$@"
;;
setup)
shift
"$PYTHON_BIN" "$SCRIPT_DIR/setup.py" "$@"
;;
setup-wizard)
"$PYTHON_BIN" "$SCRIPT_DIR/setup.py" wizard
;;
config)
"$PYTHON_BIN" "$SCRIPT_DIR/setup.py" show
;;
--help|-h|help|"")
usage
;;
*)
echo "❌ Unknown command: $1"
echo "Run 'finance-news --help' for usage."
exit 1
;;
esac
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=/dev/null
source "${SCRIPT_DIR}/_openbb_common.sh"
if [[ $# -lt 1 ]]; then
echo "Usage: openbb-quote SYMBOL [SYMBOL2 ...] [provider]" >&2
exit 1
fi
EXPLICIT_PROVIDER=""
TICKERS=()
for arg in "$@"; do
case "${arg}" in
fmp|intrinio|yfinance|tiingo|polygon|alphavantage)
EXPLICIT_PROVIDER="${arg}"
;;
*)
TICKERS+=("${arg}")
;;
esac
done
PROVIDER="$(resolve_openbb_provider "${EXPLICIT_PROVIDER}" "yfinance")"
export _OPENBB_PROVIDER="${PROVIDER}"
export _OPENBB_TICKERS
_OPENBB_TICKERS="$(printf '%s\n' "${TICKERS[@]}")"
raw_output="$(
run_openbb_python << 'PYTHON'
from __future__ import annotations
import json
import os
import sys
from openbb import obb
provider = os.environ.get("_OPENBB_PROVIDER", "yfinance")
tickers = [t.strip().upper() for t in os.environ.get("_OPENBB_TICKERS", "").split("\n") if t.strip()]
results = []
for symbol in tickers:
try:
response = obb.equity.price.quote(symbol=symbol, provider=provider)
if not response.results:
results.append({"symbol": symbol, "error": "no quote"})
continue
item = response.results[0]
results.append(
{
"symbol": symbol,
"price": float(item.last_price) if item.last_price is not None else None,
"open": float(item.open) if getattr(item, "open", None) is not None else None,
"high": float(item.high) if getattr(item, "high", None) is not None else None,
"low": float(item.low) if getattr(item, "low", None) is not None else None,
"volume": int(item.volume) if getattr(item, "volume", None) is not None else None,
"prev_close": float(item.prev_close) if getattr(item, "prev_close", None) is not None else None,
"change": float(item.change) if getattr(item, "change", None) is not None else None,
"change_percent": float(item.change_percent) if getattr(item, "change_percent", None) is not None else None,
}
)
except Exception as exc:
results.append({"symbol": symbol, "error": str(exc)})
print(json.dumps(results, ensure_ascii=False))
sys.exit(0)
PYTHON
)"
printf '%s' "${raw_output}" | python3 -c '
from __future__ import annotations
import json
import sys
text = sys.stdin.read()
decoder = json.JSONDecoder()
for idx, ch in enumerate(text):
if ch not in "[{":
continue
try:
obj, _ = decoder.raw_decode(text[idx:])
except Exception:
continue
if isinstance(obj, dict):
obj = [obj]
print(json.dumps(obj, ensure_ascii=False))
raise SystemExit(0)
print("openbb-quote: unable to parse OpenBB JSON output", file=sys.stderr)
raise SystemExit(1)
'
#!/usr/bin/env python3
"""Translate portfolio headlines in briefing JSON using openclaw.
Usage: python3 translate_portfolio.py /path/to/briefing.json [--lang de]
Reads briefing JSON, translates portfolio article headlines via openclaw,
writes back the modified JSON.
"""
import argparse
import json
import re
import subprocess
import sys
def extract_headlines(portfolio_message: str) -> list[str]:
"""Extract article headlines (lines starting with •) from portfolio message."""
headlines = []
for line in portfolio_message.split('\n'):
line = line.strip()
if line.startswith('•'):
# Remove bullet, reference number, and clean up
# Format: "• Headline text [1]"
match = re.match(r'•\s*(.+?)\s*\[\d+\]$', line)
if match:
headlines.append(match.group(1))
else:
# No reference number
headlines.append(line[1:].strip())
return headlines
def translate_headlines(headlines: list[str], lang: str = "de") -> list[str]:
"""Translate headlines using openclaw agent."""
if not headlines:
return []
prompt = f"""Translate these English headlines to German.
Return ONLY a JSON array of strings in the same order.
Example: ["Übersetzung 1", "Übersetzung 2"]
Do not add commentary.
Headlines:
"""
for idx, title in enumerate(headlines, start=1):
prompt += f"{idx}. {title}\n"
try:
result = subprocess.run(
[
'openclaw', 'agent',
'--session-id', 'finance-news-translate-portfolio',
'--message', prompt,
'--json',
'--timeout', '60'
],
capture_output=True,
text=True,
timeout=90
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
print(f"⚠️ Translation failed: {e}", file=sys.stderr)
return headlines
if result.returncode != 0:
print(f"⚠️ openclaw error: {result.stderr}", file=sys.stderr)
return headlines
# Extract reply from openclaw JSON output
# Format: {"result": {"payloads": [{"text": "..."}]}}
# Note: openclaw may print plugin loading messages before JSON, so find the JSON start
stdout = result.stdout
json_start = stdout.find('{')
if json_start > 0:
stdout = stdout[json_start:]
try:
output = json.loads(stdout)
payloads = output.get('result', {}).get('payloads', [])
if payloads and payloads[0].get('text'):
reply = payloads[0]['text']
else:
reply = output.get('reply', '') or output.get('message', '') or stdout
except json.JSONDecodeError:
reply = stdout
# Parse JSON array from reply
json_text = reply.strip()
if "```" in json_text:
match = re.search(r'```(?:json)?\s*(.*?)```', json_text, re.DOTALL)
if match:
json_text = match.group(1).strip()
try:
translated = json.loads(json_text)
if isinstance(translated, list) and len(translated) == len(headlines):
print(f"✅ Translated {len(headlines)} portfolio headlines", file=sys.stderr)
return translated
except json.JSONDecodeError as e:
print(f"⚠️ JSON parse error: {e}", file=sys.stderr)
print(f"⚠️ Translation failed, using original headlines", file=sys.stderr)
return headlines
def replace_headlines(portfolio_message: str, original: list[str], translated: list[str]) -> str:
"""Replace original headlines with translated ones in portfolio message."""
result = portfolio_message
for orig, trans in zip(original, translated):
if orig != trans:
# Replace the headline text, preserving bullet and reference
result = result.replace(f"• {orig}", f"• {trans}")
return result
def has_pretranslated_portfolio(data: dict) -> bool:
"""Return True when all relevant portfolio headlines already have title_de."""
raw_portfolio = (data.get("raw_data") or {}).get("portfolio") or {}
saw_relevant_headline = False
for stock_data in (raw_portfolio.get("stocks") or {}).values():
for article in stock_data.get("articles", [])[:2]:
title = (article.get("title") or "").strip()
if not title:
continue
saw_relevant_headline = True
if not (article.get("title_de") or "").strip():
return False
return saw_relevant_headline
def main():
parser = argparse.ArgumentParser(description='Translate portfolio headlines')
parser.add_argument('json_file', help='Path to briefing JSON file')
parser.add_argument('--lang', default='de', help='Target language (default: de)')
args = parser.parse_args()
# Read JSON
try:
with open(args.json_file, 'r') as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"❌ Error reading {args.json_file}: {e}", file=sys.stderr)
sys.exit(1)
portfolio_message = data.get('portfolio_message', '')
if not portfolio_message:
print("No portfolio_message to translate", file=sys.stderr)
print(json.dumps(data, ensure_ascii=False, indent=2))
return
if has_pretranslated_portfolio(data):
print("Portfolio headlines already translated; skipping", file=sys.stderr)
return
# Extract, translate, replace
headlines = extract_headlines(portfolio_message)
if not headlines:
print("No headlines found in portfolio_message", file=sys.stderr)
print(json.dumps(data, ensure_ascii=False, indent=2))
return
print(f"📝 Found {len(headlines)} headlines to translate", file=sys.stderr)
translated = translate_headlines(headlines, args.lang)
# Update portfolio message
data['portfolio_message'] = replace_headlines(portfolio_message, headlines, translated)
# Write back
with open(args.json_file, 'w') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"✅ Updated {args.json_file}", file=sys.stderr)
if __name__ == '__main__':
main()
"""Shared helpers."""
import os
import sys
import time
from pathlib import Path
def ensure_venv() -> None:
"""Re-exec inside local venv if available and not already active."""
if os.environ.get("FINANCE_NEWS_VENV_BOOTSTRAPPED") == "1":
return
if sys.prefix != sys.base_prefix:
return
venv_python = Path(__file__).resolve().parent.parent / "venv" / "bin" / "python3"
if not venv_python.exists():
if os.environ.get("FINANCE_NEWS_SUPPRESS_VENV_WARNING") == "1" or Path("/.dockerenv").exists():
return
print("⚠️ finance-news venv missing; run scripts from the repo venv to avoid dependency errors.", file=sys.stderr)
return
env = os.environ.copy()
env["FINANCE_NEWS_VENV_BOOTSTRAPPED"] = "1"
os.execvpe(str(venv_python), [str(venv_python)] + sys.argv, env)
def compute_deadline(deadline_sec: int | None) -> float | None:
if deadline_sec is None:
return None
if deadline_sec <= 0:
return None
return time.monotonic() + deadline_sec
def time_left(deadline: float | None) -> int | None:
if deadline is None:
return None
remaining = int(deadline - time.monotonic())
return remaining
def clamp_timeout(default_timeout: int, deadline: float | None, minimum: int = 1) -> int:
remaining = time_left(deadline)
if remaining is None:
return default_timeout
if remaining <= 0:
raise TimeoutError("Deadline exceeded")
return max(min(default_timeout, remaining), minimum)