diff --git a/Dockers/aandelen-watcher-bas/.dockerignore b/Dockers/aandelen-watcher-bas/.dockerignore new file mode 100644 index 0000000..fda018f --- /dev/null +++ b/Dockers/aandelen-watcher-bas/.dockerignore @@ -0,0 +1,13 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.log +.env +.venv/ +venv/ +env/ +.git/ +.idea/ +.vscode/ diff --git a/Dockers/aandelen-watcher-bas/.env.example b/Dockers/aandelen-watcher-bas/.env.example new file mode 100644 index 0000000..3987473 --- /dev/null +++ b/Dockers/aandelen-watcher-bas/.env.example @@ -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 diff --git a/Dockers/aandelen-watcher-bas/Dockerfile b/Dockers/aandelen-watcher-bas/Dockerfile new file mode 100644 index 0000000..c216627 --- /dev/null +++ b/Dockers/aandelen-watcher-bas/Dockerfile @@ -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"] diff --git a/Dockers/aandelen-watcher-bas/aandelen.py b/Dockers/aandelen-watcher-bas/aandelen.py new file mode 100644 index 0000000..12d7f6c --- /dev/null +++ b/Dockers/aandelen-watcher-bas/aandelen.py @@ -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 'P/E: niet beschikbaar' + 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'P/E ratio: {pe:.1f} ({uitleg})' + except: + return 'P/E: niet beschikbaar' + +# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ +# ๐ฐ 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 "
Kon geen aanbevelingen ophalen vandaag.
" + + 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""" +{reden}
+Dagelijks geselecteerd door AI op basis van stabiliteit en dividendkracht
+ {rijen} +{ticker}
+{samenvatting}
+{datum}
++ Samengevat door Groq AI ยท Analistenadvies & P/E via Yahoo Finance ยท Geen financieel advies +
+ + """ + + 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) \ No newline at end of file diff --git a/Dockers/aandelen-watcher-bas/docker-compose.yml b/Dockers/aandelen-watcher-bas/docker-compose.yml new file mode 100644 index 0000000..aa1f570 --- /dev/null +++ b/Dockers/aandelen-watcher-bas/docker-compose.yml @@ -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: diff --git a/Dockers/aandelen-watcher-bas/requirements.txt b/Dockers/aandelen-watcher-bas/requirements.txt new file mode 100644 index 0000000..42222d4 --- /dev/null +++ b/Dockers/aandelen-watcher-bas/requirements.txt @@ -0,0 +1,3 @@ +yfinance +schedule +groq diff --git a/Dockers/aandelen-watcher-bas/version b/Dockers/aandelen-watcher-bas/version new file mode 100644 index 0000000..b9bc2fd --- /dev/null +++ b/Dockers/aandelen-watcher-bas/version @@ -0,0 +1 @@ +latest \ No newline at end of file diff --git a/Dockers/aandelen-watcher-bas/watchlist.json.example b/Dockers/aandelen-watcher-bas/watchlist.json.example new file mode 100644 index 0000000..705ffbb --- /dev/null +++ b/Dockers/aandelen-watcher-bas/watchlist.json.example @@ -0,0 +1,5 @@ +{ + "HIMS": "Hims & Hers Health", + "AMD": "Advanced Micro Devices", + "EUNL.DE": "iShares Core MSCI World ETF" +}