615 lines
20 KiB
Python
615 lines
20 KiB
Python
|
|
#!/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()
|