Repository: pcctradinginc-alt/Daily-Options-Report Branch: main Commit: 21213a85b278 Files: 23 Total size: 209.4 KB Directory structure: gitextract_8n2lb_sc/ ├── .github/ │ └── workflows/ │ └── daily_run.yml ├── .gitignore ├── License ├── README.md ├── data/ │ └── .gitkeep ├── requirements.txt └── src/ ├── config_loader.py ├── data_validator.py ├── event_study.py ├── finbert_sentiment.py ├── llm_schema.py ├── main.py ├── market_calendar.py ├── market_data.py ├── news_analyzer.py ├── news_utils.py ├── report_generator.py ├── rules.py ├── sec_check.py ├── sector_map.py ├── simple_journal.py ├── trading_journal.py └── universe.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/daily_run.yml ================================================ name: Daily Options Report on: schedule: # ca. 11:45 ET (15:45 UTC) — Mo–Fr - cron: '45 15 * * 1-5' workflow_dispatch: inputs: dry_run: description: 'Dry-run (kein Email-Versand)' required: false default: 'false' type: choice options: ['false', 'true'] concurrency: group: daily-options-report cancel-in-progress: false jobs: run-bot: runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v4 - name: Python 3.11 einrichten uses: actions/setup-python@v5 with: python-version: '3.11' cache: 'pip' - name: data-Ordner erstellen run: mkdir -p data - name: Trading Journal aus Cache laden id: journal-cache uses: actions/cache/restore@v4 with: path: data/trading_journal.sqlite key: trading-journal-${{ runner.os }}-${{ github.run_id }} restore-keys: | trading-journal-${{ runner.os }}- - name: Dependencies installieren run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Bot ausführen env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} TRADIER_TOKEN: ${{ secrets.TRADIER_TOKEN }} TRADIER_SANDBOX: "false" FINNHUB_KEY: ${{ secrets.FINNHUB_KEY }} ALPHA_VANTAGE_KEY: ${{ secrets.ALPHA_VANTAGE_KEY }} GMAIL_RECIPIENT: ${{ secrets.GMAIL_RECIPIENT }} SMTP_SENDER: ${{ secrets.SMTP_SENDER }} SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} SEC_USER_AGENT: ${{ secrets.SEC_USER_AGENT }} run: | if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then python src/main.py --dry-run --verbose else python src/main.py --verbose fi - name: Trading Journal zurück in Cache speichern if: always() && hashFiles('data/trading_journal.sqlite') != '' uses: actions/cache/save@v4 with: path: data/trading_journal.sqlite key: trading-journal-${{ runner.os }}-${{ github.run_id }} - name: Journal als Artifact sichern if: always() uses: actions/upload-artifact@v4 with: name: trading-journal-${{ github.run_number }} path: data/trading_journal.sqlite retention-days: 90 ================================================ FILE: .gitignore ================================================ config/config.yaml .env __pycache__/ *.py[cod] env/ venv/ .venv/ logs/ *.log report_preview.html market_summary.txt signals.txt .DS_Store .idea/ .vscode/ ================================================ FILE: License ================================================ MIT License — Copyright (c) 2026 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. DISCLAIMER: Dieses Projekt dient ausschließlich zu Bildungszwecken. Es stellt keine Anlageberatung dar. Trading mit Optionen birgt erhebliche Risiken bis hin zum Totalverlust. ================================================ FILE: README.md ================================================ [README.md](https://github.com/user-attachments/files/26564692/README.md) # Options Trading Signal Bot Vollautomatisches tägliches Options-Trading-Signal-System. Analysiert Finanznews, bewertet Marktdaten und verschickt eine HTML-Email mit konkreten Handelsempfehlungen. --- ## Wie es funktioniert ``` 1. News-Analyse 14 RSS-Feeds (Reuters, Bloomberg, CNBC, Benzinga) parallel. Artikel werden geclustert und mit gewichtetem Score bewertet (Aktualität × Quellen-Qualität × Velocity × Earnings-Proximity). Claude analysiert Top-Cluster → handelbare Signale. 2. Marktdaten Kurse (AlphaVantage → Yahoo → Finnhub), historische Daten (MA50, MA20, RelVol) und Options-Greeks (Tradier). Normalisierter Score 0–100 mit Trend-Alignment und Liquiditäts-Filter. 3. Report Claude erstellt Trade-Empfehlung mit 5-Punkte-Begründung, Exit-Plan und Marktstatus. Versand als HTML-Email. ``` --- ## Voraussetzungen Python 3.9+ | API | Zweck | Kosten | |-----|-------|--------| | [Anthropic](https://console.anthropic.com) | Claude | ~$0.01/Tag | | [Tradier](https://developer.tradier.com) | Options-Greeks | Sandbox: kostenlos | | [Finnhub](https://finnhub.io) | Earnings | Free Tier | | [Alpha Vantage](https://www.alphavantage.co) | Kurse | Free: 25/Tag | Gmail App-Passwort: Google Account → Sicherheit → 2FA → App-Passwörter --- ## Installation ```bash git clone https://github.com/DEIN-USERNAME/options-trading-bot.git cd options-trading-bot pip install -r requirements.txt cp config/config.example.yaml config/config.yaml # config.yaml mit API Keys befüllen ``` --- ## Verwendung ```bash # Normaler Lauf (verschickt Email) python src/main.py # Dry-run (kein Email, Report als HTML gespeichert) python src/main.py --dry-run # Mit Details in der Konsole python src/main.py --dry-run --verbose # Einzelne Steps testen python src/news_analyzer.py --verbose python src/market_data.py --signals "UBER:CALL:MED:T1:21DTE" python src/report_generator.py --summary-file market_summary.txt --dry-run ``` --- ## Automatisch täglich (Cron) ```bash # Täglich Mo–Fr um 10:30 ET (14:30 UTC) 30 14 * * 1-5 cd /pfad/zum/bot && python src/main.py >> logs/daily.log 2>&1 ``` --- ## GitHub Actions (automatisch in der Cloud) Secrets setzen: Repository → Settings → Secrets and variables → Actions ``` ANTHROPIC_API_KEY TRADIER_TOKEN FINNHUB_KEY ALPHA_VANTAGE_KEY GMAIL_RECIPIENT SMTP_SENDER SMTP_PASSWORD ``` Dann läuft der Bot täglich Mo–Fr automatisch um 14:30 UTC. Manueller Start: Actions → Daily Options Report → Run workflow --- ## Handelsregeln | VIX | Einsatz | Status | |-----|---------|--------| | ≥ 25 | — | ❌ Kein Trade | | 20–24.99 | 150 € | ⚠️ Reduziert | | < 20 | 250 € | ✅ Normal | Ausschluss wenn: Score < 50 · Δ% gegen Signal · unter MA50 · Spread > 2% · OI < 5.000 --- ## Disclaimer Dieses Projekt dient ausschließlich zu Bildungszwecken und stellt keine Anlageberatung dar. Trading mit Optionen birgt erhebliche Risiken. ================================================ FILE: data/.gitkeep ================================================ ================================================ FILE: requirements.txt ================================================ requests>=2.31.0 pyyaml>=6.0 transformers>=4.40.0 torch>=2.2.0 exchange_calendars>=4.5.0 pydantic>=2.7.0 feedparser>=6.0.10 ================================================ FILE: src/config_loader.py ================================================ """ config_loader.py Lädt API Keys aus config/config.yaml oder Umgebungsvariablen. v8: - Tradier-Production ist Standard. Sandbox wird nur genutzt, wenn TRADIER_SANDBOX=true gesetzt ist. - TRADIER_TOKEN ist Pflicht, weil Options-EV und konsistente Quotes auf Tradier basieren. """ import logging import os from pathlib import Path logger = logging.getLogger(__name__) REQUIRED_KEYS = ["anthropic_api_key", "tradier_token"] try: import yaml YAML_AVAILABLE = True except ImportError: # pragma: no cover YAML_AVAILABLE = False def _parse_bool(value, default=False) -> bool: if value is None: return default if isinstance(value, bool): return value s = str(value).strip().lower() if s in ("1", "true", "yes", "y", "on", "sandbox"): return True if s in ("0", "false", "no", "n", "off", "prod", "production", "live"): return False return default def load_config() -> dict: """ Lädt Konfiguration in folgender Priorität: 1. Umgebungsvariablen überschreiben alles. 2. config/config.yaml. 3. Sichere Defaults. Wichtig: Tradier läuft standardmäßig gegen Production, nicht Sandbox. """ config = {} config_path = Path(__file__).parent.parent / "config" / "config.yaml" if config_path.exists() and YAML_AVAILABLE: try: with open(config_path, "r", encoding="utf-8") as f: config = yaml.safe_load(f) or {} except yaml.YAMLError as e: logger.error("Fehler beim Laden von config.yaml: %s", e) env_map = { "ANTHROPIC_API_KEY": "anthropic_api_key", "TRADIER_TOKEN": "tradier_token", "FINNHUB_KEY": "finnhub_key", "ALPHA_VANTAGE_KEY": "alpha_vantage_key", "GMAIL_RECIPIENT": "gmail_recipient", "SMTP_SENDER": "smtp_sender", "SMTP_PASSWORD": "smtp_password", "TRADIER_SANDBOX": "tradier_sandbox", "TRADIER_ENV": "tradier_env", "SEC_USER_AGENT": "sec_user_agent", } for env_var, key in env_map.items(): val = os.environ.get(env_var) if val is not None and str(val).strip() != "": config[key] = val.strip() # Production ist Default. Sandbox nur explizit. if "tradier_env" in config and "tradier_sandbox" not in config: config["tradier_sandbox"] = _parse_bool(config.get("tradier_env"), default=False) else: config["tradier_sandbox"] = _parse_bool(config.get("tradier_sandbox"), default=False) config["tradier_base_url"] = ( "https://sandbox.tradier.com" if config.get("tradier_sandbox") else "https://api.tradier.com" ) config["tradier_mode"] = "sandbox" if config.get("tradier_sandbox") else "production" return config def validate_config(cfg: dict) -> bool: """Prüft Pflichtfelder. Gibt False zurück, wenn etwas fehlt.""" missing = [k for k in REQUIRED_KEYS if not cfg.get(k)] if missing: logger.error("Fehlende Pflicht-Keys in config: %s", missing) return False if cfg.get("tradier_sandbox"): logger.warning("TRADIER_SANDBOX=true — Sandboxdaten sind verzögert/Simulation. Für Production Secret auf false lassen.") else: logger.info("Tradier-Modus: PRODUCTION api.tradier.com") return True ================================================ FILE: src/data_validator.py ================================================ """ data_validator.py — Datenhärtung für kostenlose und Broker-Datenquellen. Ziel: - Keine Scheingenauigkeit durch kaputte OHLCV-Historien. - Spikes/Gaps markieren statt blind handeln. - Underlying-/Options-Snapshot fail-closed prüfen. Die Funktionen sind bewusst konservativ, aber nicht blind: Ein 10% Gap wird nicht automatisch als Fehler verworfen. Es wird als Risiko-Flag gespeichert und kann über Gates wirken. """ from __future__ import annotations import math import statistics from dataclasses import dataclass from typing import Any @dataclass(frozen=True) class DataValidationResult: ok: bool reason: str flags: tuple[str, ...] = () quality_score: float = 1.0 spike_pct: float | None = None def _to_float(value: Any, default=None): try: if value is None: return default return float(value) except (TypeError, ValueError): return default def validate_ohlcv_history(closes: list, volumes: list | None = None, min_closes: int = 50) -> DataValidationResult: """ Validiert Daily-Historie. Für MA50, Realized Vol und Sektorfilter werden mindestens 50 Schlusskurse bevorzugt. Unter 21 ist die Historie nicht tradebar. """ flags: list[str] = [] quality = 1.0 if not closes or len(closes) < 21: return DataValidationResult(False, "Historie <21 Handelstage", ("history_too_short",), 0.0) clean = [_to_float(c) for c in closes if _to_float(c) is not None and _to_float(c) > 0] if len(clean) < 21: return DataValidationResult(False, "Zu wenige valide Schlusskurse", ("invalid_closes",), 0.0) if len(clean) < min_closes: flags.append("history_below_preferred_50d") quality *= 0.85 # Null-/Negativpreise sind bereits entfernt; nun extreme Lücken erkennen. rets = [] for prev, cur in zip(clean[:-1], clean[1:]): if prev > 0 and cur > 0: rets.append((cur / prev - 1.0) * 100.0) if rets: max_abs_ret = max(abs(r) for r in rets[-20:]) if max_abs_ret > 25: flags.append("extreme_recent_gap_gt25pct") quality *= 0.70 elif max_abs_ret > 12: flags.append("recent_gap_gt12pct") quality *= 0.85 if volumes: vclean = [v for v in volumes if isinstance(v, (int, float)) and v >= 0] if len(vclean) >= 21: if statistics.median(vclean[-20:]) == 0: flags.append("volume_median_zero") quality *= 0.80 else: flags.append("volume_history_short") quality *= 0.95 else: flags.append("volume_missing") quality *= 0.95 return DataValidationResult(True, "ok", tuple(flags), round(max(0.0, min(1.0, quality)), 3)) def detect_unexplained_price_spike(price: float, closes: list, news_signal_present: bool = True, threshold_pct: float = 10.0) -> DataValidationResult: """ Markiert große Kurslücken. Ein Spike ohne erkannte News ist kein automatischer Datenfehler, aber ein Risikosignal, weil der Bot möglicherweise den echten Katalysator nicht kennt. """ p = _to_float(price, 0.0) if p <= 0 or not closes: return DataValidationResult(False, "Preis oder Historie fehlt", ("price_or_history_missing",), 0.0) prev = _to_float(closes[-1], None) if prev is None or prev <= 0: return DataValidationResult(False, "Voriger Schlusskurs fehlt", ("prev_close_missing",), 0.0) spike_pct = (p / prev - 1.0) * 100.0 flags = [] quality = 1.0 if abs(spike_pct) >= threshold_pct: flags.append("price_spike_gt10pct") quality *= 0.75 if not news_signal_present: flags.append("spike_without_detected_news") quality *= 0.60 return DataValidationResult(False, "Preis-Spike >10% ohne erkannte News", tuple(flags), round(quality, 3), round(spike_pct, 2)) return DataValidationResult(True, "Preis-Spike >10% mit News-Kontext", tuple(flags), round(quality, 3), round(spike_pct, 2)) return DataValidationResult(True, "ok", tuple(flags), 1.0, round(spike_pct, 2)) def realized_volatility(closes: list, lookback: int = 20) -> float | None: """Annualisierte realisierte Volatilität aus Daily-Schlusskursen als Dezimalzahl.""" clean = [_to_float(c) for c in closes if _to_float(c) is not None and _to_float(c) > 0] if len(clean) < lookback + 1: return None recent = clean[-(lookback + 1):] rets = [math.log(cur / prev) for prev, cur in zip(recent[:-1], recent[1:]) if prev > 0 and cur > 0] if len(rets) < 10: return None return max(0.05, min(2.50, statistics.stdev(rets) * math.sqrt(252))) def data_flags_to_text(*results: DataValidationResult | None) -> str: flags: list[str] = [] reasons: list[str] = [] for res in results: if not res: continue if res.reason and res.reason != "ok": reasons.append(res.reason) flags.extend(list(res.flags or ())) dedup = [] seen = set() for item in reasons + flags: if item and item not in seen: seen.add(item) dedup.append(item) return " | ".join(dedup) if dedup else "ok" ================================================ FILE: src/event_study.py ================================================ """ event_study.py — Auswertung des SQLite-Journals. Beispiele: python src/event_study.py python src/event_study.py --selected-only python src/event_study.py --csv data/event_study.csv python src/event_study.py --group sector python src/event_study.py --group sentpx """ from __future__ import annotations import argparse import csv import sqlite3 from pathlib import Path from trading_journal import DB_PATH, connect VALID_GROUPS = {"base", "sector", "sector_momentum", "sentpx", "ev_bucket", "ivrv_bucket", "iv_rank_bucket", "data_quality"} def fetch_rows(selected_only: bool = False): con = connect() where = "AND s.selected_trade = 1" if selected_only else "" rows = con.execute( f""" SELECT s.ticker, s.direction, s.signal_strength, s.score, s.score_reason, s.ev_ok, s.ev_pct, s.ev_dollars, s.selected_trade, s.sector, s.sector_etf, s.sector_filter_ok, s.sector_filter_reason, s.sector_vs_market_pct, s.sector_momentum_confirmation, s.sentiment_price_label, s.sentiment_price_score_adjustment, s.data_quality_ok, s.data_quality_reason, s.data_quality_score, s.iv_to_rv, s.option_iv, s.iv_rank, s.iv_percentile, s.iv_history_count, s.no_trade_reason, o.horizon, o.start_price, o.end_price, o.underlying_return_pct, o.direction_return_pct FROM outcomes o JOIN signals s ON s.signal_id = o.signal_id WHERE o.status = 'done' {where} ORDER BY o.horizon, s.direction, s.ticker """ ).fetchall() con.close() return rows def _bucket_ev(ev_pct): if ev_pct is None: return "ev_unknown" try: ev = float(ev_pct) except (TypeError, ValueError): return "ev_unknown" if ev < 0: return "ev_neg" if ev < 12: return "ev_0_12" if ev < 25: return "ev_12_25" return "ev_25_plus" def _bucket_ivrv(iv_to_rv): if iv_to_rv is None: return "ivrv_unknown" try: x = float(iv_to_rv) except (TypeError, ValueError): return "ivrv_unknown" if x < 1.0: return "ivrv_lt1" if x < 1.35: return "ivrv_1_1.35" if x < 2.0: return "ivrv_1.35_2" return "ivrv_gt2" def _bucket_iv_rank(iv_rank, iv_history_count): try: n = int(iv_history_count or 0) except (TypeError, ValueError): n = 0 if n < 30 or iv_rank is None: return "ivrank_insufficient" try: x = float(iv_rank) except (TypeError, ValueError): return "ivrank_unknown" if x < 25: return "ivrank_lt25" if x < 50: return "ivrank_25_50" if x < 80: return "ivrank_50_80" return "ivrank_80_plus" def _group_key(row, group: str): selected = "selected" if row["selected_trade"] else "all" if group == "sector": bucket = row["sector_etf"] or row["sector"] or "unknown" elif group == "sector_momentum": bucket = row["sector_momentum_confirmation"] or "unknown" elif group == "sentpx": bucket = row["sentiment_price_label"] or "unknown" elif group == "ev_bucket": bucket = _bucket_ev(row["ev_pct"]) elif group == "ivrv_bucket": bucket = _bucket_ivrv(row["iv_to_rv"]) elif group == "iv_rank_bucket": bucket = _bucket_iv_rank(row["iv_rank"], row["iv_history_count"]) elif group == "data_quality": bucket = "dq_ok" if row["data_quality_ok"] else "dq_fail" else: bucket = selected return (row["horizon"], row["direction"], bucket) def summarize(rows, group: str = "base"): groups = {} for r in rows: key = _group_key(r, group) groups.setdefault(key, []).append(r["direction_return_pct"]) lines = [] title = "GROUP" if group != "base" else "SET" lines.append(f"HORIZON | DIR | {title:<18} | N | HIT% | AVG% | MEDIAN%") lines.append("-" * 82) for key in sorted(groups.keys()): vals = [v for v in groups[key] if v is not None] if not vals: continue vals_sorted = sorted(vals) n = len(vals) hit = sum(1 for v in vals if v > 0) / n * 100.0 avg = sum(vals) / n med = vals_sorted[n // 2] if n % 2 else (vals_sorted[n // 2 - 1] + vals_sorted[n // 2]) / 2 lines.append(f"{key[0]:<7} | {key[1]:<4} | {str(key[2])[:18]:<18} | {n:<3} | {hit:>5.1f} | {avg:>6.2f} | {med:>7.2f}") return "\n".join(lines) def write_csv(rows, path: Path): path.parent.mkdir(parents=True, exist_ok=True) if not rows: path.write_text("", encoding="utf-8") return with path.open("w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=list(rows[0].keys())) writer.writeheader() for r in rows: writer.writerow(dict(r)) def main(): parser = argparse.ArgumentParser(description="Event-Study aus trading_journal.sqlite") parser.add_argument("--selected-only", action="store_true", help="nur finale Trade-Auswahl") parser.add_argument("--csv", help="CSV Export-Pfad") parser.add_argument("--group", default="base", choices=sorted(VALID_GROUPS), help="Gruppierung: base, sector, sector_momentum, sentpx, ev_bucket, ivrv_bucket, iv_rank_bucket, data_quality") args = parser.parse_args() if not DB_PATH.exists(): raise SystemExit(f"Kein Journal gefunden: {DB_PATH}") rows = fetch_rows(args.selected_only) if not rows: print("Noch keine abgeschlossenen Outcomes. Nach einigen Läufen erneut ausführen.") return print(summarize(rows, args.group)) if args.csv: write_csv(rows, Path(args.csv)) print(f"CSV geschrieben: {args.csv}") if __name__ == "__main__": main() ================================================ FILE: src/finbert_sentiment.py ================================================ """ finbert_sentiment.py — robuste finBERT Sentiment-Analyse Ziel: - FinBERT wirklich lazy laden, sobald es gebraucht wird. - Kein falsches Blockieren durch FINBERT_AVAILABLE=False beim Import. - Sauberer Fallback auf Keyword-Sentiment, wenn transformers/torch/Modell nicht verfügbar sind. - Batch-Ausgabe immer längengleich zur Eingabe. Environment: ENABLE_FINBERT=false -> FinBERT komplett deaktivieren FINBERT_MODEL_NAME -> anderes HuggingFace-Modell, Default: ProsusAI/finbert FINBERT_DEVICE -> -1 CPU, 0 GPU. Default: -1 """ from __future__ import annotations import logging import os from typing import Any, Iterable logger = logging.getLogger(__name__) DEFAULT_MODEL = "ProsusAI/finbert" _pipeline = None _load_attempted = False _last_error: str | None = None # Rückwärtskompatibilität: Wird nach erfolgreichem Laden True. # Wichtig: Der Wert ist beim Import absichtlich False/unknown und darf # in anderen Modulen NICHT als Vorbedingung für den ersten Load benutzt werden. FINBERT_AVAILABLE = False def is_finbert_enabled() -> bool: """Feature-Flag. Standard: aktiv.""" raw = os.getenv("ENABLE_FINBERT", "true").strip().lower() return raw not in {"0", "false", "no", "off", "disabled"} def get_finbert_status() -> dict[str, Any]: """Status für Logging/Debug.""" return { "enabled": is_finbert_enabled(), "loaded": _pipeline is not None, "load_attempted": _load_attempted, "available": FINBERT_AVAILABLE, "model": os.getenv("FINBERT_MODEL_NAME", DEFAULT_MODEL), "error": _last_error, } def _parse_device() -> int: raw = os.getenv("FINBERT_DEVICE", "-1").strip() try: return int(raw) except ValueError: logger.warning("Ungültiger FINBERT_DEVICE=%r — nutze CPU (-1)", raw) return -1 def _load_model(): """Lädt FinBERT beim ersten echten Aufruf. Danach gecacht.""" global _pipeline, _load_attempted, _last_error, FINBERT_AVAILABLE if _pipeline is not None: return _pipeline if not is_finbert_enabled(): _last_error = "FinBERT per ENABLE_FINBERT deaktiviert" FINBERT_AVAILABLE = False return None _load_attempted = True model_name = os.getenv("FINBERT_MODEL_NAME", DEFAULT_MODEL).strip() or DEFAULT_MODEL device = _parse_device() try: from transformers import pipeline except Exception as exc: _last_error = f"transformers import failed: {exc}" FINBERT_AVAILABLE = False logger.warning("finBERT nicht verfügbar — transformers/torch Import fehlgeschlagen: %s", exc) return None try: logger.info("Lade finBERT (%s) auf %s...", model_name, "CPU" if device < 0 else f"device {device}") # Variante 1: Moderne Transformers-Versionen. try: _pipeline = pipeline( task="text-classification", model=model_name, tokenizer=model_name, top_k=None, truncation=True, max_length=512, device=device, ) except TypeError: # Variante 2: ältere Transformers-Versionen. _pipeline = pipeline( task="text-classification", model=model_name, tokenizer=model_name, return_all_scores=True, truncation=True, max_length=512, device=device, ) FINBERT_AVAILABLE = True _last_error = None logger.info("finBERT geladen") return _pipeline except Exception as exc: _pipeline = None FINBERT_AVAILABLE = False _last_error = str(exc) logger.warning("finBERT konnte nicht geladen werden — Keyword-Sentiment bleibt aktiv: %s", exc) return None def _flatten_pipeline_result(result: Any) -> list[dict[str, Any]]: """Normalisiert unterschiedliche Transformers-Pipeline-Ausgabeformen. Möglich sind u.a.: - [{'label': 'positive', 'score': ...}, ...] - [[{'label': 'positive', 'score': ...}, ...]] - [{'label': 'positive', 'score': ...}] bei top-1 """ if result is None: return [] if isinstance(result, dict): return [result] if isinstance(result, list): if not result: return [] if all(isinstance(x, dict) for x in result): return result if len(result) == 1 and isinstance(result[0], list): return _flatten_pipeline_result(result[0]) return [] def _score_from_label_rows(rows: Iterable[dict[str, Any]]) -> float: """Konvertiert FinBERT Label-Scores in [-1, +1].""" scores: dict[str, float] = {} for row in rows: try: label = str(row.get("label", "")).lower().strip() score = float(row.get("score", 0.0)) except Exception: continue if label: scores[label] = score # ProsusAI/finbert nutzt i.d.R. positive / negative / neutral. pos = scores.get("positive", scores.get("pos", 0.0)) neg = scores.get("negative", scores.get("neg", 0.0)) neutral = scores.get("neutral", scores.get("neu", 0.0)) # Falls nur top-1 zurückkommt, wenigstens Richtung abbilden. if not pos and not neg and not neutral and scores: best_label = max(scores, key=scores.get) if "pos" in best_label: pos = scores[best_label] elif "neg" in best_label: neg = scores[best_label] elif "neu" in best_label: neutral = scores[best_label] if neutral > 0.60: net = (pos - neg) * 0.30 else: net = pos - neg return round(max(-1.0, min(1.0, net)), 3) def get_finbert_sentiment(text: str) -> float: """Sentiment für einen Text. 0.0 bei Fehler/Neutral/Fallback.""" if not text or not str(text).strip(): return 0.0 pipe = _load_model() if pipe is None: return 0.0 try: raw = pipe(str(text)[:1000]) rows = _flatten_pipeline_result(raw) return _score_from_label_rows(rows) except Exception as exc: logger.debug("finBERT Inference Fehler: %s", exc) return 0.0 def get_finbert_sentiment_batch(texts: list[str]) -> list[float]: """Sentiment für mehrere Texte. Ergebnis ist immer gleich lang wie texts.""" if not texts: return [] pipe = _load_model() if pipe is None: return [0.0] * len(texts) # Positionen behalten, damit leere Texte nicht die Reihenfolge verschieben. valid_items: list[tuple[int, str]] = [] for idx, text in enumerate(texts): if text and str(text).strip(): valid_items.append((idx, str(text)[:1000])) scores = [0.0] * len(texts) if not valid_items: return scores try: valid_texts = [text for _, text in valid_items] raw_results = pipe(valid_texts) # Bei Batch sollte raw_results eine Liste pro Text sein. Falls die # Pipeline bei einem einzelnen Element anders formatiert, normalisieren. if len(valid_texts) == 1: normalized_results = [raw_results] else: normalized_results = raw_results if isinstance(raw_results, list) else [] for (original_idx, _), raw in zip(valid_items, normalized_results): rows = _flatten_pipeline_result(raw) scores[original_idx] = _score_from_label_rows(rows) return scores except Exception as exc: logger.debug("finBERT Batch Fehler: %s", exc) return [0.0] * len(texts) ================================================ FILE: src/llm_schema.py ================================================ """ llm_schema.py — Pydantic-Schema-Guard für LLM-Ausgaben. Ziel: - Ungültiger LLM-Output darf niemals zu einem Trade führen. - Signal-Output wird auf ein kleines, deterministisches Format reduziert. - Report-JSON wird validiert; bei Fehler wird fail-closed ein No-Trade-Payload erzeugt. """ from __future__ import annotations from datetime import datetime from typing import Any, Literal import re from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator VALID_DIRECTIONS = {"CALL", "PUT"} VALID_STRENGTHS = {"HIGH", "MED", "LOW"} VALID_HORIZONS = {"T1", "T2", "T3"} class TickerSignal(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) ticker: str = Field(pattern=r"^[A-Z]{1,5}$") direction: Literal["CALL", "PUT"] strength: Literal["HIGH", "MED", "LOW"] horizon: Literal["T1", "T2", "T3"] dte_days: int = Field(ge=7, le=120) @field_validator("ticker", mode="before") @classmethod def normalize_ticker(cls, value: Any) -> str: return str(value or "").strip().upper() def to_wire(self) -> str: return f"{self.ticker}:{self.direction}:{self.strength}:{self.horizon}:{self.dte_days}DTE" class SignalEnvelope(BaseModel): model_config = ConfigDict(extra="forbid") signals: list[TickerSignal] = Field(default_factory=list, max_length=5) def to_wire(self) -> str: if not self.signals: return "TICKER_SIGNALS:NONE" return "TICKER_SIGNALS:" + ",".join(s.to_wire() for s in self.signals) def validate_ticker_signal_line(raw_line: str, max_tickers: int = 5) -> tuple[str | None, list[str]]: """ Validiert TICKER_SIGNALS:TICKER:CALL:HIGH:T1:21DTE,... Rückgabe: (canonical_line|None, errors) """ if not raw_line or not str(raw_line).strip(): return None, ["Signalzeile leer"] line = str(raw_line).strip().replace("`", "") upper = line.upper().replace(" ", "") if upper in {"TICKER_SIGNALS:NONE", "NONE"}: return "TICKER_SIGNALS:NONE", [] if upper.startswith("TICKER_SIGNALS:"): payload = line.split(":", 1)[1] else: payload = line if not payload.strip() or payload.strip().upper() == "NONE": return "TICKER_SIGNALS:NONE", [] errors: list[str] = [] signals: list[TickerSignal] = [] seen: set[str] = set() for raw_entry in payload.split(","): entry = raw_entry.strip() if not entry: continue parts = [p.strip().upper() for p in entry.split(":")] if len(parts) != 5: errors.append(f"ungueltiges Signalformat: {entry[:80]}") continue ticker, direction, strength, horizon, dte_raw = parts if strength == "MEDIUM": strength = "MED" dte_match = re.fullmatch(r"(\d{1,3})DTE", dte_raw) if not dte_match: errors.append(f"ungueltige DTE: {entry[:80]}") continue try: sig = TickerSignal( ticker=ticker, direction=direction, strength=strength, horizon=horizon, dte_days=int(dte_match.group(1)), ) except ValidationError as exc: errors.append(f"Schemafehler {ticker or '?'}: {exc.errors()[0].get('msg', str(exc))}") continue if sig.ticker in seen: continue seen.add(sig.ticker) signals.append(sig) if len(signals) >= max_tickers: break if errors: return None, errors envelope = SignalEnvelope(signals=signals) return envelope.to_wire(), [] class ReportReasonDetail(BaseModel): model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) ticker_wahl: str = "" option_wahl: str = "" timing: str = "" chance_risiko: str = "" risiko: str = "" class TickerTableRow(BaseModel): model_config = ConfigDict(extra="allow", str_strip_whitespace=True) ticker: str direction: str | None = None kurs: str | None = None chg: str | None = None ma50: str | None = None trend: str | None = None sector: str | None = None rel_sector: str | None = None sentpx: str | None = None relvol: str | None = None bull: str | None = None score: str | None = None ev_ok: bool | None = None ev_pct: str | None = None gewinner: bool | None = None ausgeschlossen: bool | None = None no_trade_reason: str | None = None @field_validator("ticker", mode="before") @classmethod def normalize_ticker(cls, value: Any) -> str: return str(value or "").strip().upper() class ReportPayload(BaseModel): """Bewusst tolerantes Report-Schema, aber fail-closed bei Trade-Feldern.""" model_config = ConfigDict(extra="allow", str_strip_whitespace=True) datum: str = Field(default_factory=lambda: datetime.now().strftime("%d.%m.%Y")) vix: str | float = "n/v" regime: Literal["LOW-VOL", "TRENDING", "HIGH-VOL"] = "TRENDING" regime_farbe: Literal["gruen", "gelb", "rot"] = "gelb" no_trade: bool = False no_trade_grund: str = "" vix_warnung: bool = False direction: str | None = None ticker: str | None = None strike: str | float | None = None laufzeit: str | None = None delta: str | float | None = None iv: str | float | None = None iv_to_rv: str | float | None = None bid: str | float | None = None ask: str | float | None = None midpoint: str | float | None = None conservative_entry: str | float | None = None entry_price: str | float | None = None exit_slippage_points: str | float | None = None fill_probability: str | float | None = None ev_pct: str | float | None = None ev_dollars: str | float | None = None breakeven_move_pct: str | float | None = None time_stop: str | None = None time_stop_rule: str | None = None time_stop_hours: int | str | None = None time_stop_required_move_pct: str | float | None = None kontrakte: str | int | None = None einsatz: int | str | None = None stop_loss_eur: int | float | str | None = None unusual: bool | None = None begruendung_detail: ReportReasonDetail = Field(default_factory=ReportReasonDetail) markt: str = "" strategie: str = "" ausgeschlossen: str = "" ticker_tabelle: list[TickerTableRow] = Field(default_factory=list) @field_validator("ticker", mode="before") @classmethod def normalize_optional_ticker(cls, value: Any) -> str | None: if value in (None, ""): return None return str(value).strip().upper() @field_validator("direction", mode="before") @classmethod def normalize_direction(cls, value: Any) -> str | None: if value in (None, ""): return None return str(value).strip().upper() @model_validator(mode="after") def validate_trade_payload(self) -> "ReportPayload": if self.no_trade: if not self.no_trade_grund: self.no_trade_grund = "Kein valider Trade nach Schema Guard" return self required = { "ticker": self.ticker, "direction": self.direction, "strike": self.strike, "laufzeit": self.laufzeit, "delta": self.delta, "bid": self.bid, "ask": self.ask, "midpoint": self.midpoint, "conservative_entry": self.conservative_entry, "entry_price": self.entry_price, "ev_pct": self.ev_pct, "ev_dollars": self.ev_dollars, "ticker_tabelle": self.ticker_tabelle, } missing = [k for k, v in required.items() if v in (None, "", [])] if missing: raise ValueError("Trade-Payload unvollstaendig: " + ", ".join(missing)) if self.direction not in VALID_DIRECTIONS: raise ValueError(f"ungueltige direction: {self.direction}") return self def validate_report_payload(data: dict[str, Any]) -> tuple[dict[str, Any] | None, list[str]]: try: payload = ReportPayload.model_validate(data) return payload.model_dump(mode="python"), [] except ValidationError as exc: return None, [f"{'.'.join(str(x) for x in err.get('loc', []))}: {err.get('msg')}" for err in exc.errors()] except ValueError as exc: return None, [str(exc)] def build_cancelled_report(reason: str, raw: str | None = None) -> dict[str, Any]: detail = reason[:450] if raw: detail += " | Raw: " + raw[:250].replace("\n", " ") return { "datum": datetime.now().strftime("%d.%m.%Y"), "vix": "n/v", "regime": "TRENDING", "regime_farbe": "gelb", "no_trade": True, "no_trade_grund": "CANCELLED_SCHEMA_GUARD " + detail, "vix_warnung": False, "ticker_tabelle": [], "begruendung_detail": { "ticker_wahl": "LLM-Ausgabe war nicht schema-valide.", "option_wahl": "Kein Trade.", "timing": "Kein Trade.", "chance_risiko": "Kapitalschutz.", "risiko": detail, }, "markt": "Kein Trade, weil der Report-Output nicht schema-valide war.", "strategie": "Fail-closed.", "ausgeschlossen": detail, } ================================================ FILE: src/main.py ================================================ """ main.py — Daily Options Report Pipeline (mit simple_journal + neuen Hard Gates) v13: Integrierte TradingRules (evaluate_trade + calculate_position_size) """ import argparse import logging import sys import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta from config_loader import load_config, validate_config from news_analyzer import ( fetch_all_feeds, build_earnings_map, cluster_articles, format_clusters_for_claude, run_claude, get_market_context, ) from market_data import ( process_ticker, get_vix, get_earnings, build_summary, ) from report_generator import call_claude, build_html, send_email from rules import parse_ticker_signals, RULES from simple_journal import journal def setup_logging(verbose: bool) -> None: level = logging.DEBUG if verbose else logging.INFO fmt = "%(asctime)s %(levelname)-8s %(name)s — %(message)s" datefmt = "%H:%M:%S" logging.basicConfig(level=level, format=fmt, datefmt=datefmt) for noisy in ("urllib3", "requests", "httpcore", "httpx", "huggingface_hub", "transformers", "torch", "filelock"): logging.getLogger(noisy).setLevel(logging.WARNING) logger = logging.getLogger(__name__) # ====================== HTML HELPER ====================== def _no_trade_html(today: str, vix=None, market_status: str = "", clusters: list = None, reason: str = "Kein valides Signal") -> str: vix_str = str(vix) if vix and vix != "n/v" else "n/v" status_str = market_status or "unbekannt" clusters = clusters or [] cluster_rows = "" for c in clusters[:5]: conf = c.get("confidence_score", 0) tick = c.get("ticker", "?") head = c.get("headline_repr", "")[:60] sent = c.get("sentiment_score", 0) src = c.get("sentiment_source", "keyword") sent_icon = "📈" if sent > 0.1 else ("📉" if sent < -0.1 else "➖") src_badge = "🤖" if src == "finbert" else "🔤" cluster_rows += f'{tick}' \ f'{conf:.2f}' \ f'{sent_icon}{src_badge}' \ f'{head}' cluster_section = f'
... {cluster_rows} ...
' if cluster_rows else "" return f'''

