aandelen watcher
Build and Push Docker Images / build-and-push (push) Successful in 2m32s

This commit is contained in:
2026-04-20 14:30:57 +02:00
parent c4ca8402ca
commit 64ff6796ad
8 changed files with 449 additions and 0 deletions
@@ -0,0 +1,13 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.log
.env
.venv/
venv/
env/
.git/
.idea/
.vscode/
+11
View File
@@ -0,0 +1,11 @@
# Kopieer naar .env en vul de waarden in.
GROQ_API_KEY=
GMAIL_ADRES=
GMAIL_APP_WACHTWOORD=
ONTVANGER=
# Optioneel
VERSTUUR_OM=08:00
# Optioneel: mount een JSON file naar /data/watchlist.json (zie docker-compose.yml)
# WATCHLIST_FILE=/data/watchlist.json
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends tzdata ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN mkdir -p /data
COPY aandelen.py .
CMD ["python", "aandelen.py"]
+380
View File
@@ -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 &nbsp;|&nbsp;
<span style="color:#f5a623;">■</span> 1530 = gemiddeld &nbsp;|&nbsp;
<span style="color:#c0392b;">■</span> Boven 30 = duur &nbsp;|&nbsp;
<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 &amp; 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)
@@ -0,0 +1,17 @@
services:
aandelen-watcher:
build: .
container_name: aandelen-watcher
env_file:
- .env
environment:
- TZ=Europe/Amsterdam
- LOG_FILE=/data/nieuws_log.txt
- WATCHLIST_FILE=/data/watchlist.json
volumes:
- aandelen_data:/data
- ./watchlist.json:/data/watchlist.json:ro
restart: unless-stopped
volumes:
aandelen_data:
@@ -0,0 +1,3 @@
yfinance
schedule
groq
+1
View File
@@ -0,0 +1 @@
latest
@@ -0,0 +1,5 @@
{
"HIMS": "Hims & Hers Health",
"AMD": "Advanced Micro Devices",
"EUNL.DE": "iShares Core MSCI World ETF"
}