
Finance News
- 2.7k installs
- 635 repo stars
- Updated March 7, 2026
- sundial-org/awesome-openclaw-skills
finance-news is an agent skill that aggregates RSS market headlines, summarizes them with Gemini, and delivers morning or evening briefings with portfolio-aware context.
About
finance-news is an AI-powered market briefing CLI packaged as an OpenClaw agent skill for stock headlines, index snapshots, and portfolio-aware summaries. First-time setup runs finance-news setup to enable RSS feeds such as WSJ, Barron's, CNBC, and Yahoo Finance, choose US, Europe, and Japan indices, wire WhatsApp or Telegram delivery, set English or German default language, and schedule morning or evening cron jobs. Commands generate finance-news briefing --morning or --evening briefings, finance-news market overviews with optional JSON output, portfolio CRUD via CSV at config/portfolio.csv, and per-ticker finance-news news SYMBOL lookups. AI summaries use the Gemini CLI with styles for summary, analysis, or headlines. Automated cron scripts morning.sh and evening.sh target 6:30 AM and 1:00 PM Pacific on weekdays with optional WhatsApp send. Lobster workflow YAML adds approval gates before delivery. The skill integrates with openbb-quote for quotes and OpenClaw message tooling. Dependencies include Python 3.10+, feedparser, gemini-cli authentication, and a fifteen-minute news cache directory.
- Interactive setup wizard configures RSS feeds, regions, delivery, language, and cron schedule.
- Covers US, Europe, and Japan indices plus portfolio CSV import and ticker-specific news.
- Gemini-powered briefing styles include summary, analysis, and headlines in English or German.
- Cron morning.sh and evening.sh automate weekday briefings with optional WhatsApp delivery.
- Lobster workflows/briefing.yaml adds approval gates before sending market updates.
Finance News by the numbers
- 2,742 all-time installs (skills.sh)
- +30 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #47 of 1,136 Finance & Trading skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
finance-news capabilities & compatibility
- Capabilities
- rss aggregation from wsj, barron's, cnbc, and ya · gemini ai summarization in english or german bri · portfolio csv management with per ticker news lo · market index overview for us, europe, and japan · cron and lobster workflow delivery with optional
- Use cases
- orchestration · research
What finance-news says it does
AI-powered market news briefings with configurable language output and automated delivery
npx skills add https://github.com/sundial-org/awesome-openclaw-skills --skill finance-newsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.7k |
|---|---|
| repo stars | ★ 635 |
| Security audit | 1 / 3 scanners passed |
| Last updated | March 7, 2026 |
| Repository | sundial-org/awesome-openclaw-skills ↗ |
How do I get automated stock market briefings with AI summaries for my watchlist without manually checking multiple news feeds?
Generate AI market briefings, index overviews, and portfolio news with RSS feeds, Gemini summaries, and optional WhatsApp delivery.
Who is it for?
Developers running OpenClaw agents who want scheduled US, Europe, and Japan market news with Gemini summaries.
Skip if: Skip when you need live order execution, deep fundamental analysis, or markets outside the documented RSS and index coverage.
When should I use this skill?
User asks for stock news, market updates, morning or evening briefings, portfolio headlines, or financial market summaries.
What you get
Formatted market briefing with index moves, portfolio ticker updates, ranked headlines, and optional WhatsApp or Telegram delivery.
- morning or evening briefing markdown
- market index snapshot
- portfolio ticker news summaries
By the numbers
- [object Object]
- [object Object]
- [object Object]
Files
Finance News Skill
AI-powered market news briefings with configurable language output and automated delivery.
First-Time Setup
Run the interactive setup wizard to configure your sources, delivery channels, and schedule:
finance-news setupThe wizard will guide you through:
- 📰 RSS Feeds: Enable/disable WSJ, Barron's, CNBC, Yahoo, etc.
- 📊 Markets: Choose regions (US, Europe, Japan, Asia)
- 📤 Delivery: Configure WhatsApp/Telegram group
- 🌐 Language: Set default language (English/German)
- ⏰ Schedule: Configure morning/evening cron times
You can also configure specific sections:
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 defaults
finance-news config # Show current configQuick Start
# Generate morning briefing
finance-news briefing --morning
# View market overview
finance-news market
# Get news for your portfolio
finance-news portfolio
# Get news for specific stock
finance-news news AAPLFeatures
📊 Market Coverage
- US Markets: S&P 500, Dow Jones, NASDAQ
- Europe: DAX, STOXX 50, FTSE 100
- Japan: Nikkei 225
📰 News Sources
- Premium: WSJ, Barron's (RSS feeds)
- Free: CNBC, Yahoo Finance, Finnhub
- Portfolio: Ticker-specific news from Yahoo
🤖 AI Summaries
- Gemini-powered analysis
- Configurable language (English/German)
- Briefing styles: summary, analysis, headlines
📅 Automated Briefings
- Morning: 6:30 AM PT (US market open)
- Evening: 1:00 PM PT (US market close)
- Delivery: WhatsApp (configure group in cron scripts)
Commands
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
# List portfolio
finance-news portfolio-list
# Add stock
finance-news portfolio-add NVDA --name "NVIDIA Corporation" --category Tech
# Remove stock
finance-news portfolio-remove TSLA
# Import from CSV
finance-news portfolio-import ~/my_stocks.csv
# Interactive portfolio creation
finance-news portfolio-createTicker News
# News for specific stock
finance-news news AAPL
finance-news news TSLAConfiguration
Portfolio 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,Sources Configuration
Location: ~/clawd/skills/finance-news/config/config.json (legacy fallback: config/sources.json)
- RSS feeds for WSJ, Barron's, CNBC, Yahoo
- Market indices by region
- 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"Manual 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.shSample Output
🌅 **Börsen-Morgen-Briefing**
Dienstag, 21. Januar 2026 | 06:30 Uhr
📊 **Märkte**
• S&P 500: 5.234 (+0,3%)
• DAX: 16.890 (-0,1%)
• Nikkei: 35.678 (+0,5%)
📈 **Dein Portfolio**
• AAPL $256 (+1,2%) — iPhone-Verkäufe übertreffen Erwartungen
• NVDA $512 (+3,4%) — KI-Chip-Nachfrage steigt
🔥 **Top Stories**
• [WSJ] Fed signalisiert mögliche Zinssenkung im März
• [CNBC] Tech-Sektor führt Rally an
🤖 **Analyse**
Der S&P zeigt Stärke. Dein Portfolio profitiert von NVDA's
Momentum. Fed-Kommentare könnten Volatilität auslösen.Integration
With OpenBB (existing skill)
# Get detailed quote, then news
openbb-quote AAPL && finance-news news AAPLWith OpenClaw Agent
The agent will automatically use this skill when asked about:
- "What's the market doing?"
- "News for my portfolio"
- "Generate morning briefing"
- "What's happening with AAPL?"
With Lobster (Workflow Engine)
Run briefings via Lobster for approval gates and resumability:
# Run with approval before WhatsApp send
lobster "workflows.run --file workflows/briefing.yaml"
# With custom args
lobster "workflows.run --file workflows/briefing.yaml --args-json '{\"time\":\"evening\",\"lang\":\"en\"}'"See workflows/README.md for full documentation.
Files
skills/finance-news/
├── SKILL.md # This documentation
├── Dockerfile # NixOS-compatible container
├── config/
│ ├── portfolio.csv # Your watchlist
│ ├── config.json # RSS/API/language configuration
│ ├── alerts.json # Price target alerts
│ └── manual_earnings.json # Earnings calendar overrides
├── scripts/
│ ├── finance-news # Main CLI
│ ├── briefing.py # Briefing generator
│ ├── fetch_news.py # News aggregator
│ ├── portfolio.py # Portfolio CRUD
│ ├── summarize.py # AI summarization
│ ├── alerts.py # Price alert management
│ ├── earnings.py # Earnings calendar
│ ├── ranking.py # Headline ranking
│ └── stocks.py # Stock management
├── workflows/
│ ├── briefing.yaml # Lobster workflow with approval gate
│ └── README.md # Workflow documentation
├── cron/
│ ├── morning.sh # Morning cron (Docker-based)
│ └── evening.sh # Evening cron (Docker-based)
└── cache/ # 15-minute news cacheDependencies
- Python 3.10+
feedparser(pip install feedparser)- Gemini CLI (
brew install gemini-cli) - OpenBB (existing
openbb-quotewrapper) - OpenClaw message tool (for WhatsApp delivery)
Troubleshooting
Gemini not working
# Authenticate Gemini
gemini # Follow login flowRSS feeds timing out
- Check network connectivity
- WSJ/Barron's may require subscription cookies for some content
- Free feeds (CNBC, Yahoo) should always work
WhatsApp delivery failing
- Verify WhatsApp group exists and bot has access
- Check
openclaw doctorfor WhatsApp status
{
"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": true,
"news": "https://www.finanzen.net/rss/news"
},
"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": ["tagesschau", "handelsblatt", "zeit", "finanzen_net", "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,
"finanzen_net": 3,
"yahoo": 1
},
"source_tiers": {
"paid": ["wsj", "ft", "barrons"],
"free": ["bloomberg", "marketwatch", "yahoo", "cnbc", "tagesschau", "handelsblatt", "zeit", "finanzen_net"]
},
"headline_shortlist_size_by_lang": {
"de": 30,
"en": 20
},
"portfolio_deadline_sec": 360,
"portfolio": {
"briefing_limit": 10,
"prioritization_enabled": true,
"prioritization_weights": {
"type": 0.40,
"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": ["gemini", "minimax", "claude"],
"summary_model_order": ["gemini", "minimax", "claude"],
"translation_model_order": ["gemini", "minimax", "claude"]
},
"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_watchpoints": "Watchpoints",
"no_data": "No data available",
"no_movers": "No significant moves (±1%)",
"follows_market": " -- follows market",
"no_catalyst": " -- no specific catalyst",
"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_watchpoints": "Beobachtungspunkte",
"no_data": "Keine Daten verfügbar",
"no_movers": "Keine deutlichen Bewegungen (±1%)",
"follows_market": " -- folgt dem Markt",
"no_catalyst": " -- kein spezifischer Katalysator",
"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"
}
}#!/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:-120363421796203667@g.us}"
export FINANCE_NEWS_CHANNEL="${FINANCE_NEWS_CHANNEL:-whatsapp}"
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:-120363421796203667@g.us}"
export FINANCE_NEWS_CHANNEL="${FINANCE_NEWS_CHANNEL:-whatsapp}"
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:-120363421796203667@g.us}"
export FINANCE_NEWS_CHANNEL="${FINANCE_NEWS_CHANNEL:-whatsapp}"
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:-120363421796203667@g.us}"
export FINANCE_NEWS_CHANNEL="${FINANCE_NEWS_CHANNEL:-whatsapp}"
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:-120363421796203667@g.us}"
export FINANCE_NEWS_CHANNEL="${FINANCE_NEWS_CHANNEL:-whatsapp}"
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."
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
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.
[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 --fastLobster 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) | 120363421796203667@g.us |
FINANCE_NEWS_CHANNEL | Delivery channel | whatsapp or telegram |
SKILL_DIR | Path to skill directory (for Lobster) | $HOME/projects/finance-news-openclaw-skill |
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 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')
if args.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,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
timeout=timeout
)
if result.returncode != 0:
print(f"❌ Briefing generation failed: {result.stderr}", 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=['en', 'de'], 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='Use LLM summary')
parser.add_argument('--model', choices=['claude', 'minimax', 'gemini'],
default='claude', help='LLM model (only with --llm)')
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 python3
"""
Earnings Calendar - Track earnings dates for portfolio stocks.
Features:
- Fetch earnings dates from FMP API
- Show upcoming earnings in daily briefing
- Alert 24h before earnings release
- Cache results to avoid API spam
Usage:
earnings.py list # Show all upcoming earnings
earnings.py check # Check what's reporting today/this week
earnings.py refresh # Force refresh earnings data
"""
import argparse
import csv
import json
import os
import shutil
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path
from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError
# Paths
SCRIPT_DIR = Path(__file__).parent
CONFIG_DIR = SCRIPT_DIR.parent / "config"
CACHE_DIR = SCRIPT_DIR.parent / "cache"
PORTFOLIO_FILE = CONFIG_DIR / "portfolio.csv"
EARNINGS_CACHE = CACHE_DIR / "earnings_calendar.json"
MANUAL_EARNINGS = CONFIG_DIR / "manual_earnings.json" # For JP/other stocks not in Finnhub
# OpenBB binary path
OPENBB_BINARY = None
try:
env_path = os.environ.get('OPENBB_QUOTE_BIN')
if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK):
OPENBB_BINARY = env_path
else:
OPENBB_BINARY = shutil.which('openbb-quote')
except Exception:
pass
# API Keys
def get_fmp_key() -> str:
"""Get FMP API key from environment or .env file."""
key = os.environ.get("FMP_API_KEY", "")
if not key:
env_file = Path.home() / ".openclaw" / ".env"
if env_file.exists():
for line in env_file.read_text().splitlines():
if line.startswith("FMP_API_KEY="):
key = line.split("=", 1)[1].strip()
break
return key
def load_portfolio() -> list[dict]:
"""Load portfolio from CSV."""
if not PORTFOLIO_FILE.exists():
return []
with open(PORTFOLIO_FILE, 'r') as f:
reader = csv.DictReader(f)
return list(reader)
def load_earnings_cache() -> dict:
"""Load cached earnings data."""
if EARNINGS_CACHE.exists():
try:
return json.loads(EARNINGS_CACHE.read_text())
except Exception:
pass
return {"last_updated": None, "earnings": {}}
def load_manual_earnings() -> dict:
"""
Load manually-entered earnings dates (for JP stocks not in Finnhub).
Format: {"6857.T": {"date": "2026-01-30", "time": "amc", "note": "Q3 FY2025"}, ...}
"""
if MANUAL_EARNINGS.exists():
try:
data = json.loads(MANUAL_EARNINGS.read_text())
# Filter out metadata keys (starting with _)
return {k: v for k, v in data.items() if not k.startswith("_") and isinstance(v, dict)}
except Exception:
pass
return {}
def save_earnings_cache(data: dict):
"""Save earnings data to cache."""
CACHE_DIR.mkdir(exist_ok=True)
EARNINGS_CACHE.write_text(json.dumps(data, indent=2, default=str))
def get_finnhub_key() -> str:
"""Get Finnhub API key from environment or .env file."""
key = os.environ.get("FINNHUB_API_KEY", "")
if not key:
env_file = Path.home() / ".openclaw" / ".env"
if env_file.exists():
for line in env_file.read_text().splitlines():
if line.startswith("FINNHUB_API_KEY="):
key = line.split("=", 1)[1].strip()
break
return key
def fetch_all_earnings_finnhub(days_ahead: int = 60) -> dict:
"""
Fetch all earnings for the next N days from Finnhub.
Returns dict keyed by symbol: {"AAPL": {...}, ...}
"""
finnhub_key = get_finnhub_key()
if not finnhub_key:
return {}
from_date = datetime.now().strftime("%Y-%m-%d")
to_date = (datetime.now() + timedelta(days=days_ahead)).strftime("%Y-%m-%d")
url = f"https://finnhub.io/api/v1/calendar/earnings?from={from_date}&to={to_date}&token={finnhub_key}"
try:
req = Request(url, headers={"User-Agent": "finance-news/1.0"})
with urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
earnings_by_symbol = {}
for entry in data.get("earningsCalendar", []):
symbol = entry.get("symbol")
if symbol:
earnings_by_symbol[symbol] = {
"date": entry.get("date"),
"time": entry.get("hour", ""), # bmo/amc
"eps_estimate": entry.get("epsEstimate"),
"revenue_estimate": entry.get("revenueEstimate"),
"quarter": entry.get("quarter"),
"year": entry.get("year"),
}
return earnings_by_symbol
except Exception as e:
print(f"❌ Finnhub error: {e}", file=sys.stderr)
return {}
def normalize_ticker_for_lookup(ticker: str) -> list[str]:
"""
Convert portfolio ticker to possible Finnhub symbols.
Returns list of possible formats to try.
"""
variants = [ticker]
# Japanese stocks: 6857.T -> try 6857
if ticker.endswith('.T'):
base = ticker.replace('.T', '')
variants.extend([base, f"{base}.T"])
# Singapore stocks: D05.SI -> try D05
elif ticker.endswith('.SI'):
base = ticker.replace('.SI', '')
variants.extend([base, f"{base}.SI"])
return variants
def fetch_earnings_for_portfolio(portfolio: list[dict]) -> dict:
"""
Fetch earnings dates for portfolio stocks using Finnhub bulk API.
More efficient than per-ticker calls.
"""
# Get all earnings for next 60 days
all_earnings = fetch_all_earnings_finnhub(days_ahead=60)
if not all_earnings:
return {}
# Match portfolio tickers to earnings data
results = {}
for stock in portfolio:
ticker = stock["symbol"]
variants = normalize_ticker_for_lookup(ticker)
for variant in variants:
if variant in all_earnings:
results[ticker] = all_earnings[variant]
break
return results
def refresh_earnings(portfolio: list[dict], force: bool = False) -> dict:
"""Refresh earnings data for all portfolio stocks."""
finnhub_key = get_finnhub_key()
if not finnhub_key:
print("❌ FINNHUB_API_KEY not found", file=sys.stderr)
return {}
cache = load_earnings_cache()
# Check if cache is fresh (< 6 hours old)
if not force and cache.get("last_updated"):
try:
last = datetime.fromisoformat(cache["last_updated"])
if datetime.now() - last < timedelta(hours=6):
print(f"📦 Using cached data (updated {last.strftime('%H:%M')})")
return cache
except Exception:
pass
print(f"🔄 Fetching earnings calendar from Finnhub...")
# Use bulk fetch - much more efficient
earnings = fetch_earnings_for_portfolio(portfolio)
# Merge manual earnings (for JP stocks not in Finnhub)
manual = load_manual_earnings()
if manual:
print(f"📝 Merging {len(manual)} manual entries...")
for ticker, data in manual.items():
if ticker not in earnings: # Manual data fills gaps
earnings[ticker] = data
found = len(earnings)
total = len(portfolio)
print(f"✅ Found earnings data for {found}/{total} stocks")
if earnings:
for ticker, data in sorted(earnings.items(), key=lambda x: x[1].get("date", "")):
print(f" • {ticker}: {data.get('date', '?')}")
cache = {
"last_updated": datetime.now().isoformat(),
"earnings": earnings
}
save_earnings_cache(cache)
return cache
def list_earnings(args):
"""List all upcoming earnings for portfolio."""
portfolio = load_portfolio()
if not portfolio:
print("📂 Portfolio empty")
return
cache = refresh_earnings(portfolio, force=args.refresh)
earnings = cache.get("earnings", {})
if not earnings:
print("\n❌ No earnings dates found")
return
# Sort by date
sorted_earnings = sorted(
[(ticker, data) for ticker, data in earnings.items() if data.get("date")],
key=lambda x: x[1]["date"]
)
print(f"\n📅 Upcoming Earnings ({len(sorted_earnings)} stocks)\n")
today = datetime.now().date()
for ticker, data in sorted_earnings:
date_str = data["date"]
try:
ed = datetime.strptime(date_str, "%Y-%m-%d").date()
days_until = (ed - today).days
# Emoji based on timing
if days_until < 0:
emoji = "✅" # Past
timing = f"{-days_until}d ago"
elif days_until == 0:
emoji = "🔴" # Today!
timing = "TODAY"
elif days_until == 1:
emoji = "🟡" # Tomorrow
timing = "TOMORROW"
elif days_until <= 7:
emoji = "🟠" # This week
timing = f"in {days_until}d"
else:
emoji = "⚪" # Later
timing = f"in {days_until}d"
# Time of day
time_str = ""
if data.get("time") == "bmo":
time_str = " (pre-market)"
elif data.get("time") == "amc":
time_str = " (after-close)"
# EPS estimate
eps_str = ""
if data.get("eps_estimate"):
eps_str = f" | Est: ${data['eps_estimate']:.2f}"
# Stock name from portfolio
stock_name = next((s["name"] for s in portfolio if s["symbol"] == ticker), ticker)
print(f"{emoji} {date_str} ({timing}): **{ticker}** — {stock_name}{time_str}{eps_str}")
except ValueError:
print(f"⚪ {date_str}: {ticker}")
print()
def check_earnings(args):
"""Check earnings for today and this week (briefing format)."""
portfolio = load_portfolio()
if not portfolio:
return
cache = load_earnings_cache()
# Auto-refresh if cache is stale
if not cache.get("last_updated"):
cache = refresh_earnings(portfolio, force=False)
else:
try:
last = datetime.fromisoformat(cache["last_updated"])
if datetime.now() - last > timedelta(hours=12):
cache = refresh_earnings(portfolio, force=False)
except Exception:
cache = refresh_earnings(portfolio, force=False)
earnings = cache.get("earnings", {})
if not earnings:
return
today = datetime.now().date()
week_only = getattr(args, 'week', False)
# For weekly mode (Sunday cron), show Mon-Fri of upcoming week
# Calculation: weekday() returns 0=Mon, 6=Sun. (7 - weekday) % 7 gives days until next Monday.
# Special case: if today is Monday (result=0), we want next Monday (7 days), not today.
if week_only:
days_until_monday = (7 - today.weekday()) % 7
if days_until_monday == 0 and today.weekday() != 0:
days_until_monday = 7
week_start = today + timedelta(days=days_until_monday)
week_end = week_start + timedelta(days=4) # Mon-Fri
else:
week_end = today + timedelta(days=7)
today_list = []
week_list = []
for ticker, data in earnings.items():
if not data.get("date"):
continue
try:
ed = datetime.strptime(data["date"], "%Y-%m-%d").date()
stock = next((s for s in portfolio if s["symbol"] == ticker), None)
name = stock["name"] if stock else ticker
category = stock.get("category", "") if stock else ""
entry = {
"ticker": ticker,
"name": name,
"date": ed,
"time": data.get("time", ""),
"eps_estimate": data.get("eps_estimate"),
"category": category,
}
if week_only:
# Weekly mode: only show week range
if week_start <= ed <= week_end:
week_list.append(entry)
else:
# Daily mode: today + this week
if ed == today:
today_list.append(entry)
elif today < ed <= week_end:
week_list.append(entry)
except ValueError:
continue
# Handle JSON output
if getattr(args, 'json', False):
if week_only:
result = {
"week_start": week_start.isoformat(),
"week_end": week_end.isoformat(),
"earnings": [
{
"ticker": e["ticker"],
"name": e["name"],
"date": e["date"].isoformat(),
"time": e["time"],
"eps_estimate": e.get("eps_estimate"),
"category": e.get("category", ""),
}
for e in sorted(week_list, key=lambda x: x["date"])
],
}
else:
result = {
"today": [
{
"ticker": e["ticker"],
"name": e["name"],
"date": e["date"].isoformat(),
"time": e["time"],
"eps_estimate": e.get("eps_estimate"),
"category": e.get("category", ""),
}
for e in sorted(today_list, key=lambda x: x.get("time", "zzz"))
],
"this_week": [
{
"ticker": e["ticker"],
"name": e["name"],
"date": e["date"].isoformat(),
"time": e["time"],
"eps_estimate": e.get("eps_estimate"),
"category": e.get("category", ""),
}
for e in sorted(week_list, key=lambda x: x["date"])
],
}
print(json.dumps(result, indent=2))
return
# Translations
lang = getattr(args, 'lang', 'en')
if lang == "de":
labels = {
"today": "EARNINGS HEUTE",
"week": "EARNINGS DIESE WOCHE",
"week_preview": "EARNINGS NÄCHSTE WOCHE",
"pre": "vor Börseneröffnung",
"post": "nach Börsenschluss",
"pre_short": "vor",
"post_short": "nach",
"est": "Erw",
"none": "Keine Earnings diese Woche",
"none_week": "Keine Earnings nächste Woche",
}
else:
labels = {
"today": "EARNINGS TODAY",
"week": "EARNINGS THIS WEEK",
"week_preview": "EARNINGS NEXT WEEK",
"pre": "pre-market",
"post": "after-close",
"pre_short": "pre",
"post_short": "post",
"est": "Est",
"none": "No earnings this week",
"none_week": "No earnings next week",
}
# Date header
date_str = datetime.now().strftime("%b %d, %Y") if lang == "en" else datetime.now().strftime("%d. %b %Y")
# Output for briefing
output = []
# Daily mode: show today's earnings
if not week_only and today_list:
output.append(f"📅 {labels['today']} — {date_str}\n")
for e in sorted(today_list, key=lambda x: x.get("time", "zzz")):
time_str = f" ({labels['pre']})" if e["time"] == "bmo" else f" ({labels['post']})" if e["time"] == "amc" else ""
eps_str = f" — {labels['est']}: ${e['eps_estimate']:.2f}" if e.get("eps_estimate") else ""
output.append(f"• {e['ticker']} — {e['name']}{time_str}{eps_str}")
output.append("")
if week_list:
# Use different header for weekly preview mode
week_label = labels['week_preview'] if week_only else labels['week']
if week_only:
# Show date range for weekly preview
week_range = f"{week_start.strftime('%b %d')} - {week_end.strftime('%b %d')}"
output.append(f"📅 {week_label} ({week_range})\n")
else:
output.append(f"📅 {week_label}\n")
for e in sorted(week_list, key=lambda x: x["date"]):
day_name = e["date"].strftime("%a %d.%m")
time_str = f" ({labels['pre_short']})" if e["time"] == "bmo" else f" ({labels['post_short']})" if e["time"] == "amc" else ""
output.append(f"• {day_name}: {e['ticker']} — {e['name']}{time_str}")
output.append("")
if output:
print("\n".join(output))
else:
if args.verbose:
no_earnings_label = labels['none_week'] if week_only else labels['none']
print(f"📅 {no_earnings_label}")
def get_briefing_section() -> str:
"""Get earnings section for daily briefing (called by briefing.py)."""
from io import StringIO
import contextlib
# Capture check output
class Args:
verbose = False
f = StringIO()
with contextlib.redirect_stdout(f):
check_earnings(Args())
return f.getvalue()
def get_earnings_context(symbols: list[str]) -> list[dict]:
"""
Get recent earnings data (beats/misses) for symbols using OpenBB.
Returns list of dicts with: symbol, eps_actual, eps_estimate, surprise, revenue_actual, revenue_estimate
"""
if not OPENBB_BINARY:
return []
results = []
for symbol in symbols[:10]: # Limit to 10 symbols
try:
result = subprocess.run(
[OPENBB_BINARY, symbol, '--earnings'],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
try:
data = json.loads(result.stdout)
if isinstance(data, list) and data:
results.append({
'symbol': symbol,
'earnings': data[0] if isinstance(data[0], dict) else {}
})
except json.JSONDecodeError:
pass
except Exception:
pass
return results
def get_analyst_ratings(symbols: list[str]) -> list[dict]:
"""
Get analyst upgrades/downgrades for symbols using OpenBB.
Returns list of dicts with: symbol, rating, target_price, firm, direction
"""
if not OPENBB_BINARY:
return []
results = []
for symbol in symbols[:10]: # Limit to 10 symbols
try:
result = subprocess.run(
[OPENBB_BINARY, symbol, '--rating'],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
try:
data = json.loads(result.stdout)
if isinstance(data, list) and data:
results.append({
'symbol': symbol,
'rating': data[0] if isinstance(data[0], dict) else {}
})
except json.JSONDecodeError:
pass
except Exception:
pass
return results
def main():
parser = argparse.ArgumentParser(description="Earnings Calendar Tracker")
subparsers = parser.add_subparsers(dest="command", help="Commands")
# list command
list_parser = subparsers.add_parser("list", help="List all upcoming earnings")
list_parser.add_argument("--refresh", "-r", action="store_true", help="Force refresh")
list_parser.set_defaults(func=list_earnings)
# check command
check_parser = subparsers.add_parser("check", help="Check today/this week")
check_parser.add_argument("--verbose", "-v", action="store_true")
check_parser.add_argument("--json", action="store_true", help="JSON output")
check_parser.add_argument("--lang", default="en", help="Output language (en, de)")
check_parser.add_argument("--week", action="store_true", help="Show full week preview (for weekly cron)")
check_parser.set_defaults(func=check_earnings)
# refresh command
refresh_parser = subparsers.add_parser("refresh", help="Force refresh all data")
refresh_parser.set_defaults(func=lambda a: refresh_earnings(load_portfolio(), force=True))
args = parser.parse_args()
if not args.command:
parser.print_help()
return
args.func(args)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
News Fetcher - Aggregate news from multiple sources.
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
from datetime import datetime, timedelta
from email.utils import parsedate_to_datetime
from pathlib import Path
import ssl
import urllib.error
import urllib.request
import yfinance as yf
import pandas as pd
from utils import clamp_timeout, compute_deadline, ensure_venv, time_left
# Retry configuration
DEFAULT_MAX_RETRIES = 3
DEFAULT_RETRY_DELAY = 1 # Base delay in seconds (exponential backoff)
def fetch_with_retry(
url: str,
max_retries: int = DEFAULT_MAX_RETRIES,
base_delay: float = DEFAULT_RETRY_DELAY,
timeout: int = 15,
deadline: float | None = None,
) -> bytes | None:
"""
Fetch URL content with exponential backoff retry.
Args:
url: URL to fetch
max_retries: Maximum number of retry attempts
base_delay: Base delay in seconds (exponential backoff: delay * 2^attempt)
timeout: Request timeout in seconds
deadline: Overall deadline timestamp
Returns:
Response content as bytes (feedparser handles encoding), or None if all retries failed
"""
last_error = None
for attempt in range(max_retries + 1): # +1 because attempt 0 is the first try
# Check deadline before each attempt
if time_left(deadline) is not None and time_left(deadline) <= 0:
print(f"⚠️ Deadline exceeded, skipping fetch: {url}", file=sys.stderr)
return None
try:
req = urllib.request.Request(url, headers={'User-Agent': 'OpenClaw/1.0'})
with urllib.request.urlopen(req, timeout=timeout, context=SSL_CONTEXT) as response:
return response.read()
except urllib.error.URLError as e:
last_error = e
if attempt < max_retries:
delay = base_delay * (2 ** attempt) # Exponential backoff
print(f"⚠️ Fetch failed (attempt {attempt + 1}/{max_retries + 1}): {e}. Retrying in {delay}s...", file=sys.stderr)
time.sleep(delay)
except TimeoutError:
last_error = TimeoutError("Request timed out")
if attempt < max_retries:
delay = base_delay * (2 ** attempt)
print(f"⚠️ Timeout (attempt {attempt + 1}/{max_retries + 1}). Retrying in {delay}s...", file=sys.stderr)
time.sleep(delay)
except Exception as e:
last_error = e
print(f"⚠️ Unexpected error fetching {url}: {e}", file=sys.stderr)
return None
print(f"⚠️ All {max_retries + 1} attempts failed for {url}: {last_error}", file=sys.stderr)
return None
SCRIPT_DIR = Path(__file__).parent
CONFIG_DIR = SCRIPT_DIR.parent / "config"
CACHE_DIR = SCRIPT_DIR.parent / "cache"
# Ensure cache directory exists
CACHE_DIR.mkdir(exist_ok=True)
CA_FILE = (
os.environ.get("SSL_CERT_FILE")
or ("/etc/ssl/certs/ca-bundle.crt" if os.path.exists("/etc/ssl/certs/ca-bundle.crt") else None)
or ("/etc/ssl/certs/ca-certificates.crt" if os.path.exists("/etc/ssl/certs/ca-certificates.crt") else None)
)
SSL_CONTEXT = ssl.create_default_context(cafile=CA_FILE) if CA_FILE else ssl.create_default_context()
DEFAULT_HEADLINE_SOURCES = ["barrons", "ft", "wsj", "cnbc"]
DEFAULT_SOURCE_WEIGHTS = {
"barrons": 4,
"ft": 4,
"wsj": 3,
"cnbc": 2
}
ensure_venv()
import feedparser
class PortfolioError(Exception):
"""Portfolio configuration or fetch error."""
def ensure_portfolio_config():
"""Copy portfolio.csv.example to portfolio.csv if real file doesn't exist."""
example_file = CONFIG_DIR / "portfolio.csv.example"
real_file = CONFIG_DIR / "portfolio.csv"
if real_file.exists():
return
if example_file.exists():
try:
shutil.copy(example_file, real_file)
print(f"📋 Created portfolio.csv from example", file=sys.stderr)
except PermissionError:
print(f"⚠️ Cannot create portfolio.csv (read-only environment)", file=sys.stderr)
else:
print(f"⚠️ No portfolio.csv or portfolio.csv.example found", file=sys.stderr)
# Initialize user config (copy example if needed)
ensure_portfolio_config()
def get_openbb_binary() -> str:
"""
Find openbb-quote binary.
Checks (in order):
1. OPENBB_QUOTE_BIN environment variable
2. PATH via shutil.which()
Returns:
Path to openbb-quote binary
Raises:
RuntimeError: If openbb-quote is not found
"""
# Check env var override
env_path = os.environ.get('OPENBB_QUOTE_BIN')
if env_path:
if os.path.isfile(env_path) and os.access(env_path, os.X_OK):
return env_path
else:
print(f"⚠️ OPENBB_QUOTE_BIN={env_path} is not a valid executable", file=sys.stderr)
# Check PATH
binary = shutil.which('openbb-quote')
if binary:
return binary
# Not found - show helpful error
raise RuntimeError(
"openbb-quote not found!\n\n"
"Installation options:\n"
"1. Install via pip: pip install openbb\n"
"2. Use existing install: export OPENBB_QUOTE_BIN=/path/to/openbb-quote\n"
"3. Add to PATH: export PATH=$PATH:$HOME/.local/bin\n\n"
"See: https://github.com/kesslerio/finance-news-openclaw-skill#dependencies"
)
# Cache the binary path on module load
try:
OPENBB_BINARY = get_openbb_binary()
except RuntimeError as e:
print(f"❌ {e}", file=sys.stderr)
OPENBB_BINARY = None
def load_sources():
"""Load source configuration."""
config_path = CONFIG_DIR / "config.json"
if config_path.exists():
with open(config_path, 'r') as f:
return json.load(f)
legacy_path = CONFIG_DIR / "sources.json"
if legacy_path.exists():
print("⚠️ config/config.json missing; falling back to config/sources.json", file=sys.stderr)
with open(legacy_path, 'r') as f:
return json.load(f)
raise FileNotFoundError("Missing config/config.json")
def _get_best_feed_url(feeds: dict) -> str | None:
"""Get the best feed URL from a feeds configuration dict.
Uses explicit priority list and validates URLs to avoid selecting
non-URL values like 'name' or other config keys.
Args:
feeds: Dict with feed keys like 'top', 'markets', 'tech'
Returns:
Best URL string or None if no valid URL found
"""
# Priority order for feed types (most relevant first)
PRIORITY_KEYS = ['top', 'markets', 'headlines', 'breaking']
for key in PRIORITY_KEYS:
if key in feeds:
value = feeds[key]
# Validate it's a string and starts with http
if isinstance(value, str) and value.startswith('http'):
return value
# Fallback: search all values for valid URLs (skip non-string/non-URL)
for key, value in feeds.items():
if key == 'name':
continue # Skip 'name' field
if isinstance(value, str) and value.startswith('http'):
return value
return None
def fetch_rss(url: str, limit: int = 10, timeout: int = 15, deadline: float | None = None) -> list[dict]:
"""Fetch and parse RSS/Atom feed using feedparser with retry logic."""
# Fetch content with retry (returns bytes for feedparser to handle encoding)
content = fetch_with_retry(url, timeout=timeout, deadline=deadline)
if content is None:
return []
# Parse with feedparser (handles RSS and Atom formats, auto-detects encoding from bytes)
try:
parsed = feedparser.parse(content)
except Exception as e:
print(f"⚠️ Error parsing feed {url}: {e}", file=sys.stderr)
return []
items = []
for entry in parsed.entries[:limit]:
# Skip entries without title or link
title = entry.get('title', '').strip()
if not title:
continue
# Link handling: Atom uses 'link' dict, RSS uses string
link = entry.get('link', '')
if isinstance(link, dict):
link = link.get('href', '').strip()
if not link:
continue
# Date handling: different formats across feeds
published = entry.get('published', '') or entry.get('updated', '')
published_at = None
if published:
try:
published_at = parsedate_to_datetime(published).timestamp()
except Exception:
published_at = None
# Description handling: summary vs description
description = entry.get('summary', '') or entry.get('description', '')
items.append({
'title': title,
'link': link,
'date': published.strip() if published else '',
'published_at': published_at,
'description': (description or '')[:200].strip()
})
return items
def _fetch_via_openbb(
openbb_bin: str,
symbol: str,
timeout: int,
deadline: float | None,
allow_price_fallback: bool,
) -> dict | None:
"""Fetch single symbol via openbb-quote subprocess."""
try:
effective_timeout = clamp_timeout(timeout, deadline)
except TimeoutError:
return None
try:
result = subprocess.run(
[openbb_bin, symbol],
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
timeout=effective_timeout,
check=False
)
if result.returncode != 0:
return None
data = json.loads(result.stdout)
# Normalize response structure
if isinstance(data, dict) and "results" in data and isinstance(data["results"], list):
data = data["results"][0] if data["results"] else {}
elif isinstance(data, list):
data = data[0] if data else {}
if not isinstance(data, dict):
return None
# Price fallback: use open or prev_close if price is None
if allow_price_fallback and data.get("price") is None:
if data.get("open") is not None:
data["price"] = data["open"]
elif data.get("prev_close") is not None:
data["price"] = data["prev_close"]
# Calculate change_percent if missing
if data.get("change_percent") is None and data.get("price") and data.get("prev_close"):
price = data["price"]
prev_close = data["prev_close"]
if prev_close != 0:
data["change_percent"] = ((price - prev_close) / prev_close) * 100
data["symbol"] = symbol
return data
except Exception:
return None
def _fetch_via_yfinance(
symbols: list[str],
timeout: int,
deadline: float | None,
) -> dict:
"""Fetch symbols via yfinance batch download (fallback)."""
results = {}
if not symbols:
return results
try:
if time_left(deadline) is not None and time_left(deadline) <= 0:
return results
tickers = " ".join(symbols)
df = yf.download(tickers, period="5d", progress=False, threads=True, ignore_tz=True)
for symbol in symbols:
try:
if df.empty:
continue
# Handle yfinance MultiIndex columns (yfinance >= 0.2.0)
if isinstance(df.columns, pd.MultiIndex):
try:
s_df = df.xs(symbol, level=1, axis=1, drop_level=True)
except (KeyError, AttributeError):
continue
elif len(symbols) == 1:
# Flat columns only valid for single-symbol requests
s_df = df
else:
# Multi-symbol request but flat columns (only one ticker returned data)
# Skip to avoid misattributing prices to wrong symbols
continue
if s_df.empty:
continue
s_df = s_df.dropna(subset=['Close'])
if s_df.empty:
continue
latest = s_df.iloc[-1]
price = float(latest['Close'])
prev_close = 0.0
change_percent = 0.0
if len(s_df) > 1:
prev_row = s_df.iloc[-2]
prev_close = float(prev_row['Close'])
if prev_close > 0:
change_percent = ((price - prev_close) / prev_close) * 100
results[symbol] = {
"price": price,
"change_percent": change_percent,
"prev_close": prev_close,
"symbol": symbol
}
except Exception:
continue
except Exception as e:
print(f"⚠️ yfinance batch failed: {e}", file=sys.stderr)
return results
def fetch_market_data(
symbols: list[str],
timeout: int = 30,
deadline: float | None = None,
allow_price_fallback: bool = False,
) -> dict:
"""Fetch market data using openbb-quote (primary) with yfinance fallback."""
from concurrent.futures import ThreadPoolExecutor, as_completed
results = {}
if not symbols:
return results
failed_symbols = []
# 1. Try openbb-quote first (primary source)
if OPENBB_BINARY:
def fetch_one(sym):
return sym, _fetch_via_openbb(
OPENBB_BINARY, sym, timeout, deadline, allow_price_fallback
)
# Parallel fetch with ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=min(8, len(symbols))) as executor:
futures = {executor.submit(fetch_one, s): s for s in symbols}
for future in as_completed(futures):
try:
sym, data = future.result()
if data:
results[sym] = data
else:
failed_symbols.append(sym)
except Exception:
failed_symbols.append(futures[future])
else:
# No openbb available, all symbols go to yfinance fallback
print("⚠️ openbb-quote not found, using yfinance fallback", file=sys.stderr)
failed_symbols = list(symbols)
# 2. Fallback to yfinance for any symbols that failed
if failed_symbols:
yf_results = _fetch_via_yfinance(failed_symbols, timeout, deadline)
results.update(yf_results)
return results
def fetch_ticker_news(symbol: str, limit: int = 5) -> list[dict]:
"""Fetch news for a specific ticker via Yahoo Finance RSS."""
url = f"https://feeds.finance.yahoo.com/rss/2.0/headline?s={symbol}®ion=US&lang=en-US"
return fetch_rss(url, limit)
def get_cached_news(cache_key: str) -> dict | None:
"""Get cached news if fresh (< 15 minutes)."""
cache_file = CACHE_DIR / f"{cache_key}.json"
if cache_file.exists():
mtime = datetime.fromtimestamp(cache_file.stat().st_mtime)
if datetime.now() - mtime < timedelta(minutes=15):
with open(cache_file, 'r') as f:
return json.load(f)
return None
def save_cache(cache_key: str, data: dict):
"""Save news to cache."""
cache_file = CACHE_DIR / f"{cache_key}.json"
with open(cache_file, 'w') as f:
json.dump(data, f, indent=2, default=str)
def fetch_all_news(args):
"""Fetch news from all configured sources."""
sources = load_sources()
cache_key = f"all_news_{datetime.now().strftime('%Y%m%d_%H')}"
# Check cache first
if not args.force:
cached = get_cached_news(cache_key)
if cached:
print(json.dumps(cached, indent=2))
return
news = {
'fetched_at': datetime.now().isoformat(),
'sources': {}
}
# Fetch RSS feeds
for source_id, feeds in sources['rss_feeds'].items():
# Skip disabled sources
if not feeds.get('enabled', True):
continue
news['sources'][source_id] = {
'name': feeds.get('name', source_id),
'articles': []
}
for feed_name, feed_url in feeds.items():
if feed_name in ('name', 'enabled', 'note'):
continue
articles = fetch_rss(feed_url, args.limit)
for article in articles:
article['feed'] = feed_name
news['sources'][source_id]['articles'].extend(articles)
# Save to cache
save_cache(cache_key, news)
if args.json:
print(json.dumps(news, indent=2))
else:
for source_id, source_data in news['sources'].items():
print(f"\n### {source_data['name']}\n")
for article in source_data['articles'][:args.limit]:
print(f"• {article['title']}")
if args.verbose and article.get('description'):
print(f" {article['description'][:100]}...")
def get_market_news(
limit: int = 5,
regions: list[str] | None = None,
max_indices_per_region: int | None = None,
language: str | None = None,
deadline: float | None = None,
rss_timeout: int = 15,
subprocess_timeout: int = 30,
) -> dict:
"""Get market overview (indices + top headlines) as data."""
sources = load_sources()
source_weights = sources.get("source_weights", DEFAULT_SOURCE_WEIGHTS)
headline_sources = sources.get("headline_sources", DEFAULT_HEADLINE_SOURCES)
sources_by_lang = sources.get("headline_sources_by_lang", {})
if language and isinstance(sources_by_lang, dict):
lang_sources = sources_by_lang.get(language)
if isinstance(lang_sources, list) and lang_sources:
headline_sources = lang_sources
headline_exclude = set(sources.get("headline_exclude", []))
result = {
'fetched_at': datetime.now().isoformat(),
'markets': {},
'headlines': []
}
# Fetch market indices FIRST (fast, important for briefing)
for region, config in sources['markets'].items():
if time_left(deadline) is not None and time_left(deadline) <= 0:
break
if regions is not None and region not in regions:
continue
result['markets'][region] = {
'name': config['name'],
'indices': {}
}
symbols = config['indices']
if max_indices_per_region is not None:
symbols = symbols[:max_indices_per_region]
for symbol in symbols:
if time_left(deadline) is not None and time_left(deadline) <= 0:
break
data = fetch_market_data(
[symbol],
timeout=subprocess_timeout,
deadline=deadline,
allow_price_fallback=True,
)
if symbol in data:
result['markets'][region]['indices'][symbol] = {
'name': config['index_names'].get(symbol, symbol),
'data': data[symbol]
}
# Fetch top headlines from preferred sources
for source in headline_sources:
if time_left(deadline) is not None and time_left(deadline) <= 0:
break
if source in headline_exclude:
continue
if source in sources['rss_feeds']:
feeds = sources['rss_feeds'][source]
if not feeds.get("enabled", True):
continue
feed_url = _get_best_feed_url(feeds)
if feed_url:
try:
effective_timeout = clamp_timeout(rss_timeout, deadline)
except TimeoutError:
break
articles = fetch_rss(feed_url, limit, timeout=effective_timeout, deadline=deadline)
for article in articles:
article['source_id'] = source
article['source'] = feeds.get('name', source)
article['weight'] = source_weights.get(source, 1)
result['headlines'].extend(articles)
return result
def fetch_market_news(args):
"""Fetch market overview (indices + top headlines)."""
deadline = compute_deadline(args.deadline)
result = get_market_news(args.limit, deadline=deadline)
if args.json:
print(json.dumps(result, indent=2))
else:
print("\n📊 Market Overview\n")
for region, data in result['markets'].items():
print(f"**{data['name']}**")
for symbol, idx in data['indices'].items():
if 'data' in idx and idx['data']:
price = idx['data'].get('price', 'N/A')
change_pct = idx['data'].get('change_percent', 0)
emoji = '📈' if change_pct >= 0 else '📉'
print(f" {emoji} {idx['name']}: {price} ({change_pct:+.2f}%)")
print()
print("\n🔥 Top Headlines\n")
for article in result['headlines'][:args.limit]:
print(f"• [{article['source']}] {article['title']}")
def get_portfolio_metadata() -> dict:
"""Get metadata for portfolio symbols."""
path = CONFIG_DIR / "portfolio.csv"
meta = {}
if path.exists():
import csv
with open(path, 'r') as f:
for row in csv.DictReader(f):
sym = row.get('symbol', '').strip().upper()
if sym:
meta[sym] = row
return meta
def get_portfolio_news(
limit: int = 5,
max_stocks: int = 5,
deadline: float | None = None,
subprocess_timeout: int = 30,
) -> dict:
"""Get news for portfolio stocks as data."""
if not (CONFIG_DIR / "portfolio.csv").exists():
raise PortfolioError("Portfolio config missing: config/portfolio.csv")
# Get symbols from portfolio
symbols = get_portfolio_symbols()
if not symbols:
raise PortfolioError("No portfolio symbols found")
# Get metadata
portfolio_meta = get_portfolio_metadata()
# If large portfolio (e.g. > 15 stocks), switch to tiered fetching
if len(symbols) > 15:
print(f"⚡ Large portfolio detected ({len(symbols)} stocks); using tiered fetch.", file=sys.stderr)
return get_large_portfolio_news(
limit=limit,
top_movers_count=10,
deadline=deadline,
subprocess_timeout=subprocess_timeout,
portfolio_meta=portfolio_meta
)
# Standard fetching for small portfolios
news = {
'fetched_at': datetime.now().isoformat(),
'stocks': {}
}
# Limit stocks for performance if manual limit set (legacy logic)
if max_stocks and len(symbols) > max_stocks:
symbols = symbols[:max_stocks]
for symbol in symbols:
if time_left(deadline) is not None and time_left(deadline) <= 0:
print("⚠️ Deadline exceeded; returning partial portfolio news", file=sys.stderr)
break
if not symbol:
continue
articles = fetch_ticker_news(symbol, limit)
quotes = fetch_market_data(
[symbol],
timeout=subprocess_timeout,
deadline=deadline,
)
news['stocks'][symbol] = {
'quote': quotes.get(symbol, {}),
'articles': articles,
'info': portfolio_meta.get(symbol, {})
}
return news
def fetch_portfolio_news(args):
"""Fetch news for portfolio stocks."""
try:
deadline = compute_deadline(args.deadline)
news = get_portfolio_news(
args.limit,
args.max_stocks,
deadline=deadline
)
except PortfolioError as exc:
if not args.json:
print(f"\n❌ Error: {exc}", file=sys.stderr)
sys.exit(1)
if args.json:
print(json.dumps(news, indent=2))
else:
print(f"\n📊 Portfolio News ({len(news['stocks'])} stocks)\n")
for symbol, data in news['stocks'].items():
quote = data.get('quote', {})
price = quote.get('price')
prev_close = quote.get('prev_close', 0)
open_price = quote.get('open', 0)
# Calculate daily change
# If markets are closed (price is null), calculate from last session (prev_close vs day-before close)
# Since we don't have day-before close, use open -> prev_close as proxy for last session move
change_pct = 0
display_price = price or prev_close
if price and prev_close and prev_close != 0:
# Markets open: current price vs prev close
change_pct = ((price - prev_close) / prev_close) * 100
elif not price and open_price and prev_close and prev_close != 0:
# Markets closed: last session change (prev_close vs open)
change_pct = ((prev_close - open_price) / open_price) * 100
emoji = '📈' if change_pct >= 0 else '📉'
price_str = f"${display_price:.2f}" if isinstance(display_price, (int, float)) else str(display_price)
print(f"\n**{symbol}** {emoji} {price_str} ({change_pct:+.2f}%)")
for article in data['articles'][:3]:
print(f" • {article['title'][:80]}...")
def get_portfolio_symbols() -> list[str]:
"""Get list of portfolio symbols."""
try:
result = subprocess.run(
['python3', str(SCRIPT_DIR / 'portfolio.py'), 'symbols'],
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
timeout=10,
check=False
)
if result.returncode == 0:
return [s.strip() for s in result.stdout.strip().split(',') if s.strip()]
except Exception:
pass
return []
def deduplicate_news(articles: list[dict]) -> list[dict]:
"""Remove duplicate news by URL, fallback to title+date."""
seen = set()
unique = []
for article in articles:
url = article.get('link', '')
if not url:
key = f"{article.get('title', '')}|{article.get('date', '')}"
else:
key = url
if key not in seen:
seen.add(key)
unique.append(article)
return unique
def get_portfolio_only_news(limit_per_ticker: int = 5) -> dict:
"""
Get portfolio news with top 5 gainers and 5 losers, plus news per ticker.
Args:
limit_per_ticker: Max news items per ticker (default: 5)
Returns:
dict with 'gainers', 'losers' (each: list of tickers with price + news)
"""
symbols = get_portfolio_symbols()
if not symbols:
return {'error': 'No portfolio symbols found', 'gainers': [], 'losers': []}
# Fetch prices for all symbols
quotes = fetch_market_data(symbols)
# Build list of (symbol, change_pct)
tickers_with_prices = []
for symbol in symbols:
quote = quotes.get(symbol, {})
price = quote.get('price')
prev_close = quote.get('prev_close', 0)
open_price = quote.get('open', 0)
if price and prev_close and prev_close != 0:
change_pct = ((price - prev_close) / prev_close) * 100
elif price and open_price and open_price != 0:
change_pct = ((price - open_price) / open_price) * 100
else:
change_pct = 0
tickers_with_prices.append({
'symbol': symbol,
'price': price,
'change_pct': change_pct,
'quote': quote
})
# Sort by change_pct
sorted_tickers = sorted(tickers_with_prices, key=lambda x: x['change_pct'], reverse=True)
# Get top 5 gainers and 5 losers
gainers = sorted_tickers[:5]
losers = sorted_tickers[-5:][::-1] # Reverse to show biggest loser first
# Fetch news for each ticker
for ticker_list in [gainers, losers]:
for ticker in ticker_list:
symbol = ticker['symbol']
# Try RSS first
articles = fetch_ticker_news(symbol, limit_per_ticker)
if not articles:
# Fallback to web search if no RSS
articles = web_search_news(symbol, limit_per_ticker)
ticker['news'] = deduplicate_news(articles)
return {
'fetched_at': datetime.now().isoformat(),
'gainers': gainers,
'losers': losers
}
def get_portfolio_movers(
max_items: int = 8,
min_abs_change: float = 1.0,
deadline: float | None = None,
subprocess_timeout: int = 30,
) -> dict:
"""Return top portfolio movers without fetching news."""
symbols = get_portfolio_symbols()
if not symbols:
return {'error': 'No portfolio symbols found', 'movers': []}
try:
effective_timeout = clamp_timeout(subprocess_timeout, deadline)
except TimeoutError:
return {'error': 'Deadline exceeded while fetching portfolio quotes', 'movers': []}
quotes = fetch_market_data(symbols, timeout=effective_timeout, deadline=deadline)
gainers = []
losers = []
for symbol in symbols:
quote = quotes.get(symbol, {})
price = quote.get('price')
prev_close = quote.get('prev_close', 0)
open_price = quote.get('open', 0)
if price and prev_close and prev_close != 0:
change_pct = ((price - prev_close) / prev_close) * 100
elif price and open_price and open_price != 0:
change_pct = ((price - open_price) / open_price) * 100
else:
continue
item = {'symbol': symbol, 'change_pct': change_pct, 'price': price}
if change_pct >= min_abs_change:
gainers.append(item)
elif change_pct <= -min_abs_change:
losers.append(item)
gainers.sort(key=lambda x: x['change_pct'], reverse=True)
losers.sort(key=lambda x: x['change_pct'])
max_each = max_items // 2
selected = gainers[:max_each] + losers[:max_each]
if len(selected) < max_items:
remaining = max_items - len(selected)
extra = gainers[max_each:] + losers[max_each:]
extra.sort(key=lambda x: abs(x['change_pct']), reverse=True)
selected.extend(extra[:remaining])
return {
'fetched_at': datetime.now().isoformat(),
'movers': selected[:max_items],
}
def web_search_news(symbol: str, limit: int = 5) -> list[dict]:
"""Fallback: search for news via web search."""
articles = []
try:
result = subprocess.run(
['web-search', f'{symbol} stock news today', '--count', str(limit)],
capture_output=True,
text=True,
timeout=30,
check=False
)
if result.returncode == 0:
import json as json_mod
data = json_mod.loads(result.stdout)
for item in data.get('results', [])[:limit]:
articles.append({
'title': item.get('title', ''),
'link': item.get('url', ''),
'source': item.get('site', 'Web'),
'date': '',
'description': ''
})
except Exception as e:
print(f"⚠️ Web search failed for {symbol}: {e}", file=sys.stderr)
return articles
def get_large_portfolio_news(
limit: int = 3,
top_movers_count: int = 10,
deadline: float | None = None,
subprocess_timeout: int = 30,
portfolio_meta: dict | None = None,
) -> dict:
"""
Tiered fetch for large portfolios.
1. Batch fetch prices for ALL stocks (fast).
2. Identify top movers (gainers/losers).
3. Fetch news ONLY for top movers.
"""
symbols = get_portfolio_symbols()
if not symbols:
raise PortfolioError("No portfolio symbols found")
# 1. Batch fetch prices
try:
effective_timeout = clamp_timeout(subprocess_timeout, deadline)
except TimeoutError:
raise PortfolioError("Deadline exceeded before price fetch")
# This uses the new yfinance batching
quotes = fetch_market_data(symbols, timeout=effective_timeout, deadline=deadline)
# 2. Identify top movers
movers = []
for symbol, data in quotes.items():
change = data.get('change_percent', 0)
movers.append((symbol, change, data))
# Sort: Absolute change descending? Or Gainers vs Losers?
# Issue says: "Biggest gainers (top 5), Biggest losers (top 5)"
movers.sort(key=lambda x: x[1]) # Sort by change ascending
losers = movers[:5] # Bottom 5
gainers = movers[-5:] # Top 5
gainers.reverse() # Descending
# Combined list for news fetching
# Ensure uniqueness if < 10 stocks total
top_symbols = []
seen = set()
for m in gainers + losers:
sym = m[0]
if sym not in seen:
top_symbols.append(sym)
seen.add(sym)
# 3. Fetch news for top movers
news = {
'fetched_at': datetime.now().isoformat(),
'stocks': {},
'meta': {
'total_stocks': len(symbols),
'top_movers_count': len(top_symbols)
}
}
for symbol in top_symbols:
if time_left(deadline) is not None and time_left(deadline) <= 0:
break
articles = fetch_ticker_news(symbol, limit)
quote_data = quotes.get(symbol, {})
news['stocks'][symbol] = {
'quote': quote_data,
'articles': articles,
'info': portfolio_meta.get(symbol, {}) if portfolio_meta else {}
}
return news
"""Fetch portfolio-only news (top 5 gainers + top 5 losers with news)."""
result = get_portfolio_only_news(limit_per_ticker=args.limit)
if "error" in result:
print(f"\n❌ Error: {result.get('error', 'Unknown')}", file=sys.stderr)
sys.exit(1)
if args.json:
print(json.dumps(result, indent=2, ensure_ascii=False))
return
# Text output
def format_ticker(ticker: dict):
symbol = ticker['symbol']
price = ticker.get('price')
change = ticker['change_pct']
emoji = '📈' if change >= 0 else '📉'
price_str = f"${price:.2f}" if price else 'N/A'
lines = [f"**{symbol}** {emoji} {price_str} ({change:+.2f}%)"]
if ticker.get('news'):
for article in ticker['news'][:args.limit]:
source = article.get('source', 'Unknown')
title = article.get('title', '')[:70]
lines.append(f" • [{source}] {title}...")
else:
lines.append(" • No recent news")
return '\n'.join(lines)
print("\n🚀 **Top Gainers**\n")
for ticker in result['gainers']:
print(format_ticker(ticker))
print()
print("\n📉 **Top Losers**\n")
for ticker in result['losers']:
print(format_ticker(ticker))
print()
def fetch_portfolio_only(args):
"""Fetch portfolio-only news (top 5 gainers + top 5 losers with news)."""
result = get_portfolio_only_news(limit_per_ticker=args.limit)
if "error" in result:
print(f"\n❌ Error: {result.get('error', 'Unknown')}", file=sys.stderr)
sys.exit(1)
if args.json:
print(json.dumps(result, indent=2, ensure_ascii=False))
return
# Text output
def format_ticker(ticker: dict):
symbol = ticker['symbol']
price = ticker.get('price')
change = ticker['change_pct']
emoji = '📈' if change >= 0 else '📉'
price_str = f"${price:.2f}" if price else 'N/A'
lines = [f"**{symbol}** {emoji} {price_str} ({change:+.2f}%)"]
if ticker.get('news'):
for article in ticker['news'][:args.limit]:
source = article.get('source', 'Unknown')
title = article.get('title', '')[:70]
lines.append(f" • [{source}] {title}...")
else:
lines.append(" • No recent news")
return '\n'.join(lines)
print("\n🚀 **Top Gainers**\n")
for ticker in result['gainers']:
print(format_ticker(ticker))
print()
print("\n📉 **Top Losers**\n")
for ticker in result['losers']:
print(format_ticker(ticker))
print()
def main():
parser = argparse.ArgumentParser(description='News Fetcher')
subparsers = parser.add_subparsers(dest='command', required=True)
# All news
all_parser = subparsers.add_parser('all', help='Fetch all news sources')
all_parser.add_argument('--json', action='store_true', help='Output as JSON')
all_parser.add_argument('--limit', type=int, default=5, help='Max articles per source')
all_parser.add_argument('--force', action='store_true', help='Bypass cache')
all_parser.add_argument('--verbose', '-v', action='store_true', help='Show descriptions')
all_parser.set_defaults(func=fetch_all_news)
# Market news
market_parser = subparsers.add_parser('market', help='Market overview + headlines')
market_parser.add_argument('--json', action='store_true', help='Output as JSON')
market_parser.add_argument('--limit', type=int, default=5, help='Max articles per source')
market_parser.add_argument('--deadline', type=int, default=None, help='Overall deadline in seconds')
market_parser.set_defaults(func=fetch_market_news)
# Portfolio news
portfolio_parser = subparsers.add_parser('portfolio', help='News for portfolio stocks')
portfolio_parser.add_argument('--json', action='store_true', help='Output as JSON')
portfolio_parser.add_argument('--limit', type=int, default=5, help='Max articles per source')
portfolio_parser.add_argument('--max-stocks', type=int, default=5, help='Max stocks to fetch (default: 5)')
portfolio_parser.add_argument('--deadline', type=int, default=None, help='Overall deadline in seconds')
portfolio_parser.set_defaults(func=fetch_portfolio_news)
# Portfolio-only news (top 5 gainers + top 5 losers)
portfolio_only_parser = subparsers.add_parser('portfolio-only', help='Top 5 gainers + top 5 losers with news')
portfolio_only_parser.add_argument('--json', action='store_true', help='Output as JSON')
portfolio_only_parser.add_argument('--limit', type=int, default=5, help='Max news items per ticker')
portfolio_only_parser.set_defaults(func=fetch_portfolio_only)
args = parser.parse_args()
args.func(args)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
Research Module - Deep research using Gemini CLI.
Crawls articles, finds correlations, researches companies.
Outputs research_report.md for later analysis.
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from utils import ensure_venv
from fetch_news import PortfolioError, get_market_news, get_portfolio_news
SCRIPT_DIR = Path(__file__).parent
CONFIG_DIR = SCRIPT_DIR.parent / "config"
OUTPUT_DIR = SCRIPT_DIR.parent / "research"
ensure_venv()
def format_market_data(market_data: dict) -> str:
"""Format market data for research prompt."""
lines = ["## Market Data\n"]
for region, data in market_data.get('markets', {}).items():
lines.append(f"### {data['name']}")
for symbol, idx in data.get('indices', {}).items():
if 'data' in idx and idx['data']:
price = idx['data'].get('price', 'N/A')
change_pct = idx['data'].get('change_percent', 0)
emoji = '📈' if change_pct >= 0 else '📉'
lines.append(f"- {idx['name']}: {price} ({change_pct:+.2f}%) {emoji}")
lines.append("")
return '\n'.join(lines)
def format_headlines(headlines: list) -> str:
"""Format headlines for research prompt."""
lines = ["## Current Headlines\n"]
for article in headlines[:20]:
source = article.get('source', 'Unknown')
title = article.get('title', '')
link = article.get('link', '')
lines.append(f"- [{source}] {title}")
if link:
lines.append(f" URL: {link}")
return '\n'.join(lines)
def format_portfolio_news(portfolio_data: dict) -> str:
"""Format portfolio news for research prompt."""
lines = ["## Portfolio Analysis\n"]
for symbol, data in portfolio_data.get('stocks', {}).items():
quote = data.get('quote', {})
price = quote.get('price', 'N/A')
change_pct = quote.get('change_percent', 0)
lines.append(f"### {symbol} (${price}, {change_pct:+.2f}%)")
for article in data.get('articles', [])[:5]:
title = article.get('title', '')
link = article.get('link', '')
lines.append(f"- {title}")
if link:
lines.append(f" URL: {link}")
lines.append("")
return '\n'.join(lines)
def gemini_available() -> bool:
return shutil.which('gemini') is not None
def research_with_gemini(content: str, focus_areas: list = None) -> str:
"""Perform deep research using Gemini CLI.
Args:
content: Combined market/headlines/portfolio content
focus_areas: Optional list of focus areas (e.g., ['earnings', 'macro', 'sectors'])
Returns:
Research report text
"""
focus_prompt = ""
if focus_areas:
focus_prompt = f"""
Focus areas for the research:
{', '.join(f'- {area}' for area in focus_areas)}
Go deep on each area.
"""
prompt = f"""You are an experienced investment research analyst.
Your task is to deliver deep research on current market developments.
{focus_prompt}
Please analyze the following market data:
{content}
## Analysis Requirements:
1. **Macro Trends**: What is driving the market today? Which economic data/decisions matter?
2. **Sector Analysis**: Which sectors are performing best/worst? Why?
3. **Company News**: Relevant earnings, M&A, product launches?
4. **Risks**: What downside risks should be noted?
5. **Opportunities**: Which positive developments offer opportunities?
6. **Correlations**: Are there links between different news items/asset classes?
7. **Trade Ideas**: Concrete setups based on the analysis (not financial advice!)
8. **Sources**: Original links for further research
Be analytical, objective, and opinionated where appropriate.
Deliver a substantial report (500-800 words).
"""
try:
result = subprocess.run(
['gemini', prompt],
capture_output=True,
text=True,
timeout=120
)
if result.returncode == 0:
return result.stdout.strip()
else:
return f"⚠️ Gemini research error: {result.stderr}"
except subprocess.TimeoutExpired:
return "⚠️ Gemini research timeout"
except FileNotFoundError:
return "⚠️ Gemini CLI not found. Install: brew install gemini-cli"
def format_raw_data_report(market_data: dict, portfolio_data: dict) -> str:
parts = []
if market_data:
parts.append(format_market_data(market_data))
if market_data.get('headlines'):
parts.append(format_headlines(market_data['headlines']))
if portfolio_data and 'error' not in portfolio_data:
parts.append(format_portfolio_news(portfolio_data))
return '\n\n'.join(parts)
def generate_research_content(market_data: dict, portfolio_data: dict, focus_areas: list = None) -> dict:
raw_report = format_raw_data_report(market_data, portfolio_data)
if not raw_report.strip():
return {
'report': '',
'source': 'none'
}
if gemini_available():
return {
'report': research_with_gemini(raw_report, focus_areas),
'source': 'gemini'
}
return {
'report': raw_report,
'source': 'raw'
}
def generate_research_report(args):
"""Generate full research report."""
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
config_path = CONFIG_DIR / "config.json"
if not config_path.exists():
print("⚠️ No config found. Run 'finance-news wizard' first.", file=sys.stderr)
sys.exit(1)
# Fetch fresh data
print("📡 Fetching market data...", file=sys.stderr)
# Get market overview
market_data = get_market_news(
args.limit if hasattr(args, 'limit') else 5,
regions=args.regions.split(',') if hasattr(args, 'regions') else ["us", "europe"],
max_indices_per_region=2
)
# Get portfolio news
try:
portfolio_data = get_portfolio_news(
args.limit if hasattr(args, 'limit') else 5,
args.max_stocks if hasattr(args, 'max_stocks') else 10
)
except PortfolioError as exc:
print(f"⚠️ Skipping portfolio: {exc}", file=sys.stderr)
portfolio_data = None
# Build report
focus_areas = None
if hasattr(args, 'focus') and args.focus:
focus_areas = args.focus.split(',')
research_result = generate_research_content(market_data, portfolio_data, focus_areas)
research_report = research_result['report']
source = research_result['source']
if not research_report.strip():
print("⚠️ No data available for research", file=sys.stderr)
return
if source == 'gemini':
print("🔬 Running deep research with Gemini...", file=sys.stderr)
else:
print("🧾 Gemini not available; using raw data report", file=sys.stderr)
# Add metadata header
timestamp = datetime.now().isoformat()
date_str = datetime.now().strftime("%Y-%m-%d %H:%M")
full_report = f"""# Market Research Report
**Generiert:** {date_str}
**Quelle:** Finance News Skill
---
{research_report}
---
*This report was generated automatically. Not financial advice.*
"""
# Save to file
output_file = OUTPUT_DIR / f"research_{datetime.now().strftime('%Y-%m-%d')}.md"
with open(output_file, 'w') as f:
f.write(full_report)
print(f"✅ Research report saved to: {output_file}", file=sys.stderr)
# Also output to stdout
if args.json:
print(json.dumps({
'report': research_report,
'saved_to': str(output_file),
'timestamp': timestamp
}))
else:
print("\n" + "="*60)
print("RESEARCH REPORT")
print("="*60)
print(research_report)
def main():
parser = argparse.ArgumentParser(description='Deep Market Research')
parser.add_argument('--limit', type=int, default=5, help='Max headlines per source')
parser.add_argument('--regions', default='us,europe', help='Comma-separated regions')
parser.add_argument('--max-stocks', type=int, default=10, help='Max portfolio stocks')
parser.add_argument('--focus', help='Focus areas (comma-separated)')
parser.add_argument('--json', action='store_true', help='Output as JSON')
args = parser.parse_args()
generate_research_report(args)
if __name__ == '__main__':
main()
symbol,name,category,notes
AAPL,Apple Inc,Tech,Core holding
TSLA,Tesla Inc,Auto,Growth play
MSFT,Microsoft,Tech,Dividend stock
Related skills
FAQ
What news sources does finance-news use?
Premium RSS feeds include WSJ and Barron's; free feeds include CNBC, Yahoo Finance, and Finnhub plus ticker news from Yahoo.
How are briefings scheduled?
Morning runs at 6:30 AM PT and evening at 1:00 PM PT on weekdays via cron scripts or openclaw cron add.
What dependencies are required?
Python 3.10+, feedparser, Gemini CLI authentication, and OpenClaw message tooling for WhatsApp delivery.
Is Finance News safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.