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'
VIX: {vix_str} | Grund: {reason}
{cluster_section}{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