This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
"""
|
||||
📰 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)
|
||||
Reference in New Issue
Block a user