Daily Options Report — {today}

Heute kein Trade

VIX: {vix_str} | Grund: {reason}

{cluster_section}
''' def _error_html(error: str, today: str) -> str: return f'

Fehler am {today}

{error}

' def _send_or_save(html: str, subject: str, cfg: dict, dry_run: bool) -> None: if dry_run: with open("report_preview.html", "w", encoding="utf-8") as f: f.write(html) logger.info("Dry-run: report_preview.html gespeichert") else: send_email(subject, html, cfg) def _enrich_market_data_with_cluster_context(market_data: list, clusters: list) -> None: for d in market_data: ticker = d.get("ticker", "") matches = [c for c in (clusters or []) if c.get("ticker") == ticker] if matches: best = max(matches, key=lambda c: c.get("confidence_score", 0)) d["news_confidence_score"] = best.get("confidence_score") d["news_sentiment_score"] = best.get("sentiment_score") d["news_sentiment_source"] = best.get("sentiment_source", "keyword") # ====================== MAIN ====================== def main() -> int: parser = argparse.ArgumentParser(description="Daily Options Report") parser.add_argument("--dry-run", action="store_true") parser.add_argument("--verbose", action="store_true") args = parser.parse_args() setup_logging(args.verbose) cfg = load_config() if not validate_config(cfg): logger.error("Konfiguration unvollständig") return 1 today = datetime.now().strftime("%d.%m.%Y") t_start = time.monotonic() journal.start_run() logger.info("=" * 70) logger.info("Daily Options Report — %s (Run ID: %s)", today, journal.get_run_id()) logger.info("=" * 70) try: journal.update_outcomes(cfg) except Exception as e: logger.warning("Outcome-Update übersprungen: %s", e) # STEP 1: News logger.info("[1/3] News-Analyse...") t1 = time.monotonic() articles = fetch_all_feeds() earnings_map = build_earnings_map(cfg.get("finnhub_key", "")) clusters = cluster_articles(articles, earnings_map) logger.info("Nach Ticker-Filterung: %d Cluster übrig (von %d Artikeln)", len(clusters), len(articles)) if clusters: top = sorted(clusters, key=lambda c: c.get("confidence_score", 0), reverse=True)[:5] for c in top: logger.info(" → %s (conf=%.1f, %s): %s", c["ticker"], c["confidence_score"], c["event_type"], c["headline_repr"][:80]) cluster_text = format_clusters_for_claude(clusters) market_time, market_status = get_market_context() ticker_signals = run_claude( cluster_text, market_time, market_status, cfg.get("anthropic_api_key", "") ) vix_value = get_vix() logger.info("Claude Signal: %s | VIX: %s", ticker_signals[:100], vix_value) if ticker_signals in ("TICKER_SIGNALS:NONE", "", None): data = {"no_trade": True, "no_trade_grund": "Kein valides Signal", "vix": vix_value} journal.log_decision(data) html = _no_trade_html(today, vix_value, market_status, clusters[:3], "Kein valides Signal") _send_or_save(html, f"⏸️ Daily Options Report – Kein Trade – {today}", cfg, args.dry_run) return 0 # STEP 2: Marktdaten logger.info("[2/3] Marktdaten...") t2 = time.monotonic() parsed_signals = parse_ticker_signals(ticker_signals) if not parsed_signals: logger.error("Keine gültigen Ticker geparst") return 1 ticker_directions = {s["ticker"]: s["direction"] for s in parsed_signals} tickers = list(ticker_directions.keys()) dte_map = {s["ticker"]: s["dte_days"] for s in parsed_signals} # Earnings with ThreadPoolExecutor(max_workers=2) as ex: earnings_fut = ex.submit(get_earnings, datetime.now().strftime("%Y-%m-%d"), (datetime.now() + timedelta(days=10)).strftime("%Y-%m-%d"), cfg.get("finnhub_key", "")) earnings_list = earnings_fut.result(timeout=15) # Ticker verarbeiten with ThreadPoolExecutor(max_workers=RULES.max_tickers) as ex: futures = { ex.submit(process_ticker, t, ticker_directions[t], earnings_list, cfg, dte_map.get(t, 21)): t for t in tickers } results = [] for f in as_completed(futures, timeout=45): try: results.append(f.result()) except Exception as e: logger.error("Ticker %s fehlgeschlagen: %s", futures[f], e) market_data = [r for r in results if r] _enrich_market_data_with_cluster_context(market_data, clusters) # === NEU: Hard-Gate Prüfung mit evaluate_trade + Position Sizing === logger.info("[2.5/3] Hard-Gate Prüfung + Position Sizing...") executed = [] skipped = [] for d in market_data: ticker = d["ticker"] news_alpha = d.get("news_confidence_score", 50) # aus Cluster-Kontext ticker_info = {"market_cap": 999_999_999, "price": d["price"], "spread_pct": 5.0} # Platzhalter – später erweitern passed, reason = RULES.evaluate_trade( ticker_info=ticker_info, market_metrics=d, news_alpha=news_alpha ) if passed and d.get("score", 0) >= RULES.min_score: total_conviction = round( (news_alpha * 0.55) + (d.get("score", 50) * 0.45), 2 ) pos_size = RULES.calculate_position_size(total_conviction, 250_000) logger.info(f"✅ ALARM: {ticker} HIGH CONVICTION | Conviction={total_conviction} | Size=${pos_size:,.0f}") executed.append({ "ticker": ticker, "direction": d.get("news_direction"), "conviction": total_conviction, "position_size": pos_size, "reason": "All gates passed" }) else: skipped.append({"ticker": ticker, "reason": reason}) journal.log_signals(parsed_signals, market_data, clusters) # STEP 3: Report logger.info("[3/3] Report generieren...") try: market_summary = build_summary(market_data, vix_value, ticker_directions, earnings_list, [], []) data = call_claude(market_summary, cfg.get("anthropic_api_key", ""), vix_direct=vix_value) journal.log_decision(data) html_report = build_html(data, today) no_trade = data.get("no_trade", False) or len(executed) == 0 subject = f"⏸️ No Trade – {today}" if no_trade else f"📊 Trade-Alarm – {today}" _send_or_save(html_report, subject, cfg, args.dry_run) except Exception as e: logger.error("Report-Fehler: %s", e) data = {"no_trade": True, "no_trade_grund": f"Report Fehler: {e}"} journal.log_decision(data) _send_or_save(_error_html(str(e), today), f"⚠️ Report Fehler – {today}", cfg, args.dry_run) logger.info("✅ Gesamtlauf beendet in %.1fs | Executed: %d | Skipped: %d", time.monotonic() - t_start, len(executed), len(skipped)) return 0 if __name__ == "__main__": sys.exit(main()) ================================================ FILE: src/market_calendar.py ================================================ """ market_calendar.py — US-Market-Time ohne harte UTC-Annahmen. Primär: exchange_calendars, wenn installiert. Fallback: zoneinfo America/New_York mit regulären Handelszeiten. """ from __future__ import annotations from datetime import datetime, time, timezone from zoneinfo import ZoneInfo NY = ZoneInfo("America/New_York") UTC = timezone.utc def now_et() -> datetime: return datetime.now(UTC).astimezone(NY) def _status_from_et(dt: datetime) -> str: if dt.weekday() >= 5: return "CLOSED-WEEKEND" t = dt.time() if time(9, 30) <= t < time(16, 0): return "OPEN" if time(4, 0) <= t < time(9, 30): return "PRE-MARKET" if time(16, 0) <= t < time(20, 0): return "AFTER-HOURS" return "CLOSED" def market_status(dt: datetime | None = None) -> str: """NYSE-Status mit optionalem exchange_calendars Holiday-Check.""" dt = dt or now_et() if dt.tzinfo is None: dt = dt.replace(tzinfo=NY) dt_et = dt.astimezone(NY) # Optional: Feiertage / verkürzte Sessions über exchange_calendars. try: import exchange_calendars as xcals cal = xcals.get_calendar("XNYS") minute_utc = dt_et.astimezone(UTC) if cal.is_trading_minute(minute_utc): return "OPEN" # außerhalb regulärer Minute trotzdem PRE/AFTER anhand ET-Zeit ausgeben base = _status_from_et(dt_et) if base == "OPEN": return "CLOSED-HOLIDAY" return base except Exception: return _status_from_et(dt_et) def market_context(dt: datetime | None = None) -> tuple[str, str]: dt_et = (dt or now_et()).astimezone(NY) days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] return f"{days[dt_et.weekday()]} {dt_et:%H:%M} ET", market_status(dt_et) def market_elapsed_fraction(dt: datetime | None = None) -> float | None: """ Anteil der regulären 6.5h Session, nur wenn Markt offen. Für intraday Volumen-Hochrechnung. """ dt_et = (dt or now_et()).astimezone(NY) if market_status(dt_et) != "OPEN": return None start = dt_et.replace(hour=9, minute=30, second=0, microsecond=0) end = dt_et.replace(hour=16, minute=0, second=0, microsecond=0) total = (end - start).total_seconds() elapsed = (dt_et - start).total_seconds() return max(0.05, min(1.0, elapsed / total)) ================================================ FILE: src/market_data.py ================================================ """ market_data.py — Marktdaten + Score-Berechnung (Step 2) v12 Final Production Version - Robuste Gap + RVOL Validierung mit korrekter Trend-Direction-Confirmation - Einheitliche RVOL-Berechnung - Bonus nur bei echter High-Conviction (smoother Penalty) - Kein Double-Counting mit old unusual logic - Separate raw_score / final_score für Backtesting """ import logging import math import statistics from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError from datetime import datetime, timedelta, timezone import requests from requests.exceptions import RequestException, Timeout from rules import ( RULES, check_liquidity, conservative_entry_price, estimate_fill_probability, exit_slippage_points, check_data_quality, check_earnings_iv_gate, merge_reasons, build_time_stop_plan, ) from market_calendar import market_elapsed_fraction from data_validator import ( validate_ohlcv_history, detect_unexplained_price_spike, data_flags_to_text, realized_volatility, ) from sector_map import evaluate_sector_filter logger = logging.getLogger(__name__) ETF_TICKERS = { 'TLT','USO','GLD','SLV','GDX','SPY','QQQ','IWM','DIA', 'XLE','XLF','XLK','XLV','XLI','XLU','XLP','XLY','XLB','XLRE','XLC','SMH','SOXX', } USER_AGENTS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15", "python-requests/2.31.0", ] MARKET_OPEN_UTC_H = 13.5 MARKET_CLOSE_UTC_H = 20.0 VOLUME_EXTRAPOLATION_DELAY_H = 0.5 def robust_get(url, params=None, headers=None, timeouts=(6, 8, 10)): for i, timeout in enumerate(timeouts): try: h = {"User-Agent": USER_AGENTS[i % len(USER_AGENTS)]} if headers: h.update(headers) r = requests.get(url, params=params, headers=h, timeout=timeout) if r.status_code == 200: return r except (RequestException, Timeout): pass return None # ══════════════════════════════════════════════════════════ # KURS-QUELLEN (unverändert) # ══════════════════════════════════════════════════════════ def get_quote_tradier(symbol, tradier_token, sandbox=False): if not tradier_token: return None try: base = "https://sandbox.tradier.com" if sandbox else "https://api.tradier.com" hdrs = {"Authorization": "Bearer " + tradier_token, "Accept": "application/json"} r = robust_get(base + "/v1/markets/quotes", params={"symbols": symbol, "greeks": "false"}, headers=hdrs) if not r: return None q = (r.json().get("quotes") or {}).get("quote") if isinstance(q, list): q = q[0] if q else None if not q: return None price = q.get("last") or q.get("close") or q.get("bid") or q.get("ask") if not price or float(price) <= 0: return None prev = q.get("prevclose") or q.get("open") or price chg_pct = ((float(price) - float(prev)) / float(prev) * 100.0) if prev else 0.0 high = q.get("high") or price low = q.get("low") or price return (round(float(price), 2), round(float(chg_pct), 2), round(float(high), 2), round(float(low), 2), "tradier_sandbox" if sandbox else "tradier_production") except (ValueError, KeyError, RequestException) as e: logger.debug("Tradier quote %s: %s", symbol, e) return None def get_quote_alphavantage(symbol, api_key): try: if not api_key: return None r = robust_get("https://www.alphavantage.co/query", params={"function": "GLOBAL_QUOTE", "symbol": symbol, "apikey": api_key}) if not r: return None q = r.json().get("Global Quote", {}) if not q: return None price_str = q.get("05. price", "0") price = float(price_str) if price_str else 0.0 if price <= 0: return None chg_str = q.get("10. change percent", "0%").replace("%", "") return (round(price, 2), round(float(chg_str) if chg_str else 0.0, 2), round(float(q.get("03. high") or price), 2), round(float(q.get("04. low") or price), 2), "alphavantage") except (ValueError, KeyError, RequestException) as e: logger.debug("AlphaVantage %s: %s", symbol, e) return None def get_history_alphavantage(symbol, api_key): try: if not api_key: return [], [] r = robust_get("https://www.alphavantage.co/query", params={"function": "TIME_SERIES_DAILY", "symbol": symbol, "outputsize": "compact", "apikey": api_key}) if not r: return [], [] ts = r.json().get("Time Series (Daily)", {}) if not ts: return [], [] sorted_days = sorted(ts.items()) return ([float(v["4. close"]) for _, v in sorted_days if v.get("4. close")], [int(float(v["5. volume"])) for _, v in sorted_days if v.get("5. volume")]) except (ValueError, KeyError, RequestException) as e: logger.debug("AlphaVantage history %s: %s", symbol, e) return [], [] def get_quote_yahoo_v8(symbol): try: r = None for host in ["query1", "query2"]: r = robust_get( "https://" + host + ".finance.yahoo.com/v8/finance/chart/" + symbol, params={"interval": "1d", "range": "5d"}) if r: break if not r: return None meta = r.json()["chart"]["result"][0].get("meta", {}) price = meta.get("regularMarketPrice") or meta.get("previousClose", 0) prev = meta.get("chartPreviousClose") or meta.get("previousClose", price) if not price or price <= 0: return None chg_pct = round((price - prev) / prev * 100, 2) if prev and prev != 0 else 0.0 return (round(price, 2), chg_pct, round(meta.get("regularMarketDayHigh", price), 2), round(meta.get("regularMarketDayLow", price), 2), "yahoo_v8") except (ValueError, KeyError, IndexError, RequestException) as e: logger.debug("Yahoo v8 %s: %s", symbol, e) return None def get_quote_finnhub(symbol, api_key): if not api_key: return None try: r = robust_get("https://finnhub.io/api/v1/quote", params={"symbol": symbol, "token": api_key}) if not r: return None j = r.json() price = j.get("c", 0) or 0 if price <= 0: return None return (round(price, 2), round(j.get("dp", 0) or 0, 2), round(j.get("h", 0) or 0, 2), round(j.get("l", 0) or 0, 2), "finnhub") except (ValueError, KeyError, RequestException) as e: logger.debug("Finnhub %s: %s", symbol, e) return None def get_quote(symbol, cfg): sources = [ (get_quote_tradier, (symbol, cfg.get("tradier_token", ""), cfg.get("tradier_sandbox", False))), (get_quote_alphavantage, (symbol, cfg.get("alpha_vantage_key",""))), (get_quote_yahoo_v8, (symbol,)), (get_quote_finnhub, (symbol, cfg.get("finnhub_key",""))), ] for fn, args in sources: result = fn(*args) if result: return result logger.warning("Alle Kurs-Quellen für %s fehlgeschlagen", symbol) return (0.0, 0.0, 0.0, 0.0, "failed") def get_history(symbol, cfg): closes, volumes = get_history_alphavantage(symbol, cfg.get("alpha_vantage_key","")) if len(closes) < 20: for host in ["query1", "query2"]: try: r = robust_get( "https://" + host + ".finance.yahoo.com/v8/finance/chart/" + symbol, params={"interval": "1d", "range": "90d"}) if not r: continue quotes = r.json()["chart"]["result"][0]["indicators"]["quote"][0] closes = [c for c in quotes.get("close", []) if c is not None] volumes = [v for v in quotes.get("volume", []) if v is not None] if closes: break except (ValueError, KeyError, IndexError, RequestException) as e: logger.debug("Yahoo history %s %s: %s", host, symbol, e) continue if not closes: return [], [], "failed" source = "alphavantage" if cfg.get("alpha_vantage_key") else "yahoo" return closes, volumes, source def get_sentiment(symbol, change_pct, finnhub_key): if finnhub_key: try: r = robust_get("https://finnhub.io/api/v1/news-sentiment", params={"symbol": symbol, "token": finnhub_key}) if r: j = r.json() sent = j.get("sentiment", {}) or {} bullish = float(sent.get("bullishPercent", 0) or 0) bearish = float(sent.get("bearishPercent", 0) or 0) buzz = float((j.get("buzz", {}) or {}).get("buzz", 0) or 0) if bullish > 0 or bearish > 0: return bullish, bearish, buzz, False except (ValueError, KeyError, RequestException) as e: logger.debug("Finnhub Sentiment %s: %s", symbol, e) bullish = round(max(0.0, min(100.0, 55 + change_pct * 3 if change_pct > 0 else 45 + change_pct * 3)), 1) return bullish, round(100.0 - bullish, 1), round(abs(change_pct), 2), True def classify_sentiment_price_reaction(direction: str, bullish: float, bearish: float, change_pct: float, sent_fallback: bool) -> dict: direction = (direction or "").upper() b = float(bullish or 0.0) br = float(bearish or 0.0) gap = b - br label = "neutral" score_adjustment = 0.0 confidence = "low" if sent_fallback else "medium" if br - b >= 15 and change_pct >= -0.20: label = "bearish_news_absorbed" confidence = "medium" if not sent_fallback else "low" score_adjustment = 5.0 if direction == "CALL" else -5.0 elif b - br >= 15 and change_pct <= 0.10: label = "bullish_news_not_confirmed" confidence = "medium" if not sent_fallback else "low" score_adjustment = 5.0 if direction == "PUT" else -6.0 elif gap >= 20 and change_pct > 0.40: label = "bullish_confirmed" score_adjustment = 3.0 if direction == "CALL" else -3.0 elif gap <= -20 and change_pct < -0.40: label = "bearish_confirmed" score_adjustment = 3.0 if direction == "PUT" else -3.0 if sent_fallback: score_adjustment *= 0.5 return { "sentiment_price_label": label, "sentiment_price_score_adjustment": round(score_adjustment, 2), "sentiment_price_confidence": confidence, "sentiment_gap": round(gap, 2), } def get_vix(): for host in ["query1", "query2"]: try: r = robust_get( "https://" + host + ".finance.yahoo.com/v8/finance/chart/%5EVIX", params={"interval": "1d", "range": "5d"}) if not r: continue closes = [c for c in r.json()["chart"]["result"][0]["indicators"]["quote"][0]["close"] if c is not None] if closes: return round(closes[-1], 2) except (ValueError, KeyError, IndexError, RequestException) as e: logger.debug("VIX %s: %s", host, e) logger.warning("VIX nicht verfügbar") return "n/v" def get_earnings(start, end, finnhub_key): if not finnhub_key: return [] try: r = robust_get("https://finnhub.io/api/v1/calendar/earnings", params={"from": start, "to": end, "token": finnhub_key}) if not r: return [] return [e.get("symbol","") for e in r.json().get("earningsCalendar",[]) if e.get("symbol")] except (ValueError, KeyError, RequestException) as e: logger.warning("Earnings-Kalender Fehler: %s", e) return [] # OPTIONS-EV / KOSTENMODELL (unverändert) def calc_realized_volatility(closes: list, lookback: int = 20) -> float | None: if not closes or len(closes) < lookback + 1: return None rets = [] recent = closes[-(lookback + 1):] for prev, cur in zip(recent[:-1], recent[1:]): if prev and prev > 0 and cur and cur > 0: rets.append(math.log(cur / prev)) if len(rets) < 10: return None daily = statistics.stdev(rets) return max(0.05, min(2.0, daily * math.sqrt(252))) def estimate_expected_move_pct(price: float, change_pct: float, rel_vol, score: float, closes: list, target_dte: int) -> float: if price <= 0: return 0.0 rv = calc_realized_volatility(closes) or 0.35 days = max(1, min(target_dte, RULES.ev_hold_days)) vol_move_pct = rv * math.sqrt(days / 252.0) * 100.0 intraday_impulse = abs(change_pct) * 1.15 rel = 1.0 try: rel = float(rel_vol) if rel_vol not in (None, "n/v") else 1.0 except (ValueError, TypeError): rel = 1.0 rel_mult = max(0.85, min(1.35, 0.85 + 0.20 * rel)) score_mult = max(0.65, min(1.25, score / 70.0)) expected = max(intraday_impulse, vol_move_pct * 0.65) * rel_mult * score_mult return round(max(0.3, min(12.0, expected)), 2) def _safe_float(value, default=0.0) -> float: try: if value is None: return default return float(value) except (TypeError, ValueError): return default def evaluate_option_ev(option: dict, direction: str, underlying_price: float, expected_move_pct: float, realized_vol_20d: float | None = None, earnings_soon: bool = False, news_driven: bool = False, iv_percentile: float | None = None) -> dict | None: g = option.get("greeks") or {} bid = _safe_float(option.get("bid")) ask = _safe_float(option.get("ask")) if bid <= 0 or ask <= 0 or ask < bid: return None mid = round((bid + ask) / 2, 2) spread = ask - bid spread_pct = round(spread / ask * 100.0, 2) if ask > 0 else None strike = _safe_float(option.get("strike")) delta = _safe_float(g.get("delta")) gamma = _safe_float(g.get("gamma")) theta = _safe_float(g.get("theta")) vega = _safe_float(g.get("vega")) iv_raw = g.get("mid_iv") or g.get("ask_iv") or g.get("bid_iv") iv = _safe_float(iv_raw, None) oi = int(_safe_float(option.get("open_interest"), 0)) volume = int(_safe_float(option.get("volume"), 0)) opt_data = { "bid": bid, "ask": ask, "midpoint": mid, "spread_pct": spread_pct, "open_interest": oi, "volume": volume, } entry = conservative_entry_price(opt_data) if not entry: return None move_abs = underlying_price * expected_move_pct / 100.0 delta_gain = abs(delta) * move_abs gamma_gain = 0.5 * abs(gamma) * (move_abs ** 2) * 0.6 # gedämpft theta_cost = abs(theta) * RULES.ev_hold_days if theta else 0.0 iv_drop_decimal = 0.0 iv_crush_factor_used = 0.0 if iv and iv > 0: if earnings_soon: crush_pct = RULES.iv_crush_after_earnings_pct elif news_driven: crush_pct = min(0.35, RULES.iv_crush_after_news_pct) else: crush_pct = RULES.iv_crush_baseline_pct high_iv_flag = False if realized_vol_20d and realized_vol_20d > 0 and iv / realized_vol_20d >= RULES.mature_iv_to_rv_hard_block: high_iv_flag = True if iv_percentile is not None and iv_percentile >= 90.0: high_iv_flag = True if high_iv_flag: crush_pct += RULES.iv_crush_high_iv_bonus_pct crush_pct = max(0.0, min(0.60, crush_pct)) iv_drop_decimal = iv * crush_pct iv_crush_factor_used = crush_pct vega_cost = abs(vega) * iv_drop_decimal entry_slippage = max(0.0, entry - mid) exit_slip = exit_slippage_points(opt_data) iv_to_rv = None iv_rv_penalty = 0.0 if iv and realized_vol_20d and realized_vol_20d > 0: iv_to_rv = round(iv / realized_vol_20d, 3) if iv_to_rv > RULES.max_iv_to_rv_general: iv_rv_penalty = entry * min(0.35, (iv_to_rv - RULES.max_iv_to_rv_general) * RULES.iv_rv_penalty_factor) expected_option_gain = max(0.0, delta_gain + gamma_gain - theta_cost - vega_cost) ev_points = expected_option_gain - entry_slippage - exit_slip - iv_rv_penalty ev_dollars = round(ev_points * 100.0, 2) ev_pct = round(ev_points / entry * 100.0, 2) if entry > 0 else -999.0 if direction == "CALL": breakeven_move_pct = ((strike + entry - underlying_price) / underlying_price * 100.0 if underlying_price > 0 else 999.0) else: breakeven_move_pct = ((underlying_price - (strike - entry)) / underlying_price * 100.0 if underlying_price > 0 else 999.0) breakeven_move_pct = round(max(0.0, breakeven_move_pct), 2) fill_p = estimate_fill_probability(opt_data) ev_reasons = [] if ev_pct < RULES.min_option_ev_pct: ev_reasons.append(f"EV% {ev_pct} < {RULES.min_option_ev_pct}") if ev_dollars < RULES.min_option_ev_dollars: ev_reasons.append(f"EV$ {ev_dollars} < {RULES.min_option_ev_dollars}") if breakeven_move_pct > expected_move_pct * 1.25: ev_reasons.append("Break-even-Move zu hoch") if fill_p < RULES.min_fill_probability: ev_reasons.append(f"FillP {fill_p} < {RULES.min_fill_probability}") ev_ok = not ev_reasons delta_penalty = abs(abs(delta) - RULES.target_delta_abs) * 12.0 liquidity_bonus = min(8.0, oi / 1000.0) + min(4.0, volume / 100.0) ev_score = round(ev_pct + liquidity_bonus - delta_penalty, 2) return { "direction": direction, "strike": option.get("strike"), "bid": bid, "ask": ask, "midpoint": mid, "conservative_entry": entry, "entry_price": entry, "spread_pct": spread_pct, "delta": g.get("delta"), "gamma": g.get("gamma"), "theta": g.get("theta"), "vega": g.get("vega"), "iv": round(iv * 100, 1) if iv else None, "iv_decimal": round(iv, 5) if iv else None, "realized_vol_20d": round(realized_vol_20d, 5) if realized_vol_20d else None, "iv_to_rv": iv_to_rv, "iv_rv_penalty": round(iv_rv_penalty, 4), "vega_cost_points": round(vega_cost, 4), "vega_cost_dollars": round(vega_cost * 100.0, 2), "iv_drop_assumed_decimal": round(iv_drop_decimal, 5), "iv_crush_factor_used": round(iv_crush_factor_used, 3), "iv_crush_mode": ("earnings" if earnings_soon else "news" if news_driven else "baseline"), "open_interest": oi, "volume": volume, "fill_probability": fill_p, "expected_move_pct": expected_move_pct, "breakeven_move_pct": breakeven_move_pct, "entry_slippage_points": round(entry_slippage, 4), "exit_slippage_points": exit_slip, "ev_points": round(ev_points, 3), "ev_dollars": ev_dollars, "ev_pct": ev_pct, "ev_score": ev_score, "ev_ok": ev_ok, "ev_fail_reason": merge_reasons(ev_reasons), "option_source": "tradier", "contracts": None, } def enrich_with_journal_iv_rank(symbol: str, option_ev: dict) -> dict: try: from trading_journal import get_iv_stats stats = get_iv_stats(symbol, option_ev.get("iv_decimal"), min_samples=2) except Exception as exc: stats = { "iv_rank": None, "iv_percentile": None, "iv_history_count": 0, "iv_rank_reason": "IV-Rank nicht berechenbar: " + str(exc)[:80], } option_ev.update(stats) n = int(stats.get("iv_history_count") or 0) iv_rank = stats.get("iv_rank") iv_percentile = stats.get("iv_percentile") iv_to_rv = _safe_float(option_ev.get("iv_to_rv"), None) if n < RULES.min_iv_history_samples_for_rank: option_ev["iv_cold_start"] = True if iv_to_rv is not None and iv_to_rv >= RULES.cold_start_iv_to_rv_hard_block: option_ev["ev_ok"] = False option_ev["ev_fail_reason"] = merge_reasons( option_ev.get("ev_fail_reason"), f"Cold-Start IV/RV {iv_to_rv:.2f} >= {RULES.cold_start_iv_to_rv_hard_block:.2f} Long-Option zu teuer", ) else: option_ev["iv_cold_start"] = False if iv_to_rv is not None and iv_to_rv >= RULES.mature_iv_to_rv_hard_block: option_ev["ev_ok"] = False option_ev["ev_fail_reason"] = merge_reasons( option_ev.get("ev_fail_reason"), f"IV/RV {iv_to_rv:.2f} >= {RULES.mature_iv_to_rv_hard_block:.2f} Long-Option zu teuer", ) if n >= RULES.min_iv_history_samples_for_rank: if iv_rank is not None and iv_rank >= RULES.iv_rank_hard_block_long: option_ev["ev_ok"] = False option_ev["ev_fail_reason"] = merge_reasons( option_ev.get("ev_fail_reason"), f"IV-Rank {iv_rank:.1f} >= {RULES.iv_rank_hard_block_long:.1f} Long-Option zu teuer", ) if iv_percentile is not None and iv_percentile >= RULES.iv_percentile_hard_block_long: option_ev["ev_ok"] = False option_ev["ev_fail_reason"] = merge_reasons( option_ev.get("ev_fail_reason"), f"IV-Percentile {iv_percentile:.1f} >= {RULES.iv_percentile_hard_block_long:.1f}", ) return option_ev def get_tradier_options(symbol, direction, tradier_token, sandbox=False, target_dte=21, underlying_price=0.0, change_pct=0.0, closes=None, rel_vol=None, signal_score=50.0, earnings_soon=False) -> dict: try: if not tradier_token: return {"option_source": "none", "ev_ok": False, "ev_fail_reason": "Tradier Token fehlt"} base = "https://sandbox.tradier.com" if sandbox else "https://api.tradier.com" hdrs = {"Authorization": "Bearer " + tradier_token, "Accept": "application/json"} r_exp = robust_get(base + "/v1/markets/options/expirations", params={"symbol": symbol, "includeAllRoots": "true"}, headers=hdrs) if not r_exp: return {"option_source": "tradier", "ev_ok": False, "ev_fail_reason": "Options-Expirations nicht verfügbar"} exps = r_exp.json().get("expirations", {}).get("date", []) if not exps: return {"option_source": "tradier", "ev_ok": False, "ev_fail_reason": "Keine Expirations"} today_dt = datetime.now() target_exp = None best_diff = 999 target_days = None for exp in exps: days = (datetime.strptime(exp, "%Y-%m-%d") - today_dt).days if days < RULES.min_dte_days: continue diff = abs(days - target_dte) if diff < best_diff: best_diff = diff target_exp = exp target_days = days if not target_exp: return {"option_source": "tradier", "ev_ok": False, "ev_fail_reason": "Keine passende Laufzeit"} r_chain = robust_get(base + "/v1/markets/options/chains", params={"symbol": symbol, "expiration": target_exp, "greeks": "true"}, headers=hdrs) if not r_chain: return {"option_source": "tradier", "ev_ok": False, "expiration": target_exp, "ev_fail_reason": "Options-Chain nicht verfügbar"} opts = r_chain.json().get("options", {}).get("option", []) if not opts: return {"option_source": "tradier", "ev_ok": False, "expiration": target_exp, "ev_fail_reason": "Options-Chain leer"} opt_type = "call" if direction == "CALL" else "put" rv20 = calc_realized_volatility(closes or []) expected_move_pct = estimate_expected_move_pct( underlying_price, change_pct, rel_vol, signal_score, closes or [], target_dte ) candidates = [] for opt in opts: if opt.get("option_type") != opt_type: continue ev = evaluate_option_ev(opt, direction, underlying_price, expected_move_pct, realized_vol_20d=rv20, earnings_soon=earnings_soon, news_driven=True) if ev is None: continue ev["expiration"] = target_exp ev["dte_actual"] = target_days ok_earnings, earnings_reason = check_earnings_iv_gate(ev, earnings_soon) ev["earnings_iv_ok"] = ok_earnings ev["earnings_iv_reason"] = earnings_reason if not ok_earnings: ev["ev_ok"] = False ev["ev_fail_reason"] = merge_reasons(ev.get("ev_fail_reason"), earnings_reason) candidates.append(ev) if not candidates: return {"option_source": "tradier", "ev_ok": False, "expiration": target_exp, "ev_fail_reason": "Keine bewertbaren Optionen"} good = [c for c in candidates if c.get("ev_ok")] chosen_pool = good if good else candidates best = sorted(chosen_pool, key=lambda c: c.get("ev_score", -999), reverse=True)[0] best["candidate_count"] = len(candidates) best["ev_candidates_ok"] = len(good) best.update(build_time_stop_plan(direction, best.get("dte_actual"))) best = enrich_with_journal_iv_rank(symbol, best) if not best.get("ev_ok") and not best.get("ev_fail_reason"): best["ev_fail_reason"] = "Kein Kandidat nach EV/Kosten/Earnings-Gates" return best except (ValueError, KeyError, RequestException) as e: logger.debug("Tradier Options %s: %s", symbol, e) return {"option_source": "tradier", "ev_ok": False, "ev_fail_reason": "Tradier Options Fehler"} def calc_ma(values, period): if len(values) < period: return None return round(sum(values[-period:]) / period, 2) def calc_rel_volume(volumes): valid = [v for v in volumes if v is not None and v >= 0] if len(valid) < 21: return None avg_20 = sum(valid[-21:-1]) / 20 if avg_20 <= 0: return None return round(valid[-1] / avg_20, 2) # ==================== GAP + VOLUME CONVICTION (FINAL) ==================== def validate_gap_and_go(price: float, change_pct: float, volumes: list, closes: list) -> dict: """Finale Version: Gap + RVOL mit korrekter Trend-Direction-Confirmation.""" if price <= 0 or not volumes or len(volumes) < 21 or not closes or len(closes) < 6: return { "gap_pct": round(change_pct, 2), "rvol": None, "is_high_conviction": False, "score_bonus": 0.0 } rvol = calc_rel_volume(volumes) if rvol is None: return { "gap_pct": round(change_pct, 2), "rvol": None, "is_high_conviction": False, "score_bonus": 0.0 } gap_pct = change_pct recent_range = max(closes[-5:]) - min(closes[-5:]) trend_direction = closes[-1] - closes[-5] trend_strength = abs(trend_direction) min_move = max(0.5, recent_range * 0.3) trend_confirmed = ( trend_strength >= min_move and ((gap_pct > 0 and trend_direction > 0) or (gap_pct < 0 and trend_direction < 0)) ) is_high_conviction = ( abs(gap_pct) >= 3.0 and rvol >= 1.5 and trend_confirmed ) gap_bonus = min(abs(gap_pct) * 1.8, 18.0) rvol_bonus = min(max((rvol - 1.0) * 8.0, 0), 16.0) score_bonus = min(round(gap_bonus + rvol_bonus, 1), 20.0) if not is_high_conviction: score_bonus *= 0.3 return { "gap_pct": round(gap_pct, 2), "rvol": round(rvol, 2), "is_high_conviction": is_high_conviction, "score_bonus": round(score_bonus, 1) } # ==================== SCORE (angepasst) ==================== def calculate_score(price, change_pct, above_ma50, ma20, direction, bullish, unusual, earnings_soon, is_etf, gap_volume_bonus: float = 0.0): if price <= 0: return 0.0, "no_price" base = 50.0 momentum = min(25.0, abs(change_pct) * 7) trend_bonus = 0.0 if direction == "CALL" and above_ma50 is True: trend_bonus = 18.0 elif direction == "PUT" and above_ma50 is False: trend_bonus = 18.0 direction_malus = 0.0 if direction == "CALL" and change_pct < -0.5: direction_malus = -35.0 elif direction == "PUT" and change_pct > 0.5: direction_malus = -35.0 volume_bonus = 0.0 if gap_volume_bonus > 0 else (12.0 if unusual and not is_etf else 0.0) etf_roc_bonus = 0.0 if is_etf and ma20 and price > 0: roc = (price - ma20) / ma20 * 100 if direction == "CALL" and roc > 0: etf_roc_bonus = min(12.0, roc * 2) elif direction == "PUT" and roc < 0: etf_roc_bonus = min(12.0, abs(roc) * 2) raw = base + momentum + trend_bonus + direction_malus + volume_bonus + etf_roc_bonus + gap_volume_bonus score = round(max(0.0, min(100.0, raw)), 2) return score, "calculated_structural_no_sentiment" # ==================== TICKER VERARBEITUNG ==================== def process_ticker(ticker, direction, earnings_list, cfg, target_dte: int = 21) -> dict: is_etf = ticker in ETF_TICKERS finnhub_key = cfg.get("finnhub_key", "") q_fut: Future = None h_fut: Future = None try: with ThreadPoolExecutor(max_workers=2) as executor: q_fut = executor.submit(get_quote, ticker, cfg) h_fut = executor.submit(get_history, ticker, cfg) try: price, change_pct, high, low, quote_src = q_fut.result(timeout=12) except TimeoutError: logger.warning("%s: Kurs-Timeout", ticker) price, change_pct, high, low, quote_src = 0.0, 0.0, 0.0, 0.0, "timeout" try: closes, volumes, hist_src = h_fut.result(timeout=12) except TimeoutError: logger.warning("%s: History-Timeout", ticker) closes, volumes, hist_src = [], [], "timeout" quote_age_seconds = 0 if is_etf and price <= 0: return { "ticker": ticker, "price": 0.0, "change_pct": 0.0, "score": 0.0, "_score_reason": "etf_no_price", "options": {}, "news_direction": direction, "is_etf": True, "etf_no_data": True, "_src_quote": quote_src, "quote_age_seconds": quote_age_seconds, "_closes_count": 0, "rel_vol": "n/v", "unusual": False, "ma50": None, "ma20": None, "above_ma50": None, "new_20d_high": None, "trend_status": "n/v", "bullish": 50.0, "sent_fallback": True, "earnings_soon": False, "_data_quality_ok": False, "_data_quality_reason": "ETF ohne Preis", "_liquidity_fail": True, "_liquidity_reason": "ETF ohne Preis", "_no_trade_reason": "ETF ohne Preis", } bullish, bearish, buzz, sent_fallback = get_sentiment(ticker, change_pct, finnhub_key) sentiment_reaction = classify_sentiment_price_reaction(direction, bullish, bearish, change_pct, sent_fallback) history_validation = validate_ohlcv_history(closes, volumes) spike_validation = detect_unexplained_price_spike( price, closes, news_signal_present=True, threshold_pct=10.0 ) if closes else None data_validation_reason = data_flags_to_text(history_validation, spike_validation) data_validation_ok = bool(history_validation.ok and (spike_validation.ok if spike_validation else True)) rel_vol = calc_rel_volume(volumes) # === NEU: Gap + Volume Conviction === gap_volume = validate_gap_and_go(price, change_pct, volumes, closes) unusual = bool(rel_vol and rel_vol >= RULES.daily_rvol_unusual_threshold) ma50 = calc_ma(closes, 50) ma20 = calc_ma(closes, 20) rv20 = calc_realized_volatility(closes) above_ma50 = (price > ma50) if (ma50 is not None and price > 0) else None new_20d = None if len(closes) >= 20 and price > 0: recent_high = max(closes[-20:]) new_20d = price >= recent_high * 0.98 if recent_high > 0 else None earnings_soon = ticker in earnings_list gap_bonus = gap_volume["score_bonus"] if data_validation_ok else 0.0 score, score_reason = calculate_score( price, change_pct, above_ma50, ma20, direction, bullish, unusual, earnings_soon, is_etf, gap_volume_bonus=gap_bonus ) sector_result = evaluate_sector_filter(ticker, direction, change_pct, cfg, get_quote) score = round(max(0.0, min(100.0, score + sector_result.score_adjustment + sentiment_reaction.get("sentiment_price_score_adjustment", 0.0) )), 2) raw_signal_score = score score_reason = score_reason + "; sector=" + sector_result.severity + "; sent_price=" + sentiment_reaction.get("sentiment_price_label", "neutral") if RULES.require_tradier_quote_for_tradier_options and not str(quote_src).lower().startswith("tradier"): options_data = { "option_source": "tradier", "ev_ok": False, "ev_fail_reason": "Hard Block: Tradier-Optionen ohne Tradier-Underlying-Snapshot", "snapshot_consistency_ok": False, } else: options_data = get_tradier_options( ticker, direction, cfg.get("tradier_token", ""), cfg.get("tradier_sandbox", False), target_dte=target_dte, underlying_price=price, change_pct=change_pct, closes=closes, rel_vol=rel_vol, signal_score=score, earnings_soon=earnings_soon, ) market_stub = {"price": price, "_src_quote": quote_src, "quote_source": quote_src, "quote_age_seconds": quote_age_seconds} snapshot_ok, snapshot_reason = check_data_quality(market_stub, options_data) data_ok = bool(snapshot_ok and data_validation_ok) data_reason = merge_reasons(snapshot_reason if not snapshot_ok else "", data_validation_reason if data_validation_reason != "ok" else "") or "ok" is_liquid, liquidity_reason = check_liquidity(options_data) ev_ok = bool(options_data.get("ev_ok")) sector_ok = bool(sector_result.ok) no_trade_reason = [] if not data_ok: no_trade_reason.append(data_reason) if not sector_ok: no_trade_reason.append(sector_result.reason) if not is_liquid: no_trade_reason.append(liquidity_reason) if not ev_ok: no_trade_reason.append(options_data.get("ev_fail_reason") or "Options-EV nach Kosten nicht ausreichend") final_score = score if no_trade_reason: if not data_ok: score_reason = "data_quality_fail" elif not is_liquid: score_reason = "liquidity_fail" else: score_reason = "option_ev_fail" final_score = 0.0 final_reason = merge_reasons(no_trade_reason) if final_reason: logger.info("%s: No-Trade-Gate: %s", ticker, final_reason) logger.info( "%s: price=%.2f score=%.1f raw=%.1f data_ok=%s liquid=%s ev_ok=%s ev=%s src=%s dte=%d", ticker, price, final_score, raw_signal_score, data_ok, is_liquid, ev_ok, options_data.get("ev_pct"), quote_src, target_dte ) return { "ticker": ticker, "price": price, "change_pct": change_pct, "rel_vol": str(rel_vol) if rel_vol is not None else "n/v", "rel_vol_quality": "daily_only_no_intraday_curve", "data_validation_ok": data_validation_ok, "data_validation_reason": data_validation_reason, "data_quality_score": getattr(history_validation, "quality_score", None), "price_spike_pct": getattr(spike_validation, "spike_pct", None) if spike_validation else None, "sector": sector_result.sector, "sector_etf": sector_result.sector_etf, "sector_change_pct": sector_result.sector_change_pct, "market_change_pct": sector_result.market_change_pct, "qqq_change_pct": sector_result.qqq_change_pct, "relative_to_sector_pct": sector_result.relative_to_sector_pct, "sector_vs_market_pct": getattr(sector_result, "sector_vs_market_pct", None), "sector_momentum_confirmation": getattr(sector_result, "momentum_confirmation", "neutral"), "sector_filter_ok": sector_result.ok, "sector_filter_reason": sector_result.reason, "sector_score_adjustment": sector_result.score_adjustment, "sentiment_price_label": sentiment_reaction.get("sentiment_price_label"), "sentiment_price_score_adjustment": sentiment_reaction.get("sentiment_price_score_adjustment"), "sentiment_price_confidence": sentiment_reaction.get("sentiment_price_confidence"), "sentiment_gap": sentiment_reaction.get("sentiment_gap"), "unusual": unusual, "ma50": ma50, "ma20": ma20, "realized_vol_20d": round(rv20, 5) if rv20 else None, "above_ma50": above_ma50, "new_20d_high": new_20d, "trend_status": ("über MA50" if above_ma50 is True else ("unter MA50" if above_ma50 is False else "n/v")), "bullish": round(bullish, 1), "bearish": round(bearish, 1), "sentiment_rank_only": True, "sent_fallback": sent_fallback, "earnings_soon": earnings_soon, "raw_signal_score": raw_signal_score, "gate_adjusted_score": final_score, "score": final_score, "_score_reason": score_reason + f" | gap_bonus={gap_bonus:.1f}", "_data_quality_ok": data_ok, "_data_quality_reason": data_reason, "_liquidity_fail": not is_liquid, "_liquidity_reason": liquidity_reason, "_no_trade_reason": final_reason, "options": options_data, "news_direction": direction, "is_etf": is_etf, "_src_quote": quote_src, "quote_age_seconds": quote_age_seconds, "_src_hist": hist_src, "_closes_count": len(closes), # NEUE FELDER "gap_pct": gap_volume["gap_pct"], "rvol": gap_volume["rvol"], "gap_volume_confirmed": gap_volume["is_high_conviction"], "gap_volume_bonus": gap_bonus, } except Exception as e: logger.error("%s: Unerwarteter Fehler: %s", ticker, e) if q_fut: q_fut.cancel() if h_fut: h_fut.cancel() return { "ticker": ticker, "price": 0.0, "change_pct": 0.0, "score": 0.0, "_score_reason": "exception", "options": {}, "news_direction": direction, "_src_quote": "error", "quote_age_seconds": 0, "_closes_count": 0, "rel_vol": "n/v", "unusual": False, "ma50": None, "ma20": None, "above_ma50": None, "new_20d_high": None, "trend_status": "n/v", "bullish": 40.0, "bearish": 60.0, "sentiment_rank_only": True, "sent_fallback": True, "earnings_soon": False, "_data_quality_ok": False, "_data_quality_reason": "exception", "_liquidity_fail": True, "_liquidity_reason": "exception", "_no_trade_reason": "exception", "_error": str(e)[:120], } # ══════════════════════════════════════════════════════════ # SUMMARY BUILDER + DIREKTE AUSFÜHRUNG (unverändert) # ══════════════════════════════════════════════════════════ def build_summary(ranked, vix_value, ticker_directions, earnings_list, unusual_list, failed): today = datetime.now().strftime("%Y-%m-%d") srcs_str = ", ".join(d["ticker"] + "=" + d.get("_src_quote","?") for d in ranked) s = "DATUM: " + today + "\n" s += "VIX: " + str(vix_value) + "\n" s += "NEWS-SIGNALE: " + ( ", ".join(t + ":" + d for t, d in ticker_directions.items()) or "keine") + "\n" s += "EARNINGS NAECHSTE 10 TAGE: " + ( ", ".join(earnings_list) if earnings_list else "Keine") + "\n" s += "UNUSUAL ACTIVITY: nur Diagnose; keine lineare Intraday-Extrapolation\n" s += "TOP 3: " + ", ".join(d["ticker"] for d in ranked[:3]) + "\n" s += "QUOTE-QUELLEN: " + srcs_str + "\n" if failed: s += "API-FEHLER (Kurs=0): " + ", ".join(failed) + "\n" s += "\nHARTE GATES: Tradier-Snapshot, DATA_QUALITY_OK, LIQUIDITY_OK, EV_OK, EARNINGS_IV_OK, SECTOR_MARKET_OK muessen alle True sein.\n" s += "SENTIMENT: nur Ranking-/Kontextinfo, kein EV-Retter. Final Decision sieht keine News-Texte.\n" s += "SPREAD-REGIME: <=5% bevorzugt, 5-8% vorsichtig, 8-10% nur bei starkem EV, >10% harter Block.\n" s += "\nMARKTDATEN (sortiert nach Score):\n" s += (f"{'Ticker':<6} | {'Kurs':>7} | {'Δ%':>6} | {'MA50':>7} | " f"{'Trend':<14} | {'RelVol':>7} | {'News':>5} | {'Raw':>6} | {'Score':>6} | {'Gate':<4}\n" + "-" * 128 + "\n") for d in ranked: if d.get("etf_no_data"): s += (d["ticker"].ljust(6) + " | ETF-SIGNAL | Richtung: " + d["news_direction"] + " | Score: 0 | NO_TRADE: " + d.get("_no_trade_reason", "n/v") + "\n") continue news_flag = ("📈" if d["news_direction"] == "CALL" else "📉") + d["news_direction"] kurs_str = f"{d['price']:>7.2f}" if d["price"] > 0 else " n/v!" gate_ok = ( bool(d.get("_data_quality_ok")) and bool(d.get("sector_filter_ok", True)) and not d.get("_liquidity_fail") and bool(d.get("options", {}).get("ev_ok")) ) gate_flag = "OK" if gate_ok else "FAIL" raw_score = d.get("raw_signal_score", d.get("score", 0.0)) s += (f"{d['ticker']:<6} | {kurs_str} | {d['change_pct']:>6.2f}% | " f"{str(d.get('ma50','n/v')):>7} | {d.get('trend_status','n/v'):<14} | " f"{str(d['rel_vol']):>6}{'🔥' if d.get('unusual') else ''} | " f"{news_flag:>5} | {raw_score:>6.2f} | {d['score']:>6.2f} | {gate_flag:<4}\n") if d.get("_no_trade_reason"): s += " ⛔ NO_TRADE_REASON: " + d["_no_trade_reason"] + "\n" s += (" └─ SECTOR: ETF=" + str(d.get("sector_etf","n/v")) + " | SectorΔ=" + str(d.get("sector_change_pct","n/v")) + " | MarketΔ=" + str(d.get("market_change_pct","n/v")) + " | RelSector=" + str(d.get("relative_to_sector_pct","n/v")) + " | SectorVsMarket=" + str(d.get("sector_vs_market_pct","n/v")) + " | Momentum=" + str(d.get("sector_momentum_confirmation","neutral")) + "\n") opt = d.get("options") or {} if opt: s += (" └─ OPTIONS: Strike=" + str(opt.get("strike","n/v")) + " | Exp=" + str(opt.get("expiration","n/v")) + " | Bid=" + str(opt.get("bid","n/v")) + "/Ask=" + str(opt.get("ask","n/v")) + " | Mid=" + str(opt.get("midpoint","n/v")) + " | Entry=" + str(opt.get("conservative_entry","n/v")) + " | ExitSlip=" + str(opt.get("exit_slippage_points","n/v")) + " | Delta=" + str(opt.get("delta","n/v")) + " | IV=" + str(opt.get("iv","n/v")) + "%" + " | IV/RV=" + str(opt.get("iv_to_rv","n/v")) + " | IVRank=" + str(opt.get("iv_rank","n/v")) + " | IVPct=" + str(opt.get("iv_percentile","n/v")) + " | IVHist=" + str(opt.get("iv_history_count","n/v")) + " | IVCOLD=" + str(opt.get("iv_cold_start","n/v")) + " | TimeStop=" + str(opt.get("time_stop_hours","n/v")) + "h/" + str(opt.get("time_stop_required_move_pct","n/v")) + "%" + " | OI=" + str(opt.get("open_interest","n/v")) + " | FillP=" + str(opt.get("fill_probability","n/v")) + " | EV%=" + str(opt.get("ev_pct","n/v")) + " | EV$=" + str(opt.get("ev_dollars","n/v")) + " | EV_OK=" + str(opt.get("ev_ok", False)) + " | EARN_IV_OK=" + str(opt.get("earnings_iv_ok", True)) + "\n") sources = [] keyword_fallback = [] market_fallback = [] for d in ranked: news_src = d.get("news_sentiment_source") if news_src: sources.append(d["ticker"] + "=" + str(news_src)) if news_src == "keyword": keyword_fallback.append(d["ticker"]) if d.get("sent_fallback"): market_fallback.append(d["ticker"]) s += "\nSENTIMENT-QUELLEN: " + (", ".join(sources) or "n/v") s += "\nKEYWORD-FALLBACK NEWS: " + (", ".join(keyword_fallback) or "keiner") s += "\nMARKTDATEN-SENTIMENT-FALLBACK: " + (", ".join(market_fallback) or "keiner") return s # ══════════════════════════════════════════════════════════ # DIREKTE AUSFÜHRUNG # ══════════════════════════════════════════════════════════ if __name__ == "__main__": import argparse import re from config_loader import load_config, validate_config from rules import parse_ticker_signals logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") parser = argparse.ArgumentParser(description="Market Data Fetcher") parser.add_argument("--signals", help="Ticker-Signale") parser.add_argument("--signals-file", help="Datei mit Signalen") parser.add_argument("--output", help="Market Summary speichern") args = parser.parse_args() cfg = load_config() if not validate_config(cfg): raise SystemExit("Konfiguration unvollständig") raw = "" if args.signals: raw = args.signals elif args.signals_file: with open(args.signals_file) as f: raw = f.read().strip() else: raise SystemExit("--signals oder --signals-file erforderlich") parsed = parse_ticker_signals(raw) ticker_directions = {s["ticker"]: s["direction"] for s in parsed} dte_map = {s["ticker"]: s["dte_days"] for s in parsed} tickers = list(ticker_directions.keys()) finnhub_key = cfg.get("finnhub_key", "") today = datetime.now().strftime("%Y-%m-%d") end = (datetime.now() + timedelta(days=10)).strftime("%Y-%m-%d") with ThreadPoolExecutor(max_workers=2) as ex: vix_fut = ex.submit(get_vix) earnings_fut = ex.submit(get_earnings, today, end, finnhub_key) vix_value = vix_fut.result(timeout=12) earnings_list = earnings_fut.result(timeout=12) with ThreadPoolExecutor(max_workers=RULES.max_tickers) as ex: futures = { ex.submit(process_ticker, t, ticker_directions[t], earnings_list, cfg, dte_map.get(t, 21)): t for t in tickers } results = [] for f in as_completed(futures, timeout=30): try: results.append(f.result()) except Exception as e: logger.error("Ticker-Future Fehler: %s", e) market_data = [r for r in results if r] ranked = sorted(market_data, key=lambda x: x["score"], reverse=True) unusual_list = [d["ticker"] for d in market_data if d.get("unusual")] failed = [d["ticker"] for d in market_data if d.get("_src_quote") == "failed"] summary = build_summary(ranked, vix_value, ticker_directions, earnings_list, unusual_list, failed) print(summary) if args.output: with open(args.output, "w") as f: f.write(summary) ================================================ FILE: src/news_analyzer.py ================================================ """ news_analyzer.py — News Fetching, Clustering und Alpha-Katalysator-Validierung Stand 2026 (v2.3 - High Conviction Catalyst Edition) """ import calendar import logging import os import re import time from datetime import datetime, timedelta, timezone from typing import Dict, List, Optional, Tuple import feedparser import requests # Optional: FinBERT-Sentiment try: from finbert_sentiment import get_finbert_sentiment_batch except ImportError: get_finbert_sentiment_batch = None # Ticker-Universum try: from universe import get_known_tickers, STATIC_ETFS except ImportError: get_known_tickers = None STATIC_ETFS = {"SPY", "QQQ", "IWM", "DIA", "GLD", "SLV", "USO", "TLT"} # SEC Mapping try: from sec_check import get_company_name_to_ticker, get_cik_to_ticker_map, COMPANY_NAME_OVERRIDES except ImportError: get_company_name_to_ticker = None get_cik_to_ticker_map = None COMPANY_NAME_OVERRIDES = {} logger = logging.getLogger(__name__) # ==================== ALPHA CATALYST CONFIG ==================== CATALYST_WEIGHTS = { "fda_approval": 2.5, "phase_3": 2.1, "merger": 2.2, "acquisition": 2.2, "activist_entry": 2.3, # 13D "passive_stake": 1.45, # 13G "8k_material_event": 1.95, "earnings_beat": 1.85, "guidance_raise": 2.0, "insider_filing": 1.75, # <-- korrigiert "buyback": 1.65, "wire_strong": 1.45, "news_standard": 0.95, } # ==================== SYSTEM PROMPT (wichtig!) ==================== SYSTEM_PROMPT = """Du bist ein hochdisziplinierter Options-Trading-Bot. Antworte **ausschließlich** mit einer einzigen Zeile im exakt folgenden Format: TICKER_SIGNALS:BRK.B:CALL:HIGH:T3:45DTE,PLTR:CALL:MED:T2:30DTE Oder genau: TICKER_SIGNALS:NONE Regeln: - Maximal 3 Signale - Nur echte Ticker aus den gelieferten Clustern - Kein Markdown, kein zusätzlicher Text, keine Erklärung""" # Caches _KNOWN_TICKERS_CACHE: Optional[set] = None _NAME_TO_TICKER_CACHE: Optional[dict] = None _CIK_TO_TICKER_CACHE: Optional[dict] = None _GENERIC_ACRONYMS = { "AI", "IT", "IP", "EV", "CEO", "CFO", "CTO", "IPO", "API", "SAAS", "ESG", "AR", "VR", "ML", "USA", "UK", "EU", "US", "UN", "GDP", "FED", "ETF", "REIT", "SPAC", } _PHARMA_NAME_OVERRIDES = { "pfizer": "PFE", "merck": "MRK", "johnson and johnson": "JNJ", "eli lilly": "LLY", "lilly": "LLY", "abbvie": "ABBV", "novo nordisk": "NVO", "bristol myers squibb": "BMY", "vertex pharmaceuticals": "VRTX", "vertex": "VRTX", "moderna": "MRNA", "biontech": "BNTX", "gilead": "GILD", "amgen": "AMGN", "regeneron": "REGN", "intuitive surgical": "ISRG", "boston scientific": "BSX", "medtronic": "MDT", "stryker": "SYK", } # ==================== USER AGENT & HEADERS ==================== _USER_AGENT = os.environ.get( "NEWS_BOT_USER_AGENT", "Mozilla/5.0 (compatible; DailyOptionsBot/1.2; +contact: bot@example.com) feedparser/6.0" ) _FEED_HEADERS = { "User-Agent": _USER_AGENT, "Accept": "application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.5", "Accept-Encoding": "gzip, deflate", } # ==================== RSS FEEDS ==================== RSS_FEEDS = [ ... ] # deine Liste bleibt unverändert # ==================== REGEX ==================== _SEC_TITLE_RE = re.compile( r"^\s*(?P
\S(?:[^\s]|\s(?!-\s))*?)\s+-\s+(?P.+?)\s+\((?P\d{6,10})\)", re.IGNORECASE ) _WIRE_TICKER_RE = re.compile( r"\(\s*(?:NASDAQ|NYSEAMERICAN|NYSE\s+AMERICAN|NYSE|AMEX|OTCQX|OTCQB|CBOE|BATS)\s*:\s*" r"([A-Z]{1,5}(?:\.[A-Z])?)\s*\)", re.IGNORECASE ) _WIRE_SOURCES = ("globenewswire", "businesswire", "prnewswire", "newswire", "accesswire") # ==================== HELPERS ==================== def _score_catalyst(event_type: str, base_conf: float = 5.0) -> float: weight = CATALYST_WEIGHTS.get(event_type, 1.0) return round(base_conf * weight, 2) # ... (_load_known_tickers, _load_name_to_ticker, _load_cik_to_ticker bleiben unverändert) ... # ==================== FETCHER (unverändert) ==================== # ... deine gesamte fetch_all_feeds(), _fetch_feed_bytes(), build_earnings_map() bleiben 1:1 ... # ==================== RESOLVERS ==================== def _resolve_sec_filing(article: dict, cik_map: dict) -> Optional[Tuple[str, str, str, float]]: title = article.get("title") or "" m = _SEC_TITLE_RE.match(title) if not m: return None try: cik = int(m.group("cik")) form = m.group("form").upper().strip() name = m.group("name").strip() except Exception: return None ticker = cik_map.get(cik) or cik_map.get(str(cik)) if not ticker: return None if "8-K" in form: event_type = "8k_material_event" base_conf = 7.8 elif "13D" in form: event_type = "activist_entry" base_conf = 8.0 elif "13G" in form: event_type = "passive_stake" base_conf = 6.1 elif "4" in form: # <-- korrigiert (auch 4/A) event_type = "insider_filing" base_conf = 5.3 else: event_type = "sec_filing" base_conf = 4.4 confidence = _score_catalyst(event_type, base_conf) * 1.18 headline = f"{ticker} SEC {form}: {name[:70]}" return ticker, headline, event_type, round(confidence, 2) # ... _resolve_wire_ticker und _resolve_ticker_from_headline bleiben unverändert ... # ==================== CLUSTERING ==================== def cluster_articles(articles: List[Dict], earnings_map: Dict) -> List[Dict]: # ... dein gesamter Cluster-Code bleibt gleich, nur mit den oben korrigierten Gewichten ... # Am Ende: clusters = list(ticker_signals.values()) clusters = sorted(clusters, key=lambda x: x["confidence_score"], reverse=True) logger.info(f"Cluster erstellt: {len(clusters)} Ticker") return clusters # ==================== CLAUDE CALL (korrigiert) ==================== def run_claude(cluster_text: str, market_time: str, market_status: str, api_key: str) -> str: if not api_key: logger.error("ANTHROPIC_API_KEY fehlt") return "TICKER_SIGNALS:NONE" user_message = f"Marktzeit: {market_time}\nMarktstatus: {market_status}\n\n{cluster_text}" try: r = requests.post( "https://api.anthropic.com/v1/messages", headers={ "x-api-key": api_key, "anthropic-version": "2023-06-01", "content-type": "application/json", }, json={ "model": "claude-sonnet-4-6", # oder claude-3-5-sonnet-20241022 "max_tokens": 800, "temperature": 0.0, "system": SYSTEM_PROMPT, # <-- jetzt definiert "messages": [{"role": "user", "content": user_message}] }, timeout=40 ) r.raise_for_status() data = r.json() raw_text = data["content"][0]["text"].strip() logger.debug("Claude Rohantwort:\n%s", raw_text[:400]) match = re.search(r'(TICKER_SIGNALS:[^\n\r]+)', raw_text, re.IGNORECASE) if match: signal_line = match.group(1).strip().upper() logger.info("✅ Claude Signal extrahiert: %s", signal_line) return signal_line logger.warning("Kein gueltiges TICKER_SIGNALS-Format gefunden") return "TICKER_SIGNALS:NONE" except Exception as e: logger.error("Claude API Fehler: %s", e) return "TICKER_SIGNALS:NONE" def get_market_context() -> tuple: try: from market_calendar import market_context return market_context() except ImportError: return datetime.now().strftime("%H:%M"), "OPEN" # ==================== TEST MODUS ==================== if __name__ == "__main__": logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)-8s %(name)s — %(message)s", datefmt="%H:%M:%S", ) print("=== News Analyzer Test ===") articles = fetch_all_feeds() print(f"\n{len(articles)} Artikel geladen") if articles: print("\n=== Cluster-Test ===") clusters = cluster_articles(articles, earnings_map={}) for c in clusters[:10]: print(f" {c['ticker']:6s} conf={c['confidence_score']:.1f} " f"type={c['event_type']:18s} {c['headline_repr'][:70]}") ================================================ FILE: src/news_utils.py ================================================ """ news_utils.py — Dedupe, URL-Kanonisierung und Quellengewichtung. """ from __future__ import annotations import hashlib import re from urllib.parse import parse_qs, quote, unquote, urlparse, urlunparse TRACKING_PARAMS = { "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "fbclid", "gclid", "mc_cid", "mc_eid", "igshid", "ref", "cid", } def canonicalize_url(url: str) -> str: if not url: return "" url = url.strip() # Google-News RSS Links enthalten teils echte URL als Parameter. parsed = urlparse(url) qs = parse_qs(parsed.query) for key in ("url", "u"): if key in qs and qs[key]: candidate = unquote(qs[key][0]) if candidate.startswith("http"): url = candidate parsed = urlparse(url) qs = parse_qs(parsed.query) break clean_qs = [] for k, vals in qs.items(): if k.lower() in TRACKING_PARAMS: continue for v in vals: clean_qs.append((k, v)) query = "&".join(f"{quote(k)}={quote(v)}" for k, v in clean_qs) path = parsed.path.rstrip("/") return urlunparse((parsed.scheme.lower(), parsed.netloc.lower(), path, "", query, "")) def normalize_title(title: str) -> str: t = (title or "").lower() t = re.sub(r"[^a-z0-9 ]+", " ", t) t = re.sub(r"\s+", " ", t).strip() return t def article_fingerprint(title: str, link: str = "", summary: str = "") -> str: canonical = canonicalize_url(link) if canonical: base = canonical else: base = normalize_title(title)[:120] + "|" + normalize_title(summary)[:80] return hashlib.sha256(base.encode("utf-8", errors="ignore")).hexdigest()[:16] def near_duplicate_key(title: str) -> str: words = normalize_title(title).split() # Entferne häufige Füllwörter, damit gleiche Meldungen über mehrere RSS-Feeds matchen. stop = {"the", "a", "an", "to", "of", "and", "or", "for", "on", "in", "as", "with", "after", "before"} core = [w for w in words if w not in stop] return " ".join(core[:12]) ================================================ FILE: src/report_generator.py ================================================ """ report_generator.py — HTML-Report + Email-Versand (Step 3) Fixes v2: - call_claude() nimmt vix_direct Parameter (Fix Nr. 1+2) - VIX aus main.py direkt genutzt — nicht aus Claude-JSON - build_html() zeigt PUT/CALL korrekt an (Fix Nr. 7) - _compress_summary(): Earnings-Liste auf 10 Ticker gekürzt - max_tokens 1500, timeout 30s - Exit-Plan: Stop-Loss -40%, Take-Profit +50%, konkrete USD-Preise """ import json import logging import smtplib import sys from datetime import datetime from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText import requests from requests.exceptions import RequestException, Timeout from rules import apply_vix_rules, RULES from llm_schema import validate_report_payload, build_cancelled_report logger = logging.getLogger(__name__) PROMPT = """Du bist eine regelbasierte Options-KI. Antworte NUR mit JSON - kein Text, kein Markdown. HARTE REGELN: - VIX >= 25 -> no_trade: true, no_trade_grund: maximal 12 Woerter ohne Satzzeichen - VIX 20-24.99 -> einsatz: 150 - VIX < 20 -> einsatz: 250 - Waehle NIEMALS einen Ticker mit Score < 65 fuer echten Trade. Score 50-64 ist nur Research. - Waehle NIEMALS einen Ticker mit Gate=FAIL, DATA_QUALITY_OK=False, SECTOR_MARKET_OK=False, EV_OK=False, EARN_IV_OK=False oder Liquiditaets-Hinweis - Nutze conservative_entry/Entry als Einstiegspreis, NICHT blind Midpoint - kontrakte = floor(einsatz / (entry_price * 100)) - stop_loss_eur = 30% von einsatz - bid/ask/midpoint/entry/ev aus Marktdaten uebernehmen, nicht schaetzen - Sentiment darf NIEMALS einen schlechten EV, schlechte Liquiditaet oder Earnings-IV-Block ueberschreiben - Du siehst absichtlich KEINE News-Texte. Entscheide nur anhand nackter Marktdaten, Gates, Greeks, Preis, Liquiditaet, IV/RV und Sektor. DATENQUALITAET: - Tradier Production ist Standard. Sandbox/Delayed-Daten nur als Dry-run-Kontext betrachten. - Tradier-Optionsdaten mit nicht-Tradier-Underlying sind immer no_trade true. Kein Yahoo/AlphaVantage-Fallback fuer finalen EV. - Wenn Quote-Quelle oder Optionsdaten inkonsistent sind: no_trade true. - Wenn DATA_FLAGS auf kaputte Historie, Spike ohne News oder fehlende Basisdaten hinweisen: no_trade true. - Wenn No-Trade-Reason im Marktdatenblock steht, diese Begruendung uebernehmen. MARKT-/SEKTORFILTER: - CALL braucht idealerweise Aktie > Sektor und Sektor > SPY/QQQ. - PUT braucht idealerweise Aktie < Sektor und Sektor < SPY/QQQ. - Gegen klaren Sektor-/Markttrend: no_trade oder Research-Only, nicht schoenrechnen. - Relative Staerke/Schwaeche darf den Score verbessern, aber nie EV/Liquiditaet/Datenqualitaet ueberschreiben. SENTIMENT/PREISREAKTION: - Nutze SentPx als Divergenzfeature: bearish_news_absorbed kann CALL bestaetigen, bullish_news_not_confirmed kann PUT bestaetigen. - SentPx ist nur Ranking/Timing, kein harter EV-Ersatz. RICHTUNGSLOGIK: - CALL darf positiv laufen: change_pct > 0 und ueber MA50 ist gut - CALL ist schwach bei change_pct < 0 oder unter MA50 - PUT darf negativ laufen: change_pct < 0 und unter MA50 ist gut - PUT ist schwach bei change_pct > 0 oder ueber MA50 - Also: change_pct < 0 oder unter MA50 ist KEIN Ausschluss fuer PUT OPTIONS-EV UND KOSTEN: - Bevorzuge hoechstes EV%, positives EV$, hohe FillP, niedrigen Spread, ausreichendes OI - ExitSlip ist realer Kostenblock und muss im Risiko genannt werden - Kein Trade wenn erwarteter Move Entry+Exit-Slippage+Theta+IV-Risiko nicht klar schlaegt - Chance/Risiko muss Entry, Break-even-Move, EV%, EV$, FillP, ExitSlip, IV/RV, IVRank und TimeStop nennen EARNINGS / IV-CRUSH: - EARN_IV_OK=False ist harter Ausschluss fuer Long-Optionen - IVRank/IVPct aus eigener Journal-Historie: bei hohem Rank/Percentile ist Long-Option zu teuer - Cold Start: Wenn IV-Historie zu kurz ist und IV/RV >= 1.50, ist Long-Option no_trade wegen Overpricing - Wenn Earnings nahe und IV/RV unbekannt oder zu hoch: no_trade true - Earnings nicht nur als Score-Malus behandeln, sondern als Trade-Gate ETF-SONDERREGEL: - ETF nur ausgeben, wenn Optionsdaten und EV_OK vorhanden sind - Wenn keine Optionsdaten: no_trade true BEGRUENDUNG (begruendung_detail - 5 Felder, je max 2 Saetze, keine Anfuehrungszeichen): - ticker_wahl: Warum dieser Ticker? Score- und EV-Vergleich. - option_wahl: Strike, Delta, IV, IV/RV, Spread, Entry, ExitSlip, EV. - timing: Richtungsspezifisch: CALL vs PUT, MA50, RelVol, Sektorfilter, SentPx-Divergenz. - chance_risiko: Einsatz, Entry, Break-even, Ziel, Stop. - risiko: Hauptrisiko inklusive Spread, Slippage, Datenqualitaet, Earnings/IV. TIME-STOP: - Bei 7-14 DTE: nach 24h pruefen. - Bei 15-30 DTE: nach 48h pruefen. - Bei >30 DTE: nach 72h pruefen. - Wenn Underlying dann nicht mindestens 1% in Zielrichtung gelaufen ist: Exit/Close pruefen. MARKTSTATUS: markt-Feld 2-3 Saetze. strategie-Feld 1 Satz. TICKER_TABELLE: ALLE Ticker aus Marktdaten eintragen. Regime NUR: LOW-VOL, TRENDING oder HIGH-VOL regime_farbe NUR: gruen, gelb oder rot Gib direction exakt aus den Marktdaten zurueck: CALL oder PUT. JSON-Schema: {"datum":"DD.MM.YYYY","vix":"WERT","regime":"TRENDING","regime_farbe":"gelb","no_trade":false,"no_trade_grund":"","vix_warnung":false,"direction":"CALL","ticker":"SYMBOL","strike":"WERT","laufzeit":"DATUM","delta":"WERT","iv":"WERT%","iv_to_rv":"WERT","bid":"WERT","ask":"WERT","midpoint":"WERT","conservative_entry":"WERT","entry_price":"WERT","exit_slippage_points":"WERT","fill_probability":"WERT","ev_pct":"WERT","ev_dollars":"WERT","breakeven_move_pct":"WERT","time_stop":"Nach 48h +1% sonst Exit pruefen","kontrakte":"N","einsatz":150,"stop_loss_eur":45,"unusual":false,"begruendung_detail":{"ticker_wahl":"...","option_wahl":"...","timing":"...","chance_risiko":"...","risiko":"..."},"markt":"...","strategie":"...","ausgeschlossen":"TICKER: GRUND","ticker_tabelle":[{"ticker":"USO","direction":"CALL","kurs":"120.89","chg":"+2.11%","ma50":"84.88","trend":"ueber MA50","sector":"XLE","rel_sector":"+0.85","sentpx":"bearish_news_absorbed","relvol":"1.99","bull":"61.3%","score":"86.65","ev_ok":true,"ev_pct":"18.4","gewinner":true,"ausgeschlossen":false,"no_trade_reason":""}]} """ # ══════════════════════════════════════════════════════════ # JSON REPAIR # ══════════════════════════════════════════════════════════ def repair_json_quotes(text: str) -> str: result, in_str, escaped, i = [], False, False, 0 while i < len(text): ch = text[i] if escaped: result.append(ch); escaped = False; i += 1; continue if ch == '\\': result.append(ch); escaped = True; i += 1; continue if ch == '"': if not in_str: in_str = True; result.append(ch) else: j = i + 1 while j < len(text) and text[j] in ' \t\n\r': j += 1 next_ch = text[j] if j < len(text) else '' if next_ch in ',}]:\n' or j >= len(text): in_str = False; result.append(ch) else: result.append('\\"') i += 1; continue if in_str and ch in '\n\r': result.append(' '); i += 1; continue result.append(ch); i += 1 return ''.join(result) def close_fragment(frag: str) -> str: in_str, i = False, 0 while i < len(frag): if frag[i] == '\\' and in_str and i + 1 < len(frag): i += 2; continue if frag[i] == '"': in_str = not in_str i += 1 if in_str: frag += '"' last = frag.rfind(",") if last > 5: frag = frag[:last] in_str, i = False, 0 while i < len(frag): if frag[i] == '\\' and in_str and i + 1 < len(frag): i += 2; continue if frag[i] == '"': in_str = not in_str i += 1 if in_str: frag += '"' frag += "]" * max(0, frag.count("[") - frag.count("]")) frag += "}" * max(0, frag.count("{") - frag.count("}")) return frag def extract_json_fragment(text: str) -> str: start = text.find("{") if start == -1: raise ValueError("Kein öffnendes { im Claude-Response") end = text.rfind("}") if end == -1: logger.debug("Kein schließendes } — close_fragment wird angewendet") return text[start:] return text[start:end + 1] # ══════════════════════════════════════════════════════════ # SUMMARY KOMPRIMIERUNG # ══════════════════════════════════════════════════════════ def _compress_summary(summary: str) -> str: lines = summary.splitlines() result = [] for line in lines: if line.startswith("EARNINGS NAECHSTE"): parts = line.split(": ", 1) if len(parts) == 2: tickers = [t.strip() for t in parts[1].split(",")][:10] line = parts[0] + ": " + ", ".join(tickers) + (" ..." if len(tickers) == 10 else "") result.append(line) if "SENTIMENT-FALLBACK" in line: break return "\n".join(result)[:4000] # ══════════════════════════════════════════════════════════ # CLAUDE CALL # ══════════════════════════════════════════════════════════ def call_claude(summary: str, api_key: str, vix_direct=None) -> dict: summary = _compress_summary(summary) try: r = requests.post( "https://api.anthropic.com/v1/messages", headers={ "x-api-key": api_key, "anthropic-version": "2023-06-01", "content-type": "application/json", }, json={ "model": "claude-sonnet-4-6", "max_tokens": 1500, "system": PROMPT, "messages": [{"role": "user", "content": "Marktdaten:\n" + summary}], }, timeout=30, ) r.raise_for_status() except (RequestException, Timeout) as e: raise RuntimeError("Claude API nicht erreichbar: " + str(e)) from e data = r.json() if "content" not in data or not data["content"]: raise ValueError("Leerer Content in Claude-Response") text = data["content"][0]["text"].strip() if "```" in text: text = text.replace("```json", "").replace("```", "").strip() try: fragment = extract_json_fragment(text) except ValueError as e: raise ValueError("JSON-Extraktion fehlgeschlagen: " + str(e)) from e parsers = [ ("direkt", lambda f: json.loads(f)), ("quote_repair", lambda f: json.loads(repair_json_quotes(f))), ("close_fragment", lambda f: json.loads(close_fragment(f))), ("beide_kombiniert", lambda f: json.loads(repair_json_quotes(close_fragment(f)))), ] last_error = None result = None for name, parser in parsers: try: result = parser(fragment) if name != "direkt": logger.info("JSON repariert mit Methode: %s", name) break except json.JSONDecodeError as e: last_error = e logger.debug("Parse-Versuch '%s' fehlgeschlagen: %s", name, e) if result is None: raise ValueError("JSON Parse Fehler nach 4 Versuchen: " + str(last_error) + " | Raw: " + text[:300]) validated, errors = validate_report_payload(result) if errors: logger.error("Report-Pydantic-Schema-Guard: fail-closed: %s", errors[:5]) result = build_cancelled_report("; ".join(errors[:5]), raw=text) else: result = validated # Autoritativen VIX nutzen — nicht Claude-JSON-Feld authoritative_vix = vix_direct if vix_direct is not None else result.get("vix", "n/v") result = apply_vix_rules(authoritative_vix, result) logger.info("VIX=%s (direkt) Einsatz=%s no_trade=%s", authoritative_vix, result.get("einsatz","?"), result.get("no_trade")) return result # ══════════════════════════════════════════════════════════ # HTML BUILDER # ══════════════════════════════════════════════════════════ def build_html(d: dict, today: str) -> str: G = "#34c759"; R = "#ff3b30"; O = "#ff9500" GR = "#86868b"; LG = "#c7c7cc"; DK = "#1d1d1f" BG = "#f5f5f7"; WH = "#ffffff"; BD = "#e5e5ea" no_trade = d.get("no_trade", False) def card(icon, bg, title, content): return (f'
' f'
' f'
{icon}
' f'

{title}

' f'
{content}
') def row(label, val, col=None, last=False): c = col or DK b = "" if last else f"border-bottom:1px solid {BD};" return (f'
' f'{label}' f'{val}
') def section(label, html, border=True): b = f"border-bottom:1px solid {BD};" if border else "" return (f'
' f'

{label}

' f'

{html}

') # ── Trade Card ──────────────────────────────────────── if no_trade: trade_card = card("❌", "#ffeaea", f'No Trade', f'

' f'{d.get("no_trade_grund","")}

' f'
' f'

' f'Kein Trade heute — Kapitalschutz bei erhöhter Volatilität. ' f'Morgen läuft die Analyse erneut.

') else: einsatz = d.get("einsatz", 150) stop_loss = d.get("stop_loss_eur", round(einsatz * 0.4)) # Richtung korrekt aus Daten lesen direction = d.get("direction", "CALL") direction_str = "Long Call" if direction != "PUT" else "Long Put" direction_col = G if direction != "PUT" else O trade_icon = "✅" if direction != "PUT" else "🔽" card_bg = "#e8f5e9" if direction != "PUT" else "#fff3e0" trade_rows = ( row("Richtung", direction_str, direction_col) + row("Strike", d.get("strike","n/v")) + row("Laufzeit", d.get("laufzeit","n/v")) + row("Delta", d.get("delta","n/v")) + row("IV", d.get("iv","n/v")) + row("Bid / Ask", str(d.get("bid","n/v")) + " / " + str(d.get("ask","n/v"))) + row("Midpoint", d.get("midpoint","n/v")) + row("Einstieg konservativ", d.get("entry_price", d.get("conservative_entry","n/v"))) + row("Fill-Wahrscheinlichkeit", d.get("fill_probability","n/v")) + row("Options-EV", str(d.get("ev_pct","n/v")) + "% / " + str(d.get("ev_dollars","n/v")) + "$") + row("Break-even Move", str(d.get("breakeven_move_pct","n/v")) + "%") + row("Time-Stop", d.get("time_stop", d.get("time_stop_rule", "48h: +1% Zielrichtung sonst Exit prüfen"))) + row("Kontrakte", str(d.get("kontrakte","n/v"))) + row("Einsatz", str(einsatz) + "€") + row("Stop-Loss", "–30% = max. " + str(stop_loss) + "€", R) + row("Take-Profit 1", "+50% → 50% verkaufen", G) + row("Take-Profit 2", "Rest mit –10% Stop", G) + row("Unusual Activity", "JA 🔥" if d.get("unusual") else "nein", O if d.get("unusual") else DK, last=True) ) bd = d.get("begruendung_detail", {}) items = [ ("🏆", "Ticker", bd.get("ticker_wahl","n/v")), ("📐", "Option", bd.get("option_wahl","n/v")), ("⏱", "Timing", bd.get("timing","n/v")), ("⚖️", "Chance/Risiko", bd.get("chance_risiko","n/v")), ("⚠️", "Hauptrisiko", bd.get("risiko","n/v")), ] begr = "" for i, (icon, label, text) in enumerate(items): b = f"border-bottom:1px solid {BD};" if i < len(items) - 1 else "" begr += (f'
' f'{icon}' f'

{label}

' f'

{text}

' f'
') trade_card = card( trade_icon, card_bg, d.get("ticker","") + f' {direction_str}', trade_rows + f'
' f'

Begründung

{begr}
', ) # ── VIX Warnung ─────────────────────────────────────── vix_warning = "" if d.get("vix_warnung") and not no_trade: vix_warning = (f'
' f'⚠️' f'' f'Erhöhte Volatilität (VIX 20–24) – Einsatz auf ' f'{d.get("einsatz",150)}€ reduziert
') # ── Exit Plan mit konkreten USD-Preisen ─────────────── exit_card = "" if not no_trade: stop_pct = 0.30 tp1_pct = 0.50 stop_e = round(d.get("einsatz", 150) * stop_pct) try: mid_f = float(str(d.get("midpoint", "0")).replace(",", ".")) except (ValueError, TypeError): mid_f = 0.0 try: kontr = int(str(d.get("kontrakte", "1")).replace("n/v", "1")) except (ValueError, TypeError): kontr = 1 if mid_f > 0: stop_usd = round(mid_f * (1 - stop_pct), 2) tp1_usd = round(mid_f * (1 + tp1_pct), 2) tp2_usd = round(tp1_usd * 0.90, 2) cost_total = round(mid_f * 100 * kontr, 2) cost_str = f"Einstieg: {mid_f:.2f} USD × {kontr} Kontrakt(e) = {cost_total:.2f} USD" stop_str = f"–30% → {stop_usd:.2f} USD (max. {stop_e}€ Verlust)" tp1_str = f"+50% → {tp1_usd:.2f} USD | 50% schließen" tp2_str = f"Rest mit –10% Stop → {tp2_usd:.2f} USD" else: cost_str = "Einstieg: n/v" stop_str = f"–30% = max. {stop_e}€" tp1_str = "+50% → 50% schließen" tp2_str = "Rest mit –10% Stop" exit_card = card("🎯", "#fff3e0", "Exit-Plan", row("Gesamtkosten", cost_str) + row("Stop-Loss", stop_str, R) + row("Take-Profit 1", tp1_str, G) + row("Take-Profit 2", tp2_str, G) + row("Zeit-Exit", d.get("time_stop", d.get("time_stop_rule", "48h ohne +1% Zielbewegung → Exit prüfen"))) + row("Delta Rebalance", "Delta > ±0.30 → prüfen") + row("Vega Exit", "IV +20% → 50% schließen", last=True)) # ── Marktstatus ─────────────────────────────────────── rc = {"gruen": G, "gelb": O, "rot": R}.get(d.get("regime_farbe","gelb"), O) ampel = (f'') try: vix_f = float(str(d.get("vix","15")).replace(",",".")) except (ValueError, TypeError): vix_f = 15.0 vix_pct = min(100, int((vix_f / 40) * 100)) vix_color = G if vix_f < 18 else (O if vix_f < 25 else R) markt_card = card("🔍", "#e8f0fe", "Marktstatus", f'
' f'Regime' f'' f'{ampel}{d.get("regime","n/v")}
' f'
' f'
' f'VIX' f'' f'{d.get("vix","n/v")}
' f'
' f'
' + section("Marktlage", d.get("markt","")) + section("Strategie", d.get("strategie","")) + row("Ausgeschlossen", d.get("ausgeschlossen","–"), last=True)) # ── Ticker Tabelle ──────────────────────────────────── def th(label, align="right"): return (f'{label}') def td(val, align="right", color=DK, bold=False): fw = "700" if bold else "500" return (f'{val}') rows_html = "" for t in d.get("ticker_tabelle", []): if t.get("ticker","") in ("X","","SYMBOL"): continue chg = t.get("chg","") chg_col = G if "+" in str(chg) else (R if "-" in str(chg) else DK) row_color = LG if t.get("ausgeschlossen") else DK bold = bool(t.get("gewinner")) rows_html += (f'' + td(("★ " if bold else "") + t.get("ticker",""), "left", G if bold else row_color, bold) + td(t.get("kurs",""), "right", row_color, bold) + td(chg, "right", chg_col, bold) + td(t.get("ma50",""), "right", row_color) + td(t.get("trend",""), "center",row_color) + td(t.get("relvol",""),"right", O if t.get("unusual") else row_color) + td(t.get("bull",""), "right", row_color) + td(t.get("score",""), "right", row_color, bold) + "") if not rows_html: rows_html = (f'Keine Daten') tabelle_card = card("📋", "#f0f0f5", "Alle analysierten Titel", f'' f'{th("Ticker","left")}{th("Kurs")}{th("Δ%")}{th("MA50")}' f'{th("Trend","center")}{th("RelVol")}{th("Bull%")}{th("Score")}' f'{rows_html}
') # Status-Zeile zeigt korrekte Richtung if no_trade: status = "NO TRADE" status_col = R else: direction = d.get("direction", "CALL") status = ("CALL · " if direction != "PUT" else "PUT · ") + d.get("ticker","") status_col = G if direction != "PUT" else O return (f'' f'' f'" f'
' f'
' f'

Daily Options Report

' f'

' f'Daily Options Report

' f'
' f'' f'{d.get("datum",today)}  |  ' f'VIX {d.get("vix","n/v")}  |  ' f'{status}' f'
' f'{trade_card}{vix_warning}{exit_card}{markt_card}{tabelle_card}' f'
' f'

VIX ✓ · Earnings ✓ · Greeks ✓

' f'
') # ══════════════════════════════════════════════════════════ # EMAIL # ══════════════════════════════════════════════════════════ def send_email(subject: str, html_content: str, cfg: dict) -> bool: recipient = cfg.get("gmail_recipient","") sender = cfg.get("smtp_sender","") password = cfg.get("smtp_password","") host = cfg.get("smtp_host","smtp.gmail.com") port = int(cfg.get("smtp_port", 587)) if not all([recipient, sender, password]): logger.warning("SMTP nicht vollständig konfiguriert — Email nicht verschickt") return False msg = MIMEMultipart("alternative") msg["Subject"] = subject msg["From"] = sender msg["To"] = recipient msg.attach(MIMEText(html_content, "html", "utf-8")) try: with smtplib.SMTP(host, port, timeout=15) as smtp: smtp.starttls() smtp.login(sender, password) smtp.sendmail(sender, recipient, msg.as_string()) logger.info("Email verschickt an %s", recipient) return True except smtplib.SMTPException as e: logger.error("SMTP-Fehler: %s", e) return False except OSError as e: logger.error("Netzwerk-Fehler beim Email-Versand: %s", e) return False # ══════════════════════════════════════════════════════════ # DIREKTE AUSFÜHRUNG # ══════════════════════════════════════════════════════════ if __name__ == "__main__": import argparse from config_loader import load_config, validate_config logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") parser = argparse.ArgumentParser(description="Report Generator") parser.add_argument("--summary", help="Market Summary Text") parser.add_argument("--summary-file", help="Datei mit Market Summary") parser.add_argument("--output", help="HTML-Report speichern") parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() cfg = load_config() if not validate_config(cfg): raise SystemExit("Konfiguration unvollständig") if args.summary: market_summary = args.summary elif args.summary_file: with open(args.summary_file) as f: market_summary = f.read().strip() else: market_summary = sys.stdin.read().strip() if not market_summary: raise SystemExit("Kein Market Summary angegeben") today = datetime.now().strftime("%d.%m.%Y") subject = "Daily Options Report – " + today data = call_claude(market_summary, cfg.get("anthropic_api_key","")) html_report = build_html(data, today) if args.output: with open(args.output, "w", encoding="utf-8") as f: f.write(html_report) logger.info("Report gespeichert: %s", args.output) if not args.dry_run: send_email(subject, html_report, cfg) else: with open("report_preview.html", "w", encoding="utf-8") as f: f.write(html_report) logger.info("Dry-run: report_preview.html gespeichert") ================================================ FILE: src/rules.py ================================================ """ rules.py — Zentrale Trading-Regeln v13 Rational-Gates + TradingRules Klasse: - EV nur mit konsistentem Snapshot sinnvoll: Tradier-Optionen brauchen bevorzugt Tradier-Underlying. - Realistisches Kostenmodell: Entry-Slippage + härtere Exit-Slippage. - Earnings/IV-Crush-Schutz: Long-Optionen bei nahen Earnings und hoher/unklarer IV blockieren. - Sentiment darf Ranking unterstützen, aber keine harten Gates überschreiben. - No-Trade-Gründe werden maschinenlesbar journalisiert. - NEU: evaluate_trade() + calculate_position_size() für Hard-Gates und dynamisches Risk-Management. """ from __future__ import annotations from dataclasses import dataclass from typing import Any @dataclass(frozen=True) class TradingRules: # VIX-Grenzen vix_hard_limit: float = 25.0 vix_reduced_limit: float = 20.0 # Einsatz in EUR/USD-Äquivalent für Positionsgröße einsatz_normal: int = 250 einsatz_reduced: int = 150 # Risiko stop_loss_pct: float = 0.30 # Score-Schwellen research_min_score: int = 50 min_score: int = 65 # Datenqualität / Snapshot-Konsistenz require_tradier_quote_for_tradier_options: bool = True max_quote_age_seconds: int = 900 # Liquidität / Ausführbarkeit preferred_spread_pct: float = 5.0 caution_spread_pct: float = 8.0 max_spread_pct: float = 10.0 warn_spread_pct: float = 5.0 wide_spread_min_ev_pct: float = 25.0 wide_spread_min_ev_dollars: float = 35.0 min_open_interest: int = 500 min_option_volume: int = 1 max_entry_spread_share: float = 0.50 base_exit_spread_share: float = 0.60 high_spread_exit_share: float = 0.80 stress_exit_spread_share: float = 1.00 min_fill_probability: float = 0.35 # Options-EV Filter target_delta_abs: float = 0.45 min_option_ev_pct: float = 12.0 min_option_ev_dollars: float = 12.0 ev_hold_days: int = 2 # IV-/Earnings-Schutz earnings_window_days: int = 10 block_long_options_if_earnings_soon: bool = True block_earnings_if_iv_missing: bool = True max_iv_to_rv_for_earnings: float = 1.35 max_iv_to_rv_general: float = 2.20 cold_start_iv_to_rv_hard_block: float = 1.50 mature_iv_to_rv_hard_block: float = 1.80 iv_rv_penalty_factor: float = 0.18 # Vega-Cost-Modell iv_crush_after_news_pct: float = 0.20 iv_crush_after_earnings_pct: float = 0.40 iv_crush_baseline_pct: float = 0.05 iv_crush_high_iv_bonus_pct: float = 0.10 # Signal-Parsing valid_directions: tuple = ("CALL", "PUT") valid_scores: tuple = ("HIGH", "MED", "LOW") valid_horizons: tuple = ("T1", "T2", "T3") max_tickers: int = 5 min_dte_days: int = 7 max_dte_days: int = 120 # LLM-Schema-Guard llm_fail_closed: bool = True # Eigener IV-Verlauf aus dem Journal min_iv_history_samples_for_rank: int = 30 iv_rank_hard_block_long: float = 80.0 iv_percentile_hard_block_long: float = 90.0 iv_rank_prefer_long_below: float = 35.0 # Sektor-/Markt-Momentum sector_relative_strength_min: float = 0.30 sector_vs_market_confirm_min: float = 0.10 sector_confirms_score_bonus: float = 8.0 sector_disagrees_score_malus: float = -12.0 # Time-Stop-Plan time_stop_target_move_pct: float = 1.0 time_stop_short_dte_hours: int = 24 time_stop_normal_dte_hours: int = 48 time_stop_long_dte_hours: int = 72 # Daily-RVOL daily_rvol_unusual_threshold: float = 1.5 # === NEUE HARD GATES (aus deiner Anfrage) === min_market_cap: int = 50_000_000 # 50M Minimum min_price: float = 1.0 # Keine Penny Stocks max_spread_pct: float = 8.0 # Max Spread (konservativ) min_news_alpha: int = 55 # Mindest-Confidence aus news_analyzer def evaluate_trade(self, ticker_info: dict, market_metrics: dict, news_alpha: float): """Das ultimative Filter-System (Hard Gates).""" # 1. Grundlegende Filter if ticker_info.get('market_cap', 0) < self.min_market_cap: return False, f"Market Cap too low ({ticker_info.get('market_cap')})" if ticker_info.get('price', 0) < self.min_price: return False, f"Price below threshold ({ticker_info.get('price')})" # 2. News-Qualität Filter if news_alpha < self.min_news_alpha: return False, f"Weak News Alpha ({news_alpha})" # 3. Markt-Bestätigung Filter if not market_metrics.get('is_confirmed', False) and not market_metrics.get('gap_volume_confirmed', False): return False, "No Volume/Gap Confirmation" # 4. Spread Check if ticker_info.get('spread_pct', 0) > self.max_spread_pct: return False, f"Spread too wide ({ticker_info.get('spread_pct')}%)" return True, "All Filters Passed" def calculate_position_size(self, confidence_score: float, account_value: float) -> float: """Dynamisches Risk-Management basierend auf Conviction.""" if confidence_score >= 85: risk_pct = 0.05 # 5% für Top-Signale elif confidence_score >= 72: risk_pct = 0.03 # 3% Standard High-Conviction elif confidence_score >= 60: risk_pct = 0.02 else: risk_pct = 0.01 # 1% für marginale Setups return round(account_value * risk_pct, 2) # Globale Instanz RULES = TradingRules() def _to_float(value: Any, default=None): try: if value is None: return default return float(str(value).replace("€", "").replace("$", "").replace(",", ".").strip()) except (ValueError, TypeError): return default def merge_reasons(*parts: Any) -> str: """Kompakter, deduplizierter No-Trade-Grund.""" seen = set() out = [] for part in parts: if not part: continue if isinstance(part, (list, tuple, set)): values = part else: values = str(part).split("|") for raw in values: item = str(raw).strip() if not item or item in seen: continue seen.add(item) out.append(item) return " | ".join(out) # ══════════════════════════════════════════════════════════ # KOSTENMODELL / AUSFÜHRBARKEIT # ══════════════════════════════════════════════════════════ def conservative_entry_price(options_data: dict) -> float | None: """ Realistischer Einstieg statt Midpoint. Für Long-Optionen ist der echte Fill oft zwischen Mid und Ask. """ if not options_data: return None bid = _to_float(options_data.get("bid")) ask = _to_float(options_data.get("ask")) mid = _to_float(options_data.get("midpoint")) if bid is None or ask is None or mid is None or bid <= 0 or ask <= 0 or ask < bid: return None spread = ask - bid entry = min(ask, mid + spread * RULES.max_entry_spread_share) return round(entry, 2) def exit_slippage_points(options_data: dict) -> float: """ Exit ist konservativer als Entry. Bei breiteren Spreads steigt der Haircut. Rückgabe in Optionspreis-Punkten, nicht Prozent. """ if not options_data: return 0.0 bid = _to_float(options_data.get("bid"), 0.0) ask = _to_float(options_data.get("ask"), 0.0) spread_pct = _to_float(options_data.get("spread_pct"), 999.0) spread = max(0.0, ask - bid) if spread_pct >= 10.0: share = RULES.stress_exit_spread_share elif spread_pct >= RULES.warn_spread_pct: share = RULES.high_spread_exit_share else: share = RULES.base_exit_spread_share return round(spread * share, 4) def estimate_fill_probability(options_data: dict) -> float: """ Grobe Fill-Wahrscheinlichkeit aus Spread, OI und Volumen. """ if not options_data: return 0.0 spread_pct = _to_float(options_data.get("spread_pct"), 999.0) oi = _to_float(options_data.get("open_interest"), 0.0) vol = _to_float(options_data.get("volume"), 0.0) spread_score = max(0.0, min(1.0, 1.0 - spread_pct / 20.0)) oi_score = max(0.0, min(1.0, oi / 5000.0)) vol_score = max(0.0, min(1.0, vol / 500.0)) p = 0.55 * spread_score + 0.30 * oi_score + 0.15 * vol_score return round(max(0.0, min(1.0, p)), 3) def check_data_quality(market_data: dict, options_data: dict) -> tuple[bool, str]: """ Prüft, ob Underlying- und Optionssnapshot zusammenpassen. """ if not market_data: return False, "Marktdaten fehlen" price = _to_float(market_data.get("price"), 0.0) quote_src = str(market_data.get("_src_quote") or market_data.get("quote_source") or "").lower() option_src = str((options_data or {}).get("option_source") or "").lower() if price <= 0: return False, "Underlying-Preis fehlt" if option_src == "tradier" and RULES.require_tradier_quote_for_tradier_options: if not quote_src.startswith("tradier"): return False, "Inkonsistenter Snapshot: Option Tradier aber Underlying nicht Tradier" quote_age = _to_float(market_data.get("quote_age_seconds"), 0.0) if quote_age and quote_age > RULES.max_quote_age_seconds: return False, f"Quote zu alt: {int(quote_age)}s" return True, "ok" def check_liquidity(options_data: dict) -> tuple[bool, str]: """ Prüft Optionsliquidität als harten Filter. """ if not options_data: return False, "Keine Optionsdaten verfuegbar" bid = _to_float(options_data.get("bid")) ask = _to_float(options_data.get("ask")) mid = _to_float(options_data.get("midpoint")) spread_pct = _to_float(options_data.get("spread_pct")) open_int = _to_float(options_data.get("open_interest")) volume = _to_float(options_data.get("volume"), 0.0) if bid is None or bid <= 0: return False, "Bid fehlt oder 0" if ask is None or ask <= 0: return False, "Ask fehlt oder 0" if mid is None or mid <= 0: return False, "Midpoint fehlt" if ask < bid: return False, "Ask kleiner als Bid" if spread_pct is None: return False, "Spread nicht berechenbar" if open_int is None: return False, "Open Interest fehlt" if spread_pct > RULES.max_spread_pct: return False, f"Spread {spread_pct:.1f}% > {RULES.max_spread_pct}% harter Retail-Block" if spread_pct > RULES.caution_spread_pct: ev_pct = _to_float(options_data.get("ev_pct"), -999.0) ev_dollars = _to_float(options_data.get("ev_dollars"), -999.0) if ev_pct < RULES.wide_spread_min_ev_pct or ev_dollars < RULES.wide_spread_min_ev_dollars: return False, ( f"Spread {spread_pct:.1f}% > {RULES.caution_spread_pct}% nur bei starkem EV handelbar " f"(EV% {ev_pct}, EV$ {ev_dollars})" ) if open_int < RULES.min_open_interest: return False, f"OI {int(open_int)} < {RULES.min_open_interest} Limit" if volume < RULES.min_option_volume: return False, f"Optionsvolumen {int(volume)} < {RULES.min_option_volume}" fill_p = estimate_fill_probability(options_data) if fill_p < RULES.min_fill_probability: return False, f"Fill-Wahrscheinlichkeit {fill_p:.2f} < {RULES.min_fill_probability:.2f}" return True, "ok" def check_earnings_iv_gate(options_data: dict, earnings_soon: bool) -> tuple[bool, str]: """ Harte Sperre für Long-Optionen bei nahen Earnings und teurer/unklarer IV. """ if not earnings_soon or not RULES.block_long_options_if_earnings_soon: return True, "ok" iv = _to_float((options_data or {}).get("iv_decimal")) rv = _to_float((options_data or {}).get("realized_vol_20d")) iv_to_rv = _to_float((options_data or {}).get("iv_to_rv")) if iv is None or iv <= 0 or rv is None or rv <= 0 or iv_to_rv is None: if RULES.block_earnings_if_iv_missing: return False, "Earnings nahe und IV/RV unbekannt" return True, "ok" if iv_to_rv >= RULES.max_iv_to_rv_for_earnings: return False, f"Earnings nahe und IV/RV {iv_to_rv:.2f} zu hoch" return True, "ok" def build_time_stop_plan(direction: str, dte_actual: int | None) -> dict: """ Options-Time-Stop: Wenn der Underlying nach kurzer Zeit nicht in Zielrichtung laeuft, ist die Long-Option wegen Theta/Spread statistisch schlechter. """ try: dte = int(dte_actual or 0) except (TypeError, ValueError): dte = 0 if dte <= 14: hours = RULES.time_stop_short_dte_hours elif dte <= 30: hours = RULES.time_stop_normal_dte_hours else: hours = RULES.time_stop_long_dte_hours sign = "+" if str(direction).upper() == "CALL" else "-" return { "time_stop_hours": hours, "time_stop_required_move_pct": RULES.time_stop_target_move_pct, "time_stop_rule": ( f"Nach {hours}h pruefen: Underlying muss {sign}{RULES.time_stop_target_move_pct:.1f}% " f"in Zielrichtung gelaufen sein, sonst Exit/Close pruefen" ), } # ══════════════════════════════════════════════════════════ # VIX-REGELPRÜFUNG # ══════════════════════════════════════════════════════════ def apply_vix_rules(vix_direct, claude_output: dict) -> dict: """ VIX ist autoritativ aus get_vix(). """ result = dict(claude_output) try: vix = float(str(vix_direct).replace(",", ".")) vix_unknown = vix <= 0 except (ValueError, TypeError): vix_unknown = True vix = None if vix_unknown: result.update({ "no_trade": True, "no_trade_grund": merge_reasons(result.get("no_trade_grund"), "VIX nicht verfuegbar kein Trade"), "vix_warnung": False, "einsatz": 0, "stop_loss_eur": 0, "kontrakte": "n/v", }) return result if vix >= RULES.vix_hard_limit: result.update({ "no_trade": True, "no_trade_grund": merge_reasons(result.get("no_trade_grund"), "VIX zu hoch Kapitalschutz aktiv"), "vix_warnung": False, "einsatz": 0, "stop_loss_eur": 0, "kontrakte": "n/v", }) return result einsatz = RULES.einsatz_reduced if vix >= RULES.vix_reduced_limit else RULES.einsatz_normal result["einsatz"] = einsatz result["vix_warnung"] = vix >= RULES.vix_reduced_limit result["stop_loss_eur"] = round(einsatz * RULES.stop_loss_pct) if not result.get("no_trade"): entry = _to_float(result.get("conservative_entry")) if entry is None: entry = _to_float(result.get("entry_price")) if entry is None: entry = _to_float(result.get("midpoint")) if entry and entry > 0: kontrakte = int(einsatz // (entry * 100)) if kontrakte < 1: result.update({ "no_trade": True, "no_trade_grund": merge_reasons(result.get("no_trade_grund"), "Entry zu hoch Budget reicht nicht"), "einsatz": 0, "stop_loss_eur": 0, "kontrakte": "n/v", }) return result result["kontrakte"] = str(kontrakte) result["entry_price"] = round(entry, 2) else: result["kontrakte"] = "n/v" return result # ══════════════════════════════════════════════════════════ # CLAUDE-OUTPUT VALIDIERUNG # ══════════════════════════════════════════════════════════ def validate_claude_output(data: dict) -> tuple: errors = [] for field in ["datum", "vix", "regime", "no_trade"]: if field not in data: errors.append(f"Pflichtfeld fehlt: {field}") no_trade = data.get("no_trade", False) if not no_trade: for field in ["ticker", "strike", "laufzeit", "delta", "midpoint"]: if not data.get(field): errors.append(f"Trade-Feld fehlt: {field}") if data.get("direction") not in RULES.valid_directions: errors.append(f"Ungültige direction: {data.get('direction')}") einsatz = data.get("einsatz") if einsatz is not None: try: e = int(str(einsatz).replace("€", "").strip()) if e not in (RULES.einsatz_normal, RULES.einsatz_reduced): errors.append(f"Einsatz {e} ungültig") except (ValueError, TypeError): errors.append(f"Einsatz nicht numerisch: {einsatz}") if data.get("regime") and data.get("regime") not in ("LOW-VOL", "TRENDING", "HIGH-VOL"): errors.append(f"Ungültiges Regime: {data.get('regime')}") if data.get("regime_farbe") and data.get("regime_farbe") not in ("gruen", "gelb", "rot"): errors.append(f"Ungültige regime_farbe: {data.get('regime_farbe')}") tabelle = data.get("ticker_tabelle", []) if not isinstance(tabelle, list) or len(tabelle) == 0: errors.append("ticker_tabelle fehlt oder leer") return len(errors) == 0, errors # ══════════════════════════════════════════════════════════ # SIGNAL-PARSING # ══════════════════════════════════════════════════════════ def parse_ticker_signals(raw: str) -> list: """ Parser für TICKER_SIGNALS:TICKER:RICHTUNG:SCORE:HORIZONT:DTE,... """ if not raw: return [] clean = raw.strip() if clean.startswith("TICKER_SIGNALS:"): clean = clean[len("TICKER_SIGNALS:"):] if not clean or clean == "NONE": return [] results = [] for entry in clean.split(","): entry = entry.strip() if not entry: continue parts = entry.split(":") if len(parts) < 5: continue ticker = parts[0].strip().upper() direction = parts[1].strip().upper() score = parts[2].strip().upper() horizon = parts[3].strip().upper() dte_raw = parts[4].strip().upper() if not ticker or len(ticker) > 5: continue if direction not in RULES.valid_directions: continue if score not in RULES.valid_scores: continue if horizon not in RULES.valid_horizons: continue if not dte_raw.endswith("DTE"): continue try: dte_days = int(dte_raw.replace("DTE", "")) except ValueError: continue if dte_days < RULES.min_dte_days or dte_days > RULES.max_dte_days: continue results.append({ "ticker": ticker, "direction": direction, "score": score, "horizon": horizon, "dte": dte_raw, "dte_days": dte_days, }) return results ================================================ FILE: src/sec_check.py ================================================ """ sec_check.py — strukturierter SEC EDGAR Catalyst-Check ohne API-Key. Datenquellen: - https://www.sec.gov/files/company_tickers.json - https://data.sec.gov/submissions/CIK##########.json - Filing-Dokumente aus sec.gov/Archives Ziel: - Form 4 differenzierter: Kauf != Award != Optionsausübung != Steuerverkauf. - 8-K nach Items/Keywords klassifizieren. - Fail-safe: bei Fehler neutral. - Bonus: get_company_name_to_ticker() liefert Name->Ticker-Mapping für News-Headline-Auflösung (z.B. "Apple reports..." -> "AAPL"). """ from __future__ import annotations import json import logging import os import re import xml.etree.ElementTree as ET from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any import requests logger = logging.getLogger(__name__) DATA_DIR = Path(__file__).resolve().parent.parent / "data" CIK_CACHE = DATA_DIR / "sec_company_tickers.json" SEC_UA = (os.environ.get("SEC_USER_AGENT") or "DailyOptionsReport/1.0 contact@example.com").strip() ETF_TICKERS = { "TLT", "USO", "GLD", "SLV", "GDX", "SPY", "QQQ", "IWM", "DIA", "XLE", "XLF", "XLK", "XLV", "XLI", "XLU", "XLP", "XLY", "XLB", "XLRE", } BEARISH_8K_KEYWORDS = { "restatement": 0.75, "material weakness": 0.85, "going concern": 0.90, "bankruptcy": 0.95, "default": 0.80, "delisting": 0.80, "investigation": 0.65, "sec subpoena": 0.80, "class action": 0.55, "securities fraud": 0.80, "ceo resignation": 0.60, "cfo resignation": 0.60, "impairment": 0.65, "restructuring charge": 0.60, } BULLISH_8K_KEYWORDS = { "acquisition": 0.55, "merger agreement": 0.65, "definitive agreement": 0.55, "share repurchase": 0.55, "buyback": 0.55, "dividend increase": 0.50, "fda approval": 0.75, "fda clearance": 0.65, "accelerated approval": 0.80, "strategic partnership": 0.50, "licensing agreement": 0.55, "record revenue": 0.45, "record earnings": 0.50, } EMPTY_RESULT = { "bullish": False, "bearish": False, "insider_buy": False, "insider_sell": False, "reason": "Keine SEC-Daten", "confidence": 0.0, "filings_checked": 0, "events": [], } # ==================== NAME → TICKER MAPPING (Konstanten) ==================== # Suffixe, die beim Normalisieren von Firmennamen entfernt werden _CORP_SUFFIXES = { "inc", "corp", "corporation", "incorporated", "co", "company", "ltd", "limited", "llc", "plc", "lp", "lllp", "holdings", "holding", "group", "trust", "sa", "ag", "nv", "bv", "spa", "kgaa", "common", "stock", "ordinary", "shares", "class", "a", "b", "c", "adr", "ads", "the", } # Hand-kuratierte Aliase haben Vorrang vor der SEC-Map. # Hier landen Marketing-Namen ("Google" statt "Alphabet"), Klassenwahl # (BRK.B liquider als BRK.A), alte Firmennamen ("Facebook" -> META) und # "Rettungsanker" für kurze Ticker oder Ein-Buchstaben-Symbole, die sonst # durch die Sicherheitsregeln im Resolver fallen würden. COMPANY_NAME_OVERRIDES = { # --- TECH & GROWTH --- "alphabet": "GOOGL", "google": "GOOGL", "meta platforms": "META", "meta": "META", "facebook": "META", "apple": "AAPL", "microsoft": "MSFT", "amazon": "AMZN", "nvidia": "NVDA", "tesla": "TSLA", "netflix": "NFLX", "salesforce": "CRM", "oracle": "ORCL", "adobe": "ADBE", "palantir": "PLTR", "shopify": "SHOP", "spotify": "SPOT", "uber": "UBER", "airbnb": "ABNB", "lyft": "LYFT", "doordash": "DASH", "door dash": "DASH", "super micro": "SMCI", "supermicro": "SMCI", "snowflake": "SNOW", "crowdstrike": "CRWD", "palo alto networks": "PANW", # --- CHIPS & HARDWARE --- "advanced micro devices": "AMD", "amd": "AMD", "intel": "INTC", "broadcom": "AVGO", "qualcomm": "QCOM", "taiwan semiconductor": "TSM", "tsmc": "TSM", "asml": "ASML", "arm holdings": "ARM", "applied materials": "AMAT", "ibm": "IBM", "dell": "DELL", # --- FINANCE --- "jpmorgan": "JPM", "jp morgan": "JPM", "jpmorgan chase": "JPM", "goldman sachs": "GS", "morgan stanley": "MS", "bank of america": "BAC", "wells fargo": "WFC", "citigroup": "C", # 1-Buchstabe-Ticker, Override-Privileg "visa": "V", # 1-Buchstabe-Ticker, Override-Privileg "mastercard": "MA", "paypal": "PYPL", "robinhood": "HOOD", "coinbase": "COIN", "blackrock": "BLK", "charles schwab": "SCHW", # --- RETAIL & CONSUMER --- "walmart": "WMT", "costco": "COST", "home depot": "HD", "lowes": "LOW", "nike": "NKE", "starbucks": "SBUX", "mcdonalds": "MCD", "coca cola": "KO", "coca-cola": "KO", "pepsi": "PEP", "pepsico": "PEP", "procter and gamble": "PG", # nach &-Normalisierung "p and g": "PG", "estee lauder": "EL", "lululemon": "LULU", "ford": "F", # 1-Buchstabe-Ticker, Override-Privileg "general motors": "GM", "ebay": "EBAY", # --- ENERGY & INDUSTRIAL --- "exxon": "XOM", "exxon mobil": "XOM", "exxonmobil": "XOM", "chevron": "CVX", "shell": "SHEL", "boeing": "BA", "lockheed martin": "LMT", "raytheon": "RTX", "general electric": "GE", "ge aerospace": "GE", "ge healthcare": "GEHC", "ge vernova": "GEV", "caterpillar": "CAT", # --- HEALTHCARE & PHARMA --- "pfizer": "PFE", "eli lilly": "LLY", "johnson and johnson": "JNJ", # nach &-Normalisierung "j and j": "JNJ", "merck": "MRK", "unitedhealth": "UNH", "cigna": "CI", "moderna": "MRNA", "abbvie": "ABBV", "amgen": "AMGN", "gilead": "GILD", "astrazeneca": "AZN", "novo nordisk": "NVO", # --- TELECOM, MEDIA, SPECIALS --- "disney": "DIS", "walt disney": "DIS", "at and t": "T", # nach &-Normalisierung "verizon": "VZ", "t-mobile": "TMUS", "berkshire hathaway": "BRK.B", "berkshire": "BRK.B", } # Generische Wörter, die als Firmenname zu False-Positives führen. # Liste wird laufend erweitert basierend auf beobachteten Match-Fehlern. _NAME_BLOCKLIST = { # Generische Geo/Größe-Adjektive "global", "international", "american", "national", "general", "first", "new", "us", "usa", "united", "world", "the", "and", "of", "for", # Penny-Stock-Falle: Ticker existiert, Wort ist aber zu häufig "block", "match", "snap", "square", "trade", "city", "state", "here", "there", "this", "that", "them", "viking", "emerging", "target", # Sektor-/Branchenwörter, die News oft enthalten "strategy", # MSTR seit Umbenennung von MicroStrategy "energy", "tech", "financial", "industrial", "consumer", "media", "data", "research", "services", "solutions", "systems", "products", "technologies", "innovations", # Rohstoff-Begriffe — Headlines über Preise, nicht über die Firmen "coffee", "cocoa", "wheat", "corn", "oil", "gold", "silver", "copper", "platinum", "uranium", "lithium", "nickel", "natural gas", "crude", # Investment-Vokabular "capital", "equity", "fund", "income", "growth", "value", "dividend", "premium", "core", "alpha", "beta", # Generische Akronyme — meinen in Headlines fast immer das Konzept, # nicht den gleichnamigen Ticker (z.B. "AI" statt C3.ai) "ai", "it", "ip", "ev", "ceo", "cfo", "cto", "ipo", "api", "saas", "esg", "ar", "vr", "ml", } # Modul-weiter Cache, vermeidet wiederholtes Parsen der SEC-Datei _cached_name_map: dict[str, str] | None = None _cached_cik_map: dict[int, str] | None = None def _headers() -> dict: # Kein manueller Host-Header: derselbe Helper wird fuer www.sec.gov # und data.sec.gov genutzt. Ein falscher Host verursacht 403. return { "User-Agent": SEC_UA, "Accept-Encoding": "gzip, deflate", "Accept": "application/json,text/plain,*/*", } def _archive_headers() -> dict: return { "User-Agent": SEC_UA, "Accept-Encoding": "gzip, deflate", "Accept": "application/xml,text/html,text/plain,*/*", } def _get_json(url: str) -> Any: r = requests.get(url, headers=_headers(), timeout=12) r.raise_for_status() return r.json() def _get_text(url: str) -> str: r = requests.get(url, headers=_archive_headers(), timeout=12) r.raise_for_status() return r.text def _load_sec_raw_tickers() -> dict: """Lädt company_tickers.json (mit 7-Tage-Cache). Zentrale Helper-Funktion, damit _load_ticker_map und get_company_name_to_ticker dieselbe Logik nutzen. """ DATA_DIR.mkdir(parents=True, exist_ok=True) if CIK_CACHE.exists(): age = datetime.now(timezone.utc) - datetime.fromtimestamp( CIK_CACHE.stat().st_mtime, tz=timezone.utc) if age.days < 7: return json.loads(CIK_CACHE.read_text(encoding="utf-8")) raw = _get_json("https://www.sec.gov/files/company_tickers.json") CIK_CACHE.write_text(json.dumps(raw), encoding="utf-8") return raw def _load_ticker_map() -> dict[str, int]: try: raw = _load_sec_raw_tickers() return {v["ticker"].upper(): int(v["cik_str"]) for v in raw.values()} except Exception as e: logger.warning("Ticker-Map konnte nicht geladen werden: %s", e) return {} def _filing_url(cik: int, accession: str, primary_doc: str) -> str: cik_plain = str(cik) acc_plain = accession.replace("-", "") return f"https://www.sec.gov/Archives/edgar/data/{cik_plain}/{acc_plain}/{primary_doc}" def _recent_filings(cik: int) -> list[dict]: cik10 = str(cik).zfill(10) data = _get_json(f"https://data.sec.gov/submissions/CIK{cik10}.json") recent = data.get("filings", {}).get("recent", {}) keys = ["form", "filingDate", "accessionNumber", "primaryDocument", "items", "primaryDocDescription"] n = len(recent.get("form", [])) rows = [] for i in range(n): rows.append({k: (recent.get(k, [None] * n)[i] if i < len(recent.get(k, [])) else None) for k in keys}) return rows def _within_days(date_str: str, days_back: int) -> bool: try: d = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc) return d >= datetime.now(timezone.utc) - timedelta(days=days_back) except Exception: return False def _xml_text(root: ET.Element, tag: str) -> str: # SEC XML nutzt Namespaces teils inkonsistent; suffix match ist robuster. for el in root.iter(): if el.tag.lower().endswith(tag.lower()): return (el.text or "").strip() return "" def _iter_form4_transactions(xml_text: str) -> list[dict]: try: root = ET.fromstring(xml_text.encode("utf-8", errors="ignore")) except ET.ParseError: return [] txns = [] for txn in root.iter(): if not txn.tag.lower().endswith("nonderivativetransaction"): continue code = "" shares = 0.0 price = 0.0 footnote_text = "" for el in txn.iter(): name = el.tag.lower() text = (el.text or "").strip() if name.endswith("transactioncode"): code = text.upper() elif name.endswith("transactionshares"): try: shares = float(text) except Exception: pass elif name.endswith("transactionpricepershare"): try: price = float(text) except Exception: pass elif "footnote" in name: footnote_text += " " + text.lower() txns.append({"code": code, "shares": shares, "price": price, "value": shares * price, "footnotes": footnote_text}) return txns def _classify_form4(text: str) -> list[dict]: events = [] for txn in _iter_form4_transactions(text): code = txn["code"] value = txn["value"] shares = txn["shares"] foot = txn.get("footnotes", "") tenb51 = "10b5" in foot # P = Open-market purchase: klar bullisher als Awards. if code == "P" and value >= 50_000: events.append({ "type": "insider_purchase", "bullish": True, "bearish": False, "confidence": min(0.9, 0.55 + value / 2_000_000), "reason": f"Form 4 Insider-Kauf ${value:,.0f}", }) # S = Sale. Nur große Verkäufe bearish; 10b5-1 wird gedämpft. elif code == "S" and value >= 1_000_000: conf = min(0.65, 0.30 + value / 10_000_000) if tenb51: conf *= 0.5 events.append({ "type": "insider_sale_10b5" if tenb51 else "insider_sale", "bullish": False, "bearish": conf >= 0.35, "confidence": round(conf, 2), "reason": f"Form 4 Insider-Verkauf ${value:,.0f}" + (" 10b5-1" if tenb51 else ""), }) # A/M/F sind meist Award, Option Exercise, Tax Withholding → nicht als Alpha-Signal werten. elif code in {"A", "M", "F", "G"} and shares > 0: events.append({ "type": f"neutral_form4_{code}", "bullish": False, "bearish": False, "confidence": 0.05, "reason": f"Form 4 neutral Code {code}", }) return events def _classify_8k(text: str, filing: dict) -> list[dict]: low = (text[:250_000] + " " + str(filing.get("items", "")) + " " + str(filing.get("primaryDocDescription", ""))).lower() events = [] for kw, conf in BEARISH_8K_KEYWORDS.items(): if kw in low: events.append({"type": "8k_bearish", "bullish": False, "bearish": True, "confidence": conf, "reason": f"8-K Warnsignal: {kw}"}) break for kw, conf in BULLISH_8K_KEYWORDS.items(): if kw in low: events.append({"type": "8k_bullish", "bullish": True, "bearish": False, "confidence": conf, "reason": f"8-K Katalysator: {kw}"}) break return events def get_sec_signal(ticker: str, days_back: int = 14) -> dict: if ticker in ETF_TICKERS: return {**EMPTY_RESULT, "reason": "ETF — kein SEC-Check"} try: ticker_map = _load_ticker_map() cik = ticker_map.get(ticker.upper()) if not cik: return {**EMPTY_RESULT, "reason": "Ticker nicht in SEC Map"} filings = [f for f in _recent_filings(cik) if _within_days(str(f.get("filingDate", "")), days_back)] events = [] checked = 0 for f in filings[:30]: form = str(f.get("form", "")) if form not in {"4", "8-K"}: continue primary = f.get("primaryDocument") accession = f.get("accessionNumber") if not primary or not accession: continue checked += 1 try: text = _get_text(_filing_url(cik, accession, primary)) if form == "4": events.extend(_classify_form4(text)) elif form == "8-K": events.extend(_classify_8k(text, f)) except Exception as e: logger.debug("SEC Dokument %s %s Fehler: %s", ticker, form, e) continue bullish_events = [e for e in events if e.get("bullish")] bearish_events = [e for e in events if e.get("bearish")] bullish_conf = max([e.get("confidence", 0.0) for e in bullish_events] or [0.0]) bearish_conf = max([e.get("confidence", 0.0) for e in bearish_events] or [0.0]) bullish = bullish_conf > bearish_conf and bullish_conf >= 0.45 bearish = bearish_conf > bullish_conf and bearish_conf >= 0.45 confidence = max(bullish_conf, bearish_conf) if bullish_conf and bearish_conf: confidence *= 0.65 top_events = sorted(events, key=lambda e: e.get("confidence", 0), reverse=True)[:4] reason = " | ".join(e.get("reason", "") for e in top_events) or "Keine relevanten Filings" result = { "bullish": bullish, "bearish": bearish, "insider_buy": any(e.get("type") == "insider_purchase" for e in events), "insider_sell": any(str(e.get("type", "")).startswith("insider_sale") for e in events), "reason": reason, "confidence": round(confidence, 2), "filings_checked": checked, "events": top_events, } if checked: logger.info("SEC %s: %d Filings | bull=%s bear=%s | %s", ticker, checked, bullish, bearish, reason[:80]) return result except Exception as e: logger.warning("SEC-Check %s fehlgeschlagen: %s", ticker, e) return {**EMPTY_RESULT, "reason": f"SEC-Fehler: {str(e)[:60]}"} # ==================== NAME → TICKER MAPPING (Funktionen) ==================== def _normalize_company_name(name: str) -> str: """'Apple Inc.' -> 'apple' 'BERKSHIRE HATHAWAY INC /DE/' -> 'berkshire hathaway' 'AT&T INC' -> 'at and t' 'Johnson & Johnson' -> 'johnson and johnson' """ s = name.lower() s = s.replace("&", " and ") # AT&T -> at and t s = re.sub(r"/[a-z]{2,3}/", " ", s) # /DE/, /MD/, /NY/ Suffixe s = re.sub(r"[^a-z0-9\s\-]", " ", s) # Punkte, Kommas raus s = re.sub(r"\s+", " ", s).strip() tokens = s.split() # Suffix-Tokens am Ende abschneiden, solange welche da sind while tokens and tokens[-1] in _CORP_SUFFIXES: tokens.pop() return " ".join(tokens) def get_company_name_to_ticker() -> dict[str, str]: """Liefert Mapping 'apple' -> 'AAPL', 'microsoft' -> 'MSFT', etc. Quelle: bereits gecachte SEC-Datei sec_company_tickers.json + Overrides. Bei Konflikt gewinnt der Override. Bei doppelten SEC-Einträgen mit gleichem normalisierten Namen gewinnt der erste (= meist die Haupt-Aktienklasse). Modul-weiter Cache verhindert mehrfaches Parsen der ~1 MB SEC-Datei. """ global _cached_name_map if _cached_name_map is not None: return _cached_name_map name_map: dict[str, str] = {} try: raw = _load_sec_raw_tickers() for v in raw.values(): ticker = (v.get("ticker") or "").upper().strip() title = (v.get("title") or "").strip() if not ticker or not title: continue normalized = _normalize_company_name(title) if not normalized or len(normalized) < 4: continue if normalized in _NAME_BLOCKLIST: continue # Erster gewinnt (vermeidet, dass z.B. BRK.A später BRK.B überschreibt) if normalized not in name_map: name_map[normalized] = ticker except Exception as e: logger.warning("SEC Name-Map konnte nicht geladen werden: %s", e) # Overrides drüberlegen — die haben immer Vorrang name_map.update(COMPANY_NAME_OVERRIDES) _cached_name_map = name_map logger.info("Name->Ticker Mapping: %d Einträge geladen", len(name_map)) return name_map def get_cik_to_ticker_map() -> dict[int, str]: """Liefert Mapping CIK -> Ticker fuer SEC EDGAR Filings-Aufloesung. Beispiel: 320193 -> "AAPL", 1318605 -> "TSLA" Hintergrund: Der SEC-EDGAR-Atom-Feed identifiziert Firmen ueber CIK (Central Index Key), nicht ueber Ticker. Wenn der News-Bot 8-K-Filings aus dem SEC-Feed verarbeiten will, braucht er die Inverse der Ticker->CIK-Map, die _load_ticker_map() bereits liefert. Caveat: Mehrere Tickers koennen denselben CIK haben (z.B. BRK.A und BRK.B teilen den Berkshire-CIK). Hier gewinnt der erste Eintrag in der SEC-Datei, was praktisch oft die Klasse-A-Aktie ist. Fuer Trading-Zwecke ist das suboptimal (BRK.B ist liquider), deshalb sollten kritische Faelle ueber COMPANY_NAME_OVERRIDES nachgesteuert werden. """ global _cached_cik_map if _cached_cik_map is not None: return _cached_cik_map cik_map: dict[int, str] = {} try: raw = _load_sec_raw_tickers() for v in raw.values(): ticker = (v.get("ticker") or "").upper().strip() cik = v.get("cik_str") if not ticker or cik is None: continue try: cik_int = int(cik) except (TypeError, ValueError): continue # Erster gewinnt; Override-Tickers haetten hier keine Wirkung, # weil die SEC-Map nur primaere Tickers liefert. if cik_int not in cik_map: cik_map[cik_int] = ticker except Exception as e: logger.warning("CIK->Ticker Map konnte nicht geladen werden: %s", e) return {} _cached_cik_map = cik_map logger.info("CIK->Ticker Mapping: %d Eintraege geladen", len(cik_map)) return cik_map ================================================ FILE: src/sector_map.py ================================================ """ sector_map.py — Markt-/Sektorfilter für Daily-Options-Signale. Ziel: - Keine Long-Calls gegen klaren Sektor-/Marktwind. - Keine Long-Puts gegen klar starke Sektor-/Marktbreite. - Relative Stärke/Schwäche als Feature journalisieren. Die Zuordnung ist bewusst pragmatisch und kostenlos: Sektor-ETFs + einfache Ticker-Maps. Unbekannte Ticker fallen auf QQQ/SPY zurück, damit das Gate nicht unbrauchbar wird. """ from __future__ import annotations from dataclasses import dataclass from typing import Callable, Any from rules import RULES SECTOR_ETFS = { "technology": "XLK", "semiconductors": "SMH", "communication": "XLC", "consumer_discretionary": "XLY", "consumer_staples": "XLP", "energy": "XLE", "financials": "XLF", "healthcare": "XLV", "industrials": "XLI", "materials": "XLB", "real_estate": "XLRE", "utilities": "XLU", "small_caps": "IWM", "market": "SPY", "nasdaq": "QQQ", } # Nur die häufigsten/alpha-relevanten Namen. Unbekannte fallen auf QQQ/SPY zurück. TICKER_TO_SECTOR = { # Mega-cap tech / AI "AAPL": "technology", "MSFT": "technology", "ORCL": "technology", "CRM": "technology", "ADBE": "technology", "NOW": "technology", "SNOW": "technology", "PLTR": "technology", "AI": "technology", "SHOP": "technology", "DDOG": "technology", "NET": "technology", "CRWD": "technology", "PANW": "technology", "ZS": "technology", "MDB": "technology", # Semis "NVDA": "semiconductors", "AMD": "semiconductors", "AVGO": "semiconductors", "INTC": "semiconductors", "MU": "semiconductors", "ARM": "semiconductors", "TSM": "semiconductors", "ASML": "semiconductors", "QCOM": "semiconductors", "TXN": "semiconductors", "AMAT": "semiconductors", "LRCX": "semiconductors", # Communication / internet "GOOGL": "communication", "GOOG": "communication", "META": "communication", "NFLX": "communication", "DIS": "communication", "ROKU": "communication", "SNAP": "communication", "PINS": "communication", "SPOT": "communication", # Consumer discretionary / autos / retail "TSLA": "consumer_discretionary", "AMZN": "consumer_discretionary", "NKE": "consumer_discretionary", "SBUX": "consumer_discretionary", "MCD": "consumer_discretionary", "HD": "consumer_discretionary", "LOW": "consumer_discretionary", "TGT": "consumer_discretionary", "WMT": "consumer_staples", "COST": "consumer_staples", "PG": "consumer_staples", "KO": "consumer_staples", "PEP": "consumer_staples", # Energy / commodities "XOM": "energy", "CVX": "energy", "OXY": "energy", "COP": "energy", "SLB": "energy", "HAL": "energy", "USO": "energy", # Financials "JPM": "financials", "BAC": "financials", "C": "financials", "WFC": "financials", "GS": "financials", "MS": "financials", "BLK": "financials", "SCHW": "financials", "AXP": "financials", "V": "financials", "MA": "financials", "PYPL": "financials", # Healthcare / biotech "LLY": "healthcare", "PFE": "healthcare", "MRNA": "healthcare", "BMY": "healthcare", "JNJ": "healthcare", "UNH": "healthcare", "HUM": "healthcare", "ABBV": "healthcare", "MRK": "healthcare", "GILD": "healthcare", "REGN": "healthcare", "VRTX": "healthcare", # Industrials/materials/utilities/real estate "BA": "industrials", "CAT": "industrials", "DE": "industrials", "GE": "industrials", "HON": "industrials", "UPS": "industrials", "FDX": "industrials", "LMT": "industrials", "NOC": "industrials", "RTX": "industrials", "FCX": "materials", "NEM": "materials", "AA": "materials", "LIN": "materials", "NEE": "utilities", "DUK": "utilities", "SO": "utilities", "PLD": "real_estate", "AMT": "real_estate", "O": "real_estate", # ETFs map to themselves/market context "SPY": "market", "QQQ": "nasdaq", "IWM": "small_caps", "XLK": "technology", "SMH": "semiconductors", "SOXX": "semiconductors", "XLE": "energy", "XLF": "financials", "XLV": "healthcare", "XLY": "consumer_discretionary", "XLP": "consumer_staples", "XLI": "industrials", "XLB": "materials", "XLU": "utilities", "XLRE": "real_estate", "XLC": "communication", } @dataclass(frozen=True) class SectorFilterResult: ok: bool reason: str sector: str sector_etf: str sector_change_pct: float | None market_change_pct: float | None qqq_change_pct: float | None relative_to_sector_pct: float | None sector_vs_market_pct: float | None momentum_confirmation: str score_adjustment: float severity: str def _quote_change(symbol: str, cfg: dict, quote_fn: Callable[[str, dict], Any]) -> float | None: try: result = quote_fn(symbol, cfg) if not result: return None # get_quote liefert: price, change_pct, high, low, source return float(result[1]) except Exception: return None def sector_for_ticker(ticker: str) -> tuple[str, str]: t = (ticker or "").upper().strip() sector = TICKER_TO_SECTOR.get(t) if not sector: # Grober Fallback: unbekannte Single Stocks gegen QQQ + SPY prüfen. sector = "nasdaq" return sector, SECTOR_ETFS.get(sector, "QQQ") def evaluate_sector_filter(ticker: str, direction: str, stock_change_pct: float, cfg: dict, quote_fn: Callable[[str, dict], Any]) -> SectorFilterResult: """ Bewertet Markt-/Sektorbestätigung. Fail-closed nur bei klaren Konflikten; fehlende ETF-Daten führen zu Warnung, nicht zu Block. """ direction = (direction or "").upper() sector, sector_etf = sector_for_ticker(ticker) sector_change = _quote_change(sector_etf, cfg, quote_fn) spy_change = _quote_change("SPY", cfg, quote_fn) qqq_change = _quote_change("QQQ", cfg, quote_fn) market_change = spy_change if spy_change is not None else qqq_change if sector_change is None and market_change is None: return SectorFilterResult( ok=True, reason="Sektor-/Marktdaten fehlen; kein harter Block", sector=sector, sector_etf=sector_etf, sector_change_pct=None, market_change_pct=market_change, qqq_change_pct=qqq_change, relative_to_sector_pct=None, sector_vs_market_pct=None, momentum_confirmation="unknown", score_adjustment=-3.0, severity="warning", ) rel = None if sector_change is not None: rel = round(stock_change_pct - sector_change, 2) sector_vs_market = None if sector_change is not None and market_change is not None: sector_vs_market = round(sector_change - market_change, 2) reasons: list[str] = [] score_adj = 0.0 momentum_confirmation = "neutral" ok = True severity = "ok" # CALL: ideal ist Aktie > Sektor, Sektor/Markt nicht klar negativ. if direction == "CALL": if sector_change is not None and sector_change < -0.60 and (rel is None or rel < 0.20): ok = False severity = "block" reasons.append(f"CALL gegen schwachen Sektor {sector_etf} {sector_change:.2f}% ohne relative Staerke") if market_change is not None and market_change < -0.80 and stock_change_pct <= 0: ok = False severity = "block" reasons.append(f"CALL gegen schwachen Markt SPY/QQQ {market_change:.2f}%") if rel is not None: if rel >= RULES.sector_relative_strength_min: score_adj += RULES.sector_confirms_score_bonus momentum_confirmation = "stock_outperforms_sector" elif rel < -0.40: score_adj += RULES.sector_disagrees_score_malus momentum_confirmation = "stock_lags_sector" if sector_vs_market is not None: if sector_vs_market >= RULES.sector_vs_market_confirm_min and direction == "CALL": score_adj += 4.0 if momentum_confirmation == "stock_outperforms_sector": momentum_confirmation = "stock_and_sector_outperform_market" elif sector_vs_market < -0.30: score_adj -= 5.0 if market_change is not None and market_change < -0.40: score_adj -= 4.0 # PUT: ideal ist Aktie < Sektor, Sektor/Markt nicht klar stark. elif direction == "PUT": if sector_change is not None and sector_change > 0.60 and (rel is None or rel > -0.20): ok = False severity = "block" reasons.append(f"PUT gegen starken Sektor {sector_etf} {sector_change:.2f}% ohne relative Schwaeche") if market_change is not None and market_change > 0.80 and stock_change_pct >= 0: ok = False severity = "block" reasons.append(f"PUT gegen starken Markt SPY/QQQ {market_change:.2f}%") if rel is not None: if rel <= -RULES.sector_relative_strength_min: score_adj += RULES.sector_confirms_score_bonus momentum_confirmation = "stock_underperforms_sector" elif rel > 0.40: score_adj += RULES.sector_disagrees_score_malus momentum_confirmation = "stock_stronger_than_sector" if sector_vs_market is not None: if sector_vs_market <= -RULES.sector_vs_market_confirm_min and direction == "PUT": score_adj += 4.0 if momentum_confirmation == "stock_underperforms_sector": momentum_confirmation = "stock_and_sector_underperform_market" elif sector_vs_market > 0.30: score_adj -= 5.0 if market_change is not None and market_change > 0.40: score_adj -= 4.0 else: reasons.append("Unbekannte Richtung fuer Sektorfilter") score_adj -= 5.0 severity = "warning" if not reasons: reasons.append("ok") return SectorFilterResult( ok=ok, reason=" | ".join(reasons), sector=sector, sector_etf=sector_etf, sector_change_pct=round(sector_change, 2) if sector_change is not None else None, market_change_pct=round(market_change, 2) if market_change is not None else None, qqq_change_pct=round(qqq_change, 2) if qqq_change is not None else None, relative_to_sector_pct=rel, sector_vs_market_pct=sector_vs_market, momentum_confirmation=momentum_confirmation, score_adjustment=round(score_adj, 2), severity=severity, ) ================================================ FILE: src/simple_journal.py ================================================ """ Einfaches Interface auf dem bestehenden, robusten TradingJournal. """ from trading_journal import ( create_run, log_market_signals, log_final_decision, update_due_outcomes, get_iv_stats ) class TradingJournal: """Einfaches, benutzerfreundliches Interface""" def __init__(self): self.run_id = None def start_run(self): """Neuen Run starten""" self.run_id = create_run() return self.run_id def log_signals(self, parsed_signals, market_data, clusters=None): """Signale + Marktdaten loggen""" if self.run_id is None: self.start_run() log_market_signals(self.run_id, parsed_signals, market_data, clusters) def log_decision(self, result: dict): """Finale Entscheidung (Trade oder No-Trade) loggen""" if self.run_id is None: self.start_run() log_final_decision(self.run_id, result) def update_outcomes(self, cfg): """Fällige Outcomes updaten""" return update_due_outcomes(cfg) def get_iv_stats(self, ticker: str, current_iv: float | None = None): """IV-Rank aus eigener Historie""" return get_iv_stats(ticker, current_iv) def get_run_id(self): return self.run_id # Singleton für einfache Nutzung journal = TradingJournal() ================================================ FILE: src/trading_journal.py ================================================ """ trading_journal.py — Signal-/Trade-Journal und Outcome-Tracking. Speichert jeden Lauf in SQLite: - Rohsignale aus News/Claude - Marktdaten, Optionsdaten, SEC-Daten - finale Report-Entscheidung - spätere Underlying-Outcomes für Event-Study Wichtig: In GitHub Actions muss data/ persistent gemacht werden, sonst ist SQLite nach jedem Lauf weg. """ from __future__ import annotations import json import logging import sqlite3 from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any logger = logging.getLogger(__name__) DATA_DIR = Path(__file__).resolve().parent.parent / "data" DB_PATH = DATA_DIR / "trading_journal.sqlite" OUTCOME_HORIZONS = { "1H": timedelta(hours=1), "EOD": None, # wird auf 21:00 UTC des Signaltags gesetzt "1D": timedelta(days=1), "3D": timedelta(days=3), "5D": timedelta(days=5), "10D": timedelta(days=10), } def utc_now() -> datetime: return datetime.now(timezone.utc) def iso(dt: datetime | None = None) -> str: return (dt or utc_now()).astimezone(timezone.utc).isoformat(timespec="seconds") def _json(obj: Any) -> str: return json.dumps(obj, ensure_ascii=False, sort_keys=True, default=str) def connect(db_path: Path = DB_PATH) -> sqlite3.Connection: DATA_DIR.mkdir(parents=True, exist_ok=True) con = sqlite3.connect(db_path, timeout=10) con.row_factory = sqlite3.Row con.execute("PRAGMA journal_mode=WAL") con.execute("PRAGMA busy_timeout=5000") init_db(con) return con def init_db(con: sqlite3.Connection) -> None: con.executescript( """ CREATE TABLE IF NOT EXISTS runs ( run_id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, market_date TEXT NOT NULL, market_status TEXT, vix TEXT, raw_ticker_signals TEXT, article_count INTEGER, cluster_count INTEGER, no_trade INTEGER DEFAULT 0, no_trade_reason TEXT, final_ticker TEXT, final_direction TEXT, final_payload_json TEXT ); CREATE TABLE IF NOT EXISTS signals ( signal_id INTEGER PRIMARY KEY AUTOINCREMENT, run_id INTEGER NOT NULL, created_at TEXT NOT NULL, ticker TEXT NOT NULL, direction TEXT NOT NULL, signal_strength TEXT, horizon TEXT, dte_days INTEGER, cluster_json TEXT, market_json TEXT, option_json TEXT, sec_json TEXT, price REAL, change_pct REAL, rel_vol TEXT, score REAL, raw_signal_score REAL, gate_adjusted_score REAL, news_confidence_score REAL, news_sentiment_score REAL, news_sentiment_source TEXT, score_reason TEXT, liquidity_fail INTEGER, liquidity_reason TEXT, ev_ok INTEGER, ev_pct REAL, ev_dollars REAL, conservative_entry REAL, data_quality_ok INTEGER, data_quality_reason TEXT, no_trade_reason TEXT, quote_source TEXT, option_source TEXT, realized_vol_20d REAL, option_iv REAL, iv_to_rv REAL, exit_slippage_points REAL, earnings_iv_ok INTEGER, earnings_iv_reason TEXT, sector TEXT, sector_etf TEXT, sector_change_pct REAL, market_change_pct REAL, relative_to_sector_pct REAL, sector_filter_ok INTEGER, sector_filter_reason TEXT, sentiment_price_label TEXT, sentiment_price_score_adjustment REAL, data_quality_score REAL, price_spike_pct REAL, iv_rank REAL, iv_percentile REAL, iv_history_count INTEGER, iv_rank_reason TEXT, iv_cold_start INTEGER, sector_vs_market_pct REAL, sector_momentum_confirmation TEXT, time_stop_hours INTEGER, time_stop_required_move_pct REAL, time_stop_rule TEXT, selected_trade INTEGER DEFAULT 0, FOREIGN KEY(run_id) REFERENCES runs(run_id) ); CREATE TABLE IF NOT EXISTS outcomes ( outcome_id INTEGER PRIMARY KEY AUTOINCREMENT, signal_id INTEGER NOT NULL, horizon TEXT NOT NULL, due_at TEXT NOT NULL, checked_at TEXT, start_price REAL, end_price REAL, underlying_return_pct REAL, direction_return_pct REAL, status TEXT DEFAULT 'open', FOREIGN KEY(signal_id) REFERENCES signals(signal_id), UNIQUE(signal_id, horizon) ); CREATE TABLE IF NOT EXISTS option_iv_history ( iv_id INTEGER PRIMARY KEY AUTOINCREMENT, market_date TEXT NOT NULL, created_at TEXT NOT NULL, run_id INTEGER, signal_id INTEGER, ticker TEXT NOT NULL, direction TEXT, expiration TEXT, strike REAL, dte_actual INTEGER, option_iv REAL NOT NULL, realized_vol_20d REAL, iv_to_rv REAL, source TEXT DEFAULT 'tradier', UNIQUE(market_date, ticker, direction, expiration, strike) ); CREATE INDEX IF NOT EXISTS idx_signals_ticker ON signals(ticker); CREATE INDEX IF NOT EXISTS idx_outcomes_due ON outcomes(status, due_at); CREATE INDEX IF NOT EXISTS idx_iv_history_ticker ON option_iv_history(ticker, created_at); """ ) _ensure_columns(con, "signals", { "raw_signal_score": "REAL", "gate_adjusted_score": "REAL", "news_confidence_score": "REAL", "news_sentiment_score": "REAL", "news_sentiment_source": "TEXT", "data_quality_ok": "INTEGER", "data_quality_reason": "TEXT", "no_trade_reason": "TEXT", "quote_source": "TEXT", "option_source": "TEXT", "realized_vol_20d": "REAL", "option_iv": "REAL", "iv_to_rv": "REAL", "exit_slippage_points": "REAL", "earnings_iv_ok": "INTEGER", "earnings_iv_reason": "TEXT", "sector": "TEXT", "sector_etf": "TEXT", "sector_change_pct": "REAL", "market_change_pct": "REAL", "relative_to_sector_pct": "REAL", "sector_filter_ok": "INTEGER", "sector_filter_reason": "TEXT", "sentiment_price_label": "TEXT", "sentiment_price_score_adjustment": "REAL", "data_quality_score": "REAL", "price_spike_pct": "REAL", "iv_rank": "REAL", "iv_percentile": "REAL", "iv_history_count": "INTEGER", "iv_rank_reason": "TEXT", "iv_cold_start": "INTEGER", "sector_vs_market_pct": "REAL", "sector_momentum_confirmation": "TEXT", "time_stop_hours": "INTEGER", "time_stop_required_move_pct": "REAL", "time_stop_rule": "TEXT", }) con.commit() def _ensure_columns(con: sqlite3.Connection, table: str, columns: dict[str, str]) -> None: existing = {row[1] for row in con.execute(f"PRAGMA table_info({table})").fetchall()} for name, ddl in columns.items(): if name not in existing: con.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}") def create_run(market_status: str = "", vix: Any = None, raw_ticker_signals: str = "", article_count: int = 0, cluster_count: int = 0) -> int: con = connect() now = utc_now() cur = con.execute( """ INSERT INTO runs(started_at, market_date, market_status, vix, raw_ticker_signals, article_count, cluster_count) VALUES (?, ?, ?, ?, ?, ?, ?) """, (iso(now), now.date().isoformat(), market_status, str(vix), raw_ticker_signals, article_count, cluster_count), ) con.commit() run_id = int(cur.lastrowid) con.close() return run_id def update_run_context(run_id: int, market_status: str = "", vix: Any = None, raw_ticker_signals: str = "", article_count: int | None = None, cluster_count: int | None = None) -> None: con = connect() con.execute( """ UPDATE runs SET market_status = COALESCE(NULLIF(?, ''), market_status), vix = COALESCE(NULLIF(?, ''), vix), raw_ticker_signals = COALESCE(NULLIF(?, ''), raw_ticker_signals), article_count = COALESCE(?, article_count), cluster_count = COALESCE(?, cluster_count) WHERE run_id = ? """, (market_status, str(vix) if vix is not None else "", raw_ticker_signals, article_count, cluster_count, run_id), ) con.commit() con.close() def _cluster_for_ticker(clusters: list[dict], ticker: str) -> dict: matches = [c for c in clusters if c.get("ticker") == ticker] if not matches: return {} return sorted(matches, key=lambda c: c.get("confidence_score", 0), reverse=True)[0] def _parsed_signal_for_ticker(parsed_signals: list[dict], ticker: str) -> dict: for s in parsed_signals: if s.get("ticker") == ticker: return s return {} def log_market_signals(run_id: int, parsed_signals: list[dict], market_data: list[dict], clusters: list[dict] | None = None) -> None: """Schreibt alle geprüften Ticker inkl. Options-/SEC-/Kostenfeldern atomar.""" clusters = clusters or [] con = connect() created = utc_now() signal_ids: list[tuple[int, float | None]] = [] columns = [ "run_id", "created_at", "ticker", "direction", "signal_strength", "horizon", "dte_days", "cluster_json", "market_json", "option_json", "sec_json", "price", "change_pct", "rel_vol", "score", "raw_signal_score", "gate_adjusted_score", "news_confidence_score", "news_sentiment_score", "news_sentiment_source", "score_reason", "liquidity_fail", "liquidity_reason", "ev_ok", "ev_pct", "ev_dollars", "conservative_entry", "data_quality_ok", "data_quality_reason", "no_trade_reason", "quote_source", "option_source", "realized_vol_20d", "option_iv", "iv_to_rv", "exit_slippage_points", "earnings_iv_ok", "earnings_iv_reason", "sector", "sector_etf", "sector_change_pct", "market_change_pct", "relative_to_sector_pct", "sector_filter_ok", "sector_filter_reason", "sentiment_price_label", "sentiment_price_score_adjustment", "data_quality_score", "price_spike_pct", "iv_rank", "iv_percentile", "iv_history_count", "iv_rank_reason", "iv_cold_start", "sector_vs_market_pct", "sector_momentum_confirmation", "time_stop_hours", "time_stop_required_move_pct", "time_stop_rule", ] placeholders = ", ".join(["?"] * len(columns)) sql = f"INSERT INTO signals({', '.join(columns)}) VALUES ({placeholders})" with con: for d in market_data: ticker = d.get("ticker", "") ps = _parsed_signal_for_ticker(parsed_signals, ticker) opt = d.get("options") or {} sec = { "sec_bullish": d.get("sec_bullish"), "sec_bearish": d.get("sec_bearish"), "sec_insider": d.get("sec_insider"), "sec_reason": d.get("sec_reason"), "sec_confidence": d.get("sec_confidence"), } cluster = _cluster_for_ticker(clusters, ticker) values = [ run_id, iso(created), ticker, d.get("news_direction") or ps.get("direction"), ps.get("score"), ps.get("horizon"), ps.get("dte_days"), _json(cluster), _json(d), _json(opt), _json(sec), d.get("price"), d.get("change_pct"), str(d.get("rel_vol")), d.get("score"), d.get("raw_signal_score"), d.get("gate_adjusted_score"), d.get("news_confidence_score", cluster.get("confidence_score")), d.get("news_sentiment_score", cluster.get("sentiment_score")), d.get("news_sentiment_source", cluster.get("sentiment_source")), d.get("_score_reason"), 1 if d.get("_liquidity_fail") else 0, d.get("_liquidity_reason", ""), 1 if opt.get("ev_ok") else 0, opt.get("ev_pct"), opt.get("ev_dollars"), opt.get("conservative_entry"), 1 if d.get("_data_quality_ok") else 0, d.get("_data_quality_reason", ""), d.get("_no_trade_reason", ""), d.get("_src_quote", ""), opt.get("option_source", ""), d.get("realized_vol_20d"), opt.get("iv_decimal"), opt.get("iv_to_rv"), opt.get("exit_slippage_points"), 1 if opt.get("earnings_iv_ok", True) else 0, opt.get("earnings_iv_reason", ""), d.get("sector"), d.get("sector_etf"), d.get("sector_change_pct"), d.get("market_change_pct"), d.get("relative_to_sector_pct"), 1 if d.get("sector_filter_ok", True) else 0, d.get("sector_filter_reason", ""), d.get("sentiment_price_label", ""), d.get("sentiment_price_score_adjustment"), d.get("data_quality_score"), d.get("price_spike_pct"), opt.get("iv_rank"), opt.get("iv_percentile"), opt.get("iv_history_count"), opt.get("iv_rank_reason", ""), 1 if opt.get("iv_cold_start") else 0, d.get("sector_vs_market_pct"), d.get("sector_momentum_confirmation", ""), opt.get("time_stop_hours"), opt.get("time_stop_required_move_pct"), opt.get("time_stop_rule", ""), ] cur = con.execute(sql, values) signal_id = int(cur.lastrowid) signal_ids.append((signal_id, d.get("price"))) _record_iv_snapshot(con, run_id, signal_id, ticker, d.get("news_direction") or ps.get("direction"), opt) # Outcome-Zeitpunkte anlegen. for signal_id, start_price in signal_ids: if not start_price or start_price <= 0: continue for horizon, delta in OUTCOME_HORIZONS.items(): if delta is None: now = created due = now.replace(hour=21, minute=0, second=0, microsecond=0) if due <= now: due = now + timedelta(hours=1) else: due = created + delta con.execute( """ INSERT OR IGNORE INTO outcomes(signal_id, horizon, due_at, start_price) VALUES (?, ?, ?, ?) """, (signal_id, horizon, iso(due), start_price), ) con.close() logger.info("Journal: %d Signale gespeichert", len(signal_ids)) def log_final_decision(run_id: int, result: dict) -> None: con = connect() no_trade = 1 if result.get("no_trade") else 0 ticker = result.get("ticker", "") direction = result.get("direction", "") with con: con.execute( """ UPDATE runs SET no_trade=?, no_trade_reason=?, final_ticker=?, final_direction=?, final_payload_json=? WHERE run_id=? """, (no_trade, result.get("no_trade_grund", ""), ticker, direction, _json(result), run_id), ) if ticker: con.execute( """ UPDATE signals SET selected_trade = 1 WHERE run_id = ? AND ticker = ? AND direction = ? """, (run_id, ticker, direction), ) con.close() def _as_float(value: Any) -> float | None: try: if value is None or value == "": return None return float(value) except (TypeError, ValueError): return None def _record_iv_snapshot(con: sqlite3.Connection, run_id: int, signal_id: int, ticker: str, direction: str | None, opt: dict) -> None: iv = _as_float((opt or {}).get("iv_decimal")) if iv is None or iv <= 0: return strike = _as_float((opt or {}).get("strike")) con.execute( """ INSERT OR REPLACE INTO option_iv_history( market_date, created_at, run_id, signal_id, ticker, direction, expiration, strike, dte_actual, option_iv, realized_vol_20d, iv_to_rv, source ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( utc_now().date().isoformat(), iso(), run_id, signal_id, ticker, direction, (opt or {}).get("expiration"), strike, (opt or {}).get("dte_actual"), iv, (opt or {}).get("realized_vol_20d"), (opt or {}).get("iv_to_rv"), (opt or {}).get("option_source", "tradier"), ), ) def get_iv_stats(ticker: str, current_iv: float | None, min_samples: int = 2) -> dict: """ Eigener IV-Rank aus bereits journalisierten Options-IVs. Keine Yahoo-/Underlying-Naeherung. Wenn Historie zu kurz ist, wird nur diagnostiziert. """ iv = _as_float(current_iv) if iv is None or iv <= 0: return { "iv_rank": None, "iv_percentile": None, "iv_history_count": 0, "iv_rank_reason": "IV fehlt", } con = connect() rows = con.execute( """ SELECT option_iv FROM option_iv_history WHERE ticker = ? AND option_iv > 0 ORDER BY created_at DESC LIMIT 260 """, (ticker.upper(),), ).fetchall() con.close() values = [float(r[0]) for r in rows if r[0] is not None and float(r[0]) > 0] n = len(values) if n < min_samples: return { "iv_rank": None, "iv_percentile": None, "iv_history_count": n, "iv_rank_reason": f"IV-Historie zu kurz: {n} Samples", } lo = min(values) hi = max(values) if hi <= lo: iv_rank = 50.0 else: iv_rank = max(0.0, min(100.0, (iv - lo) / (hi - lo) * 100.0)) percentile = sum(1 for v in values if v <= iv) / n * 100.0 return { "iv_rank": round(iv_rank, 2), "iv_percentile": round(percentile, 2), "iv_history_count": n, "iv_rank_reason": f"eigene Journal-Historie n={n}", } def update_due_outcomes(cfg: dict, max_updates: int = 50) -> int: """ Aktualisiert fällige Outcomes mit aktuellem Underlying-Preis. Wird bei jedem Lauf aufgerufen. """ con = connect() due_rows = con.execute( """ SELECT o.outcome_id, o.signal_id, o.horizon, o.start_price, s.ticker, s.direction FROM outcomes o JOIN signals s ON s.signal_id = o.signal_id WHERE o.status = 'open' AND o.due_at <= ? ORDER BY o.due_at ASC LIMIT ? """, (iso(), max_updates), ).fetchall() if not due_rows: con.close() return 0 try: from market_data import get_quote except Exception as e: logger.warning("Outcome-Update ohne market_data nicht möglich: %s", e) con.close() return 0 updated = 0 quote_cache: dict[str, float] = {} for row in due_rows: ticker = row["ticker"] if ticker not in quote_cache: price, *_ = get_quote(ticker, cfg) quote_cache[ticker] = price end_price = quote_cache[ticker] start_price = row["start_price"] if not end_price or not start_price or start_price <= 0: continue ret = round((end_price - start_price) / start_price * 100.0, 3) direction_ret = ret if row["direction"] == "CALL" else -ret con.execute( """ UPDATE outcomes SET checked_at=?, end_price=?, underlying_return_pct=?, direction_return_pct=?, status='done' WHERE outcome_id=? """, (iso(), end_price, ret, direction_ret, row["outcome_id"]), ) updated += 1 con.commit() con.close() if updated: logger.info("Journal: %d Outcomes aktualisiert", updated) return updated ================================================ FILE: src/universe.py ================================================ """ universe.py — kostenloses dynamisches US-Ticker-Universum. Quelle: Nasdaq Trader Symbol Directory. - nasdaqlisted.txt - otherlisted.txt Fail-safe: Wenn Download/Parse scheitert, nutzt news_analyzer.py die übergebene Fallback-Liste. """ from __future__ import annotations import csv import json import logging from datetime import datetime, timedelta, timezone from pathlib import Path import requests logger = logging.getLogger(__name__) DATA_DIR = Path(__file__).resolve().parent.parent / "data" CACHE_FILE = DATA_DIR / "universe_cache.json" CACHE_TTL_HOURS = 24 NASDAQ_LISTED_URL = "https://www.nasdaqtrader.com/dynamic/SymDir/nasdaqlisted.txt" OTHER_LISTED_URL = "https://www.nasdaqtrader.com/dynamic/SymDir/otherlisted.txt" STATIC_ETFS = { "SPY", "QQQ", "IWM", "DIA", "GLD", "SLV", "USO", "TLT", "GDX", "XLE", "XLF", "XLK", "XLV", "XLI", "XLU", "XLP", "XLY", "XLB", "XLRE", } def _is_cache_fresh(path: Path) -> bool: if not path.exists(): return False age = datetime.now(timezone.utc) - datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc) return age < timedelta(hours=CACHE_TTL_HOURS) def _download_text(url: str) -> str: r = requests.get(url, timeout=10, headers={"User-Agent": "daily-options-report/1.0"}) r.raise_for_status() return r.text def _parse_pipe_table(text: str, symbol_field: str) -> set[str]: result: set[str] = set() rows = [line for line in text.splitlines() if line and not line.startswith("File Creation Time")] reader = csv.DictReader(rows, delimiter="|") for row in reader: sym = (row.get(symbol_field) or "").strip().upper() if not sym or sym == "File Creation Time": continue # Ausschluss von Test-Issues und Sonder-Symbolen, die RSS oft falsch triggert. if row.get("Test Issue", "N").strip().upper() == "Y": continue if row.get("ETF", "N").strip().upper() == "Y": # Makro-ETFs separat kontrolliert behalten. if sym not in STATIC_ETFS: continue if "$" in sym or "." in sym or "^" in sym or "/" in sym: continue if 1 <= len(sym) <= 5 and sym.isalpha(): result.add(sym) return result def refresh_universe() -> set[str]: DATA_DIR.mkdir(parents=True, exist_ok=True) tickers: set[str] = set() nasdaq_text = _download_text(NASDAQ_LISTED_URL) other_text = _download_text(OTHER_LISTED_URL) tickers |= _parse_pipe_table(nasdaq_text, "Symbol") tickers |= _parse_pipe_table(other_text, "ACT Symbol") tickers |= STATIC_ETFS payload = { "created_at": datetime.now(timezone.utc).isoformat(), "count": len(tickers), "tickers": sorted(tickers), } CACHE_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8") logger.info("Ticker-Universum aktualisiert: %d Symbole", len(tickers)) return tickers def get_known_tickers(fallback: set[str] | None = None) -> set[str]: fallback = fallback or set() try: if _is_cache_fresh(CACHE_FILE): payload = json.loads(CACHE_FILE.read_text(encoding="utf-8")) cached = set(payload.get("tickers", [])) if cached: return cached | STATIC_ETFS | fallback return refresh_universe() | fallback except Exception as e: logger.warning("Ticker-Universum Fallback aktiv: %s", e) return fallback | STATIC_ETFS