380 lines
16 KiB
Python
380 lines
16 KiB
Python
"""
|
||
📰 Aandelen Nieuwsbrief via AI + E-mail (Groq - gratis)
|
||
---------------------------------------------------------
|
||
Haalt elke ochtend om 8u nieuws op over je aandelen,
|
||
laat AI een samenvatting maken via Groq, en stuurt dit per mail.
|
||
Toont ook het analistenadvies, de P/E ratio, en 5 AI-gekozen low-risk aandelen.
|
||
|
||
Vereisten:
|
||
pip install yfinance schedule groq
|
||
|
||
Groq API key aanmaken (gratis):
|
||
Ga naar https://console.groq.com → "API Keys"
|
||
Plak je key hieronder bij GROQ_API_KEY
|
||
"""
|
||
|
||
import yfinance as yf
|
||
import schedule
|
||
import time
|
||
import smtplib
|
||
import logging
|
||
import json
|
||
import os
|
||
from groq import Groq
|
||
from email.mime.text import MIMEText
|
||
from email.mime.multipart import MIMEMultipart
|
||
from datetime import datetime
|
||
|
||
# ─────────────────────────────────────────────
|
||
# 📝 LOGBESTAND
|
||
# ─────────────────────────────────────────────
|
||
|
||
logging.basicConfig(
|
||
filename=os.getenv("LOG_FILE", "/data/nieuws_log.txt"),
|
||
level=logging.INFO,
|
||
format="%(asctime)s %(message)s",
|
||
datefmt="%d/%m/%Y %H:%M:%S",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
def log(bericht: str):
|
||
logging.info(bericht)
|
||
print(bericht)
|
||
|
||
# ─────────────────────────────────────────────
|
||
# ⚙️ CONFIGURATIE — pas dit aan
|
||
# ─────────────────────────────────────────────
|
||
|
||
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip() # verplicht
|
||
GMAIL_ADRES = os.getenv("GMAIL_ADRES", "").strip() # verplicht
|
||
GMAIL_APP_WACHTWOORD = os.getenv("GMAIL_APP_WACHTWOORD", "").strip() # verplicht (Google App Password)
|
||
ONTVANGER = os.getenv("ONTVANGER", "").strip() # verplicht
|
||
|
||
DEFAULT_WATCHLIST = {
|
||
"HIMS": "Hims & Hers Health",
|
||
"AMD": "Advanced Micro Devices",
|
||
"AGNC": "AGNC Investment",
|
||
"TSLA": "Tesla",
|
||
"EUNL.DE": "iShares Core MSCI World ETF",
|
||
"CCEP.AS": "Coca-Cola Europacific Partners",
|
||
}
|
||
|
||
WATCHLIST_FILE = os.getenv("WATCHLIST_FILE", "").strip()
|
||
|
||
def _normaliseer_watchlist(data) -> dict[str, str]:
|
||
if isinstance(data, dict):
|
||
out = {}
|
||
for k, v in data.items():
|
||
ticker = str(k).strip()
|
||
naam = str(v).strip() if v is not None else ""
|
||
if not ticker:
|
||
continue
|
||
out[ticker] = naam or ticker
|
||
return out
|
||
|
||
if isinstance(data, list):
|
||
out = {}
|
||
for item in data:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
ticker = str(item.get("ticker", "")).strip()
|
||
naam = str(item.get("naam", "")).strip()
|
||
if not ticker:
|
||
continue
|
||
out[ticker] = naam or ticker
|
||
return out
|
||
|
||
return {}
|
||
|
||
def laad_watchlist() -> dict[str, str]:
|
||
if not WATCHLIST_FILE:
|
||
return DEFAULT_WATCHLIST
|
||
try:
|
||
with open(WATCHLIST_FILE, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
wl = _normaliseer_watchlist(data)
|
||
if wl:
|
||
return wl
|
||
log(f"⚠️ WATCHLIST_FILE bevat geen geldige tickers, gebruik DEFAULT_WATCHLIST. Pad: {WATCHLIST_FILE}")
|
||
return DEFAULT_WATCHLIST
|
||
except FileNotFoundError:
|
||
log(f"⚠️ WATCHLIST_FILE niet gevonden, gebruik DEFAULT_WATCHLIST. Pad: {WATCHLIST_FILE}")
|
||
return DEFAULT_WATCHLIST
|
||
except Exception as e:
|
||
log(f"⚠️ Fout bij lezen WATCHLIST_FILE, gebruik DEFAULT_WATCHLIST. Pad: {WATCHLIST_FILE} fout: {e}")
|
||
return DEFAULT_WATCHLIST
|
||
|
||
WATCHLIST = laad_watchlist()
|
||
|
||
VERSTUUR_OM = os.getenv("VERSTUUR_OM", "08:00").strip()
|
||
|
||
if __name__ != "__main__":
|
||
# Bij importeren (bv. tests) willen we niet hard falen op ontbrekende envs.
|
||
pass
|
||
else:
|
||
ontbrekend = [naam for naam, waarde in {
|
||
"GROQ_API_KEY": GROQ_API_KEY,
|
||
"GMAIL_ADRES": GMAIL_ADRES,
|
||
"GMAIL_APP_WACHTWOORD": GMAIL_APP_WACHTWOORD,
|
||
"ONTVANGER": ONTVANGER,
|
||
}.items() if not waarde]
|
||
if ontbrekend:
|
||
raise SystemExit(
|
||
"Ontbrekende environment variables: "
|
||
+ ", ".join(ontbrekend)
|
||
+ ". Zet ze in je omgeving of via docker-compose (.env)."
|
||
)
|
||
|
||
# ─────────────────────────────────────────────
|
||
# 📊 ANALISTENADVIES
|
||
# ─────────────────────────────────────────────
|
||
|
||
ADVIES_KLEUREN = {
|
||
"strong buy": ("#1a7a1a", "🟢 Strong Buy"),
|
||
"buy": ("#4caf50", "🟩 Buy"),
|
||
"hold": ("#f5a623", "🟡 Hold"),
|
||
"underperform": ("#e67e22", "🟠 Underperform"),
|
||
"sell": ("#c0392b", "🔴 Sell"),
|
||
"strong sell": ("#7b0000", "🔴 Strong Sell"),
|
||
}
|
||
|
||
def haal_advies_op(ticker: str) -> tuple:
|
||
try:
|
||
info = yf.Ticker(ticker).info
|
||
advies = info.get("recommendationKey", "").lower()
|
||
if advies in ADVIES_KLEUREN:
|
||
return ADVIES_KLEUREN[advies]
|
||
return ("#888888", "⚪ Geen advies")
|
||
except:
|
||
return ("#888888", "⚪ Niet beschikbaar")
|
||
|
||
# ─────────────────────────────────────────────
|
||
# 📉 P/E RATIO
|
||
# ─────────────────────────────────────────────
|
||
|
||
def haal_pe_op(ticker: str) -> str:
|
||
try:
|
||
info = yf.Ticker(ticker).info
|
||
pe = info.get("trailingPE", None)
|
||
if pe is None:
|
||
return '<span style="color:#888; font-size:13px;">P/E: niet beschikbaar</span>'
|
||
if pe < 0:
|
||
kleur, uitleg = "#c0392b", "verlies"
|
||
elif pe < 15:
|
||
kleur, uitleg = "#1a7a1a", "goedkoop"
|
||
elif pe < 30:
|
||
kleur, uitleg = "#f5a623", "gemiddeld"
|
||
else:
|
||
kleur, uitleg = "#c0392b", "duur"
|
||
return f'<span style="font-size:13px; color:#555;">P/E ratio: <strong style="color:{kleur};">{pe:.1f}</strong> <span style="color:#aaa;">({uitleg})</span></span>'
|
||
except:
|
||
return '<span style="color:#888; font-size:13px;">P/E: niet beschikbaar</span>'
|
||
|
||
# ─────────────────────────────────────────────
|
||
# 📰 NIEUWS OPHALEN
|
||
# ─────────────────────────────────────────────
|
||
|
||
def haal_nieuws_op(ticker: str, max_artikels: int = 5) -> list:
|
||
try:
|
||
nieuws = yf.Ticker(ticker).news
|
||
koppen = []
|
||
for artikel in nieuws[:max_artikels]:
|
||
titel = artikel.get("content", {}).get("title", "")
|
||
if titel:
|
||
koppen.append(titel)
|
||
return koppen
|
||
except Exception as e:
|
||
log(f"Fout bij nieuws ophalen voor {ticker}: {e}")
|
||
return []
|
||
|
||
# ─────────────────────────────────────────────
|
||
# 🤖 AI SAMENVATTING
|
||
# ─────────────────────────────────────────────
|
||
|
||
def maak_samenvatting(naam: str, ticker: str, koppen: list) -> str:
|
||
if not koppen:
|
||
return "Geen recent nieuws gevonden."
|
||
koppen_tekst = "\n".join(f"- {k}" for k in koppen)
|
||
prompt = f"""
|
||
Dit zijn recente nieuwskoppen over het aandeel {naam} ({ticker}):
|
||
|
||
{koppen_tekst}
|
||
|
||
Schrijf een korte samenvatting van 2-3 zinnen in het Nederlands.
|
||
Wees concreet en to-the-point. Geen inleiding of afsluiting nodig.
|
||
"""
|
||
client = Groq(api_key=GROQ_API_KEY)
|
||
response = client.chat.completions.create(
|
||
model="llama-3.3-70b-versatile",
|
||
messages=[{"role": "user", "content": prompt}],
|
||
max_tokens=300,
|
||
)
|
||
return response.choices[0].message.content.strip()
|
||
|
||
# ─────────────────────────────────────────────
|
||
# 💡 AI KIEST 5 LOW-RISK AANDELEN
|
||
# ─────────────────────────────────────────────
|
||
|
||
def haal_lowrisk_aandelen() -> list:
|
||
"""
|
||
Vraagt aan Groq om 5 low-risk aandelen te selecteren.
|
||
Geeft een lijst van dicts terug: [{ticker, naam, reden}, ...]
|
||
"""
|
||
log(" AI selecteert 5 low-risk aandelen...")
|
||
datum = datetime.now().strftime("%d %B %Y")
|
||
prompt = f"""
|
||
Vandaag is het {datum}.
|
||
|
||
Selecteer 5 interessante, low-risk aandelen voor een voorzichtige belegger.
|
||
Criteria: stabiele bedrijven, voorkeur voor dividend, lage volatiliteit, sterke balans.
|
||
Denk aan sectoren zoals consumentengoederen, gezondheidszorg, nutsbedrijven, ETFs.
|
||
|
||
Geef je antwoord ALLEEN als een JSON-array, zonder uitleg of extra tekst, in dit formaat:
|
||
[
|
||
{{"ticker": "JNJ", "naam": "Johnson & Johnson", "reden": "Stabiel dividendaandeel met sterke cashflow."}},
|
||
{{"ticker": "PG", "naam": "Procter & Gamble", "reden": "Defensief consumentengoederen bedrijf."}},
|
||
...
|
||
]
|
||
"""
|
||
client = Groq(api_key=GROQ_API_KEY)
|
||
response = client.chat.completions.create(
|
||
model="llama-3.3-70b-versatile",
|
||
messages=[{"role": "user", "content": prompt}],
|
||
max_tokens=500,
|
||
)
|
||
tekst = response.choices[0].message.content.strip()
|
||
|
||
try:
|
||
# Verwijder eventuele markdown backticks
|
||
tekst = tekst.replace("```json", "").replace("```", "").strip()
|
||
aandelen = json.loads(tekst)
|
||
return aandelen[:5]
|
||
except Exception as e:
|
||
log(f"Fout bij parsen van low-risk aandelen: {e}")
|
||
return []
|
||
|
||
def bouw_lowrisk_sectie(aandelen: list) -> str:
|
||
"""Bouwt de HTML-sectie op voor de 5 low-risk aandelen."""
|
||
if not aandelen:
|
||
return "<p style='color:#888;'>Kon geen aanbevelingen ophalen vandaag.</p>"
|
||
|
||
rijen = ""
|
||
for a in aandelen:
|
||
ticker = a.get("ticker", "")
|
||
naam = a.get("naam", ticker)
|
||
reden = a.get("reden", "")
|
||
kleur, advies_label = haal_advies_op(ticker)
|
||
pe_badge = haal_pe_op(ticker)
|
||
|
||
rijen += f"""
|
||
<div style="padding:14px 0; border-bottom:1px solid #e0e0e0;">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap;">
|
||
<div>
|
||
<strong style="color:#1a1a2e;">{naam}</strong>
|
||
<span style="color:#888; font-size:12px; margin-left:6px;">{ticker}</span>
|
||
</div>
|
||
<span style="background:{kleur}; color:white; padding:3px 8px; border-radius:10px; font-size:11px; font-weight:bold;">
|
||
{advies_label}
|
||
</span>
|
||
</div>
|
||
<div style="margin:4px 0;">{pe_badge}</div>
|
||
<p style="margin:4px 0 0 0; font-size:13px; color:#555; font-style:italic;">{reden}</p>
|
||
</div>
|
||
"""
|
||
|
||
return f"""
|
||
<div style="margin-top:32px; padding:20px; background:#eef6ee; border-left:4px solid #1a7a1a; border-radius:4px;">
|
||
<h2 style="margin:0 0 4px 0; font-size:16px; color:#1a7a1a;">💡 AI-tip: 5 Low-Risk Aandelen van Vandaag</h2>
|
||
<p style="margin:0 0 14px 0; font-size:12px; color:#888;">Dagelijks geselecteerd door AI op basis van stabiliteit en dividendkracht</p>
|
||
{rijen}
|
||
</div>
|
||
"""
|
||
|
||
# ─────────────────────────────────────────────
|
||
# 📧 NIEUWSBRIEF SAMENSTELLEN EN STUREN
|
||
# ─────────────────────────────────────────────
|
||
|
||
def stuur_nieuwsbrief():
|
||
log("── Nieuwsbrief samenstellen ──")
|
||
datum = datetime.now().strftime("%A %d %B %Y")
|
||
|
||
secties_html = ""
|
||
for ticker, naam in WATCHLIST.items():
|
||
log(f" Verwerken: {naam} ({ticker})")
|
||
koppen = haal_nieuws_op(ticker)
|
||
samenvatting = maak_samenvatting(naam, ticker, koppen)
|
||
kleur, advies_label = haal_advies_op(ticker)
|
||
pe_badge = haal_pe_op(ticker)
|
||
|
||
secties_html += f"""
|
||
<div style="margin-bottom:28px; padding:20px; background:#f9f9f9; border-left:4px solid #1a1a2e; border-radius:4px;">
|
||
<div style="display:flex; justify-content:space-between; align-items:flex-start; flex-wrap:wrap; margin-bottom:8px;">
|
||
<div>
|
||
<h2 style="margin:0 0 2px 0; font-size:16px; color:#1a1a2e;">{naam}</h2>
|
||
<p style="margin:0; font-size:12px; color:#888;">{ticker}</p>
|
||
</div>
|
||
<span style="background:{kleur}; color:white; padding:4px 10px; border-radius:12px; font-size:12px; font-weight:bold; white-space:nowrap;">
|
||
{advies_label}
|
||
</span>
|
||
</div>
|
||
<div style="margin-bottom:10px;">{pe_badge}</div>
|
||
<p style="margin:0; font-size:14px; color:#333; line-height:1.6;">{samenvatting}</p>
|
||
</div>
|
||
"""
|
||
|
||
# Low-risk sectie
|
||
lowrisk_aandelen = haal_lowrisk_aandelen()
|
||
lowrisk_html = bouw_lowrisk_sectie(lowrisk_aandelen)
|
||
|
||
legenda = """
|
||
<div style="margin-top:24px; padding:16px; background:#f0f0f0; border-radius:4px; font-size:12px; color:#555;">
|
||
<strong>P/E ratio legenda:</strong><br>
|
||
<span style="color:#1a7a1a;">■</span> Onder 15 = goedkoop |
|
||
<span style="color:#f5a623;">■</span> 15–30 = gemiddeld |
|
||
<span style="color:#c0392b;">■</span> Boven 30 = duur |
|
||
<span style="color:#c0392b;">■</span> Negatief = verlies
|
||
</div>
|
||
"""
|
||
|
||
html = f"""
|
||
<html><body style="font-family: Georgia, serif; max-width:600px; margin:auto; padding:20px; color:#222;">
|
||
<div style="border-bottom:2px solid #1a1a2e; padding-bottom:12px; margin-bottom:24px;">
|
||
<h1 style="margin:0; font-size:22px; color:#1a1a2e;">📰 Jouw Dagelijkse Aandelenupdate</h1>
|
||
<p style="margin:4px 0 0 0; font-size:13px; color:#888;">{datum}</p>
|
||
</div>
|
||
{secties_html}
|
||
{lowrisk_html}
|
||
{legenda}
|
||
<p style="font-size:11px; color:#aaa; margin-top:16px; border-top:1px solid #eee; padding-top:12px;">
|
||
Samengevat door Groq AI · Analistenadvies & P/E via Yahoo Finance · Geen financieel advies
|
||
</p>
|
||
</body></html>
|
||
"""
|
||
|
||
onderwerp = f"📰 Aandelenupdate {datetime.now().strftime('%d/%m/%Y')}"
|
||
bericht = MIMEMultipart("alternative")
|
||
bericht["Subject"] = onderwerp
|
||
bericht["From"] = GMAIL_ADRES
|
||
bericht["To"] = ONTVANGER
|
||
bericht.attach(MIMEText(html, "html"))
|
||
|
||
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
|
||
server.login(GMAIL_ADRES, GMAIL_APP_WACHTWOORD)
|
||
server.sendmail(GMAIL_ADRES, ONTVANGER, bericht.as_string())
|
||
|
||
log("✅ Nieuwsbrief verstuurd!")
|
||
|
||
# ─────────────────────────────────────────────
|
||
# 🚀 STARTEN
|
||
# ─────────────────────────────────────────────
|
||
|
||
if __name__ == "__main__":
|
||
log(f"📰 Nieuwsbrief-bot gestart! Verstuurt elke ochtend om {VERSTUUR_OM}.")
|
||
|
||
# Meteen 1x uitvoeren voor de test (verwijder # hieronder om te testen)
|
||
#stuur_nieuwsbrief()
|
||
|
||
schedule.every().day.at(VERSTUUR_OM).do(stuur_nieuwsbrief)
|
||
while True:
|
||
schedule.run_pending()
|
||
time.sleep(30) |