[
  {
    "path": ".github/workflows/daily_run.yml",
    "content": "name: Daily Options Report\n\non:\n  schedule:\n    # ca. 11:45 ET (15:45 UTC) — Mo–Fr\n    - cron: '45 15 * * 1-5'\n\n  workflow_dispatch:\n    inputs:\n      dry_run:\n        description: 'Dry-run (kein Email-Versand)'\n        required: false\n        default: 'false'\n        type: choice\n        options: ['false', 'true']\n\nconcurrency:\n  group: daily-options-report\n  cancel-in-progress: false\n\njobs:\n  run-bot:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout Repository\n        uses: actions/checkout@v4\n\n      - name: Python 3.11 einrichten\n        uses: actions/setup-python@v5\n        with:\n          python-version: '3.11'\n          cache: 'pip'\n\n      - name: data-Ordner erstellen\n        run: mkdir -p data\n\n      - name: Trading Journal aus Cache laden\n        id: journal-cache\n        uses: actions/cache/restore@v4\n        with:\n          path: data/trading_journal.sqlite\n          key: trading-journal-${{ runner.os }}-${{ github.run_id }}\n          restore-keys: |\n            trading-journal-${{ runner.os }}-\n\n      - name: Dependencies installieren\n        run: |\n          python -m pip install --upgrade pip\n          pip install -r requirements.txt\n\n      - name: Bot ausführen\n        env:\n          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n          TRADIER_TOKEN:     ${{ secrets.TRADIER_TOKEN }}\n          TRADIER_SANDBOX:   \"false\"\n          FINNHUB_KEY:       ${{ secrets.FINNHUB_KEY }}\n          ALPHA_VANTAGE_KEY: ${{ secrets.ALPHA_VANTAGE_KEY }}\n          GMAIL_RECIPIENT:   ${{ secrets.GMAIL_RECIPIENT }}\n          SMTP_SENDER:       ${{ secrets.SMTP_SENDER }}\n          SMTP_PASSWORD:     ${{ secrets.SMTP_PASSWORD }}\n          SEC_USER_AGENT:    ${{ secrets.SEC_USER_AGENT }}\n        run: |\n          if [ \"${{ github.event.inputs.dry_run }}\" = \"true\" ]; then\n            python src/main.py --dry-run --verbose\n          else\n            python src/main.py --verbose\n          fi\n\n      - name: Trading Journal zurück in Cache speichern\n        if: always() && hashFiles('data/trading_journal.sqlite') != ''\n        uses: actions/cache/save@v4\n        with:\n          path: data/trading_journal.sqlite\n          key: trading-journal-${{ runner.os }}-${{ github.run_id }}\n\n      - name: Journal als Artifact sichern\n        if: always()\n        uses: actions/upload-artifact@v4\n        with:\n          name: trading-journal-${{ github.run_number }}\n          path: data/trading_journal.sqlite\n          retention-days: 90\n"
  },
  {
    "path": ".gitignore",
    "content": "config/config.yaml\n.env\n__pycache__/\n*.py[cod]\nenv/\nvenv/\n.venv/\nlogs/\n*.log\nreport_preview.html\nmarket_summary.txt\nsignals.txt\n.DS_Store\n.idea/\n.vscode/\n"
  },
  {
    "path": "License",
    "content": "MIT License — Copyright (c) 2026\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\n\nDISCLAIMER: Dieses Projekt dient ausschließlich zu Bildungszwecken.\nEs stellt keine Anlageberatung dar. Trading mit Optionen birgt\nerhebliche Risiken bis hin zum Totalverlust.\n"
  },
  {
    "path": "README.md",
    "content": "[README.md](https://github.com/user-attachments/files/26564692/README.md)\n# Options Trading Signal Bot\n\nVollautomatisches tägliches Options-Trading-Signal-System.\nAnalysiert Finanznews, bewertet Marktdaten und verschickt\neine HTML-Email mit konkreten Handelsempfehlungen.\n\n---\n\n## Wie es funktioniert\n\n```\n1. News-Analyse\n   14 RSS-Feeds (Reuters, Bloomberg, CNBC, Benzinga) parallel.\n   Artikel werden geclustert und mit gewichtetem Score bewertet\n   (Aktualität × Quellen-Qualität × Velocity × Earnings-Proximity).\n   Claude analysiert Top-Cluster → handelbare Signale.\n\n2. Marktdaten\n   Kurse (AlphaVantage → Yahoo → Finnhub), historische Daten\n   (MA50, MA20, RelVol) und Options-Greeks (Tradier).\n   Normalisierter Score 0–100 mit Trend-Alignment und Liquiditäts-Filter.\n\n3. Report\n   Claude erstellt Trade-Empfehlung mit 5-Punkte-Begründung,\n   Exit-Plan und Marktstatus. Versand als HTML-Email.\n```\n\n---\n\n## Voraussetzungen\n\nPython 3.9+\n\n| API | Zweck | Kosten |\n|-----|-------|--------|\n| [Anthropic](https://console.anthropic.com) | Claude | ~$0.01/Tag |\n| [Tradier](https://developer.tradier.com) | Options-Greeks | Sandbox: kostenlos |\n| [Finnhub](https://finnhub.io) | Earnings | Free Tier |\n| [Alpha Vantage](https://www.alphavantage.co) | Kurse | Free: 25/Tag |\n\nGmail App-Passwort: Google Account → Sicherheit → 2FA → App-Passwörter\n\n---\n\n## Installation\n\n```bash\ngit clone https://github.com/DEIN-USERNAME/options-trading-bot.git\ncd options-trading-bot\npip install -r requirements.txt\ncp config/config.example.yaml config/config.yaml\n# config.yaml mit API Keys befüllen\n```\n\n---\n\n## Verwendung\n\n```bash\n# Normaler Lauf (verschickt Email)\npython src/main.py\n\n# Dry-run (kein Email, Report als HTML gespeichert)\npython src/main.py --dry-run\n\n# Mit Details in der Konsole\npython src/main.py --dry-run --verbose\n\n# Einzelne Steps testen\npython src/news_analyzer.py --verbose\npython src/market_data.py --signals \"UBER:CALL:MED:T1:21DTE\"\npython src/report_generator.py --summary-file market_summary.txt --dry-run\n```\n\n---\n\n## Automatisch täglich (Cron)\n\n```bash\n# Täglich Mo–Fr um 10:30 ET (14:30 UTC)\n30 14 * * 1-5 cd /pfad/zum/bot && python src/main.py >> logs/daily.log 2>&1\n```\n\n---\n\n## GitHub Actions (automatisch in der Cloud)\n\nSecrets setzen: Repository → Settings → Secrets and variables → Actions\n\n```\nANTHROPIC_API_KEY\nTRADIER_TOKEN\nFINNHUB_KEY\nALPHA_VANTAGE_KEY\nGMAIL_RECIPIENT\nSMTP_SENDER\nSMTP_PASSWORD\n```\n\nDann läuft der Bot täglich Mo–Fr automatisch um 14:30 UTC.\nManueller Start: Actions → Daily Options Report → Run workflow\n\n---\n\n## Handelsregeln\n\n| VIX | Einsatz | Status |\n|-----|---------|--------|\n| ≥ 25 | — | ❌ Kein Trade |\n| 20–24.99 | 150 € | ⚠️ Reduziert |\n| < 20 | 250 € | ✅ Normal |\n\nAusschluss wenn: Score < 50 · Δ% gegen Signal · unter MA50 · Spread > 2% · OI < 5.000\n\n---\n\n## Disclaimer\n\nDieses Projekt dient ausschließlich zu Bildungszwecken und stellt\nkeine Anlageberatung dar. Trading mit Optionen birgt erhebliche Risiken.\n"
  },
  {
    "path": "data/.gitkeep",
    "content": "\n"
  },
  {
    "path": "requirements.txt",
    "content": "requests>=2.31.0\npyyaml>=6.0\ntransformers>=4.40.0\ntorch>=2.2.0\nexchange_calendars>=4.5.0\npydantic>=2.7.0\nfeedparser>=6.0.10\n"
  },
  {
    "path": "src/config_loader.py",
    "content": "\"\"\"\nconfig_loader.py\nLädt API Keys aus config/config.yaml oder Umgebungsvariablen.\n\nv8:\n- Tradier-Production ist Standard. Sandbox wird nur genutzt, wenn TRADIER_SANDBOX=true gesetzt ist.\n- TRADIER_TOKEN ist Pflicht, weil Options-EV und konsistente Quotes auf Tradier basieren.\n\"\"\"\n\nimport logging\nimport os\nfrom pathlib import Path\n\nlogger = logging.getLogger(__name__)\n\nREQUIRED_KEYS = [\"anthropic_api_key\", \"tradier_token\"]\n\ntry:\n    import yaml\n    YAML_AVAILABLE = True\nexcept ImportError:  # pragma: no cover\n    YAML_AVAILABLE = False\n\n\ndef _parse_bool(value, default=False) -> bool:\n    if value is None:\n        return default\n    if isinstance(value, bool):\n        return value\n    s = str(value).strip().lower()\n    if s in (\"1\", \"true\", \"yes\", \"y\", \"on\", \"sandbox\"):\n        return True\n    if s in (\"0\", \"false\", \"no\", \"n\", \"off\", \"prod\", \"production\", \"live\"):\n        return False\n    return default\n\n\ndef load_config() -> dict:\n    \"\"\"\n    Lädt Konfiguration in folgender Priorität:\n    1. Umgebungsvariablen überschreiben alles.\n    2. config/config.yaml.\n    3. Sichere Defaults.\n\n    Wichtig: Tradier läuft standardmäßig gegen Production, nicht Sandbox.\n    \"\"\"\n    config = {}\n\n    config_path = Path(__file__).parent.parent / \"config\" / \"config.yaml\"\n    if config_path.exists() and YAML_AVAILABLE:\n        try:\n            with open(config_path, \"r\", encoding=\"utf-8\") as f:\n                config = yaml.safe_load(f) or {}\n        except yaml.YAMLError as e:\n            logger.error(\"Fehler beim Laden von config.yaml: %s\", e)\n\n    env_map = {\n        \"ANTHROPIC_API_KEY\": \"anthropic_api_key\",\n        \"TRADIER_TOKEN\":     \"tradier_token\",\n        \"FINNHUB_KEY\":       \"finnhub_key\",\n        \"ALPHA_VANTAGE_KEY\": \"alpha_vantage_key\",\n        \"GMAIL_RECIPIENT\":   \"gmail_recipient\",\n        \"SMTP_SENDER\":       \"smtp_sender\",\n        \"SMTP_PASSWORD\":     \"smtp_password\",\n        \"TRADIER_SANDBOX\":   \"tradier_sandbox\",\n        \"TRADIER_ENV\":       \"tradier_env\",\n        \"SEC_USER_AGENT\":    \"sec_user_agent\",\n    }\n    for env_var, key in env_map.items():\n        val = os.environ.get(env_var)\n        if val is not None and str(val).strip() != \"\":\n            config[key] = val.strip()\n\n    # Production ist Default. Sandbox nur explizit.\n    if \"tradier_env\" in config and \"tradier_sandbox\" not in config:\n        config[\"tradier_sandbox\"] = _parse_bool(config.get(\"tradier_env\"), default=False)\n    else:\n        config[\"tradier_sandbox\"] = _parse_bool(config.get(\"tradier_sandbox\"), default=False)\n\n    config[\"tradier_base_url\"] = (\n        \"https://sandbox.tradier.com\" if config.get(\"tradier_sandbox\")\n        else \"https://api.tradier.com\"\n    )\n    config[\"tradier_mode\"] = \"sandbox\" if config.get(\"tradier_sandbox\") else \"production\"\n\n    return config\n\n\ndef validate_config(cfg: dict) -> bool:\n    \"\"\"Prüft Pflichtfelder. Gibt False zurück, wenn etwas fehlt.\"\"\"\n    missing = [k for k in REQUIRED_KEYS if not cfg.get(k)]\n    if missing:\n        logger.error(\"Fehlende Pflicht-Keys in config: %s\", missing)\n        return False\n    if cfg.get(\"tradier_sandbox\"):\n        logger.warning(\"TRADIER_SANDBOX=true — Sandboxdaten sind verzögert/Simulation. Für Production Secret auf false lassen.\")\n    else:\n        logger.info(\"Tradier-Modus: PRODUCTION api.tradier.com\")\n    return True\n"
  },
  {
    "path": "src/data_validator.py",
    "content": "\"\"\"\ndata_validator.py — Datenhärtung für kostenlose und Broker-Datenquellen.\n\nZiel:\n- Keine Scheingenauigkeit durch kaputte OHLCV-Historien.\n- Spikes/Gaps markieren statt blind handeln.\n- Underlying-/Options-Snapshot fail-closed prüfen.\n\nDie Funktionen sind bewusst konservativ, aber nicht blind: Ein 10% Gap wird nicht automatisch\nals Fehler verworfen. Es wird als Risiko-Flag gespeichert und kann über Gates wirken.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport math\nimport statistics\nfrom dataclasses import dataclass\nfrom typing import Any\n\n\n@dataclass(frozen=True)\nclass DataValidationResult:\n    ok: bool\n    reason: str\n    flags: tuple[str, ...] = ()\n    quality_score: float = 1.0\n    spike_pct: float | None = None\n\n\ndef _to_float(value: Any, default=None):\n    try:\n        if value is None:\n            return default\n        return float(value)\n    except (TypeError, ValueError):\n        return default\n\n\ndef validate_ohlcv_history(closes: list, volumes: list | None = None,\n                           min_closes: int = 50) -> DataValidationResult:\n    \"\"\"\n    Validiert Daily-Historie. Für MA50, Realized Vol und Sektorfilter werden mindestens\n    50 Schlusskurse bevorzugt. Unter 21 ist die Historie nicht tradebar.\n    \"\"\"\n    flags: list[str] = []\n    quality = 1.0\n\n    if not closes or len(closes) < 21:\n        return DataValidationResult(False, \"Historie <21 Handelstage\", (\"history_too_short\",), 0.0)\n\n    clean = [_to_float(c) for c in closes if _to_float(c) is not None and _to_float(c) > 0]\n    if len(clean) < 21:\n        return DataValidationResult(False, \"Zu wenige valide Schlusskurse\", (\"invalid_closes\",), 0.0)\n\n    if len(clean) < min_closes:\n        flags.append(\"history_below_preferred_50d\")\n        quality *= 0.85\n\n    # Null-/Negativpreise sind bereits entfernt; nun extreme Lücken erkennen.\n    rets = []\n    for prev, cur in zip(clean[:-1], clean[1:]):\n        if prev > 0 and cur > 0:\n            rets.append((cur / prev - 1.0) * 100.0)\n    if rets:\n        max_abs_ret = max(abs(r) for r in rets[-20:])\n        if max_abs_ret > 25:\n            flags.append(\"extreme_recent_gap_gt25pct\")\n            quality *= 0.70\n        elif max_abs_ret > 12:\n            flags.append(\"recent_gap_gt12pct\")\n            quality *= 0.85\n\n    if volumes:\n        vclean = [v for v in volumes if isinstance(v, (int, float)) and v >= 0]\n        if len(vclean) >= 21:\n            if statistics.median(vclean[-20:]) == 0:\n                flags.append(\"volume_median_zero\")\n                quality *= 0.80\n        else:\n            flags.append(\"volume_history_short\")\n            quality *= 0.95\n    else:\n        flags.append(\"volume_missing\")\n        quality *= 0.95\n\n    return DataValidationResult(True, \"ok\", tuple(flags), round(max(0.0, min(1.0, quality)), 3))\n\n\ndef detect_unexplained_price_spike(price: float, closes: list, news_signal_present: bool = True,\n                                   threshold_pct: float = 10.0) -> DataValidationResult:\n    \"\"\"\n    Markiert große Kurslücken. Ein Spike ohne erkannte News ist kein automatischer Datenfehler,\n    aber ein Risikosignal, weil der Bot möglicherweise den echten Katalysator nicht kennt.\n    \"\"\"\n    p = _to_float(price, 0.0)\n    if p <= 0 or not closes:\n        return DataValidationResult(False, \"Preis oder Historie fehlt\", (\"price_or_history_missing\",), 0.0)\n    prev = _to_float(closes[-1], None)\n    if prev is None or prev <= 0:\n        return DataValidationResult(False, \"Voriger Schlusskurs fehlt\", (\"prev_close_missing\",), 0.0)\n\n    spike_pct = (p / prev - 1.0) * 100.0\n    flags = []\n    quality = 1.0\n    if abs(spike_pct) >= threshold_pct:\n        flags.append(\"price_spike_gt10pct\")\n        quality *= 0.75\n        if not news_signal_present:\n            flags.append(\"spike_without_detected_news\")\n            quality *= 0.60\n            return DataValidationResult(False, \"Preis-Spike >10% ohne erkannte News\", tuple(flags), round(quality, 3), round(spike_pct, 2))\n        return DataValidationResult(True, \"Preis-Spike >10% mit News-Kontext\", tuple(flags), round(quality, 3), round(spike_pct, 2))\n\n    return DataValidationResult(True, \"ok\", tuple(flags), 1.0, round(spike_pct, 2))\n\n\ndef realized_volatility(closes: list, lookback: int = 20) -> float | None:\n    \"\"\"Annualisierte realisierte Volatilität aus Daily-Schlusskursen als Dezimalzahl.\"\"\"\n    clean = [_to_float(c) for c in closes if _to_float(c) is not None and _to_float(c) > 0]\n    if len(clean) < lookback + 1:\n        return None\n    recent = clean[-(lookback + 1):]\n    rets = [math.log(cur / prev) for prev, cur in zip(recent[:-1], recent[1:]) if prev > 0 and cur > 0]\n    if len(rets) < 10:\n        return None\n    return max(0.05, min(2.50, statistics.stdev(rets) * math.sqrt(252)))\n\n\ndef data_flags_to_text(*results: DataValidationResult | None) -> str:\n    flags: list[str] = []\n    reasons: list[str] = []\n    for res in results:\n        if not res:\n            continue\n        if res.reason and res.reason != \"ok\":\n            reasons.append(res.reason)\n        flags.extend(list(res.flags or ()))\n    dedup = []\n    seen = set()\n    for item in reasons + flags:\n        if item and item not in seen:\n            seen.add(item)\n            dedup.append(item)\n    return \" | \".join(dedup) if dedup else \"ok\"\n"
  },
  {
    "path": "src/event_study.py",
    "content": "\"\"\"\nevent_study.py — Auswertung des SQLite-Journals.\n\nBeispiele:\n    python src/event_study.py\n    python src/event_study.py --selected-only\n    python src/event_study.py --csv data/event_study.csv\n    python src/event_study.py --group sector\n    python src/event_study.py --group sentpx\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport csv\nimport sqlite3\nfrom pathlib import Path\n\nfrom trading_journal import DB_PATH, connect\n\n\nVALID_GROUPS = {\"base\", \"sector\", \"sector_momentum\", \"sentpx\", \"ev_bucket\", \"ivrv_bucket\", \"iv_rank_bucket\", \"data_quality\"}\n\n\ndef fetch_rows(selected_only: bool = False):\n    con = connect()\n    where = \"AND s.selected_trade = 1\" if selected_only else \"\"\n    rows = con.execute(\n        f\"\"\"\n        SELECT s.ticker, s.direction, s.signal_strength, s.score, s.score_reason,\n               s.ev_ok, s.ev_pct, s.ev_dollars, s.selected_trade,\n               s.sector, s.sector_etf, s.sector_filter_ok, s.sector_filter_reason,\n               s.sector_vs_market_pct, s.sector_momentum_confirmation,\n               s.sentiment_price_label, s.sentiment_price_score_adjustment,\n               s.data_quality_ok, s.data_quality_reason, s.data_quality_score,\n               s.iv_to_rv, s.option_iv, s.iv_rank, s.iv_percentile, s.iv_history_count, s.no_trade_reason,\n               o.horizon, o.start_price, o.end_price,\n               o.underlying_return_pct, o.direction_return_pct\n        FROM outcomes o\n        JOIN signals s ON s.signal_id = o.signal_id\n        WHERE o.status = 'done' {where}\n        ORDER BY o.horizon, s.direction, s.ticker\n        \"\"\"\n    ).fetchall()\n    con.close()\n    return rows\n\n\ndef _bucket_ev(ev_pct):\n    if ev_pct is None:\n        return \"ev_unknown\"\n    try:\n        ev = float(ev_pct)\n    except (TypeError, ValueError):\n        return \"ev_unknown\"\n    if ev < 0:\n        return \"ev_neg\"\n    if ev < 12:\n        return \"ev_0_12\"\n    if ev < 25:\n        return \"ev_12_25\"\n    return \"ev_25_plus\"\n\n\ndef _bucket_ivrv(iv_to_rv):\n    if iv_to_rv is None:\n        return \"ivrv_unknown\"\n    try:\n        x = float(iv_to_rv)\n    except (TypeError, ValueError):\n        return \"ivrv_unknown\"\n    if x < 1.0:\n        return \"ivrv_lt1\"\n    if x < 1.35:\n        return \"ivrv_1_1.35\"\n    if x < 2.0:\n        return \"ivrv_1.35_2\"\n    return \"ivrv_gt2\"\n\n\n\ndef _bucket_iv_rank(iv_rank, iv_history_count):\n    try:\n        n = int(iv_history_count or 0)\n    except (TypeError, ValueError):\n        n = 0\n    if n < 30 or iv_rank is None:\n        return \"ivrank_insufficient\"\n    try:\n        x = float(iv_rank)\n    except (TypeError, ValueError):\n        return \"ivrank_unknown\"\n    if x < 25:\n        return \"ivrank_lt25\"\n    if x < 50:\n        return \"ivrank_25_50\"\n    if x < 80:\n        return \"ivrank_50_80\"\n    return \"ivrank_80_plus\"\n\ndef _group_key(row, group: str):\n    selected = \"selected\" if row[\"selected_trade\"] else \"all\"\n    if group == \"sector\":\n        bucket = row[\"sector_etf\"] or row[\"sector\"] or \"unknown\"\n    elif group == \"sector_momentum\":\n        bucket = row[\"sector_momentum_confirmation\"] or \"unknown\"\n    elif group == \"sentpx\":\n        bucket = row[\"sentiment_price_label\"] or \"unknown\"\n    elif group == \"ev_bucket\":\n        bucket = _bucket_ev(row[\"ev_pct\"])\n    elif group == \"ivrv_bucket\":\n        bucket = _bucket_ivrv(row[\"iv_to_rv\"])\n    elif group == \"iv_rank_bucket\":\n        bucket = _bucket_iv_rank(row[\"iv_rank\"], row[\"iv_history_count\"])\n    elif group == \"data_quality\":\n        bucket = \"dq_ok\" if row[\"data_quality_ok\"] else \"dq_fail\"\n    else:\n        bucket = selected\n    return (row[\"horizon\"], row[\"direction\"], bucket)\n\n\ndef summarize(rows, group: str = \"base\"):\n    groups = {}\n    for r in rows:\n        key = _group_key(r, group)\n        groups.setdefault(key, []).append(r[\"direction_return_pct\"])\n\n    lines = []\n    title = \"GROUP\" if group != \"base\" else \"SET\"\n    lines.append(f\"HORIZON | DIR  | {title:<18} | N   | HIT%  | AVG%   | MEDIAN%\")\n    lines.append(\"-\" * 82)\n    for key in sorted(groups.keys()):\n        vals = [v for v in groups[key] if v is not None]\n        if not vals:\n            continue\n        vals_sorted = sorted(vals)\n        n = len(vals)\n        hit = sum(1 for v in vals if v > 0) / n * 100.0\n        avg = sum(vals) / n\n        med = vals_sorted[n // 2] if n % 2 else (vals_sorted[n // 2 - 1] + vals_sorted[n // 2]) / 2\n        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}\")\n    return \"\\n\".join(lines)\n\n\ndef write_csv(rows, path: Path):\n    path.parent.mkdir(parents=True, exist_ok=True)\n    if not rows:\n        path.write_text(\"\", encoding=\"utf-8\")\n        return\n    with path.open(\"w\", newline=\"\", encoding=\"utf-8\") as f:\n        writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))\n        writer.writeheader()\n        for r in rows:\n            writer.writerow(dict(r))\n\n\ndef main():\n    parser = argparse.ArgumentParser(description=\"Event-Study aus trading_journal.sqlite\")\n    parser.add_argument(\"--selected-only\", action=\"store_true\", help=\"nur finale Trade-Auswahl\")\n    parser.add_argument(\"--csv\", help=\"CSV Export-Pfad\")\n    parser.add_argument(\"--group\", default=\"base\", choices=sorted(VALID_GROUPS),\n                        help=\"Gruppierung: base, sector, sector_momentum, sentpx, ev_bucket, ivrv_bucket, iv_rank_bucket, data_quality\")\n    args = parser.parse_args()\n\n    if not DB_PATH.exists():\n        raise SystemExit(f\"Kein Journal gefunden: {DB_PATH}\")\n\n    rows = fetch_rows(args.selected_only)\n    if not rows:\n        print(\"Noch keine abgeschlossenen Outcomes. Nach einigen Läufen erneut ausführen.\")\n        return\n    print(summarize(rows, args.group))\n    if args.csv:\n        write_csv(rows, Path(args.csv))\n        print(f\"CSV geschrieben: {args.csv}\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "src/finbert_sentiment.py",
    "content": "\"\"\"\nfinbert_sentiment.py — robuste finBERT Sentiment-Analyse\n\nZiel:\n- FinBERT wirklich lazy laden, sobald es gebraucht wird.\n- Kein falsches Blockieren durch FINBERT_AVAILABLE=False beim Import.\n- Sauberer Fallback auf Keyword-Sentiment, wenn transformers/torch/Modell nicht verfügbar sind.\n- Batch-Ausgabe immer längengleich zur Eingabe.\n\nEnvironment:\n    ENABLE_FINBERT=false   -> FinBERT komplett deaktivieren\n    FINBERT_MODEL_NAME     -> anderes HuggingFace-Modell, Default: ProsusAI/finbert\n    FINBERT_DEVICE         -> -1 CPU, 0 GPU. Default: -1\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nimport os\nfrom typing import Any, Iterable\n\nlogger = logging.getLogger(__name__)\n\nDEFAULT_MODEL = \"ProsusAI/finbert\"\n\n_pipeline = None\n_load_attempted = False\n_last_error: str | None = None\n\n# Rückwärtskompatibilität: Wird nach erfolgreichem Laden True.\n# Wichtig: Der Wert ist beim Import absichtlich False/unknown und darf\n# in anderen Modulen NICHT als Vorbedingung für den ersten Load benutzt werden.\nFINBERT_AVAILABLE = False\n\n\ndef is_finbert_enabled() -> bool:\n    \"\"\"Feature-Flag. Standard: aktiv.\"\"\"\n    raw = os.getenv(\"ENABLE_FINBERT\", \"true\").strip().lower()\n    return raw not in {\"0\", \"false\", \"no\", \"off\", \"disabled\"}\n\n\ndef get_finbert_status() -> dict[str, Any]:\n    \"\"\"Status für Logging/Debug.\"\"\"\n    return {\n        \"enabled\": is_finbert_enabled(),\n        \"loaded\": _pipeline is not None,\n        \"load_attempted\": _load_attempted,\n        \"available\": FINBERT_AVAILABLE,\n        \"model\": os.getenv(\"FINBERT_MODEL_NAME\", DEFAULT_MODEL),\n        \"error\": _last_error,\n    }\n\n\ndef _parse_device() -> int:\n    raw = os.getenv(\"FINBERT_DEVICE\", \"-1\").strip()\n    try:\n        return int(raw)\n    except ValueError:\n        logger.warning(\"Ungültiger FINBERT_DEVICE=%r — nutze CPU (-1)\", raw)\n        return -1\n\n\ndef _load_model():\n    \"\"\"Lädt FinBERT beim ersten echten Aufruf. Danach gecacht.\"\"\"\n    global _pipeline, _load_attempted, _last_error, FINBERT_AVAILABLE\n\n    if _pipeline is not None:\n        return _pipeline\n\n    if not is_finbert_enabled():\n        _last_error = \"FinBERT per ENABLE_FINBERT deaktiviert\"\n        FINBERT_AVAILABLE = False\n        return None\n\n    _load_attempted = True\n    model_name = os.getenv(\"FINBERT_MODEL_NAME\", DEFAULT_MODEL).strip() or DEFAULT_MODEL\n    device = _parse_device()\n\n    try:\n        from transformers import pipeline\n    except Exception as exc:\n        _last_error = f\"transformers import failed: {exc}\"\n        FINBERT_AVAILABLE = False\n        logger.warning(\"finBERT nicht verfügbar — transformers/torch Import fehlgeschlagen: %s\", exc)\n        return None\n\n    try:\n        logger.info(\"Lade finBERT (%s) auf %s...\", model_name, \"CPU\" if device < 0 else f\"device {device}\")\n\n        # Variante 1: Moderne Transformers-Versionen.\n        try:\n            _pipeline = pipeline(\n                task=\"text-classification\",\n                model=model_name,\n                tokenizer=model_name,\n                top_k=None,\n                truncation=True,\n                max_length=512,\n                device=device,\n            )\n        except TypeError:\n            # Variante 2: ältere Transformers-Versionen.\n            _pipeline = pipeline(\n                task=\"text-classification\",\n                model=model_name,\n                tokenizer=model_name,\n                return_all_scores=True,\n                truncation=True,\n                max_length=512,\n                device=device,\n            )\n\n        FINBERT_AVAILABLE = True\n        _last_error = None\n        logger.info(\"finBERT geladen\")\n        return _pipeline\n\n    except Exception as exc:\n        _pipeline = None\n        FINBERT_AVAILABLE = False\n        _last_error = str(exc)\n        logger.warning(\"finBERT konnte nicht geladen werden — Keyword-Sentiment bleibt aktiv: %s\", exc)\n        return None\n\n\ndef _flatten_pipeline_result(result: Any) -> list[dict[str, Any]]:\n    \"\"\"Normalisiert unterschiedliche Transformers-Pipeline-Ausgabeformen.\n\n    Möglich sind u.a.:\n    - [{'label': 'positive', 'score': ...}, ...]\n    - [[{'label': 'positive', 'score': ...}, ...]]\n    - [{'label': 'positive', 'score': ...}] bei top-1\n    \"\"\"\n    if result is None:\n        return []\n\n    if isinstance(result, dict):\n        return [result]\n\n    if isinstance(result, list):\n        if not result:\n            return []\n        if all(isinstance(x, dict) for x in result):\n            return result\n        if len(result) == 1 and isinstance(result[0], list):\n            return _flatten_pipeline_result(result[0])\n\n    return []\n\n\ndef _score_from_label_rows(rows: Iterable[dict[str, Any]]) -> float:\n    \"\"\"Konvertiert FinBERT Label-Scores in [-1, +1].\"\"\"\n    scores: dict[str, float] = {}\n    for row in rows:\n        try:\n            label = str(row.get(\"label\", \"\")).lower().strip()\n            score = float(row.get(\"score\", 0.0))\n        except Exception:\n            continue\n        if label:\n            scores[label] = score\n\n    # ProsusAI/finbert nutzt i.d.R. positive / negative / neutral.\n    pos = scores.get(\"positive\", scores.get(\"pos\", 0.0))\n    neg = scores.get(\"negative\", scores.get(\"neg\", 0.0))\n    neutral = scores.get(\"neutral\", scores.get(\"neu\", 0.0))\n\n    # Falls nur top-1 zurückkommt, wenigstens Richtung abbilden.\n    if not pos and not neg and not neutral and scores:\n        best_label = max(scores, key=scores.get)\n        if \"pos\" in best_label:\n            pos = scores[best_label]\n        elif \"neg\" in best_label:\n            neg = scores[best_label]\n        elif \"neu\" in best_label:\n            neutral = scores[best_label]\n\n    if neutral > 0.60:\n        net = (pos - neg) * 0.30\n    else:\n        net = pos - neg\n\n    return round(max(-1.0, min(1.0, net)), 3)\n\n\ndef get_finbert_sentiment(text: str) -> float:\n    \"\"\"Sentiment für einen Text. 0.0 bei Fehler/Neutral/Fallback.\"\"\"\n    if not text or not str(text).strip():\n        return 0.0\n\n    pipe = _load_model()\n    if pipe is None:\n        return 0.0\n\n    try:\n        raw = pipe(str(text)[:1000])\n        rows = _flatten_pipeline_result(raw)\n        return _score_from_label_rows(rows)\n    except Exception as exc:\n        logger.debug(\"finBERT Inference Fehler: %s\", exc)\n        return 0.0\n\n\ndef get_finbert_sentiment_batch(texts: list[str]) -> list[float]:\n    \"\"\"Sentiment für mehrere Texte. Ergebnis ist immer gleich lang wie texts.\"\"\"\n    if not texts:\n        return []\n\n    pipe = _load_model()\n    if pipe is None:\n        return [0.0] * len(texts)\n\n    # Positionen behalten, damit leere Texte nicht die Reihenfolge verschieben.\n    valid_items: list[tuple[int, str]] = []\n    for idx, text in enumerate(texts):\n        if text and str(text).strip():\n            valid_items.append((idx, str(text)[:1000]))\n\n    scores = [0.0] * len(texts)\n    if not valid_items:\n        return scores\n\n    try:\n        valid_texts = [text for _, text in valid_items]\n        raw_results = pipe(valid_texts)\n\n        # Bei Batch sollte raw_results eine Liste pro Text sein. Falls die\n        # Pipeline bei einem einzelnen Element anders formatiert, normalisieren.\n        if len(valid_texts) == 1:\n            normalized_results = [raw_results]\n        else:\n            normalized_results = raw_results if isinstance(raw_results, list) else []\n\n        for (original_idx, _), raw in zip(valid_items, normalized_results):\n            rows = _flatten_pipeline_result(raw)\n            scores[original_idx] = _score_from_label_rows(rows)\n\n        return scores\n\n    except Exception as exc:\n        logger.debug(\"finBERT Batch Fehler: %s\", exc)\n        return [0.0] * len(texts)\n"
  },
  {
    "path": "src/llm_schema.py",
    "content": "\"\"\"\nllm_schema.py — Pydantic-Schema-Guard für LLM-Ausgaben.\n\nZiel:\n- Ungültiger LLM-Output darf niemals zu einem Trade führen.\n- Signal-Output wird auf ein kleines, deterministisches Format reduziert.\n- Report-JSON wird validiert; bei Fehler wird fail-closed ein No-Trade-Payload erzeugt.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import datetime\nfrom typing import Any, Literal\nimport re\n\nfrom pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator\n\n\nVALID_DIRECTIONS = {\"CALL\", \"PUT\"}\nVALID_STRENGTHS = {\"HIGH\", \"MED\", \"LOW\"}\nVALID_HORIZONS = {\"T1\", \"T2\", \"T3\"}\n\n\nclass TickerSignal(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\", str_strip_whitespace=True)\n\n    ticker: str = Field(pattern=r\"^[A-Z]{1,5}$\")\n    direction: Literal[\"CALL\", \"PUT\"]\n    strength: Literal[\"HIGH\", \"MED\", \"LOW\"]\n    horizon: Literal[\"T1\", \"T2\", \"T3\"]\n    dte_days: int = Field(ge=7, le=120)\n\n    @field_validator(\"ticker\", mode=\"before\")\n    @classmethod\n    def normalize_ticker(cls, value: Any) -> str:\n        return str(value or \"\").strip().upper()\n\n    def to_wire(self) -> str:\n        return f\"{self.ticker}:{self.direction}:{self.strength}:{self.horizon}:{self.dte_days}DTE\"\n\n\nclass SignalEnvelope(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    signals: list[TickerSignal] = Field(default_factory=list, max_length=5)\n\n    def to_wire(self) -> str:\n        if not self.signals:\n            return \"TICKER_SIGNALS:NONE\"\n        return \"TICKER_SIGNALS:\" + \",\".join(s.to_wire() for s in self.signals)\n\n\ndef validate_ticker_signal_line(raw_line: str, max_tickers: int = 5) -> tuple[str | None, list[str]]:\n    \"\"\"\n    Validiert TICKER_SIGNALS:TICKER:CALL:HIGH:T1:21DTE,...\n    Rückgabe: (canonical_line|None, errors)\n    \"\"\"\n    if not raw_line or not str(raw_line).strip():\n        return None, [\"Signalzeile leer\"]\n\n    line = str(raw_line).strip().replace(\"`\", \"\")\n    upper = line.upper().replace(\" \", \"\")\n    if upper in {\"TICKER_SIGNALS:NONE\", \"NONE\"}:\n        return \"TICKER_SIGNALS:NONE\", []\n\n    if upper.startswith(\"TICKER_SIGNALS:\"):\n        payload = line.split(\":\", 1)[1]\n    else:\n        payload = line\n\n    if not payload.strip() or payload.strip().upper() == \"NONE\":\n        return \"TICKER_SIGNALS:NONE\", []\n\n    errors: list[str] = []\n    signals: list[TickerSignal] = []\n    seen: set[str] = set()\n\n    for raw_entry in payload.split(\",\"):\n        entry = raw_entry.strip()\n        if not entry:\n            continue\n        parts = [p.strip().upper() for p in entry.split(\":\")]\n        if len(parts) != 5:\n            errors.append(f\"ungueltiges Signalformat: {entry[:80]}\")\n            continue\n        ticker, direction, strength, horizon, dte_raw = parts\n        if strength == \"MEDIUM\":\n            strength = \"MED\"\n        dte_match = re.fullmatch(r\"(\\d{1,3})DTE\", dte_raw)\n        if not dte_match:\n            errors.append(f\"ungueltige DTE: {entry[:80]}\")\n            continue\n        try:\n            sig = TickerSignal(\n                ticker=ticker,\n                direction=direction,\n                strength=strength,\n                horizon=horizon,\n                dte_days=int(dte_match.group(1)),\n            )\n        except ValidationError as exc:\n            errors.append(f\"Schemafehler {ticker or '?'}: {exc.errors()[0].get('msg', str(exc))}\")\n            continue\n        if sig.ticker in seen:\n            continue\n        seen.add(sig.ticker)\n        signals.append(sig)\n        if len(signals) >= max_tickers:\n            break\n\n    if errors:\n        return None, errors\n    envelope = SignalEnvelope(signals=signals)\n    return envelope.to_wire(), []\n\n\nclass ReportReasonDetail(BaseModel):\n    model_config = ConfigDict(extra=\"ignore\", str_strip_whitespace=True)\n    ticker_wahl: str = \"\"\n    option_wahl: str = \"\"\n    timing: str = \"\"\n    chance_risiko: str = \"\"\n    risiko: str = \"\"\n\n\nclass TickerTableRow(BaseModel):\n    model_config = ConfigDict(extra=\"allow\", str_strip_whitespace=True)\n    ticker: str\n    direction: str | None = None\n    kurs: str | None = None\n    chg: str | None = None\n    ma50: str | None = None\n    trend: str | None = None\n    sector: str | None = None\n    rel_sector: str | None = None\n    sentpx: str | None = None\n    relvol: str | None = None\n    bull: str | None = None\n    score: str | None = None\n    ev_ok: bool | None = None\n    ev_pct: str | None = None\n    gewinner: bool | None = None\n    ausgeschlossen: bool | None = None\n    no_trade_reason: str | None = None\n\n    @field_validator(\"ticker\", mode=\"before\")\n    @classmethod\n    def normalize_ticker(cls, value: Any) -> str:\n        return str(value or \"\").strip().upper()\n\n\nclass ReportPayload(BaseModel):\n    \"\"\"Bewusst tolerantes Report-Schema, aber fail-closed bei Trade-Feldern.\"\"\"\n\n    model_config = ConfigDict(extra=\"allow\", str_strip_whitespace=True)\n\n    datum: str = Field(default_factory=lambda: datetime.now().strftime(\"%d.%m.%Y\"))\n    vix: str | float = \"n/v\"\n    regime: Literal[\"LOW-VOL\", \"TRENDING\", \"HIGH-VOL\"] = \"TRENDING\"\n    regime_farbe: Literal[\"gruen\", \"gelb\", \"rot\"] = \"gelb\"\n    no_trade: bool = False\n    no_trade_grund: str = \"\"\n    vix_warnung: bool = False\n\n    direction: str | None = None\n    ticker: str | None = None\n    strike: str | float | None = None\n    laufzeit: str | None = None\n    delta: str | float | None = None\n    iv: str | float | None = None\n    iv_to_rv: str | float | None = None\n    bid: str | float | None = None\n    ask: str | float | None = None\n    midpoint: str | float | None = None\n    conservative_entry: str | float | None = None\n    entry_price: str | float | None = None\n    exit_slippage_points: str | float | None = None\n    fill_probability: str | float | None = None\n    ev_pct: str | float | None = None\n    ev_dollars: str | float | None = None\n    breakeven_move_pct: str | float | None = None\n    time_stop: str | None = None\n    time_stop_rule: str | None = None\n    time_stop_hours: int | str | None = None\n    time_stop_required_move_pct: str | float | None = None\n    kontrakte: str | int | None = None\n    einsatz: int | str | None = None\n    stop_loss_eur: int | float | str | None = None\n    unusual: bool | None = None\n\n    begruendung_detail: ReportReasonDetail = Field(default_factory=ReportReasonDetail)\n    markt: str = \"\"\n    strategie: str = \"\"\n    ausgeschlossen: str = \"\"\n    ticker_tabelle: list[TickerTableRow] = Field(default_factory=list)\n\n    @field_validator(\"ticker\", mode=\"before\")\n    @classmethod\n    def normalize_optional_ticker(cls, value: Any) -> str | None:\n        if value in (None, \"\"):\n            return None\n        return str(value).strip().upper()\n\n    @field_validator(\"direction\", mode=\"before\")\n    @classmethod\n    def normalize_direction(cls, value: Any) -> str | None:\n        if value in (None, \"\"):\n            return None\n        return str(value).strip().upper()\n\n    @model_validator(mode=\"after\")\n    def validate_trade_payload(self) -> \"ReportPayload\":\n        if self.no_trade:\n            if not self.no_trade_grund:\n                self.no_trade_grund = \"Kein valider Trade nach Schema Guard\"\n            return self\n\n        required = {\n            \"ticker\": self.ticker,\n            \"direction\": self.direction,\n            \"strike\": self.strike,\n            \"laufzeit\": self.laufzeit,\n            \"delta\": self.delta,\n            \"bid\": self.bid,\n            \"ask\": self.ask,\n            \"midpoint\": self.midpoint,\n            \"conservative_entry\": self.conservative_entry,\n            \"entry_price\": self.entry_price,\n            \"ev_pct\": self.ev_pct,\n            \"ev_dollars\": self.ev_dollars,\n            \"ticker_tabelle\": self.ticker_tabelle,\n        }\n        missing = [k for k, v in required.items() if v in (None, \"\", [])]\n        if missing:\n            raise ValueError(\"Trade-Payload unvollstaendig: \" + \", \".join(missing))\n        if self.direction not in VALID_DIRECTIONS:\n            raise ValueError(f\"ungueltige direction: {self.direction}\")\n        return self\n\n\ndef validate_report_payload(data: dict[str, Any]) -> tuple[dict[str, Any] | None, list[str]]:\n    try:\n        payload = ReportPayload.model_validate(data)\n        return payload.model_dump(mode=\"python\"), []\n    except ValidationError as exc:\n        return None, [f\"{'.'.join(str(x) for x in err.get('loc', []))}: {err.get('msg')}\" for err in exc.errors()]\n    except ValueError as exc:\n        return None, [str(exc)]\n\n\ndef build_cancelled_report(reason: str, raw: str | None = None) -> dict[str, Any]:\n    detail = reason[:450]\n    if raw:\n        detail += \" | Raw: \" + raw[:250].replace(\"\\n\", \" \")\n    return {\n        \"datum\": datetime.now().strftime(\"%d.%m.%Y\"),\n        \"vix\": \"n/v\",\n        \"regime\": \"TRENDING\",\n        \"regime_farbe\": \"gelb\",\n        \"no_trade\": True,\n        \"no_trade_grund\": \"CANCELLED_SCHEMA_GUARD \" + detail,\n        \"vix_warnung\": False,\n        \"ticker_tabelle\": [],\n        \"begruendung_detail\": {\n            \"ticker_wahl\": \"LLM-Ausgabe war nicht schema-valide.\",\n            \"option_wahl\": \"Kein Trade.\",\n            \"timing\": \"Kein Trade.\",\n            \"chance_risiko\": \"Kapitalschutz.\",\n            \"risiko\": detail,\n        },\n        \"markt\": \"Kein Trade, weil der Report-Output nicht schema-valide war.\",\n        \"strategie\": \"Fail-closed.\",\n        \"ausgeschlossen\": detail,\n    }\n"
  },
  {
    "path": "src/main.py",
    "content": "\"\"\"\nmain.py — Daily Options Report Pipeline (mit simple_journal + neuen Hard Gates)\nv13: Integrierte TradingRules (evaluate_trade + calculate_position_size)\n\"\"\"\n\nimport argparse\nimport logging\nimport sys\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom datetime import datetime, timedelta\n\nfrom config_loader import load_config, validate_config\nfrom news_analyzer import (\n    fetch_all_feeds, build_earnings_map, cluster_articles,\n    format_clusters_for_claude, run_claude, get_market_context,\n)\nfrom market_data import (\n    process_ticker, get_vix, get_earnings, build_summary,\n)\nfrom report_generator import call_claude, build_html, send_email\nfrom rules import parse_ticker_signals, RULES\nfrom simple_journal import journal\n\ndef setup_logging(verbose: bool) -> None:\n    level = logging.DEBUG if verbose else logging.INFO\n    fmt = \"%(asctime)s %(levelname)-8s %(name)s — %(message)s\"\n    datefmt = \"%H:%M:%S\"\n    logging.basicConfig(level=level, format=fmt, datefmt=datefmt)\n    for noisy in (\"urllib3\", \"requests\", \"httpcore\", \"httpx\", \"huggingface_hub\",\n                  \"transformers\", \"torch\", \"filelock\"):\n        logging.getLogger(noisy).setLevel(logging.WARNING)\n\nlogger = logging.getLogger(__name__)\n\n\n# ====================== HTML HELPER ======================\ndef _no_trade_html(today: str, vix=None, market_status: str = \"\",\n                   clusters: list = None, reason: str = \"Kein valides Signal\") -> str:\n    vix_str = str(vix) if vix and vix != \"n/v\" else \"n/v\"\n    status_str = market_status or \"unbekannt\"\n    clusters = clusters or []\n    cluster_rows = \"\"\n    for c in clusters[:5]:\n        conf = c.get(\"confidence_score\", 0)\n        tick = c.get(\"ticker\", \"?\")\n        head = c.get(\"headline_repr\", \"\")[:60]\n        sent = c.get(\"sentiment_score\", 0)\n        src = c.get(\"sentiment_source\", \"keyword\")\n        sent_icon = \"📈\" if sent > 0.1 else (\"📉\" if sent < -0.1 else \"➖\")\n        src_badge = \"🤖\" if src == \"finbert\" else \"🔤\"\n        cluster_rows += f'<tr><td style=\"padding:6px 8px;font-weight:600;\">{tick}</td>' \\\n                        f'<td style=\"padding:6px 8px;text-align:center;\">{conf:.2f}</td>' \\\n                        f'<td style=\"padding:6px 8px;text-align:center;\">{sent_icon}{src_badge}</td>' \\\n                        f'<td style=\"padding:6px 8px;color:#86868b;\">{head}</td></tr>'\n    cluster_section = f'<div style=\"margin-top:20px;\">... {cluster_rows} ...</div>' if cluster_rows else \"\"\n    return f'''<html><head><meta charset=\"UTF-8\"></head><body style=\"background:#f5f5f7;\">\n    <div style=\"max-width:520px;margin:0 auto;padding:32px 16px;background:white;border-radius:18px;\">\n        <h2>Daily Options Report — {today}</h2>\n        <h3 style=\"color:#ff3b30;\">Heute kein Trade</h3>\n        <p>VIX: {vix_str} | Grund: {reason}</p>\n        {cluster_section}\n    </div></body></html>'''\n\n\ndef _error_html(error: str, today: str) -> str:\n    return f'<html><body><h2>Fehler am {today}</h2><p>{error}</p></body></html>'\n\n\ndef _send_or_save(html: str, subject: str, cfg: dict, dry_run: bool) -> None:\n    if dry_run:\n        with open(\"report_preview.html\", \"w\", encoding=\"utf-8\") as f:\n            f.write(html)\n        logger.info(\"Dry-run: report_preview.html gespeichert\")\n    else:\n        send_email(subject, html, cfg)\n\n\ndef _enrich_market_data_with_cluster_context(market_data: list, clusters: list) -> None:\n    for d in market_data:\n        ticker = d.get(\"ticker\", \"\")\n        matches = [c for c in (clusters or []) if c.get(\"ticker\") == ticker]\n        if matches:\n            best = max(matches, key=lambda c: c.get(\"confidence_score\", 0))\n            d[\"news_confidence_score\"] = best.get(\"confidence_score\")\n            d[\"news_sentiment_score\"] = best.get(\"sentiment_score\")\n            d[\"news_sentiment_source\"] = best.get(\"sentiment_source\", \"keyword\")\n\n\n# ====================== MAIN ======================\ndef main() -> int:\n    parser = argparse.ArgumentParser(description=\"Daily Options Report\")\n    parser.add_argument(\"--dry-run\", action=\"store_true\")\n    parser.add_argument(\"--verbose\", action=\"store_true\")\n    args = parser.parse_args()\n\n    setup_logging(args.verbose)\n\n    cfg = load_config()\n    if not validate_config(cfg):\n        logger.error(\"Konfiguration unvollständig\")\n        return 1\n\n    today = datetime.now().strftime(\"%d.%m.%Y\")\n    t_start = time.monotonic()\n\n    journal.start_run()\n    logger.info(\"=\" * 70)\n    logger.info(\"Daily Options Report — %s (Run ID: %s)\", today, journal.get_run_id())\n    logger.info(\"=\" * 70)\n\n    try:\n        journal.update_outcomes(cfg)\n    except Exception as e:\n        logger.warning(\"Outcome-Update übersprungen: %s\", e)\n\n    # STEP 1: News\n    logger.info(\"[1/3] News-Analyse...\")\n    t1 = time.monotonic()\n    articles = fetch_all_feeds()\n    earnings_map = build_earnings_map(cfg.get(\"finnhub_key\", \"\"))\n    clusters = cluster_articles(articles, earnings_map)\n\n    logger.info(\"Nach Ticker-Filterung: %d Cluster übrig (von %d Artikeln)\", len(clusters), len(articles))\n    if clusters:\n        top = sorted(clusters, key=lambda c: c.get(\"confidence_score\", 0), reverse=True)[:5]\n        for c in top:\n            logger.info(\" → %s (conf=%.1f, %s): %s\",\n                        c[\"ticker\"], c[\"confidence_score\"],\n                        c[\"event_type\"], c[\"headline_repr\"][:80])\n\n    cluster_text = format_clusters_for_claude(clusters)\n    market_time, market_status = get_market_context()\n\n    ticker_signals = run_claude(\n        cluster_text, market_time, market_status, cfg.get(\"anthropic_api_key\", \"\")\n    )\n    vix_value = get_vix()\n    logger.info(\"Claude Signal: %s | VIX: %s\", ticker_signals[:100], vix_value)\n\n    if ticker_signals in (\"TICKER_SIGNALS:NONE\", \"\", None):\n        data = {\"no_trade\": True, \"no_trade_grund\": \"Kein valides Signal\", \"vix\": vix_value}\n        journal.log_decision(data)\n        html = _no_trade_html(today, vix_value, market_status, clusters[:3], \"Kein valides Signal\")\n        _send_or_save(html, f\"⏸️ Daily Options Report – Kein Trade – {today}\", cfg, args.dry_run)\n        return 0\n\n    # STEP 2: Marktdaten\n    logger.info(\"[2/3] Marktdaten...\")\n    t2 = time.monotonic()\n    parsed_signals = parse_ticker_signals(ticker_signals)\n    if not parsed_signals:\n        logger.error(\"Keine gültigen Ticker geparst\")\n        return 1\n\n    ticker_directions = {s[\"ticker\"]: s[\"direction\"] for s in parsed_signals}\n    tickers = list(ticker_directions.keys())\n    dte_map = {s[\"ticker\"]: s[\"dte_days\"] for s in parsed_signals}\n\n    # Earnings\n    with ThreadPoolExecutor(max_workers=2) as ex:\n        earnings_fut = ex.submit(get_earnings,\n                                 datetime.now().strftime(\"%Y-%m-%d\"),\n                                 (datetime.now() + timedelta(days=10)).strftime(\"%Y-%m-%d\"),\n                                 cfg.get(\"finnhub_key\", \"\"))\n        earnings_list = earnings_fut.result(timeout=15)\n\n    # Ticker verarbeiten\n    with ThreadPoolExecutor(max_workers=RULES.max_tickers) as ex:\n        futures = {\n            ex.submit(process_ticker, t, ticker_directions[t], earnings_list, cfg, dte_map.get(t, 21)): t\n            for t in tickers\n        }\n        results = []\n        for f in as_completed(futures, timeout=45):\n            try:\n                results.append(f.result())\n            except Exception as e:\n                logger.error(\"Ticker %s fehlgeschlagen: %s\", futures[f], e)\n\n    market_data = [r for r in results if r]\n    _enrich_market_data_with_cluster_context(market_data, clusters)\n\n    # === NEU: Hard-Gate Prüfung mit evaluate_trade + Position Sizing ===\n    logger.info(\"[2.5/3] Hard-Gate Prüfung + Position Sizing...\")\n    executed = []\n    skipped = []\n\n    for d in market_data:\n        ticker = d[\"ticker\"]\n        news_alpha = d.get(\"news_confidence_score\", 50)   # aus Cluster-Kontext\n        ticker_info = {\"market_cap\": 999_999_999, \"price\": d[\"price\"], \"spread_pct\": 5.0}  # Platzhalter – später erweitern\n\n        passed, reason = RULES.evaluate_trade(\n            ticker_info=ticker_info,\n            market_metrics=d,\n            news_alpha=news_alpha\n        )\n\n        if passed and d.get(\"score\", 0) >= RULES.min_score:\n            total_conviction = round(\n                (news_alpha * 0.55) + (d.get(\"score\", 50) * 0.45), 2\n            )\n            pos_size = RULES.calculate_position_size(total_conviction, 250_000)\n\n            logger.info(f\"✅ ALARM: {ticker} HIGH CONVICTION | Conviction={total_conviction} | Size=${pos_size:,.0f}\")\n\n            executed.append({\n                \"ticker\": ticker,\n                \"direction\": d.get(\"news_direction\"),\n                \"conviction\": total_conviction,\n                \"position_size\": pos_size,\n                \"reason\": \"All gates passed\"\n            })\n        else:\n            skipped.append({\"ticker\": ticker, \"reason\": reason})\n\n    journal.log_signals(parsed_signals, market_data, clusters)\n\n    # STEP 3: Report\n    logger.info(\"[3/3] Report generieren...\")\n    try:\n        market_summary = build_summary(market_data, vix_value, ticker_directions, earnings_list, [], [])\n        data = call_claude(market_summary, cfg.get(\"anthropic_api_key\", \"\"), vix_direct=vix_value)\n        journal.log_decision(data)\n\n        html_report = build_html(data, today)\n        no_trade = data.get(\"no_trade\", False) or len(executed) == 0\n        subject = f\"⏸️ No Trade – {today}\" if no_trade else f\"📊 Trade-Alarm – {today}\"\n        _send_or_save(html_report, subject, cfg, args.dry_run)\n    except Exception as e:\n        logger.error(\"Report-Fehler: %s\", e)\n        data = {\"no_trade\": True, \"no_trade_grund\": f\"Report Fehler: {e}\"}\n        journal.log_decision(data)\n        _send_or_save(_error_html(str(e), today), f\"⚠️ Report Fehler – {today}\", cfg, args.dry_run)\n\n    logger.info(\"✅ Gesamtlauf beendet in %.1fs | Executed: %d | Skipped: %d\",\n                time.monotonic() - t_start, len(executed), len(skipped))\n    return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n"
  },
  {
    "path": "src/market_calendar.py",
    "content": "\"\"\"\nmarket_calendar.py — US-Market-Time ohne harte UTC-Annahmen.\n\nPrimär: exchange_calendars, wenn installiert.\nFallback: zoneinfo America/New_York mit regulären Handelszeiten.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import datetime, time, timezone\nfrom zoneinfo import ZoneInfo\n\nNY = ZoneInfo(\"America/New_York\")\nUTC = timezone.utc\n\n\ndef now_et() -> datetime:\n    return datetime.now(UTC).astimezone(NY)\n\n\ndef _status_from_et(dt: datetime) -> str:\n    if dt.weekday() >= 5:\n        return \"CLOSED-WEEKEND\"\n    t = dt.time()\n    if time(9, 30) <= t < time(16, 0):\n        return \"OPEN\"\n    if time(4, 0) <= t < time(9, 30):\n        return \"PRE-MARKET\"\n    if time(16, 0) <= t < time(20, 0):\n        return \"AFTER-HOURS\"\n    return \"CLOSED\"\n\n\ndef market_status(dt: datetime | None = None) -> str:\n    \"\"\"NYSE-Status mit optionalem exchange_calendars Holiday-Check.\"\"\"\n    dt = dt or now_et()\n    if dt.tzinfo is None:\n        dt = dt.replace(tzinfo=NY)\n    dt_et = dt.astimezone(NY)\n\n    # Optional: Feiertage / verkürzte Sessions über exchange_calendars.\n    try:\n        import exchange_calendars as xcals\n        cal = xcals.get_calendar(\"XNYS\")\n        minute_utc = dt_et.astimezone(UTC)\n        if cal.is_trading_minute(minute_utc):\n            return \"OPEN\"\n        # außerhalb regulärer Minute trotzdem PRE/AFTER anhand ET-Zeit ausgeben\n        base = _status_from_et(dt_et)\n        if base == \"OPEN\":\n            return \"CLOSED-HOLIDAY\"\n        return base\n    except Exception:\n        return _status_from_et(dt_et)\n\n\ndef market_context(dt: datetime | None = None) -> tuple[str, str]:\n    dt_et = (dt or now_et()).astimezone(NY)\n    days = [\"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\", \"Sunday\"]\n    return f\"{days[dt_et.weekday()]} {dt_et:%H:%M} ET\", market_status(dt_et)\n\n\ndef market_elapsed_fraction(dt: datetime | None = None) -> float | None:\n    \"\"\"\n    Anteil der regulären 6.5h Session, nur wenn Markt offen.\n    Für intraday Volumen-Hochrechnung.\n    \"\"\"\n    dt_et = (dt or now_et()).astimezone(NY)\n    if market_status(dt_et) != \"OPEN\":\n        return None\n    start = dt_et.replace(hour=9, minute=30, second=0, microsecond=0)\n    end = dt_et.replace(hour=16, minute=0, second=0, microsecond=0)\n    total = (end - start).total_seconds()\n    elapsed = (dt_et - start).total_seconds()\n    return max(0.05, min(1.0, elapsed / total))\n"
  },
  {
    "path": "src/market_data.py",
    "content": "\"\"\"\nmarket_data.py — Marktdaten + Score-Berechnung (Step 2)\n\nv12 Final Production Version\n- Robuste Gap + RVOL Validierung mit korrekter Trend-Direction-Confirmation\n- Einheitliche RVOL-Berechnung\n- Bonus nur bei echter High-Conviction (smoother Penalty)\n- Kein Double-Counting mit old unusual logic\n- Separate raw_score / final_score für Backtesting\n\"\"\"\n\nimport logging\nimport math\nimport statistics\nfrom concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError\nfrom datetime import datetime, timedelta, timezone\n\nimport requests\nfrom requests.exceptions import RequestException, Timeout\n\nfrom rules import (\n    RULES, check_liquidity, conservative_entry_price, estimate_fill_probability,\n    exit_slippage_points, check_data_quality, check_earnings_iv_gate, merge_reasons,\n    build_time_stop_plan,\n)\nfrom market_calendar import market_elapsed_fraction\nfrom data_validator import (\n    validate_ohlcv_history, detect_unexplained_price_spike,\n    data_flags_to_text, realized_volatility,\n)\nfrom sector_map import evaluate_sector_filter\n\nlogger = logging.getLogger(__name__)\n\nETF_TICKERS = {\n    'TLT','USO','GLD','SLV','GDX','SPY','QQQ','IWM','DIA',\n    'XLE','XLF','XLK','XLV','XLI','XLU','XLP','XLY','XLB','XLRE','XLC','SMH','SOXX',\n}\n\nUSER_AGENTS = [\n    \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36\",\n    \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15\",\n    \"python-requests/2.31.0\",\n]\n\nMARKET_OPEN_UTC_H            = 13.5\nMARKET_CLOSE_UTC_H           = 20.0\nVOLUME_EXTRAPOLATION_DELAY_H = 0.5\n\n\ndef robust_get(url, params=None, headers=None, timeouts=(6, 8, 10)):\n    for i, timeout in enumerate(timeouts):\n        try:\n            h = {\"User-Agent\": USER_AGENTS[i % len(USER_AGENTS)]}\n            if headers:\n                h.update(headers)\n            r = requests.get(url, params=params, headers=h, timeout=timeout)\n            if r.status_code == 200:\n                return r\n        except (RequestException, Timeout):\n            pass\n    return None\n\n\n# ══════════════════════════════════════════════════════════\n# KURS-QUELLEN (unverändert)\n# ══════════════════════════════════════════════════════════\n\ndef get_quote_tradier(symbol, tradier_token, sandbox=False):\n    if not tradier_token:\n        return None\n    try:\n        base = \"https://sandbox.tradier.com\" if sandbox else \"https://api.tradier.com\"\n        hdrs = {\"Authorization\": \"Bearer \" + tradier_token, \"Accept\": \"application/json\"}\n        r = robust_get(base + \"/v1/markets/quotes\",\n                       params={\"symbols\": symbol, \"greeks\": \"false\"},\n                       headers=hdrs)\n        if not r:\n            return None\n        q = (r.json().get(\"quotes\") or {}).get(\"quote\")\n        if isinstance(q, list):\n            q = q[0] if q else None\n        if not q:\n            return None\n        price = q.get(\"last\") or q.get(\"close\") or q.get(\"bid\") or q.get(\"ask\")\n        if not price or float(price) <= 0:\n            return None\n        prev = q.get(\"prevclose\") or q.get(\"open\") or price\n        chg_pct = ((float(price) - float(prev)) / float(prev) * 100.0) if prev else 0.0\n        high = q.get(\"high\") or price\n        low = q.get(\"low\") or price\n        return (round(float(price), 2), round(float(chg_pct), 2),\n                round(float(high), 2), round(float(low), 2), \"tradier_sandbox\" if sandbox else \"tradier_production\")\n    except (ValueError, KeyError, RequestException) as e:\n        logger.debug(\"Tradier quote %s: %s\", symbol, e)\n        return None\n\n\ndef get_quote_alphavantage(symbol, api_key):\n    try:\n        if not api_key:\n            return None\n        r = robust_get(\"https://www.alphavantage.co/query\",\n                       params={\"function\": \"GLOBAL_QUOTE\", \"symbol\": symbol,\n                               \"apikey\": api_key})\n        if not r:\n            return None\n        q = r.json().get(\"Global Quote\", {})\n        if not q:\n            return None\n        price_str = q.get(\"05. price\", \"0\")\n        price     = float(price_str) if price_str else 0.0\n        if price <= 0:\n            return None\n        chg_str = q.get(\"10. change percent\", \"0%\").replace(\"%\", \"\")\n        return (round(price, 2),\n                round(float(chg_str) if chg_str else 0.0, 2),\n                round(float(q.get(\"03. high\") or price), 2),\n                round(float(q.get(\"04. low\")  or price), 2),\n                \"alphavantage\")\n    except (ValueError, KeyError, RequestException) as e:\n        logger.debug(\"AlphaVantage %s: %s\", symbol, e)\n        return None\n\n\ndef get_history_alphavantage(symbol, api_key):\n    try:\n        if not api_key:\n            return [], []\n        r = robust_get(\"https://www.alphavantage.co/query\",\n                       params={\"function\": \"TIME_SERIES_DAILY\", \"symbol\": symbol,\n                               \"outputsize\": \"compact\", \"apikey\": api_key})\n        if not r:\n            return [], []\n        ts = r.json().get(\"Time Series (Daily)\", {})\n        if not ts:\n            return [], []\n        sorted_days = sorted(ts.items())\n        return ([float(v[\"4. close\"])       for _, v in sorted_days if v.get(\"4. close\")],\n                [int(float(v[\"5. volume\"])) for _, v in sorted_days if v.get(\"5. volume\")])\n    except (ValueError, KeyError, RequestException) as e:\n        logger.debug(\"AlphaVantage history %s: %s\", symbol, e)\n        return [], []\n\n\ndef get_quote_yahoo_v8(symbol):\n    try:\n        r = None\n        for host in [\"query1\", \"query2\"]:\n            r = robust_get(\n                \"https://\" + host + \".finance.yahoo.com/v8/finance/chart/\" + symbol,\n                params={\"interval\": \"1d\", \"range\": \"5d\"})\n            if r:\n                break\n        if not r:\n            return None\n        meta  = r.json()[\"chart\"][\"result\"][0].get(\"meta\", {})\n        price = meta.get(\"regularMarketPrice\") or meta.get(\"previousClose\", 0)\n        prev  = meta.get(\"chartPreviousClose\") or meta.get(\"previousClose\", price)\n        if not price or price <= 0:\n            return None\n        chg_pct = round((price - prev) / prev * 100, 2) if prev and prev != 0 else 0.0\n        return (round(price, 2), chg_pct,\n                round(meta.get(\"regularMarketDayHigh\", price), 2),\n                round(meta.get(\"regularMarketDayLow\",  price), 2),\n                \"yahoo_v8\")\n    except (ValueError, KeyError, IndexError, RequestException) as e:\n        logger.debug(\"Yahoo v8 %s: %s\", symbol, e)\n        return None\n\n\ndef get_quote_finnhub(symbol, api_key):\n    if not api_key:\n        return None\n    try:\n        r = robust_get(\"https://finnhub.io/api/v1/quote\",\n                       params={\"symbol\": symbol, \"token\": api_key})\n        if not r:\n            return None\n        j     = r.json()\n        price = j.get(\"c\", 0) or 0\n        if price <= 0:\n            return None\n        return (round(price, 2), round(j.get(\"dp\", 0) or 0, 2),\n                round(j.get(\"h\",  0) or 0, 2), round(j.get(\"l\",  0) or 0, 2),\n                \"finnhub\")\n    except (ValueError, KeyError, RequestException) as e:\n        logger.debug(\"Finnhub %s: %s\", symbol, e)\n        return None\n\n\ndef get_quote(symbol, cfg):\n    sources = [\n        (get_quote_tradier, (symbol, cfg.get(\"tradier_token\", \"\"), cfg.get(\"tradier_sandbox\", False))),\n        (get_quote_alphavantage, (symbol, cfg.get(\"alpha_vantage_key\",\"\"))),\n        (get_quote_yahoo_v8,     (symbol,)),\n        (get_quote_finnhub,      (symbol, cfg.get(\"finnhub_key\",\"\"))),\n    ]\n    for fn, args in sources:\n        result = fn(*args)\n        if result:\n            return result\n    logger.warning(\"Alle Kurs-Quellen für %s fehlgeschlagen\", symbol)\n    return (0.0, 0.0, 0.0, 0.0, \"failed\")\n\n\ndef get_history(symbol, cfg):\n    closes, volumes = get_history_alphavantage(symbol, cfg.get(\"alpha_vantage_key\",\"\"))\n\n    if len(closes) < 20:\n        for host in [\"query1\", \"query2\"]:\n            try:\n                r = robust_get(\n                    \"https://\" + host + \".finance.yahoo.com/v8/finance/chart/\" + symbol,\n                    params={\"interval\": \"1d\", \"range\": \"90d\"})\n                if not r:\n                    continue\n                quotes  = r.json()[\"chart\"][\"result\"][0][\"indicators\"][\"quote\"][0]\n                closes  = [c for c in quotes.get(\"close\",  []) if c is not None]\n                volumes = [v for v in quotes.get(\"volume\", []) if v is not None]\n                if closes:\n                    break\n            except (ValueError, KeyError, IndexError, RequestException) as e:\n                logger.debug(\"Yahoo history %s %s: %s\", host, symbol, e)\n                continue\n\n    if not closes:\n        return [], [], \"failed\"\n\n    source = \"alphavantage\" if cfg.get(\"alpha_vantage_key\") else \"yahoo\"\n    return closes, volumes, source\n\n\ndef get_sentiment(symbol, change_pct, finnhub_key):\n    if finnhub_key:\n        try:\n            r = robust_get(\"https://finnhub.io/api/v1/news-sentiment\",\n                           params={\"symbol\": symbol, \"token\": finnhub_key})\n            if r:\n                j       = r.json()\n                sent    = j.get(\"sentiment\", {}) or {}\n                bullish = float(sent.get(\"bullishPercent\", 0) or 0)\n                bearish = float(sent.get(\"bearishPercent\", 0) or 0)\n                buzz    = float((j.get(\"buzz\", {}) or {}).get(\"buzz\", 0) or 0)\n                if bullish > 0 or bearish > 0:\n                    return bullish, bearish, buzz, False\n        except (ValueError, KeyError, RequestException) as e:\n            logger.debug(\"Finnhub Sentiment %s: %s\", symbol, e)\n\n    bullish = round(max(0.0, min(100.0,\n        55 + change_pct * 3 if change_pct > 0 else 45 + change_pct * 3)), 1)\n    return bullish, round(100.0 - bullish, 1), round(abs(change_pct), 2), True\n\n\ndef classify_sentiment_price_reaction(direction: str, bullish: float, bearish: float,\n                                      change_pct: float, sent_fallback: bool) -> dict:\n    direction = (direction or \"\").upper()\n    b = float(bullish or 0.0)\n    br = float(bearish or 0.0)\n    gap = b - br\n    label = \"neutral\"\n    score_adjustment = 0.0\n    confidence = \"low\" if sent_fallback else \"medium\"\n\n    if br - b >= 15 and change_pct >= -0.20:\n        label = \"bearish_news_absorbed\"\n        confidence = \"medium\" if not sent_fallback else \"low\"\n        score_adjustment = 5.0 if direction == \"CALL\" else -5.0\n    elif b - br >= 15 and change_pct <= 0.10:\n        label = \"bullish_news_not_confirmed\"\n        confidence = \"medium\" if not sent_fallback else \"low\"\n        score_adjustment = 5.0 if direction == \"PUT\" else -6.0\n    elif gap >= 20 and change_pct > 0.40:\n        label = \"bullish_confirmed\"\n        score_adjustment = 3.0 if direction == \"CALL\" else -3.0\n    elif gap <= -20 and change_pct < -0.40:\n        label = \"bearish_confirmed\"\n        score_adjustment = 3.0 if direction == \"PUT\" else -3.0\n\n    if sent_fallback:\n        score_adjustment *= 0.5\n\n    return {\n        \"sentiment_price_label\": label,\n        \"sentiment_price_score_adjustment\": round(score_adjustment, 2),\n        \"sentiment_price_confidence\": confidence,\n        \"sentiment_gap\": round(gap, 2),\n    }\n\n\ndef get_vix():\n    for host in [\"query1\", \"query2\"]:\n        try:\n            r = robust_get(\n                \"https://\" + host + \".finance.yahoo.com/v8/finance/chart/%5EVIX\",\n                params={\"interval\": \"1d\", \"range\": \"5d\"})\n            if not r:\n                continue\n            closes = [c for c in\n                      r.json()[\"chart\"][\"result\"][0][\"indicators\"][\"quote\"][0][\"close\"]\n                      if c is not None]\n            if closes:\n                return round(closes[-1], 2)\n        except (ValueError, KeyError, IndexError, RequestException) as e:\n            logger.debug(\"VIX %s: %s\", host, e)\n    logger.warning(\"VIX nicht verfügbar\")\n    return \"n/v\"\n\n\ndef get_earnings(start, end, finnhub_key):\n    if not finnhub_key:\n        return []\n    try:\n        r = robust_get(\"https://finnhub.io/api/v1/calendar/earnings\",\n                       params={\"from\": start, \"to\": end, \"token\": finnhub_key})\n        if not r:\n            return []\n        return [e.get(\"symbol\",\"\") for e in r.json().get(\"earningsCalendar\",[])\n                if e.get(\"symbol\")]\n    except (ValueError, KeyError, RequestException) as e:\n        logger.warning(\"Earnings-Kalender Fehler: %s\", e)\n        return []\n\n\n# OPTIONS-EV / KOSTENMODELL (unverändert)\ndef calc_realized_volatility(closes: list, lookback: int = 20) -> float | None:\n    if not closes or len(closes) < lookback + 1:\n        return None\n    rets = []\n    recent = closes[-(lookback + 1):]\n    for prev, cur in zip(recent[:-1], recent[1:]):\n        if prev and prev > 0 and cur and cur > 0:\n            rets.append(math.log(cur / prev))\n    if len(rets) < 10:\n        return None\n    daily = statistics.stdev(rets)\n    return max(0.05, min(2.0, daily * math.sqrt(252)))\n\n\ndef estimate_expected_move_pct(price: float, change_pct: float, rel_vol,\n                               score: float, closes: list, target_dte: int) -> float:\n    if price <= 0:\n        return 0.0\n    rv = calc_realized_volatility(closes) or 0.35\n    days = max(1, min(target_dte, RULES.ev_hold_days))\n    vol_move_pct = rv * math.sqrt(days / 252.0) * 100.0\n    intraday_impulse = abs(change_pct) * 1.15\n    rel = 1.0\n    try:\n        rel = float(rel_vol) if rel_vol not in (None, \"n/v\") else 1.0\n    except (ValueError, TypeError):\n        rel = 1.0\n    rel_mult = max(0.85, min(1.35, 0.85 + 0.20 * rel))\n    score_mult = max(0.65, min(1.25, score / 70.0))\n    expected = max(intraday_impulse, vol_move_pct * 0.65) * rel_mult * score_mult\n    return round(max(0.3, min(12.0, expected)), 2)\n\n\ndef _safe_float(value, default=0.0) -> float:\n    try:\n        if value is None:\n            return default\n        return float(value)\n    except (TypeError, ValueError):\n        return default\n\n\ndef evaluate_option_ev(option: dict, direction: str, underlying_price: float,\n                       expected_move_pct: float, realized_vol_20d: float | None = None,\n                       earnings_soon: bool = False, news_driven: bool = False,\n                       iv_percentile: float | None = None) -> dict | None:\n    g = option.get(\"greeks\") or {}\n    bid = _safe_float(option.get(\"bid\"))\n    ask = _safe_float(option.get(\"ask\"))\n    if bid <= 0 or ask <= 0 or ask < bid:\n        return None\n    mid = round((bid + ask) / 2, 2)\n    spread = ask - bid\n    spread_pct = round(spread / ask * 100.0, 2) if ask > 0 else None\n    strike = _safe_float(option.get(\"strike\"))\n    delta = _safe_float(g.get(\"delta\"))\n    gamma = _safe_float(g.get(\"gamma\"))\n    theta = _safe_float(g.get(\"theta\"))\n    vega = _safe_float(g.get(\"vega\"))\n    iv_raw = g.get(\"mid_iv\") or g.get(\"ask_iv\") or g.get(\"bid_iv\")\n    iv = _safe_float(iv_raw, None)\n    oi = int(_safe_float(option.get(\"open_interest\"), 0))\n    volume = int(_safe_float(option.get(\"volume\"), 0))\n\n    opt_data = {\n        \"bid\": bid, \"ask\": ask, \"midpoint\": mid, \"spread_pct\": spread_pct,\n        \"open_interest\": oi, \"volume\": volume,\n    }\n    entry = conservative_entry_price(opt_data)\n    if not entry:\n        return None\n\n    move_abs = underlying_price * expected_move_pct / 100.0\n    delta_gain = abs(delta) * move_abs\n    gamma_gain = 0.5 * abs(gamma) * (move_abs ** 2) * 0.6   # gedämpft\n    theta_cost = abs(theta) * RULES.ev_hold_days if theta else 0.0\n\n    iv_drop_decimal = 0.0\n    iv_crush_factor_used = 0.0\n    if iv and iv > 0:\n        if earnings_soon:\n            crush_pct = RULES.iv_crush_after_earnings_pct\n        elif news_driven:\n            crush_pct = min(0.35, RULES.iv_crush_after_news_pct)\n        else:\n            crush_pct = RULES.iv_crush_baseline_pct\n\n        high_iv_flag = False\n        if realized_vol_20d and realized_vol_20d > 0 and iv / realized_vol_20d >= RULES.mature_iv_to_rv_hard_block:\n            high_iv_flag = True\n        if iv_percentile is not None and iv_percentile >= 90.0:\n            high_iv_flag = True\n        if high_iv_flag:\n            crush_pct += RULES.iv_crush_high_iv_bonus_pct\n\n        crush_pct = max(0.0, min(0.60, crush_pct))\n        iv_drop_decimal = iv * crush_pct\n        iv_crush_factor_used = crush_pct\n\n    vega_cost = abs(vega) * iv_drop_decimal\n\n    entry_slippage = max(0.0, entry - mid)\n    exit_slip = exit_slippage_points(opt_data)\n\n    iv_to_rv = None\n    iv_rv_penalty = 0.0\n    if iv and realized_vol_20d and realized_vol_20d > 0:\n        iv_to_rv = round(iv / realized_vol_20d, 3)\n        if iv_to_rv > RULES.max_iv_to_rv_general:\n            iv_rv_penalty = entry * min(0.35, (iv_to_rv - RULES.max_iv_to_rv_general) * RULES.iv_rv_penalty_factor)\n\n    expected_option_gain = max(0.0, delta_gain + gamma_gain - theta_cost - vega_cost)\n    ev_points = expected_option_gain - entry_slippage - exit_slip - iv_rv_penalty\n    ev_dollars = round(ev_points * 100.0, 2)\n    ev_pct = round(ev_points / entry * 100.0, 2) if entry > 0 else -999.0\n\n    if direction == \"CALL\":\n        breakeven_move_pct = ((strike + entry - underlying_price) / underlying_price * 100.0\n                              if underlying_price > 0 else 999.0)\n    else:\n        breakeven_move_pct = ((underlying_price - (strike - entry)) / underlying_price * 100.0\n                              if underlying_price > 0 else 999.0)\n    breakeven_move_pct = round(max(0.0, breakeven_move_pct), 2)\n\n    fill_p = estimate_fill_probability(opt_data)\n    ev_reasons = []\n    if ev_pct < RULES.min_option_ev_pct:\n        ev_reasons.append(f\"EV% {ev_pct} < {RULES.min_option_ev_pct}\")\n    if ev_dollars < RULES.min_option_ev_dollars:\n        ev_reasons.append(f\"EV$ {ev_dollars} < {RULES.min_option_ev_dollars}\")\n    if breakeven_move_pct > expected_move_pct * 1.25:\n        ev_reasons.append(\"Break-even-Move zu hoch\")\n    if fill_p < RULES.min_fill_probability:\n        ev_reasons.append(f\"FillP {fill_p} < {RULES.min_fill_probability}\")\n\n    ev_ok = not ev_reasons\n    delta_penalty = abs(abs(delta) - RULES.target_delta_abs) * 12.0\n    liquidity_bonus = min(8.0, oi / 1000.0) + min(4.0, volume / 100.0)\n    ev_score = round(ev_pct + liquidity_bonus - delta_penalty, 2)\n\n    return {\n        \"direction\": direction,\n        \"strike\": option.get(\"strike\"),\n        \"bid\": bid,\n        \"ask\": ask,\n        \"midpoint\": mid,\n        \"conservative_entry\": entry,\n        \"entry_price\": entry,\n        \"spread_pct\": spread_pct,\n        \"delta\": g.get(\"delta\"),\n        \"gamma\": g.get(\"gamma\"),\n        \"theta\": g.get(\"theta\"),\n        \"vega\": g.get(\"vega\"),\n        \"iv\": round(iv * 100, 1) if iv else None,\n        \"iv_decimal\": round(iv, 5) if iv else None,\n        \"realized_vol_20d\": round(realized_vol_20d, 5) if realized_vol_20d else None,\n        \"iv_to_rv\": iv_to_rv,\n        \"iv_rv_penalty\": round(iv_rv_penalty, 4),\n        \"vega_cost_points\": round(vega_cost, 4),\n        \"vega_cost_dollars\": round(vega_cost * 100.0, 2),\n        \"iv_drop_assumed_decimal\": round(iv_drop_decimal, 5),\n        \"iv_crush_factor_used\": round(iv_crush_factor_used, 3),\n        \"iv_crush_mode\": (\"earnings\" if earnings_soon else \"news\" if news_driven else \"baseline\"),\n        \"open_interest\": oi,\n        \"volume\": volume,\n        \"fill_probability\": fill_p,\n        \"expected_move_pct\": expected_move_pct,\n        \"breakeven_move_pct\": breakeven_move_pct,\n        \"entry_slippage_points\": round(entry_slippage, 4),\n        \"exit_slippage_points\": exit_slip,\n        \"ev_points\": round(ev_points, 3),\n        \"ev_dollars\": ev_dollars,\n        \"ev_pct\": ev_pct,\n        \"ev_score\": ev_score,\n        \"ev_ok\": ev_ok,\n        \"ev_fail_reason\": merge_reasons(ev_reasons),\n        \"option_source\": \"tradier\",\n        \"contracts\": None,\n    }\n\n\ndef enrich_with_journal_iv_rank(symbol: str, option_ev: dict) -> dict:\n    try:\n        from trading_journal import get_iv_stats\n        stats = get_iv_stats(symbol, option_ev.get(\"iv_decimal\"), min_samples=2)\n    except Exception as exc:\n        stats = {\n            \"iv_rank\": None,\n            \"iv_percentile\": None,\n            \"iv_history_count\": 0,\n            \"iv_rank_reason\": \"IV-Rank nicht berechenbar: \" + str(exc)[:80],\n        }\n\n    option_ev.update(stats)\n    n = int(stats.get(\"iv_history_count\") or 0)\n    iv_rank = stats.get(\"iv_rank\")\n    iv_percentile = stats.get(\"iv_percentile\")\n\n    iv_to_rv = _safe_float(option_ev.get(\"iv_to_rv\"), None)\n\n    if n < RULES.min_iv_history_samples_for_rank:\n        option_ev[\"iv_cold_start\"] = True\n        if iv_to_rv is not None and iv_to_rv >= RULES.cold_start_iv_to_rv_hard_block:\n            option_ev[\"ev_ok\"] = False\n            option_ev[\"ev_fail_reason\"] = merge_reasons(\n                option_ev.get(\"ev_fail_reason\"),\n                f\"Cold-Start IV/RV {iv_to_rv:.2f} >= {RULES.cold_start_iv_to_rv_hard_block:.2f} Long-Option zu teuer\",\n            )\n    else:\n        option_ev[\"iv_cold_start\"] = False\n        if iv_to_rv is not None and iv_to_rv >= RULES.mature_iv_to_rv_hard_block:\n            option_ev[\"ev_ok\"] = False\n            option_ev[\"ev_fail_reason\"] = merge_reasons(\n                option_ev.get(\"ev_fail_reason\"),\n                f\"IV/RV {iv_to_rv:.2f} >= {RULES.mature_iv_to_rv_hard_block:.2f} Long-Option zu teuer\",\n            )\n\n    if n >= RULES.min_iv_history_samples_for_rank:\n        if iv_rank is not None and iv_rank >= RULES.iv_rank_hard_block_long:\n            option_ev[\"ev_ok\"] = False\n            option_ev[\"ev_fail_reason\"] = merge_reasons(\n                option_ev.get(\"ev_fail_reason\"),\n                f\"IV-Rank {iv_rank:.1f} >= {RULES.iv_rank_hard_block_long:.1f} Long-Option zu teuer\",\n            )\n        if iv_percentile is not None and iv_percentile >= RULES.iv_percentile_hard_block_long:\n            option_ev[\"ev_ok\"] = False\n            option_ev[\"ev_fail_reason\"] = merge_reasons(\n                option_ev.get(\"ev_fail_reason\"),\n                f\"IV-Percentile {iv_percentile:.1f} >= {RULES.iv_percentile_hard_block_long:.1f}\",\n            )\n    return option_ev\n\n\ndef get_tradier_options(symbol, direction, tradier_token,\n                        sandbox=False, target_dte=21, underlying_price=0.0,\n                        change_pct=0.0, closes=None, rel_vol=None,\n                        signal_score=50.0, earnings_soon=False) -> dict:\n    try:\n        if not tradier_token:\n            return {\"option_source\": \"none\", \"ev_ok\": False, \"ev_fail_reason\": \"Tradier Token fehlt\"}\n        base = \"https://sandbox.tradier.com\" if sandbox else \"https://api.tradier.com\"\n        hdrs = {\"Authorization\": \"Bearer \" + tradier_token, \"Accept\": \"application/json\"}\n\n        r_exp = robust_get(base + \"/v1/markets/options/expirations\",\n                           params={\"symbol\": symbol, \"includeAllRoots\": \"true\"},\n                           headers=hdrs)\n        if not r_exp:\n            return {\"option_source\": \"tradier\", \"ev_ok\": False, \"ev_fail_reason\": \"Options-Expirations nicht verfügbar\"}\n        exps = r_exp.json().get(\"expirations\", {}).get(\"date\", [])\n        if not exps:\n            return {\"option_source\": \"tradier\", \"ev_ok\": False, \"ev_fail_reason\": \"Keine Expirations\"}\n\n        today_dt = datetime.now()\n        target_exp = None\n        best_diff = 999\n        target_days = None\n        for exp in exps:\n            days = (datetime.strptime(exp, \"%Y-%m-%d\") - today_dt).days\n            if days < RULES.min_dte_days:\n                continue\n            diff = abs(days - target_dte)\n            if diff < best_diff:\n                best_diff = diff\n                target_exp = exp\n                target_days = days\n\n        if not target_exp:\n            return {\"option_source\": \"tradier\", \"ev_ok\": False, \"ev_fail_reason\": \"Keine passende Laufzeit\"}\n\n        r_chain = robust_get(base + \"/v1/markets/options/chains\",\n                             params={\"symbol\": symbol, \"expiration\": target_exp,\n                                     \"greeks\": \"true\"},\n                             headers=hdrs)\n        if not r_chain:\n            return {\"option_source\": \"tradier\", \"ev_ok\": False, \"expiration\": target_exp,\n                    \"ev_fail_reason\": \"Options-Chain nicht verfügbar\"}\n        opts = r_chain.json().get(\"options\", {}).get(\"option\", [])\n        if not opts:\n            return {\"option_source\": \"tradier\", \"ev_ok\": False, \"expiration\": target_exp,\n                    \"ev_fail_reason\": \"Options-Chain leer\"}\n\n        opt_type = \"call\" if direction == \"CALL\" else \"put\"\n        rv20 = calc_realized_volatility(closes or [])\n        expected_move_pct = estimate_expected_move_pct(\n            underlying_price, change_pct, rel_vol, signal_score, closes or [], target_dte\n        )\n\n        candidates = []\n        for opt in opts:\n            if opt.get(\"option_type\") != opt_type:\n                continue\n            ev = evaluate_option_ev(opt, direction, underlying_price, expected_move_pct,\n                                    realized_vol_20d=rv20,\n                                    earnings_soon=earnings_soon,\n                                    news_driven=True)\n            if ev is None:\n                continue\n            ev[\"expiration\"] = target_exp\n            ev[\"dte_actual\"] = target_days\n            ok_earnings, earnings_reason = check_earnings_iv_gate(ev, earnings_soon)\n            ev[\"earnings_iv_ok\"] = ok_earnings\n            ev[\"earnings_iv_reason\"] = earnings_reason\n            if not ok_earnings:\n                ev[\"ev_ok\"] = False\n                ev[\"ev_fail_reason\"] = merge_reasons(ev.get(\"ev_fail_reason\"), earnings_reason)\n            candidates.append(ev)\n\n        if not candidates:\n            return {\"option_source\": \"tradier\", \"ev_ok\": False, \"expiration\": target_exp,\n                    \"ev_fail_reason\": \"Keine bewertbaren Optionen\"}\n\n        good = [c for c in candidates if c.get(\"ev_ok\")]\n        chosen_pool = good if good else candidates\n        best = sorted(chosen_pool, key=lambda c: c.get(\"ev_score\", -999), reverse=True)[0]\n        best[\"candidate_count\"] = len(candidates)\n        best[\"ev_candidates_ok\"] = len(good)\n        best.update(build_time_stop_plan(direction, best.get(\"dte_actual\")))\n        best = enrich_with_journal_iv_rank(symbol, best)\n        if not best.get(\"ev_ok\") and not best.get(\"ev_fail_reason\"):\n            best[\"ev_fail_reason\"] = \"Kein Kandidat nach EV/Kosten/Earnings-Gates\"\n        return best\n\n    except (ValueError, KeyError, RequestException) as e:\n        logger.debug(\"Tradier Options %s: %s\", symbol, e)\n        return {\"option_source\": \"tradier\", \"ev_ok\": False, \"ev_fail_reason\": \"Tradier Options Fehler\"}\n\n\ndef calc_ma(values, period):\n    if len(values) < period:\n        return None\n    return round(sum(values[-period:]) / period, 2)\n\n\ndef calc_rel_volume(volumes):\n    valid = [v for v in volumes if v is not None and v >= 0]\n    if len(valid) < 21:\n        return None\n    avg_20 = sum(valid[-21:-1]) / 20\n    if avg_20 <= 0:\n        return None\n    return round(valid[-1] / avg_20, 2)\n\n\n# ==================== GAP + VOLUME CONVICTION (FINAL) ====================\n\ndef validate_gap_and_go(price: float, change_pct: float, volumes: list, closes: list) -> dict:\n    \"\"\"Finale Version: Gap + RVOL mit korrekter Trend-Direction-Confirmation.\"\"\"\n    if price <= 0 or not volumes or len(volumes) < 21 or not closes or len(closes) < 6:\n        return {\n            \"gap_pct\": round(change_pct, 2),\n            \"rvol\": None,\n            \"is_high_conviction\": False,\n            \"score_bonus\": 0.0\n        }\n\n    rvol = calc_rel_volume(volumes)\n    if rvol is None:\n        return {\n            \"gap_pct\": round(change_pct, 2),\n            \"rvol\": None,\n            \"is_high_conviction\": False,\n            \"score_bonus\": 0.0\n        }\n\n    gap_pct = change_pct\n\n    recent_range = max(closes[-5:]) - min(closes[-5:])\n    trend_direction = closes[-1] - closes[-5]\n    trend_strength = abs(trend_direction)\n\n    min_move = max(0.5, recent_range * 0.3)\n    trend_confirmed = (\n        trend_strength >= min_move and\n        ((gap_pct > 0 and trend_direction > 0) or\n         (gap_pct < 0 and trend_direction < 0))\n    )\n\n    is_high_conviction = (\n        abs(gap_pct) >= 3.0 and\n        rvol >= 1.5 and\n        trend_confirmed\n    )\n\n    gap_bonus = min(abs(gap_pct) * 1.8, 18.0)\n    rvol_bonus = min(max((rvol - 1.0) * 8.0, 0), 16.0)\n    score_bonus = min(round(gap_bonus + rvol_bonus, 1), 20.0)\n\n    if not is_high_conviction:\n        score_bonus *= 0.3\n\n    return {\n        \"gap_pct\": round(gap_pct, 2),\n        \"rvol\": round(rvol, 2),\n        \"is_high_conviction\": is_high_conviction,\n        \"score_bonus\": round(score_bonus, 1)\n    }\n\n\n# ==================== SCORE (angepasst) ====================\n\ndef calculate_score(price, change_pct, above_ma50, ma20,\n                    direction, bullish, unusual, earnings_soon, is_etf,\n                    gap_volume_bonus: float = 0.0):\n    if price <= 0:\n        return 0.0, \"no_price\"\n\n    base = 50.0\n    momentum = min(25.0, abs(change_pct) * 7)\n\n    trend_bonus = 0.0\n    if direction == \"CALL\" and above_ma50 is True:\n        trend_bonus = 18.0\n    elif direction == \"PUT\" and above_ma50 is False:\n        trend_bonus = 18.0\n\n    direction_malus = 0.0\n    if direction == \"CALL\" and change_pct < -0.5:\n        direction_malus = -35.0\n    elif direction == \"PUT\" and change_pct > 0.5:\n        direction_malus = -35.0\n\n    volume_bonus = 0.0 if gap_volume_bonus > 0 else (12.0 if unusual and not is_etf else 0.0)\n\n    etf_roc_bonus = 0.0\n    if is_etf and ma20 and price > 0:\n        roc = (price - ma20) / ma20 * 100\n        if direction == \"CALL\" and roc > 0:\n            etf_roc_bonus = min(12.0, roc * 2)\n        elif direction == \"PUT\" and roc < 0:\n            etf_roc_bonus = min(12.0, abs(roc) * 2)\n\n    raw = base + momentum + trend_bonus + direction_malus + volume_bonus + etf_roc_bonus + gap_volume_bonus\n    score = round(max(0.0, min(100.0, raw)), 2)\n    return score, \"calculated_structural_no_sentiment\"\n\n\n# ==================== TICKER VERARBEITUNG ====================\n\ndef process_ticker(ticker, direction, earnings_list, cfg, target_dte: int = 21) -> dict:\n    is_etf = ticker in ETF_TICKERS\n    finnhub_key = cfg.get(\"finnhub_key\", \"\")\n    q_fut: Future = None\n    h_fut: Future = None\n\n    try:\n        with ThreadPoolExecutor(max_workers=2) as executor:\n            q_fut = executor.submit(get_quote, ticker, cfg)\n            h_fut = executor.submit(get_history, ticker, cfg)\n\n            try:\n                price, change_pct, high, low, quote_src = q_fut.result(timeout=12)\n            except TimeoutError:\n                logger.warning(\"%s: Kurs-Timeout\", ticker)\n                price, change_pct, high, low, quote_src = 0.0, 0.0, 0.0, 0.0, \"timeout\"\n\n            try:\n                closes, volumes, hist_src = h_fut.result(timeout=12)\n            except TimeoutError:\n                logger.warning(\"%s: History-Timeout\", ticker)\n                closes, volumes, hist_src = [], [], \"timeout\"\n\n        quote_age_seconds = 0\n\n        if is_etf and price <= 0:\n            return {\n                \"ticker\": ticker, \"price\": 0.0, \"change_pct\": 0.0,\n                \"score\": 0.0, \"_score_reason\": \"etf_no_price\",\n                \"options\": {}, \"news_direction\": direction,\n                \"is_etf\": True, \"etf_no_data\": True,\n                \"_src_quote\": quote_src, \"quote_age_seconds\": quote_age_seconds,\n                \"_closes_count\": 0, \"rel_vol\": \"n/v\", \"unusual\": False,\n                \"ma50\": None, \"ma20\": None, \"above_ma50\": None,\n                \"new_20d_high\": None, \"trend_status\": \"n/v\",\n                \"bullish\": 50.0, \"sent_fallback\": True, \"earnings_soon\": False,\n                \"_data_quality_ok\": False, \"_data_quality_reason\": \"ETF ohne Preis\",\n                \"_liquidity_fail\": True, \"_liquidity_reason\": \"ETF ohne Preis\",\n                \"_no_trade_reason\": \"ETF ohne Preis\",\n            }\n\n        bullish, bearish, buzz, sent_fallback = get_sentiment(ticker, change_pct, finnhub_key)\n        sentiment_reaction = classify_sentiment_price_reaction(direction, bullish, bearish, change_pct, sent_fallback)\n\n        history_validation = validate_ohlcv_history(closes, volumes)\n        spike_validation = detect_unexplained_price_spike(\n            price, closes, news_signal_present=True, threshold_pct=10.0\n        ) if closes else None\n\n        data_validation_reason = data_flags_to_text(history_validation, spike_validation)\n        data_validation_ok = bool(history_validation.ok and (spike_validation.ok if spike_validation else True))\n\n        rel_vol = calc_rel_volume(volumes)\n\n        # === NEU: Gap + Volume Conviction ===\n        gap_volume = validate_gap_and_go(price, change_pct, volumes, closes)\n\n        unusual = bool(rel_vol and rel_vol >= RULES.daily_rvol_unusual_threshold)\n        ma50 = calc_ma(closes, 50)\n        ma20 = calc_ma(closes, 20)\n        rv20 = calc_realized_volatility(closes)\n        above_ma50 = (price > ma50) if (ma50 is not None and price > 0) else None\n        new_20d = None\n        if len(closes) >= 20 and price > 0:\n            recent_high = max(closes[-20:])\n            new_20d = price >= recent_high * 0.98 if recent_high > 0 else None\n        earnings_soon = ticker in earnings_list\n\n        gap_bonus = gap_volume[\"score_bonus\"] if data_validation_ok else 0.0\n\n        score, score_reason = calculate_score(\n            price, change_pct, above_ma50, ma20, direction,\n            bullish, unusual, earnings_soon, is_etf,\n            gap_volume_bonus=gap_bonus\n        )\n\n        sector_result = evaluate_sector_filter(ticker, direction, change_pct, cfg, get_quote)\n        score = round(max(0.0, min(100.0,\n            score + sector_result.score_adjustment + sentiment_reaction.get(\"sentiment_price_score_adjustment\", 0.0)\n        )), 2)\n\n        raw_signal_score = score\n        score_reason = score_reason + \"; sector=\" + sector_result.severity + \"; sent_price=\" + sentiment_reaction.get(\"sentiment_price_label\", \"neutral\")\n\n        if RULES.require_tradier_quote_for_tradier_options and not str(quote_src).lower().startswith(\"tradier\"):\n            options_data = {\n                \"option_source\": \"tradier\",\n                \"ev_ok\": False,\n                \"ev_fail_reason\": \"Hard Block: Tradier-Optionen ohne Tradier-Underlying-Snapshot\",\n                \"snapshot_consistency_ok\": False,\n            }\n        else:\n            options_data = get_tradier_options(\n                ticker, direction,\n                cfg.get(\"tradier_token\", \"\"),\n                cfg.get(\"tradier_sandbox\", False),\n                target_dte=target_dte,\n                underlying_price=price,\n                change_pct=change_pct,\n                closes=closes,\n                rel_vol=rel_vol,\n                signal_score=score,\n                earnings_soon=earnings_soon,\n            )\n\n        market_stub = {\"price\": price, \"_src_quote\": quote_src, \"quote_source\": quote_src, \"quote_age_seconds\": quote_age_seconds}\n        snapshot_ok, snapshot_reason = check_data_quality(market_stub, options_data)\n        data_ok = bool(snapshot_ok and data_validation_ok)\n        data_reason = merge_reasons(snapshot_reason if not snapshot_ok else \"\", data_validation_reason if data_validation_reason != \"ok\" else \"\") or \"ok\"\n        is_liquid, liquidity_reason = check_liquidity(options_data)\n        ev_ok = bool(options_data.get(\"ev_ok\"))\n        sector_ok = bool(sector_result.ok)\n\n        no_trade_reason = []\n        if not data_ok:\n            no_trade_reason.append(data_reason)\n        if not sector_ok:\n            no_trade_reason.append(sector_result.reason)\n        if not is_liquid:\n            no_trade_reason.append(liquidity_reason)\n        if not ev_ok:\n            no_trade_reason.append(options_data.get(\"ev_fail_reason\") or \"Options-EV nach Kosten nicht ausreichend\")\n\n        final_score = score\n        if no_trade_reason:\n            if not data_ok:\n                score_reason = \"data_quality_fail\"\n            elif not is_liquid:\n                score_reason = \"liquidity_fail\"\n            else:\n                score_reason = \"option_ev_fail\"\n            final_score = 0.0\n\n        final_reason = merge_reasons(no_trade_reason)\n        if final_reason:\n            logger.info(\"%s: No-Trade-Gate: %s\", ticker, final_reason)\n\n        logger.info(\n            \"%s: price=%.2f score=%.1f raw=%.1f data_ok=%s liquid=%s ev_ok=%s ev=%s src=%s dte=%d\",\n            ticker, price, final_score, raw_signal_score, data_ok, is_liquid, ev_ok, options_data.get(\"ev_pct\"), quote_src, target_dte\n        )\n\n        return {\n            \"ticker\": ticker,\n            \"price\": price,\n            \"change_pct\": change_pct,\n            \"rel_vol\": str(rel_vol) if rel_vol is not None else \"n/v\",\n            \"rel_vol_quality\": \"daily_only_no_intraday_curve\",\n            \"data_validation_ok\": data_validation_ok,\n            \"data_validation_reason\": data_validation_reason,\n            \"data_quality_score\": getattr(history_validation, \"quality_score\", None),\n            \"price_spike_pct\": getattr(spike_validation, \"spike_pct\", None) if spike_validation else None,\n            \"sector\": sector_result.sector,\n            \"sector_etf\": sector_result.sector_etf,\n            \"sector_change_pct\": sector_result.sector_change_pct,\n            \"market_change_pct\": sector_result.market_change_pct,\n            \"qqq_change_pct\": sector_result.qqq_change_pct,\n            \"relative_to_sector_pct\": sector_result.relative_to_sector_pct,\n            \"sector_vs_market_pct\": getattr(sector_result, \"sector_vs_market_pct\", None),\n            \"sector_momentum_confirmation\": getattr(sector_result, \"momentum_confirmation\", \"neutral\"),\n            \"sector_filter_ok\": sector_result.ok,\n            \"sector_filter_reason\": sector_result.reason,\n            \"sector_score_adjustment\": sector_result.score_adjustment,\n            \"sentiment_price_label\": sentiment_reaction.get(\"sentiment_price_label\"),\n            \"sentiment_price_score_adjustment\": sentiment_reaction.get(\"sentiment_price_score_adjustment\"),\n            \"sentiment_price_confidence\": sentiment_reaction.get(\"sentiment_price_confidence\"),\n            \"sentiment_gap\": sentiment_reaction.get(\"sentiment_gap\"),\n            \"unusual\": unusual,\n            \"ma50\": ma50,\n            \"ma20\": ma20,\n            \"realized_vol_20d\": round(rv20, 5) if rv20 else None,\n            \"above_ma50\": above_ma50,\n            \"new_20d_high\": new_20d,\n            \"trend_status\": (\"über MA50\" if above_ma50 is True else (\"unter MA50\" if above_ma50 is False else \"n/v\")),\n            \"bullish\": round(bullish, 1),\n            \"bearish\": round(bearish, 1),\n            \"sentiment_rank_only\": True,\n            \"sent_fallback\": sent_fallback,\n            \"earnings_soon\": earnings_soon,\n            \"raw_signal_score\": raw_signal_score,\n            \"gate_adjusted_score\": final_score,\n            \"score\": final_score,\n            \"_score_reason\": score_reason + f\" | gap_bonus={gap_bonus:.1f}\",\n            \"_data_quality_ok\": data_ok,\n            \"_data_quality_reason\": data_reason,\n            \"_liquidity_fail\": not is_liquid,\n            \"_liquidity_reason\": liquidity_reason,\n            \"_no_trade_reason\": final_reason,\n            \"options\": options_data,\n            \"news_direction\": direction,\n            \"is_etf\": is_etf,\n            \"_src_quote\": quote_src,\n            \"quote_age_seconds\": quote_age_seconds,\n            \"_src_hist\": hist_src,\n            \"_closes_count\": len(closes),\n            # NEUE FELDER\n            \"gap_pct\": gap_volume[\"gap_pct\"],\n            \"rvol\": gap_volume[\"rvol\"],\n            \"gap_volume_confirmed\": gap_volume[\"is_high_conviction\"],\n            \"gap_volume_bonus\": gap_bonus,\n        }\n\n    except Exception as e:\n        logger.error(\"%s: Unerwarteter Fehler: %s\", ticker, e)\n        if q_fut: q_fut.cancel()\n        if h_fut: h_fut.cancel()\n        return {\n            \"ticker\": ticker, \"price\": 0.0, \"change_pct\": 0.0,\n            \"score\": 0.0, \"_score_reason\": \"exception\",\n            \"options\": {}, \"news_direction\": direction,\n            \"_src_quote\": \"error\", \"quote_age_seconds\": 0, \"_closes_count\": 0,\n            \"rel_vol\": \"n/v\", \"unusual\": False, \"ma50\": None, \"ma20\": None,\n            \"above_ma50\": None, \"new_20d_high\": None, \"trend_status\": \"n/v\",\n            \"bullish\": 40.0, \"bearish\": 60.0, \"sentiment_rank_only\": True,\n            \"sent_fallback\": True, \"earnings_soon\": False,\n            \"_data_quality_ok\": False, \"_data_quality_reason\": \"exception\",\n            \"_liquidity_fail\": True, \"_liquidity_reason\": \"exception\",\n            \"_no_trade_reason\": \"exception\",\n            \"_error\": str(e)[:120],\n        }\n\n\n# ══════════════════════════════════════════════════════════\n# SUMMARY BUILDER + DIREKTE AUSFÜHRUNG (unverändert)\n# ══════════════════════════════════════════════════════════\n\ndef build_summary(ranked, vix_value, ticker_directions,\n                  earnings_list, unusual_list, failed):\n    today = datetime.now().strftime(\"%Y-%m-%d\")\n    srcs_str = \", \".join(d[\"ticker\"] + \"=\" + d.get(\"_src_quote\",\"?\") for d in ranked)\n\n    s = \"DATUM: \" + today + \"\\n\"\n    s += \"VIX: \" + str(vix_value) + \"\\n\"\n    s += \"NEWS-SIGNALE: \" + (\n        \", \".join(t + \":\" + d for t, d in ticker_directions.items()) or \"keine\") + \"\\n\"\n    s += \"EARNINGS NAECHSTE 10 TAGE: \" + (\n        \", \".join(earnings_list) if earnings_list else \"Keine\") + \"\\n\"\n    s += \"UNUSUAL ACTIVITY: nur Diagnose; keine lineare Intraday-Extrapolation\\n\"\n    s += \"TOP 3: \" + \", \".join(d[\"ticker\"] for d in ranked[:3]) + \"\\n\"\n    s += \"QUOTE-QUELLEN: \" + srcs_str + \"\\n\"\n    if failed:\n        s += \"API-FEHLER (Kurs=0): \" + \", \".join(failed) + \"\\n\"\n\n    s += \"\\nHARTE GATES: Tradier-Snapshot, DATA_QUALITY_OK, LIQUIDITY_OK, EV_OK, EARNINGS_IV_OK, SECTOR_MARKET_OK muessen alle True sein.\\n\"\n    s += \"SENTIMENT: nur Ranking-/Kontextinfo, kein EV-Retter. Final Decision sieht keine News-Texte.\\n\"\n    s += \"SPREAD-REGIME: <=5% bevorzugt, 5-8% vorsichtig, 8-10% nur bei starkem EV, >10% harter Block.\\n\"\n\n    s += \"\\nMARKTDATEN (sortiert nach Score):\\n\"\n    s += (f\"{'Ticker':<6} | {'Kurs':>7} | {'Δ%':>6} | {'MA50':>7} | \"\n          f\"{'Trend':<14} | {'RelVol':>7} | {'News':>5} | {'Raw':>6} | {'Score':>6} | {'Gate':<4}\\n\" + \"-\" * 128 + \"\\n\")\n\n    for d in ranked:\n        if d.get(\"etf_no_data\"):\n            s += (d[\"ticker\"].ljust(6) + \" | ETF-SIGNAL | Richtung: \" +\n                  d[\"news_direction\"] + \" | Score: 0 | NO_TRADE: \" + d.get(\"_no_trade_reason\", \"n/v\") + \"\\n\")\n            continue\n\n        news_flag = (\"📈\" if d[\"news_direction\"] == \"CALL\" else \"📉\") + d[\"news_direction\"]\n        kurs_str = f\"{d['price']:>7.2f}\" if d[\"price\"] > 0 else \"   n/v!\"\n        gate_ok = (\n            bool(d.get(\"_data_quality_ok\"))\n            and bool(d.get(\"sector_filter_ok\", True))\n            and not d.get(\"_liquidity_fail\")\n            and bool(d.get(\"options\", {}).get(\"ev_ok\"))\n        )\n        gate_flag = \"OK\" if gate_ok else \"FAIL\"\n\n        raw_score = d.get(\"raw_signal_score\", d.get(\"score\", 0.0))\n        s += (f\"{d['ticker']:<6} | {kurs_str} | {d['change_pct']:>6.2f}% | \"\n              f\"{str(d.get('ma50','n/v')):>7} | {d.get('trend_status','n/v'):<14} | \"\n              f\"{str(d['rel_vol']):>6}{'🔥' if d.get('unusual') else ''} | \"\n              f\"{news_flag:>5} | {raw_score:>6.2f} | {d['score']:>6.2f} | {gate_flag:<4}\\n\")\n\n        if d.get(\"_no_trade_reason\"):\n            s += \"  ⛔ NO_TRADE_REASON: \" + d[\"_no_trade_reason\"] + \"\\n\"\n        s += (\"  └─ SECTOR: ETF=\" + str(d.get(\"sector_etf\",\"n/v\")) +\n              \" | SectorΔ=\" + str(d.get(\"sector_change_pct\",\"n/v\")) +\n              \" | MarketΔ=\" + str(d.get(\"market_change_pct\",\"n/v\")) +\n              \" | RelSector=\" + str(d.get(\"relative_to_sector_pct\",\"n/v\")) +\n              \" | SectorVsMarket=\" + str(d.get(\"sector_vs_market_pct\",\"n/v\")) +\n              \" | Momentum=\" + str(d.get(\"sector_momentum_confirmation\",\"neutral\")) + \"\\n\")\n\n        opt = d.get(\"options\") or {}\n        if opt:\n            s += (\"  └─ OPTIONS: Strike=\" + str(opt.get(\"strike\",\"n/v\")) +\n                  \" | Exp=\" + str(opt.get(\"expiration\",\"n/v\")) +\n                  \" | Bid=\" + str(opt.get(\"bid\",\"n/v\")) +\n                  \"/Ask=\" + str(opt.get(\"ask\",\"n/v\")) +\n                  \" | Mid=\" + str(opt.get(\"midpoint\",\"n/v\")) +\n                  \" | Entry=\" + str(opt.get(\"conservative_entry\",\"n/v\")) +\n                  \" | ExitSlip=\" + str(opt.get(\"exit_slippage_points\",\"n/v\")) +\n                  \" | Delta=\" + str(opt.get(\"delta\",\"n/v\")) +\n                  \" | IV=\" + str(opt.get(\"iv\",\"n/v\")) + \"%\" +\n                  \" | IV/RV=\" + str(opt.get(\"iv_to_rv\",\"n/v\")) +\n                  \" | IVRank=\" + str(opt.get(\"iv_rank\",\"n/v\")) +\n                  \" | IVPct=\" + str(opt.get(\"iv_percentile\",\"n/v\")) +\n                  \" | IVHist=\" + str(opt.get(\"iv_history_count\",\"n/v\")) +\n                  \" | IVCOLD=\" + str(opt.get(\"iv_cold_start\",\"n/v\")) +\n                  \" | TimeStop=\" + str(opt.get(\"time_stop_hours\",\"n/v\")) + \"h/\" + str(opt.get(\"time_stop_required_move_pct\",\"n/v\")) + \"%\" +\n                  \" | OI=\" + str(opt.get(\"open_interest\",\"n/v\")) +\n                  \" | FillP=\" + str(opt.get(\"fill_probability\",\"n/v\")) +\n                  \" | EV%=\" + str(opt.get(\"ev_pct\",\"n/v\")) +\n                  \" | EV$=\" + str(opt.get(\"ev_dollars\",\"n/v\")) +\n                  \" | EV_OK=\" + str(opt.get(\"ev_ok\", False)) +\n                  \" | EARN_IV_OK=\" + str(opt.get(\"earnings_iv_ok\", True)) + \"\\n\")\n\n    sources = []\n    keyword_fallback = []\n    market_fallback = []\n    for d in ranked:\n        news_src = d.get(\"news_sentiment_source\")\n        if news_src:\n            sources.append(d[\"ticker\"] + \"=\" + str(news_src))\n            if news_src == \"keyword\":\n                keyword_fallback.append(d[\"ticker\"])\n        if d.get(\"sent_fallback\"):\n            market_fallback.append(d[\"ticker\"])\n\n    s += \"\\nSENTIMENT-QUELLEN: \" + (\", \".join(sources) or \"n/v\")\n    s += \"\\nKEYWORD-FALLBACK NEWS: \" + (\", \".join(keyword_fallback) or \"keiner\")\n    s += \"\\nMARKTDATEN-SENTIMENT-FALLBACK: \" + (\", \".join(market_fallback) or \"keiner\")\n    return s\n\n\n# ══════════════════════════════════════════════════════════\n# DIREKTE AUSFÜHRUNG\n# ══════════════════════════════════════════════════════════\n\nif __name__ == \"__main__\":\n    import argparse\n    import re\n    from config_loader import load_config, validate_config\n    from rules import parse_ticker_signals\n\n    logging.basicConfig(level=logging.INFO,\n                        format=\"%(asctime)s %(levelname)s %(message)s\")\n\n    parser = argparse.ArgumentParser(description=\"Market Data Fetcher\")\n    parser.add_argument(\"--signals\",      help=\"Ticker-Signale\")\n    parser.add_argument(\"--signals-file\", help=\"Datei mit Signalen\")\n    parser.add_argument(\"--output\",       help=\"Market Summary speichern\")\n    args = parser.parse_args()\n\n    cfg = load_config()\n    if not validate_config(cfg):\n        raise SystemExit(\"Konfiguration unvollständig\")\n\n    raw = \"\"\n    if args.signals:\n        raw = args.signals\n    elif args.signals_file:\n        with open(args.signals_file) as f:\n            raw = f.read().strip()\n    else:\n        raise SystemExit(\"--signals oder --signals-file erforderlich\")\n\n    parsed            = parse_ticker_signals(raw)\n    ticker_directions = {s[\"ticker\"]: s[\"direction\"] for s in parsed}\n    dte_map           = {s[\"ticker\"]: s[\"dte_days\"]  for s in parsed}\n    tickers           = list(ticker_directions.keys())\n\n    finnhub_key = cfg.get(\"finnhub_key\", \"\")\n    today       = datetime.now().strftime(\"%Y-%m-%d\")\n    end         = (datetime.now() + timedelta(days=10)).strftime(\"%Y-%m-%d\")\n\n    with ThreadPoolExecutor(max_workers=2) as ex:\n        vix_fut       = ex.submit(get_vix)\n        earnings_fut  = ex.submit(get_earnings, today, end, finnhub_key)\n        vix_value     = vix_fut.result(timeout=12)\n        earnings_list = earnings_fut.result(timeout=12)\n\n    with ThreadPoolExecutor(max_workers=RULES.max_tickers) as ex:\n        futures = {\n            ex.submit(process_ticker, t, ticker_directions[t],\n                      earnings_list, cfg, dte_map.get(t, 21)): t\n            for t in tickers\n        }\n        results = []\n        for f in as_completed(futures, timeout=30):\n            try:\n                results.append(f.result())\n            except Exception as e:\n                logger.error(\"Ticker-Future Fehler: %s\", e)\n\n    market_data  = [r for r in results if r]\n    ranked       = sorted(market_data, key=lambda x: x[\"score\"], reverse=True)\n    unusual_list = [d[\"ticker\"] for d in market_data if d.get(\"unusual\")]\n    failed       = [d[\"ticker\"] for d in market_data if d.get(\"_src_quote\") == \"failed\"]\n\n    summary = build_summary(ranked, vix_value, ticker_directions,\n                            earnings_list, unusual_list, failed)\n    print(summary)\n    if args.output:\n        with open(args.output, \"w\") as f:\n            f.write(summary)\n"
  },
  {
    "path": "src/news_analyzer.py",
    "content": "\"\"\"\nnews_analyzer.py — News Fetching, Clustering und Alpha-Katalysator-Validierung\nStand 2026 (v2.3 - High Conviction Catalyst Edition)\n\"\"\"\n\nimport calendar\nimport logging\nimport os\nimport re\nimport time\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Dict, List, Optional, Tuple\n\nimport feedparser\nimport requests\n\n# Optional: FinBERT-Sentiment\ntry:\n    from finbert_sentiment import get_finbert_sentiment_batch\nexcept ImportError:\n    get_finbert_sentiment_batch = None\n\n# Ticker-Universum\ntry:\n    from universe import get_known_tickers, STATIC_ETFS\nexcept ImportError:\n    get_known_tickers = None\n    STATIC_ETFS = {\"SPY\", \"QQQ\", \"IWM\", \"DIA\", \"GLD\", \"SLV\", \"USO\", \"TLT\"}\n\n# SEC Mapping\ntry:\n    from sec_check import get_company_name_to_ticker, get_cik_to_ticker_map, COMPANY_NAME_OVERRIDES\nexcept ImportError:\n    get_company_name_to_ticker = None\n    get_cik_to_ticker_map = None\n    COMPANY_NAME_OVERRIDES = {}\n\nlogger = logging.getLogger(__name__)\n\n# ==================== ALPHA CATALYST CONFIG ====================\nCATALYST_WEIGHTS = {\n    \"fda_approval\": 2.5,\n    \"phase_3\": 2.1,\n    \"merger\": 2.2,\n    \"acquisition\": 2.2,\n    \"activist_entry\": 2.3,      # 13D\n    \"passive_stake\": 1.45,      # 13G\n    \"8k_material_event\": 1.95,\n    \"earnings_beat\": 1.85,\n    \"guidance_raise\": 2.0,\n    \"insider_filing\": 1.75,     # <-- korrigiert\n    \"buyback\": 1.65,\n    \"wire_strong\": 1.45,\n    \"news_standard\": 0.95,\n}\n\n# ==================== SYSTEM PROMPT (wichtig!) ====================\nSYSTEM_PROMPT = \"\"\"Du bist ein hochdisziplinierter Options-Trading-Bot.\n\nAntworte **ausschließlich** mit einer einzigen Zeile im exakt folgenden Format:\nTICKER_SIGNALS:BRK.B:CALL:HIGH:T3:45DTE,PLTR:CALL:MED:T2:30DTE\n\nOder genau: TICKER_SIGNALS:NONE\n\nRegeln:\n- Maximal 3 Signale\n- Nur echte Ticker aus den gelieferten Clustern\n- Kein Markdown, kein zusätzlicher Text, keine Erklärung\"\"\"\n\n# Caches\n_KNOWN_TICKERS_CACHE: Optional[set] = None\n_NAME_TO_TICKER_CACHE: Optional[dict] = None\n_CIK_TO_TICKER_CACHE: Optional[dict] = None\n\n_GENERIC_ACRONYMS = {\n    \"AI\", \"IT\", \"IP\", \"EV\", \"CEO\", \"CFO\", \"CTO\", \"IPO\",\n    \"API\", \"SAAS\", \"ESG\", \"AR\", \"VR\", \"ML\",\n    \"USA\", \"UK\", \"EU\", \"US\", \"UN\", \"GDP\", \"FED\", \"ETF\", \"REIT\", \"SPAC\",\n}\n\n_PHARMA_NAME_OVERRIDES = {\n    \"pfizer\": \"PFE\", \"merck\": \"MRK\", \"johnson and johnson\": \"JNJ\", \"eli lilly\": \"LLY\",\n    \"lilly\": \"LLY\", \"abbvie\": \"ABBV\", \"novo nordisk\": \"NVO\", \"bristol myers squibb\": \"BMY\",\n    \"vertex pharmaceuticals\": \"VRTX\", \"vertex\": \"VRTX\", \"moderna\": \"MRNA\", \"biontech\": \"BNTX\",\n    \"gilead\": \"GILD\", \"amgen\": \"AMGN\", \"regeneron\": \"REGN\", \"intuitive surgical\": \"ISRG\",\n    \"boston scientific\": \"BSX\", \"medtronic\": \"MDT\", \"stryker\": \"SYK\",\n}\n\n# ==================== USER AGENT & HEADERS ====================\n_USER_AGENT = os.environ.get(\n    \"NEWS_BOT_USER_AGENT\",\n    \"Mozilla/5.0 (compatible; DailyOptionsBot/1.2; +contact: bot@example.com) feedparser/6.0\"\n)\n_FEED_HEADERS = {\n    \"User-Agent\": _USER_AGENT,\n    \"Accept\": \"application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.5\",\n    \"Accept-Encoding\": \"gzip, deflate\",\n}\n\n# ==================== RSS FEEDS ====================\nRSS_FEEDS = [ ... ]  # deine Liste bleibt unverändert\n\n# ==================== REGEX ====================\n_SEC_TITLE_RE = re.compile(\n    r\"^\\s*(?P<form>\\S(?:[^\\s]|\\s(?!-\\s))*?)\\s+-\\s+(?P<name>.+?)\\s+\\((?P<cik>\\d{6,10})\\)\",\n    re.IGNORECASE\n)\n_WIRE_TICKER_RE = re.compile(\n    r\"\\(\\s*(?:NASDAQ|NYSEAMERICAN|NYSE\\s+AMERICAN|NYSE|AMEX|OTCQX|OTCQB|CBOE|BATS)\\s*:\\s*\"\n    r\"([A-Z]{1,5}(?:\\.[A-Z])?)\\s*\\)\",\n    re.IGNORECASE\n)\n_WIRE_SOURCES = (\"globenewswire\", \"businesswire\", \"prnewswire\", \"newswire\", \"accesswire\")\n\n# ==================== HELPERS ====================\ndef _score_catalyst(event_type: str, base_conf: float = 5.0) -> float:\n    weight = CATALYST_WEIGHTS.get(event_type, 1.0)\n    return round(base_conf * weight, 2)\n\n# ... (_load_known_tickers, _load_name_to_ticker, _load_cik_to_ticker bleiben unverändert) ...\n\n# ==================== FETCHER (unverändert) ====================\n# ... deine gesamte fetch_all_feeds(), _fetch_feed_bytes(), build_earnings_map() bleiben 1:1 ...\n\n# ==================== RESOLVERS ====================\ndef _resolve_sec_filing(article: dict, cik_map: dict) -> Optional[Tuple[str, str, str, float]]:\n    title = article.get(\"title\") or \"\"\n    m = _SEC_TITLE_RE.match(title)\n    if not m:\n        return None\n    try:\n        cik = int(m.group(\"cik\"))\n        form = m.group(\"form\").upper().strip()\n        name = m.group(\"name\").strip()\n    except Exception:\n        return None\n\n    ticker = cik_map.get(cik) or cik_map.get(str(cik))\n    if not ticker:\n        return None\n\n    if \"8-K\" in form:\n        event_type = \"8k_material_event\"\n        base_conf = 7.8\n    elif \"13D\" in form:\n        event_type = \"activist_entry\"\n        base_conf = 8.0\n    elif \"13G\" in form:\n        event_type = \"passive_stake\"\n        base_conf = 6.1\n    elif \"4\" in form:                     # <-- korrigiert (auch 4/A)\n        event_type = \"insider_filing\"\n        base_conf = 5.3\n    else:\n        event_type = \"sec_filing\"\n        base_conf = 4.4\n\n    confidence = _score_catalyst(event_type, base_conf) * 1.18\n    headline = f\"{ticker} SEC {form}: {name[:70]}\"\n    return ticker, headline, event_type, round(confidence, 2)\n\n# ... _resolve_wire_ticker und _resolve_ticker_from_headline bleiben unverändert ...\n\n# ==================== CLUSTERING ====================\ndef cluster_articles(articles: List[Dict], earnings_map: Dict) -> List[Dict]:\n    # ... dein gesamter Cluster-Code bleibt gleich, nur mit den oben korrigierten Gewichten ...\n\n    # Am Ende:\n    clusters = list(ticker_signals.values())\n    clusters = sorted(clusters, key=lambda x: x[\"confidence_score\"], reverse=True)\n    logger.info(f\"Cluster erstellt: {len(clusters)} Ticker\")\n    return clusters\n\n# ==================== CLAUDE CALL (korrigiert) ====================\ndef run_claude(cluster_text: str, market_time: str, market_status: str, api_key: str) -> str:\n    if not api_key:\n        logger.error(\"ANTHROPIC_API_KEY fehlt\")\n        return \"TICKER_SIGNALS:NONE\"\n\n    user_message = f\"Marktzeit: {market_time}\\nMarktstatus: {market_status}\\n\\n{cluster_text}\"\n\n    try:\n        r = requests.post(\n            \"https://api.anthropic.com/v1/messages\",\n            headers={\n                \"x-api-key\": api_key,\n                \"anthropic-version\": \"2023-06-01\",\n                \"content-type\": \"application/json\",\n            },\n            json={\n                \"model\": \"claude-sonnet-4-6\",   # oder claude-3-5-sonnet-20241022\n                \"max_tokens\": 800,\n                \"temperature\": 0.0,\n                \"system\": SYSTEM_PROMPT,        # <-- jetzt definiert\n                \"messages\": [{\"role\": \"user\", \"content\": user_message}]\n            },\n            timeout=40\n        )\n        r.raise_for_status()\n        data = r.json()\n        raw_text = data[\"content\"][0][\"text\"].strip()\n        logger.debug(\"Claude Rohantwort:\\n%s\", raw_text[:400])\n\n        match = re.search(r'(TICKER_SIGNALS:[^\\n\\r]+)', raw_text, re.IGNORECASE)\n        if match:\n            signal_line = match.group(1).strip().upper()\n            logger.info(\"✅ Claude Signal extrahiert: %s\", signal_line)\n            return signal_line\n\n        logger.warning(\"Kein gueltiges TICKER_SIGNALS-Format gefunden\")\n        return \"TICKER_SIGNALS:NONE\"\n\n    except Exception as e:\n        logger.error(\"Claude API Fehler: %s\", e)\n        return \"TICKER_SIGNALS:NONE\"\n\n\ndef get_market_context() -> tuple:\n    try:\n        from market_calendar import market_context\n        return market_context()\n    except ImportError:\n        return datetime.now().strftime(\"%H:%M\"), \"OPEN\"\n\n\n# ==================== TEST MODUS ====================\nif __name__ == \"__main__\":\n    logging.basicConfig(\n        level=logging.INFO,\n        format=\"%(asctime)s %(levelname)-8s %(name)s — %(message)s\",\n        datefmt=\"%H:%M:%S\",\n    )\n    print(\"=== News Analyzer Test ===\")\n    articles = fetch_all_feeds()\n    print(f\"\\n{len(articles)} Artikel geladen\")\n    if articles:\n        print(\"\\n=== Cluster-Test ===\")\n        clusters = cluster_articles(articles, earnings_map={})\n        for c in clusters[:10]:\n            print(f\" {c['ticker']:6s} conf={c['confidence_score']:.1f} \"\n                  f\"type={c['event_type']:18s} {c['headline_repr'][:70]}\")\n"
  },
  {
    "path": "src/news_utils.py",
    "content": "\"\"\"\nnews_utils.py — Dedupe, URL-Kanonisierung und Quellengewichtung.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport hashlib\nimport re\nfrom urllib.parse import parse_qs, quote, unquote, urlparse, urlunparse\n\nTRACKING_PARAMS = {\n    \"utm_source\", \"utm_medium\", \"utm_campaign\", \"utm_term\", \"utm_content\",\n    \"fbclid\", \"gclid\", \"mc_cid\", \"mc_eid\", \"igshid\", \"ref\", \"cid\",\n}\n\n\ndef canonicalize_url(url: str) -> str:\n    if not url:\n        return \"\"\n    url = url.strip()\n    # Google-News RSS Links enthalten teils echte URL als Parameter.\n    parsed = urlparse(url)\n    qs = parse_qs(parsed.query)\n    for key in (\"url\", \"u\"):\n        if key in qs and qs[key]:\n            candidate = unquote(qs[key][0])\n            if candidate.startswith(\"http\"):\n                url = candidate\n                parsed = urlparse(url)\n                qs = parse_qs(parsed.query)\n                break\n    clean_qs = []\n    for k, vals in qs.items():\n        if k.lower() in TRACKING_PARAMS:\n            continue\n        for v in vals:\n            clean_qs.append((k, v))\n    query = \"&\".join(f\"{quote(k)}={quote(v)}\" for k, v in clean_qs)\n    path = parsed.path.rstrip(\"/\")\n    return urlunparse((parsed.scheme.lower(), parsed.netloc.lower(), path, \"\", query, \"\"))\n\n\ndef normalize_title(title: str) -> str:\n    t = (title or \"\").lower()\n    t = re.sub(r\"[^a-z0-9 ]+\", \" \", t)\n    t = re.sub(r\"\\s+\", \" \", t).strip()\n    return t\n\n\ndef article_fingerprint(title: str, link: str = \"\", summary: str = \"\") -> str:\n    canonical = canonicalize_url(link)\n    if canonical:\n        base = canonical\n    else:\n        base = normalize_title(title)[:120] + \"|\" + normalize_title(summary)[:80]\n    return hashlib.sha256(base.encode(\"utf-8\", errors=\"ignore\")).hexdigest()[:16]\n\n\ndef near_duplicate_key(title: str) -> str:\n    words = normalize_title(title).split()\n    # Entferne häufige Füllwörter, damit gleiche Meldungen über mehrere RSS-Feeds matchen.\n    stop = {\"the\", \"a\", \"an\", \"to\", \"of\", \"and\", \"or\", \"for\", \"on\", \"in\", \"as\", \"with\", \"after\", \"before\"}\n    core = [w for w in words if w not in stop]\n    return \" \".join(core[:12])\n"
  },
  {
    "path": "src/report_generator.py",
    "content": "\"\"\"\nreport_generator.py — HTML-Report + Email-Versand (Step 3)\n\nFixes v2:\n- call_claude() nimmt vix_direct Parameter (Fix Nr. 1+2)\n- VIX aus main.py direkt genutzt — nicht aus Claude-JSON\n- build_html() zeigt PUT/CALL korrekt an (Fix Nr. 7)\n- _compress_summary(): Earnings-Liste auf 10 Ticker gekürzt\n- max_tokens 1500, timeout 30s\n- Exit-Plan: Stop-Loss -40%, Take-Profit +50%, konkrete USD-Preise\n\"\"\"\n\nimport json\nimport logging\nimport smtplib\nimport sys\nfrom datetime import datetime\nfrom email.mime.multipart import MIMEMultipart\nfrom email.mime.text import MIMEText\n\nimport requests\nfrom requests.exceptions import RequestException, Timeout\n\nfrom rules import apply_vix_rules, RULES\nfrom llm_schema import validate_report_payload, build_cancelled_report\n\nlogger = logging.getLogger(__name__)\n\nPROMPT = \"\"\"Du bist eine regelbasierte Options-KI. Antworte NUR mit JSON - kein Text, kein Markdown.\n\nHARTE REGELN:\n- VIX >= 25 -> no_trade: true, no_trade_grund: maximal 12 Woerter ohne Satzzeichen\n- VIX 20-24.99 -> einsatz: 150\n- VIX < 20 -> einsatz: 250\n- Waehle NIEMALS einen Ticker mit Score < 65 fuer echten Trade. Score 50-64 ist nur Research.\n- 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\n- Nutze conservative_entry/Entry als Einstiegspreis, NICHT blind Midpoint\n- kontrakte = floor(einsatz / (entry_price * 100))\n- stop_loss_eur = 30% von einsatz\n- bid/ask/midpoint/entry/ev aus Marktdaten uebernehmen, nicht schaetzen\n- Sentiment darf NIEMALS einen schlechten EV, schlechte Liquiditaet oder Earnings-IV-Block ueberschreiben\n- Du siehst absichtlich KEINE News-Texte. Entscheide nur anhand nackter Marktdaten, Gates, Greeks, Preis, Liquiditaet, IV/RV und Sektor.\n\nDATENQUALITAET:\n- Tradier Production ist Standard. Sandbox/Delayed-Daten nur als Dry-run-Kontext betrachten.\n- Tradier-Optionsdaten mit nicht-Tradier-Underlying sind immer no_trade true. Kein Yahoo/AlphaVantage-Fallback fuer finalen EV.\n- Wenn Quote-Quelle oder Optionsdaten inkonsistent sind: no_trade true.\n- Wenn DATA_FLAGS auf kaputte Historie, Spike ohne News oder fehlende Basisdaten hinweisen: no_trade true.\n- Wenn No-Trade-Reason im Marktdatenblock steht, diese Begruendung uebernehmen.\n\nMARKT-/SEKTORFILTER:\n- CALL braucht idealerweise Aktie > Sektor und Sektor > SPY/QQQ.\n- PUT braucht idealerweise Aktie < Sektor und Sektor < SPY/QQQ.\n- Gegen klaren Sektor-/Markttrend: no_trade oder Research-Only, nicht schoenrechnen.\n- Relative Staerke/Schwaeche darf den Score verbessern, aber nie EV/Liquiditaet/Datenqualitaet ueberschreiben.\n\nSENTIMENT/PREISREAKTION:\n- Nutze SentPx als Divergenzfeature: bearish_news_absorbed kann CALL bestaetigen, bullish_news_not_confirmed kann PUT bestaetigen.\n- SentPx ist nur Ranking/Timing, kein harter EV-Ersatz.\n\nRICHTUNGSLOGIK:\n- CALL darf positiv laufen: change_pct > 0 und ueber MA50 ist gut\n- CALL ist schwach bei change_pct < 0 oder unter MA50\n- PUT darf negativ laufen: change_pct < 0 und unter MA50 ist gut\n- PUT ist schwach bei change_pct > 0 oder ueber MA50\n- Also: change_pct < 0 oder unter MA50 ist KEIN Ausschluss fuer PUT\n\nOPTIONS-EV UND KOSTEN:\n- Bevorzuge hoechstes EV%, positives EV$, hohe FillP, niedrigen Spread, ausreichendes OI\n- ExitSlip ist realer Kostenblock und muss im Risiko genannt werden\n- Kein Trade wenn erwarteter Move Entry+Exit-Slippage+Theta+IV-Risiko nicht klar schlaegt\n- Chance/Risiko muss Entry, Break-even-Move, EV%, EV$, FillP, ExitSlip, IV/RV, IVRank und TimeStop nennen\n\nEARNINGS / IV-CRUSH:\n- EARN_IV_OK=False ist harter Ausschluss fuer Long-Optionen\n- IVRank/IVPct aus eigener Journal-Historie: bei hohem Rank/Percentile ist Long-Option zu teuer\n- Cold Start: Wenn IV-Historie zu kurz ist und IV/RV >= 1.50, ist Long-Option no_trade wegen Overpricing\n- Wenn Earnings nahe und IV/RV unbekannt oder zu hoch: no_trade true\n- Earnings nicht nur als Score-Malus behandeln, sondern als Trade-Gate\n\nETF-SONDERREGEL:\n- ETF nur ausgeben, wenn Optionsdaten und EV_OK vorhanden sind\n- Wenn keine Optionsdaten: no_trade true\n\nBEGRUENDUNG (begruendung_detail - 5 Felder, je max 2 Saetze, keine Anfuehrungszeichen):\n- ticker_wahl: Warum dieser Ticker? Score- und EV-Vergleich.\n- option_wahl: Strike, Delta, IV, IV/RV, Spread, Entry, ExitSlip, EV.\n- timing: Richtungsspezifisch: CALL vs PUT, MA50, RelVol, Sektorfilter, SentPx-Divergenz.\n- chance_risiko: Einsatz, Entry, Break-even, Ziel, Stop.\n- risiko: Hauptrisiko inklusive Spread, Slippage, Datenqualitaet, Earnings/IV.\n\nTIME-STOP:\n- Bei 7-14 DTE: nach 24h pruefen.\n- Bei 15-30 DTE: nach 48h pruefen.\n- Bei >30 DTE: nach 72h pruefen.\n- Wenn Underlying dann nicht mindestens 1% in Zielrichtung gelaufen ist: Exit/Close pruefen.\n\nMARKTSTATUS: markt-Feld 2-3 Saetze. strategie-Feld 1 Satz.\nTICKER_TABELLE: ALLE Ticker aus Marktdaten eintragen.\nRegime NUR: LOW-VOL, TRENDING oder HIGH-VOL\nregime_farbe NUR: gruen, gelb oder rot\n\nGib direction exakt aus den Marktdaten zurueck: CALL oder PUT.\n\nJSON-Schema:\n{\"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\":\"\"}]}\n\"\"\"\n\n\n# ══════════════════════════════════════════════════════════\n# JSON REPAIR\n# ══════════════════════════════════════════════════════════\n\ndef repair_json_quotes(text: str) -> str:\n    result, in_str, escaped, i = [], False, False, 0\n    while i < len(text):\n        ch = text[i]\n        if escaped:\n            result.append(ch); escaped = False; i += 1; continue\n        if ch == '\\\\':\n            result.append(ch); escaped = True; i += 1; continue\n        if ch == '\"':\n            if not in_str:\n                in_str = True; result.append(ch)\n            else:\n                j = i + 1\n                while j < len(text) and text[j] in ' \\t\\n\\r':\n                    j += 1\n                next_ch = text[j] if j < len(text) else ''\n                if next_ch in ',}]:\\n' or j >= len(text):\n                    in_str = False; result.append(ch)\n                else:\n                    result.append('\\\\\"')\n            i += 1; continue\n        if in_str and ch in '\\n\\r':\n            result.append(' '); i += 1; continue\n        result.append(ch); i += 1\n    return ''.join(result)\n\n\ndef close_fragment(frag: str) -> str:\n    in_str, i = False, 0\n    while i < len(frag):\n        if frag[i] == '\\\\' and in_str and i + 1 < len(frag):\n            i += 2; continue\n        if frag[i] == '\"':\n            in_str = not in_str\n        i += 1\n    if in_str:\n        frag += '\"'\n    last = frag.rfind(\",\")\n    if last > 5:\n        frag = frag[:last]\n    in_str, i = False, 0\n    while i < len(frag):\n        if frag[i] == '\\\\' and in_str and i + 1 < len(frag):\n            i += 2; continue\n        if frag[i] == '\"':\n            in_str = not in_str\n        i += 1\n    if in_str:\n        frag += '\"'\n    frag += \"]\" * max(0, frag.count(\"[\") - frag.count(\"]\"))\n    frag += \"}\" * max(0, frag.count(\"{\") - frag.count(\"}\"))\n    return frag\n\n\ndef extract_json_fragment(text: str) -> str:\n    start = text.find(\"{\")\n    if start == -1:\n        raise ValueError(\"Kein öffnendes { im Claude-Response\")\n    end = text.rfind(\"}\")\n    if end == -1:\n        logger.debug(\"Kein schließendes } — close_fragment wird angewendet\")\n        return text[start:]\n    return text[start:end + 1]\n\n\n# ══════════════════════════════════════════════════════════\n# SUMMARY KOMPRIMIERUNG\n# ══════════════════════════════════════════════════════════\n\ndef _compress_summary(summary: str) -> str:\n    lines = summary.splitlines()\n    result = []\n    for line in lines:\n        if line.startswith(\"EARNINGS NAECHSTE\"):\n            parts = line.split(\": \", 1)\n            if len(parts) == 2:\n                tickers = [t.strip() for t in parts[1].split(\",\")][:10]\n                line = parts[0] + \": \" + \", \".join(tickers) + (\" ...\" if len(tickers) == 10 else \"\")\n        result.append(line)\n        if \"SENTIMENT-FALLBACK\" in line:\n            break\n    return \"\\n\".join(result)[:4000]\n\n\n# ══════════════════════════════════════════════════════════\n# CLAUDE CALL\n# ══════════════════════════════════════════════════════════\n\ndef call_claude(summary: str, api_key: str, vix_direct=None) -> dict:\n    summary = _compress_summary(summary)\n\n    try:\n        r = requests.post(\n            \"https://api.anthropic.com/v1/messages\",\n            headers={\n                \"x-api-key\":         api_key,\n                \"anthropic-version\": \"2023-06-01\",\n                \"content-type\":      \"application/json\",\n            },\n            json={\n                \"model\":      \"claude-sonnet-4-6\",\n                \"max_tokens\": 1500,\n                \"system\":     PROMPT,\n                \"messages\":   [{\"role\": \"user\", \"content\": \"Marktdaten:\\n\" + summary}],\n            },\n            timeout=30,\n        )\n        r.raise_for_status()\n    except (RequestException, Timeout) as e:\n        raise RuntimeError(\"Claude API nicht erreichbar: \" + str(e)) from e\n\n    data = r.json()\n    if \"content\" not in data or not data[\"content\"]:\n        raise ValueError(\"Leerer Content in Claude-Response\")\n\n    text = data[\"content\"][0][\"text\"].strip()\n    if \"```\" in text:\n        text = text.replace(\"```json\", \"\").replace(\"```\", \"\").strip()\n\n    try:\n        fragment = extract_json_fragment(text)\n    except ValueError as e:\n        raise ValueError(\"JSON-Extraktion fehlgeschlagen: \" + str(e)) from e\n\n    parsers = [\n        (\"direkt\",           lambda f: json.loads(f)),\n        (\"quote_repair\",     lambda f: json.loads(repair_json_quotes(f))),\n        (\"close_fragment\",   lambda f: json.loads(close_fragment(f))),\n        (\"beide_kombiniert\", lambda f: json.loads(repair_json_quotes(close_fragment(f)))),\n    ]\n    last_error = None\n    result     = None\n    for name, parser in parsers:\n        try:\n            result = parser(fragment)\n            if name != \"direkt\":\n                logger.info(\"JSON repariert mit Methode: %s\", name)\n            break\n        except json.JSONDecodeError as e:\n            last_error = e\n            logger.debug(\"Parse-Versuch '%s' fehlgeschlagen: %s\", name, e)\n\n    if result is None:\n        raise ValueError(\"JSON Parse Fehler nach 4 Versuchen: \" + str(last_error) +\n                         \" | Raw: \" + text[:300])\n\n    validated, errors = validate_report_payload(result)\n    if errors:\n        logger.error(\"Report-Pydantic-Schema-Guard: fail-closed: %s\", errors[:5])\n        result = build_cancelled_report(\"; \".join(errors[:5]), raw=text)\n    else:\n        result = validated\n\n    # Autoritativen VIX nutzen — nicht Claude-JSON-Feld\n    authoritative_vix = vix_direct if vix_direct is not None else result.get(\"vix\", \"n/v\")\n    result = apply_vix_rules(authoritative_vix, result)\n    logger.info(\"VIX=%s (direkt) Einsatz=%s no_trade=%s\",\n                authoritative_vix, result.get(\"einsatz\",\"?\"), result.get(\"no_trade\"))\n\n    return result\n\n\n# ══════════════════════════════════════════════════════════\n# HTML BUILDER\n# ══════════════════════════════════════════════════════════\n\ndef build_html(d: dict, today: str) -> str:\n    G = \"#34c759\"; R = \"#ff3b30\"; O = \"#ff9500\"\n    GR = \"#86868b\"; LG = \"#c7c7cc\"; DK = \"#1d1d1f\"\n    BG = \"#f5f5f7\"; WH = \"#ffffff\"; BD = \"#e5e5ea\"\n    no_trade = d.get(\"no_trade\", False)\n\n    def card(icon, bg, title, content):\n        return (f'<div style=\"background:{WH};border-radius:18px;padding:28px;'\n                f'margin-bottom:16px;box-shadow:0 2px 12px rgba(0,0,0,0.07);\">'\n                f'<div style=\"display:flex;align-items:center;margin-bottom:20px;\">'\n                f'<div style=\"width:36px;height:36px;background:{bg};border-radius:10px;'\n                f'text-align:center;line-height:36px;margin-right:12px;font-size:18px;\">{icon}</div>'\n                f'<h2 style=\"margin:0;font-size:18px;font-weight:700;color:{DK};\">{title}</h2>'\n                f'</div>{content}</div>')\n\n    def row(label, val, col=None, last=False):\n        c = col or DK\n        b = \"\" if last else f\"border-bottom:1px solid {BD};\"\n        return (f'<div style=\"display:flex;justify-content:space-between;padding:10px 0;{b}\">'\n                f'<span style=\"font-size:14px;color:{GR};\">{label}</span>'\n                f'<span style=\"font-size:14px;font-weight:600;color:{c};\">{val}</span></div>')\n\n    def section(label, html, border=True):\n        b = f\"border-bottom:1px solid {BD};\" if border else \"\"\n        return (f'<div style=\"padding:14px 0;{b}\">'\n                f'<p style=\"margin:0 0 6px 0;font-size:11px;font-weight:600;color:{GR};'\n                f'text-transform:uppercase;letter-spacing:0.06em;\">{label}</p>'\n                f'<p style=\"margin:0;font-size:13px;color:{DK};line-height:1.6;\">{html}</p></div>')\n\n    # ── Trade Card ────────────────────────────────────────\n    if no_trade:\n        trade_card = card(\"❌\", \"#ffeaea\", f'<span style=\"color:{R};\">No Trade</span>',\n                          f'<p style=\"margin:0 0 16px 0;font-size:14px;color:{DK};\">'\n                          f'{d.get(\"no_trade_grund\",\"\")}</p>'\n                          f'<div style=\"background:{BG};border-radius:12px;padding:16px;\">'\n                          f'<p style=\"margin:0;font-size:13px;color:{DK};line-height:1.6;\">'\n                          f'Kein Trade heute — Kapitalschutz bei erhöhter Volatilität. '\n                          f'Morgen läuft die Analyse erneut.</p></div>')\n    else:\n        einsatz   = d.get(\"einsatz\", 150)\n        stop_loss = d.get(\"stop_loss_eur\", round(einsatz * 0.4))\n\n        # Richtung korrekt aus Daten lesen\n        direction     = d.get(\"direction\", \"CALL\")\n        direction_str = \"Long Call\" if direction != \"PUT\" else \"Long Put\"\n        direction_col = G if direction != \"PUT\" else O\n        trade_icon    = \"✅\" if direction != \"PUT\" else \"🔽\"\n        card_bg       = \"#e8f5e9\" if direction != \"PUT\" else \"#fff3e0\"\n\n        trade_rows = (\n            row(\"Richtung\",            direction_str, direction_col) +\n            row(\"Strike\",              d.get(\"strike\",\"n/v\")) +\n            row(\"Laufzeit\",            d.get(\"laufzeit\",\"n/v\")) +\n            row(\"Delta\",               d.get(\"delta\",\"n/v\")) +\n            row(\"IV\",                  d.get(\"iv\",\"n/v\")) +\n            row(\"Bid / Ask\",           str(d.get(\"bid\",\"n/v\")) + \" / \" + str(d.get(\"ask\",\"n/v\"))) +\n            row(\"Midpoint\",             d.get(\"midpoint\",\"n/v\")) +\n            row(\"Einstieg konservativ\", d.get(\"entry_price\", d.get(\"conservative_entry\",\"n/v\"))) +\n            row(\"Fill-Wahrscheinlichkeit\", d.get(\"fill_probability\",\"n/v\")) +\n            row(\"Options-EV\",          str(d.get(\"ev_pct\",\"n/v\")) + \"% / \" + str(d.get(\"ev_dollars\",\"n/v\")) + \"$\") +\n            row(\"Break-even Move\",     str(d.get(\"breakeven_move_pct\",\"n/v\")) + \"%\") +\n            row(\"Time-Stop\",           d.get(\"time_stop\", d.get(\"time_stop_rule\", \"48h: +1% Zielrichtung sonst Exit prüfen\"))) +\n            row(\"Kontrakte\",           str(d.get(\"kontrakte\",\"n/v\"))) +\n            row(\"Einsatz\",             str(einsatz) + \"€\") +\n            row(\"Stop-Loss\",           \"–30% = max. \" + str(stop_loss) + \"€\", R) +\n            row(\"Take-Profit 1\",       \"+50% → 50% verkaufen\", G) +\n            row(\"Take-Profit 2\",       \"Rest mit –10% Stop\", G) +\n            row(\"Unusual Activity\",    \"JA 🔥\" if d.get(\"unusual\") else \"nein\",\n                O if d.get(\"unusual\") else DK, last=True)\n        )\n        bd    = d.get(\"begruendung_detail\", {})\n        items = [\n            (\"🏆\", \"Ticker\",        bd.get(\"ticker_wahl\",\"n/v\")),\n            (\"📐\", \"Option\",        bd.get(\"option_wahl\",\"n/v\")),\n            (\"⏱\",  \"Timing\",        bd.get(\"timing\",\"n/v\")),\n            (\"⚖️\", \"Chance/Risiko\", bd.get(\"chance_risiko\",\"n/v\")),\n            (\"⚠️\", \"Hauptrisiko\",   bd.get(\"risiko\",\"n/v\")),\n        ]\n        begr = \"\"\n        for i, (icon, label, text) in enumerate(items):\n            b = f\"border-bottom:1px solid {BD};\" if i < len(items) - 1 else \"\"\n            begr += (f'<div style=\"display:flex;gap:10px;padding:10px 0;{b}\">'\n                     f'<span style=\"font-size:16px;min-width:24px;\">{icon}</span>'\n                     f'<div><p style=\"margin:0 0 2px 0;font-size:10px;font-weight:700;'\n                     f'color:{GR};text-transform:uppercase;\">{label}</p>'\n                     f'<p style=\"margin:0;font-size:12px;color:{DK};line-height:1.5;\">{text}</p>'\n                     f'</div></div>')\n        trade_card = card(\n            trade_icon, card_bg,\n            d.get(\"ticker\",\"\") +\n            f' <span style=\"font-size:14px;color:{direction_col};\">{direction_str}</span>',\n            trade_rows +\n            f'<div style=\"margin-top:20px;background:{BG};border-radius:14px;'\n            f'padding:8px 16px 4px 16px;\">'\n            f'<p style=\"margin:10px 0 4px 0;font-size:10px;font-weight:700;color:{GR};'\n            f'text-transform:uppercase;\">Begründung</p>{begr}</div>',\n        )\n\n    # ── VIX Warnung ───────────────────────────────────────\n    vix_warning = \"\"\n    if d.get(\"vix_warnung\") and not no_trade:\n        vix_warning = (f'<div style=\"background:#fff9e6;border-left:4px solid {O};'\n                       f'border-radius:12px;padding:14px 18px;margin-bottom:16px;\">'\n                       f'<span style=\"font-size:18px;\">⚠️</span>'\n                       f'<span style=\"font-size:13px;font-weight:600;color:{DK};margin-left:8px;\">'\n                       f'Erhöhte Volatilität (VIX 20–24) – Einsatz auf '\n                       f'<strong>{d.get(\"einsatz\",150)}€</strong> reduziert</span></div>')\n\n    # ── Exit Plan mit konkreten USD-Preisen ───────────────\n    exit_card = \"\"\n    if not no_trade:\n        stop_pct = 0.30\n        tp1_pct  = 0.50\n        stop_e   = round(d.get(\"einsatz\", 150) * stop_pct)\n\n        try:\n            mid_f = float(str(d.get(\"midpoint\", \"0\")).replace(\",\", \".\"))\n        except (ValueError, TypeError):\n            mid_f = 0.0\n\n        try:\n            kontr = int(str(d.get(\"kontrakte\", \"1\")).replace(\"n/v\", \"1\"))\n        except (ValueError, TypeError):\n            kontr = 1\n\n        if mid_f > 0:\n            stop_usd   = round(mid_f * (1 - stop_pct), 2)\n            tp1_usd    = round(mid_f * (1 + tp1_pct), 2)\n            tp2_usd    = round(tp1_usd * 0.90, 2)\n            cost_total = round(mid_f * 100 * kontr, 2)\n            cost_str   = f\"Einstieg: {mid_f:.2f} USD × {kontr} Kontrakt(e) = {cost_total:.2f} USD\"\n            stop_str   = f\"–30% → {stop_usd:.2f} USD (max. {stop_e}€ Verlust)\"\n            tp1_str    = f\"+50% → {tp1_usd:.2f} USD | 50% schließen\"\n            tp2_str    = f\"Rest mit –10% Stop → {tp2_usd:.2f} USD\"\n        else:\n            cost_str = \"Einstieg: n/v\"\n            stop_str = f\"–30% = max. {stop_e}€\"\n            tp1_str  = \"+50% → 50% schließen\"\n            tp2_str  = \"Rest mit –10% Stop\"\n\n        exit_card = card(\"🎯\", \"#fff3e0\", \"Exit-Plan\",\n                         row(\"Gesamtkosten\",   cost_str) +\n                         row(\"Stop-Loss\",       stop_str, R) +\n                         row(\"Take-Profit 1\",   tp1_str, G) +\n                         row(\"Take-Profit 2\",   tp2_str, G) +\n                         row(\"Zeit-Exit\",       d.get(\"time_stop\", d.get(\"time_stop_rule\", \"48h ohne +1% Zielbewegung → Exit prüfen\"))) +\n                         row(\"Delta Rebalance\", \"Delta > ±0.30 → prüfen\") +\n                         row(\"Vega Exit\",       \"IV +20% → 50% schließen\", last=True))\n\n    # ── Marktstatus ───────────────────────────────────────\n    rc    = {\"gruen\": G, \"gelb\": O, \"rot\": R}.get(d.get(\"regime_farbe\",\"gelb\"), O)\n    ampel = (f'<span style=\"display:inline-block;width:11px;height:11px;border-radius:50%;'\n             f'background:{rc};margin-right:7px;vertical-align:middle;\"></span>')\n    try:\n        vix_f = float(str(d.get(\"vix\",\"15\")).replace(\",\",\".\"))\n    except (ValueError, TypeError):\n        vix_f = 15.0\n    vix_pct   = min(100, int((vix_f / 40) * 100))\n    vix_color = G if vix_f < 18 else (O if vix_f < 25 else R)\n\n    markt_card = card(\"🔍\", \"#e8f0fe\", \"Marktstatus\",\n                      f'<div style=\"display:flex;justify-content:space-between;'\n                      f'padding:12px 0;border-bottom:1px solid {BD};\">'\n                      f'<span style=\"font-size:14px;color:{GR};\">Regime</span>'\n                      f'<span style=\"font-size:15px;font-weight:700;color:{rc};\">'\n                      f'{ampel}{d.get(\"regime\",\"n/v\")}</span></div>'\n                      f'<div style=\"padding:12px 0;border-bottom:1px solid {BD};\">'\n                      f'<div style=\"display:flex;justify-content:space-between;margin-bottom:6px;\">'\n                      f'<span style=\"font-size:14px;color:{GR};\">VIX</span>'\n                      f'<span style=\"font-size:16px;font-weight:700;color:{vix_color};\">'\n                      f'{d.get(\"vix\",\"n/v\")}</span></div>'\n                      f'<div style=\"height:5px;background:#e5e5ea;border-radius:3px;\">'\n                      f'<div style=\"height:5px;width:{vix_pct}%;background:{vix_color};'\n                      f'border-radius:3px;\"></div></div></div>' +\n                      section(\"Marktlage\", d.get(\"markt\",\"\")) +\n                      section(\"Strategie\", d.get(\"strategie\",\"\")) +\n                      row(\"Ausgeschlossen\", d.get(\"ausgeschlossen\",\"–\"), last=True))\n\n    # ── Ticker Tabelle ────────────────────────────────────\n    def th(label, align=\"right\"):\n        return (f'<th style=\"padding:8px 6px;text-align:{align};font-size:11px;'\n                f'font-weight:600;color:{GR};text-transform:uppercase;'\n                f'border-bottom:2px solid {BD};\">{label}</th>')\n\n    def td(val, align=\"right\", color=DK, bold=False):\n        fw = \"700\" if bold else \"500\"\n        return (f'<td style=\"padding:10px 6px;text-align:{align};font-size:12px;'\n                f'font-weight:{fw};color:{color};border-bottom:1px solid {BD};\">{val}</td>')\n\n    rows_html = \"\"\n    for t in d.get(\"ticker_tabelle\", []):\n        if t.get(\"ticker\",\"\") in (\"X\",\"\",\"SYMBOL\"):\n            continue\n        chg       = t.get(\"chg\",\"\")\n        chg_col   = G if \"+\" in str(chg) else (R if \"-\" in str(chg) else DK)\n        row_color = LG if t.get(\"ausgeschlossen\") else DK\n        bold      = bool(t.get(\"gewinner\"))\n        rows_html += (f'<tr {\"style=background:#f0fff4;\" if bold else \"\"}>' +\n                      td((\"★ \" if bold else \"\") + t.get(\"ticker\",\"\"), \"left\",\n                         G if bold else row_color, bold) +\n                      td(t.get(\"kurs\",\"\"),   \"right\", row_color, bold) +\n                      td(chg,               \"right\", chg_col,   bold) +\n                      td(t.get(\"ma50\",\"\"),  \"right\", row_color) +\n                      td(t.get(\"trend\",\"\"), \"center\",row_color) +\n                      td(t.get(\"relvol\",\"\"),\"right\", O if t.get(\"unusual\") else row_color) +\n                      td(t.get(\"bull\",\"\"),  \"right\", row_color) +\n                      td(t.get(\"score\",\"\"), \"right\", row_color, bold) + \"</tr>\")\n\n    if not rows_html:\n        rows_html = (f'<tr><td colspan=\"8\" style=\"padding:16px;text-align:center;'\n                     f'font-size:12px;color:{GR};\">Keine Daten</td></tr>')\n\n    tabelle_card = card(\"📋\", \"#f0f0f5\", \"Alle analysierten Titel\",\n                        f'<table style=\"width:100%;border-collapse:collapse;\"><thead><tr>'\n                        f'{th(\"Ticker\",\"left\")}{th(\"Kurs\")}{th(\"Δ%\")}{th(\"MA50\")}'\n                        f'{th(\"Trend\",\"center\")}{th(\"RelVol\")}{th(\"Bull%\")}{th(\"Score\")}'\n                        f'</tr></thead><tbody>{rows_html}</tbody></table>')\n\n    # Status-Zeile zeigt korrekte Richtung\n    if no_trade:\n        status     = \"NO TRADE\"\n        status_col = R\n    else:\n        direction  = d.get(\"direction\", \"CALL\")\n        status     = (\"CALL · \" if direction != \"PUT\" else \"PUT · \") + d.get(\"ticker\",\"\")\n        status_col = G if direction != \"PUT\" else O\n\n    return (f'<html><head><meta charset=\"UTF-8\">'\n            f'<meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\"></head>'\n            f'<body style=\"margin:0;padding:0;background:{BG};'\n            f\"font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue',Arial,sans-serif;\\\">\"\n            f'<div style=\"max-width:620px;margin:0 auto;padding:32px 16px;\">'\n            f'<div style=\"text-align:center;margin-bottom:28px;\">'\n            f'<p style=\"margin:0 0 6px 0;font-size:12px;font-weight:600;color:{GR};'\n            f'letter-spacing:0.08em;text-transform:uppercase;\">Daily Options Report</p>'\n            f'<h1 style=\"margin:0 0 8px 0;font-size:30px;font-weight:700;color:{DK};\">'\n            f'Daily Options Report</h1>'\n            f'<div style=\"display:inline-block;background:{WH};border-radius:20px;'\n            f'padding:6px 18px;box-shadow:0 1px 6px rgba(0,0,0,0.08);\">'\n            f'<span style=\"font-size:14px;color:{GR};\">'\n            f'{d.get(\"datum\",today)} &nbsp;|&nbsp; '\n            f'VIX <strong>{d.get(\"vix\",\"n/v\")}</strong> &nbsp;|&nbsp; '\n            f'<strong style=\"color:{status_col};\">{status}</strong>'\n            f'</span></div></div>'\n            f'{trade_card}{vix_warning}{exit_card}{markt_card}{tabelle_card}'\n            f'<div style=\"text-align:center;padding:20px 0;'\n            f'border-top:1px solid {BD};margin-top:8px;\">'\n            f'<p style=\"margin:0;font-size:12px;color:{GR};\">VIX ✓ · Earnings ✓ · Greeks ✓</p>'\n            f'</div></div></body></html>')\n\n\n# ══════════════════════════════════════════════════════════\n# EMAIL\n# ══════════════════════════════════════════════════════════\n\ndef send_email(subject: str, html_content: str, cfg: dict) -> bool:\n    recipient = cfg.get(\"gmail_recipient\",\"\")\n    sender    = cfg.get(\"smtp_sender\",\"\")\n    password  = cfg.get(\"smtp_password\",\"\")\n    host      = cfg.get(\"smtp_host\",\"smtp.gmail.com\")\n    port      = int(cfg.get(\"smtp_port\", 587))\n\n    if not all([recipient, sender, password]):\n        logger.warning(\"SMTP nicht vollständig konfiguriert — Email nicht verschickt\")\n        return False\n\n    msg            = MIMEMultipart(\"alternative\")\n    msg[\"Subject\"] = subject\n    msg[\"From\"]    = sender\n    msg[\"To\"]      = recipient\n    msg.attach(MIMEText(html_content, \"html\", \"utf-8\"))\n\n    try:\n        with smtplib.SMTP(host, port, timeout=15) as smtp:\n            smtp.starttls()\n            smtp.login(sender, password)\n            smtp.sendmail(sender, recipient, msg.as_string())\n        logger.info(\"Email verschickt an %s\", recipient)\n        return True\n    except smtplib.SMTPException as e:\n        logger.error(\"SMTP-Fehler: %s\", e)\n        return False\n    except OSError as e:\n        logger.error(\"Netzwerk-Fehler beim Email-Versand: %s\", e)\n        return False\n\n\n# ══════════════════════════════════════════════════════════\n# DIREKTE AUSFÜHRUNG\n# ══════════════════════════════════════════════════════════\n\nif __name__ == \"__main__\":\n    import argparse\n    from config_loader import load_config, validate_config\n\n    logging.basicConfig(level=logging.INFO, format=\"%(asctime)s %(levelname)s %(message)s\")\n\n    parser = argparse.ArgumentParser(description=\"Report Generator\")\n    parser.add_argument(\"--summary\",      help=\"Market Summary Text\")\n    parser.add_argument(\"--summary-file\", help=\"Datei mit Market Summary\")\n    parser.add_argument(\"--output\",       help=\"HTML-Report speichern\")\n    parser.add_argument(\"--dry-run\",      action=\"store_true\")\n    args = parser.parse_args()\n\n    cfg = load_config()\n    if not validate_config(cfg):\n        raise SystemExit(\"Konfiguration unvollständig\")\n\n    if args.summary:\n        market_summary = args.summary\n    elif args.summary_file:\n        with open(args.summary_file) as f:\n            market_summary = f.read().strip()\n    else:\n        market_summary = sys.stdin.read().strip()\n\n    if not market_summary:\n        raise SystemExit(\"Kein Market Summary angegeben\")\n\n    today   = datetime.now().strftime(\"%d.%m.%Y\")\n    subject = \"Daily Options Report – \" + today\n\n    data        = call_claude(market_summary, cfg.get(\"anthropic_api_key\",\"\"))\n    html_report = build_html(data, today)\n\n    if args.output:\n        with open(args.output, \"w\", encoding=\"utf-8\") as f:\n            f.write(html_report)\n        logger.info(\"Report gespeichert: %s\", args.output)\n\n    if not args.dry_run:\n        send_email(subject, html_report, cfg)\n    else:\n        with open(\"report_preview.html\", \"w\", encoding=\"utf-8\") as f:\n            f.write(html_report)\n        logger.info(\"Dry-run: report_preview.html gespeichert\")\n"
  },
  {
    "path": "src/rules.py",
    "content": "\"\"\"\nrules.py — Zentrale Trading-Regeln\nv13 Rational-Gates + TradingRules Klasse:\n- EV nur mit konsistentem Snapshot sinnvoll: Tradier-Optionen brauchen bevorzugt Tradier-Underlying.\n- Realistisches Kostenmodell: Entry-Slippage + härtere Exit-Slippage.\n- Earnings/IV-Crush-Schutz: Long-Optionen bei nahen Earnings und hoher/unklarer IV blockieren.\n- Sentiment darf Ranking unterstützen, aber keine harten Gates überschreiben.\n- No-Trade-Gründe werden maschinenlesbar journalisiert.\n- NEU: evaluate_trade() + calculate_position_size() für Hard-Gates und dynamisches Risk-Management.\n\"\"\"\n\nfrom __future__ import annotations\nfrom dataclasses import dataclass\nfrom typing import Any\n\n@dataclass(frozen=True)\nclass TradingRules:\n    # VIX-Grenzen\n    vix_hard_limit: float = 25.0\n    vix_reduced_limit: float = 20.0\n    # Einsatz in EUR/USD-Äquivalent für Positionsgröße\n    einsatz_normal: int = 250\n    einsatz_reduced: int = 150\n    # Risiko\n    stop_loss_pct: float = 0.30\n    # Score-Schwellen\n    research_min_score: int = 50\n    min_score: int = 65\n    # Datenqualität / Snapshot-Konsistenz\n    require_tradier_quote_for_tradier_options: bool = True\n    max_quote_age_seconds: int = 900\n    # Liquidität / Ausführbarkeit\n    preferred_spread_pct: float = 5.0\n    caution_spread_pct: float = 8.0\n    max_spread_pct: float = 10.0\n    warn_spread_pct: float = 5.0\n    wide_spread_min_ev_pct: float = 25.0\n    wide_spread_min_ev_dollars: float = 35.0\n    min_open_interest: int = 500\n    min_option_volume: int = 1\n    max_entry_spread_share: float = 0.50\n    base_exit_spread_share: float = 0.60\n    high_spread_exit_share: float = 0.80\n    stress_exit_spread_share: float = 1.00\n    min_fill_probability: float = 0.35\n    # Options-EV Filter\n    target_delta_abs: float = 0.45\n    min_option_ev_pct: float = 12.0\n    min_option_ev_dollars: float = 12.0\n    ev_hold_days: int = 2\n    # IV-/Earnings-Schutz\n    earnings_window_days: int = 10\n    block_long_options_if_earnings_soon: bool = True\n    block_earnings_if_iv_missing: bool = True\n    max_iv_to_rv_for_earnings: float = 1.35\n    max_iv_to_rv_general: float = 2.20\n    cold_start_iv_to_rv_hard_block: float = 1.50\n    mature_iv_to_rv_hard_block: float = 1.80\n    iv_rv_penalty_factor: float = 0.18\n    # Vega-Cost-Modell\n    iv_crush_after_news_pct: float = 0.20\n    iv_crush_after_earnings_pct: float = 0.40\n    iv_crush_baseline_pct: float = 0.05\n    iv_crush_high_iv_bonus_pct: float = 0.10\n    # Signal-Parsing\n    valid_directions: tuple = (\"CALL\", \"PUT\")\n    valid_scores: tuple = (\"HIGH\", \"MED\", \"LOW\")\n    valid_horizons: tuple = (\"T1\", \"T2\", \"T3\")\n    max_tickers: int = 5\n    min_dte_days: int = 7\n    max_dte_days: int = 120\n    # LLM-Schema-Guard\n    llm_fail_closed: bool = True\n    # Eigener IV-Verlauf aus dem Journal\n    min_iv_history_samples_for_rank: int = 30\n    iv_rank_hard_block_long: float = 80.0\n    iv_percentile_hard_block_long: float = 90.0\n    iv_rank_prefer_long_below: float = 35.0\n    # Sektor-/Markt-Momentum\n    sector_relative_strength_min: float = 0.30\n    sector_vs_market_confirm_min: float = 0.10\n    sector_confirms_score_bonus: float = 8.0\n    sector_disagrees_score_malus: float = -12.0\n    # Time-Stop-Plan\n    time_stop_target_move_pct: float = 1.0\n    time_stop_short_dte_hours: int = 24\n    time_stop_normal_dte_hours: int = 48\n    time_stop_long_dte_hours: int = 72\n    # Daily-RVOL\n    daily_rvol_unusual_threshold: float = 1.5\n\n    # === NEUE HARD GATES (aus deiner Anfrage) ===\n    min_market_cap: int = 50_000_000          # 50M Minimum\n    min_price: float = 1.0                    # Keine Penny Stocks\n    max_spread_pct: float = 8.0               # Max Spread (konservativ)\n    min_news_alpha: int = 55                  # Mindest-Confidence aus news_analyzer\n\n    def evaluate_trade(self, ticker_info: dict, market_metrics: dict, news_alpha: float):\n        \"\"\"Das ultimative Filter-System (Hard Gates).\"\"\"\n        # 1. Grundlegende Filter\n        if ticker_info.get('market_cap', 0) < self.min_market_cap:\n            return False, f\"Market Cap too low ({ticker_info.get('market_cap')})\"\n\n        if ticker_info.get('price', 0) < self.min_price:\n            return False, f\"Price below threshold ({ticker_info.get('price')})\"\n\n        # 2. News-Qualität Filter\n        if news_alpha < self.min_news_alpha:\n            return False, f\"Weak News Alpha ({news_alpha})\"\n\n        # 3. Markt-Bestätigung Filter\n        if not market_metrics.get('is_confirmed', False) and not market_metrics.get('gap_volume_confirmed', False):\n            return False, \"No Volume/Gap Confirmation\"\n\n        # 4. Spread Check\n        if ticker_info.get('spread_pct', 0) > self.max_spread_pct:\n            return False, f\"Spread too wide ({ticker_info.get('spread_pct')}%)\"\n\n        return True, \"All Filters Passed\"\n\n    def calculate_position_size(self, confidence_score: float, account_value: float) -> float:\n        \"\"\"Dynamisches Risk-Management basierend auf Conviction.\"\"\"\n        if confidence_score >= 85:\n            risk_pct = 0.05   # 5% für Top-Signale\n        elif confidence_score >= 72:\n            risk_pct = 0.03   # 3% Standard High-Conviction\n        elif confidence_score >= 60:\n            risk_pct = 0.02\n        else:\n            risk_pct = 0.01   # 1% für marginale Setups\n\n        return round(account_value * risk_pct, 2)\n\n\n# Globale Instanz\nRULES = TradingRules()\n\n\ndef _to_float(value: Any, default=None):\n    try:\n        if value is None:\n            return default\n        return float(str(value).replace(\"€\", \"\").replace(\"$\", \"\").replace(\",\", \".\").strip())\n    except (ValueError, TypeError):\n        return default\n\n\ndef merge_reasons(*parts: Any) -> str:\n    \"\"\"Kompakter, deduplizierter No-Trade-Grund.\"\"\"\n    seen = set()\n    out = []\n    for part in parts:\n        if not part:\n            continue\n        if isinstance(part, (list, tuple, set)):\n            values = part\n        else:\n            values = str(part).split(\"|\")\n        for raw in values:\n            item = str(raw).strip()\n            if not item or item in seen:\n                continue\n            seen.add(item)\n            out.append(item)\n    return \" | \".join(out)\n\n\n# ══════════════════════════════════════════════════════════\n# KOSTENMODELL / AUSFÜHRBARKEIT\n# ══════════════════════════════════════════════════════════\ndef conservative_entry_price(options_data: dict) -> float | None:\n    \"\"\"\n    Realistischer Einstieg statt Midpoint.\n    Für Long-Optionen ist der echte Fill oft zwischen Mid und Ask.\n    \"\"\"\n    if not options_data:\n        return None\n    bid = _to_float(options_data.get(\"bid\"))\n    ask = _to_float(options_data.get(\"ask\"))\n    mid = _to_float(options_data.get(\"midpoint\"))\n    if bid is None or ask is None or mid is None or bid <= 0 or ask <= 0 or ask < bid:\n        return None\n    spread = ask - bid\n    entry = min(ask, mid + spread * RULES.max_entry_spread_share)\n    return round(entry, 2)\n\n\ndef exit_slippage_points(options_data: dict) -> float:\n    \"\"\"\n    Exit ist konservativer als Entry. Bei breiteren Spreads steigt der Haircut.\n    Rückgabe in Optionspreis-Punkten, nicht Prozent.\n    \"\"\"\n    if not options_data:\n        return 0.0\n    bid = _to_float(options_data.get(\"bid\"), 0.0)\n    ask = _to_float(options_data.get(\"ask\"), 0.0)\n    spread_pct = _to_float(options_data.get(\"spread_pct\"), 999.0)\n    spread = max(0.0, ask - bid)\n    if spread_pct >= 10.0:\n        share = RULES.stress_exit_spread_share\n    elif spread_pct >= RULES.warn_spread_pct:\n        share = RULES.high_spread_exit_share\n    else:\n        share = RULES.base_exit_spread_share\n    return round(spread * share, 4)\n\n\ndef estimate_fill_probability(options_data: dict) -> float:\n    \"\"\"\n    Grobe Fill-Wahrscheinlichkeit aus Spread, OI und Volumen.\n    \"\"\"\n    if not options_data:\n        return 0.0\n    spread_pct = _to_float(options_data.get(\"spread_pct\"), 999.0)\n    oi = _to_float(options_data.get(\"open_interest\"), 0.0)\n    vol = _to_float(options_data.get(\"volume\"), 0.0)\n    spread_score = max(0.0, min(1.0, 1.0 - spread_pct / 20.0))\n    oi_score = max(0.0, min(1.0, oi / 5000.0))\n    vol_score = max(0.0, min(1.0, vol / 500.0))\n    p = 0.55 * spread_score + 0.30 * oi_score + 0.15 * vol_score\n    return round(max(0.0, min(1.0, p)), 3)\n\n\ndef check_data_quality(market_data: dict, options_data: dict) -> tuple[bool, str]:\n    \"\"\"\n    Prüft, ob Underlying- und Optionssnapshot zusammenpassen.\n    \"\"\"\n    if not market_data:\n        return False, \"Marktdaten fehlen\"\n    price = _to_float(market_data.get(\"price\"), 0.0)\n    quote_src = str(market_data.get(\"_src_quote\") or market_data.get(\"quote_source\") or \"\").lower()\n    option_src = str((options_data or {}).get(\"option_source\") or \"\").lower()\n    if price <= 0:\n        return False, \"Underlying-Preis fehlt\"\n    if option_src == \"tradier\" and RULES.require_tradier_quote_for_tradier_options:\n        if not quote_src.startswith(\"tradier\"):\n            return False, \"Inkonsistenter Snapshot: Option Tradier aber Underlying nicht Tradier\"\n    quote_age = _to_float(market_data.get(\"quote_age_seconds\"), 0.0)\n    if quote_age and quote_age > RULES.max_quote_age_seconds:\n        return False, f\"Quote zu alt: {int(quote_age)}s\"\n    return True, \"ok\"\n\n\ndef check_liquidity(options_data: dict) -> tuple[bool, str]:\n    \"\"\"\n    Prüft Optionsliquidität als harten Filter.\n    \"\"\"\n    if not options_data:\n        return False, \"Keine Optionsdaten verfuegbar\"\n    bid = _to_float(options_data.get(\"bid\"))\n    ask = _to_float(options_data.get(\"ask\"))\n    mid = _to_float(options_data.get(\"midpoint\"))\n    spread_pct = _to_float(options_data.get(\"spread_pct\"))\n    open_int = _to_float(options_data.get(\"open_interest\"))\n    volume = _to_float(options_data.get(\"volume\"), 0.0)\n    if bid is None or bid <= 0:\n        return False, \"Bid fehlt oder 0\"\n    if ask is None or ask <= 0:\n        return False, \"Ask fehlt oder 0\"\n    if mid is None or mid <= 0:\n        return False, \"Midpoint fehlt\"\n    if ask < bid:\n        return False, \"Ask kleiner als Bid\"\n    if spread_pct is None:\n        return False, \"Spread nicht berechenbar\"\n    if open_int is None:\n        return False, \"Open Interest fehlt\"\n    if spread_pct > RULES.max_spread_pct:\n        return False, f\"Spread {spread_pct:.1f}% > {RULES.max_spread_pct}% harter Retail-Block\"\n    if spread_pct > RULES.caution_spread_pct:\n        ev_pct = _to_float(options_data.get(\"ev_pct\"), -999.0)\n        ev_dollars = _to_float(options_data.get(\"ev_dollars\"), -999.0)\n        if ev_pct < RULES.wide_spread_min_ev_pct or ev_dollars < RULES.wide_spread_min_ev_dollars:\n            return False, (\n                f\"Spread {spread_pct:.1f}% > {RULES.caution_spread_pct}% nur bei starkem EV handelbar \"\n                f\"(EV% {ev_pct}, EV$ {ev_dollars})\"\n            )\n    if open_int < RULES.min_open_interest:\n        return False, f\"OI {int(open_int)} < {RULES.min_open_interest} Limit\"\n    if volume < RULES.min_option_volume:\n        return False, f\"Optionsvolumen {int(volume)} < {RULES.min_option_volume}\"\n    fill_p = estimate_fill_probability(options_data)\n    if fill_p < RULES.min_fill_probability:\n        return False, f\"Fill-Wahrscheinlichkeit {fill_p:.2f} < {RULES.min_fill_probability:.2f}\"\n    return True, \"ok\"\n\n\ndef check_earnings_iv_gate(options_data: dict, earnings_soon: bool) -> tuple[bool, str]:\n    \"\"\"\n    Harte Sperre für Long-Optionen bei nahen Earnings und teurer/unklarer IV.\n    \"\"\"\n    if not earnings_soon or not RULES.block_long_options_if_earnings_soon:\n        return True, \"ok\"\n    iv = _to_float((options_data or {}).get(\"iv_decimal\"))\n    rv = _to_float((options_data or {}).get(\"realized_vol_20d\"))\n    iv_to_rv = _to_float((options_data or {}).get(\"iv_to_rv\"))\n    if iv is None or iv <= 0 or rv is None or rv <= 0 or iv_to_rv is None:\n        if RULES.block_earnings_if_iv_missing:\n            return False, \"Earnings nahe und IV/RV unbekannt\"\n        return True, \"ok\"\n    if iv_to_rv >= RULES.max_iv_to_rv_for_earnings:\n        return False, f\"Earnings nahe und IV/RV {iv_to_rv:.2f} zu hoch\"\n    return True, \"ok\"\n\n\ndef build_time_stop_plan(direction: str, dte_actual: int | None) -> dict:\n    \"\"\"\n    Options-Time-Stop: Wenn der Underlying nach kurzer Zeit nicht in Zielrichtung laeuft,\n    ist die Long-Option wegen Theta/Spread statistisch schlechter.\n    \"\"\"\n    try:\n        dte = int(dte_actual or 0)\n    except (TypeError, ValueError):\n        dte = 0\n    if dte <= 14:\n        hours = RULES.time_stop_short_dte_hours\n    elif dte <= 30:\n        hours = RULES.time_stop_normal_dte_hours\n    else:\n        hours = RULES.time_stop_long_dte_hours\n    sign = \"+\" if str(direction).upper() == \"CALL\" else \"-\"\n    return {\n        \"time_stop_hours\": hours,\n        \"time_stop_required_move_pct\": RULES.time_stop_target_move_pct,\n        \"time_stop_rule\": (\n            f\"Nach {hours}h pruefen: Underlying muss {sign}{RULES.time_stop_target_move_pct:.1f}% \"\n            f\"in Zielrichtung gelaufen sein, sonst Exit/Close pruefen\"\n        ),\n    }\n\n\n# ══════════════════════════════════════════════════════════\n# VIX-REGELPRÜFUNG\n# ══════════════════════════════════════════════════════════\ndef apply_vix_rules(vix_direct, claude_output: dict) -> dict:\n    \"\"\"\n    VIX ist autoritativ aus get_vix().\n    \"\"\"\n    result = dict(claude_output)\n    try:\n        vix = float(str(vix_direct).replace(\",\", \".\"))\n        vix_unknown = vix <= 0\n    except (ValueError, TypeError):\n        vix_unknown = True\n        vix = None\n    if vix_unknown:\n        result.update({\n            \"no_trade\": True,\n            \"no_trade_grund\": merge_reasons(result.get(\"no_trade_grund\"), \"VIX nicht verfuegbar kein Trade\"),\n            \"vix_warnung\": False,\n            \"einsatz\": 0,\n            \"stop_loss_eur\": 0,\n            \"kontrakte\": \"n/v\",\n        })\n        return result\n    if vix >= RULES.vix_hard_limit:\n        result.update({\n            \"no_trade\": True,\n            \"no_trade_grund\": merge_reasons(result.get(\"no_trade_grund\"), \"VIX zu hoch Kapitalschutz aktiv\"),\n            \"vix_warnung\": False,\n            \"einsatz\": 0,\n            \"stop_loss_eur\": 0,\n            \"kontrakte\": \"n/v\",\n        })\n        return result\n    einsatz = RULES.einsatz_reduced if vix >= RULES.vix_reduced_limit else RULES.einsatz_normal\n    result[\"einsatz\"] = einsatz\n    result[\"vix_warnung\"] = vix >= RULES.vix_reduced_limit\n    result[\"stop_loss_eur\"] = round(einsatz * RULES.stop_loss_pct)\n    if not result.get(\"no_trade\"):\n        entry = _to_float(result.get(\"conservative_entry\"))\n        if entry is None:\n            entry = _to_float(result.get(\"entry_price\"))\n        if entry is None:\n            entry = _to_float(result.get(\"midpoint\"))\n        if entry and entry > 0:\n            kontrakte = int(einsatz // (entry * 100))\n            if kontrakte < 1:\n                result.update({\n                    \"no_trade\": True,\n                    \"no_trade_grund\": merge_reasons(result.get(\"no_trade_grund\"), \"Entry zu hoch Budget reicht nicht\"),\n                    \"einsatz\": 0,\n                    \"stop_loss_eur\": 0,\n                    \"kontrakte\": \"n/v\",\n                })\n                return result\n            result[\"kontrakte\"] = str(kontrakte)\n            result[\"entry_price\"] = round(entry, 2)\n        else:\n            result[\"kontrakte\"] = \"n/v\"\n    return result\n\n\n# ══════════════════════════════════════════════════════════\n# CLAUDE-OUTPUT VALIDIERUNG\n# ══════════════════════════════════════════════════════════\ndef validate_claude_output(data: dict) -> tuple:\n    errors = []\n    for field in [\"datum\", \"vix\", \"regime\", \"no_trade\"]:\n        if field not in data:\n            errors.append(f\"Pflichtfeld fehlt: {field}\")\n    no_trade = data.get(\"no_trade\", False)\n    if not no_trade:\n        for field in [\"ticker\", \"strike\", \"laufzeit\", \"delta\", \"midpoint\"]:\n            if not data.get(field):\n                errors.append(f\"Trade-Feld fehlt: {field}\")\n        if data.get(\"direction\") not in RULES.valid_directions:\n            errors.append(f\"Ungültige direction: {data.get('direction')}\")\n        einsatz = data.get(\"einsatz\")\n        if einsatz is not None:\n            try:\n                e = int(str(einsatz).replace(\"€\", \"\").strip())\n                if e not in (RULES.einsatz_normal, RULES.einsatz_reduced):\n                    errors.append(f\"Einsatz {e} ungültig\")\n            except (ValueError, TypeError):\n                errors.append(f\"Einsatz nicht numerisch: {einsatz}\")\n    if data.get(\"regime\") and data.get(\"regime\") not in (\"LOW-VOL\", \"TRENDING\", \"HIGH-VOL\"):\n        errors.append(f\"Ungültiges Regime: {data.get('regime')}\")\n    if data.get(\"regime_farbe\") and data.get(\"regime_farbe\") not in (\"gruen\", \"gelb\", \"rot\"):\n        errors.append(f\"Ungültige regime_farbe: {data.get('regime_farbe')}\")\n    tabelle = data.get(\"ticker_tabelle\", [])\n    if not isinstance(tabelle, list) or len(tabelle) == 0:\n        errors.append(\"ticker_tabelle fehlt oder leer\")\n    return len(errors) == 0, errors\n\n\n# ══════════════════════════════════════════════════════════\n# SIGNAL-PARSING\n# ══════════════════════════════════════════════════════════\ndef parse_ticker_signals(raw: str) -> list:\n    \"\"\"\n    Parser für TICKER_SIGNALS:TICKER:RICHTUNG:SCORE:HORIZONT:DTE,...\n    \"\"\"\n    if not raw:\n        return []\n    clean = raw.strip()\n    if clean.startswith(\"TICKER_SIGNALS:\"):\n        clean = clean[len(\"TICKER_SIGNALS:\"):]\n    if not clean or clean == \"NONE\":\n        return []\n    results = []\n    for entry in clean.split(\",\"):\n        entry = entry.strip()\n        if not entry:\n            continue\n        parts = entry.split(\":\")\n        if len(parts) < 5:\n            continue\n        ticker = parts[0].strip().upper()\n        direction = parts[1].strip().upper()\n        score = parts[2].strip().upper()\n        horizon = parts[3].strip().upper()\n        dte_raw = parts[4].strip().upper()\n        if not ticker or len(ticker) > 5:\n            continue\n        if direction not in RULES.valid_directions:\n            continue\n        if score not in RULES.valid_scores:\n            continue\n        if horizon not in RULES.valid_horizons:\n            continue\n        if not dte_raw.endswith(\"DTE\"):\n            continue\n        try:\n            dte_days = int(dte_raw.replace(\"DTE\", \"\"))\n        except ValueError:\n            continue\n        if dte_days < RULES.min_dte_days or dte_days > RULES.max_dte_days:\n            continue\n        results.append({\n            \"ticker\": ticker,\n            \"direction\": direction,\n            \"score\": score,\n            \"horizon\": horizon,\n            \"dte\": dte_raw,\n            \"dte_days\": dte_days,\n        })\n    return results\n"
  },
  {
    "path": "src/sec_check.py",
    "content": "\"\"\"\nsec_check.py — strukturierter SEC EDGAR Catalyst-Check ohne API-Key.\n\nDatenquellen:\n- https://www.sec.gov/files/company_tickers.json\n- https://data.sec.gov/submissions/CIK##########.json\n- Filing-Dokumente aus sec.gov/Archives\n\nZiel:\n- Form 4 differenzierter: Kauf != Award != Optionsausübung != Steuerverkauf.\n- 8-K nach Items/Keywords klassifizieren.\n- Fail-safe: bei Fehler neutral.\n- Bonus: get_company_name_to_ticker() liefert Name->Ticker-Mapping\n  für News-Headline-Auflösung (z.B. \"Apple reports...\" -> \"AAPL\").\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nimport re\nimport xml.etree.ElementTree as ET\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\nfrom typing import Any\n\nimport requests\n\nlogger = logging.getLogger(__name__)\n\nDATA_DIR = Path(__file__).resolve().parent.parent / \"data\"\nCIK_CACHE = DATA_DIR / \"sec_company_tickers.json\"\nSEC_UA = (os.environ.get(\"SEC_USER_AGENT\") or \"DailyOptionsReport/1.0 contact@example.com\").strip()\n\nETF_TICKERS = {\n    \"TLT\", \"USO\", \"GLD\", \"SLV\", \"GDX\", \"SPY\", \"QQQ\", \"IWM\", \"DIA\",\n    \"XLE\", \"XLF\", \"XLK\", \"XLV\", \"XLI\", \"XLU\", \"XLP\", \"XLY\", \"XLB\", \"XLRE\",\n}\n\nBEARISH_8K_KEYWORDS = {\n    \"restatement\": 0.75,\n    \"material weakness\": 0.85,\n    \"going concern\": 0.90,\n    \"bankruptcy\": 0.95,\n    \"default\": 0.80,\n    \"delisting\": 0.80,\n    \"investigation\": 0.65,\n    \"sec subpoena\": 0.80,\n    \"class action\": 0.55,\n    \"securities fraud\": 0.80,\n    \"ceo resignation\": 0.60,\n    \"cfo resignation\": 0.60,\n    \"impairment\": 0.65,\n    \"restructuring charge\": 0.60,\n}\n\nBULLISH_8K_KEYWORDS = {\n    \"acquisition\": 0.55,\n    \"merger agreement\": 0.65,\n    \"definitive agreement\": 0.55,\n    \"share repurchase\": 0.55,\n    \"buyback\": 0.55,\n    \"dividend increase\": 0.50,\n    \"fda approval\": 0.75,\n    \"fda clearance\": 0.65,\n    \"accelerated approval\": 0.80,\n    \"strategic partnership\": 0.50,\n    \"licensing agreement\": 0.55,\n    \"record revenue\": 0.45,\n    \"record earnings\": 0.50,\n}\n\nEMPTY_RESULT = {\n    \"bullish\": False,\n    \"bearish\": False,\n    \"insider_buy\": False,\n    \"insider_sell\": False,\n    \"reason\": \"Keine SEC-Daten\",\n    \"confidence\": 0.0,\n    \"filings_checked\": 0,\n    \"events\": [],\n}\n\n# ==================== NAME → TICKER MAPPING (Konstanten) ====================\n\n# Suffixe, die beim Normalisieren von Firmennamen entfernt werden\n_CORP_SUFFIXES = {\n    \"inc\", \"corp\", \"corporation\", \"incorporated\", \"co\", \"company\",\n    \"ltd\", \"limited\", \"llc\", \"plc\", \"lp\", \"lllp\",\n    \"holdings\", \"holding\", \"group\", \"trust\",\n    \"sa\", \"ag\", \"nv\", \"bv\", \"spa\", \"kgaa\",\n    \"common\", \"stock\", \"ordinary\", \"shares\",\n    \"class\", \"a\", \"b\", \"c\", \"adr\", \"ads\",\n    \"the\",\n}\n\n# Hand-kuratierte Aliase haben Vorrang vor der SEC-Map.\n# Hier landen Marketing-Namen (\"Google\" statt \"Alphabet\"), Klassenwahl\n# (BRK.B liquider als BRK.A), alte Firmennamen (\"Facebook\" -> META) und\n# \"Rettungsanker\" für kurze Ticker oder Ein-Buchstaben-Symbole, die sonst\n# durch die Sicherheitsregeln im Resolver fallen würden.\nCOMPANY_NAME_OVERRIDES = {\n    # --- TECH & GROWTH ---\n    \"alphabet\": \"GOOGL\",\n    \"google\": \"GOOGL\",\n    \"meta platforms\": \"META\",\n    \"meta\": \"META\",\n    \"facebook\": \"META\",\n    \"apple\": \"AAPL\",\n    \"microsoft\": \"MSFT\",\n    \"amazon\": \"AMZN\",\n    \"nvidia\": \"NVDA\",\n    \"tesla\": \"TSLA\",\n    \"netflix\": \"NFLX\",\n    \"salesforce\": \"CRM\",\n    \"oracle\": \"ORCL\",\n    \"adobe\": \"ADBE\",\n    \"palantir\": \"PLTR\",\n    \"shopify\": \"SHOP\",\n    \"spotify\": \"SPOT\",\n    \"uber\": \"UBER\",\n    \"airbnb\": \"ABNB\",\n    \"lyft\": \"LYFT\",\n    \"doordash\": \"DASH\",\n    \"door dash\": \"DASH\",\n    \"super micro\": \"SMCI\",\n    \"supermicro\": \"SMCI\",\n    \"snowflake\": \"SNOW\",\n    \"crowdstrike\": \"CRWD\",\n    \"palo alto networks\": \"PANW\",\n\n    # --- CHIPS & HARDWARE ---\n    \"advanced micro devices\": \"AMD\",\n    \"amd\": \"AMD\",\n    \"intel\": \"INTC\",\n    \"broadcom\": \"AVGO\",\n    \"qualcomm\": \"QCOM\",\n    \"taiwan semiconductor\": \"TSM\",\n    \"tsmc\": \"TSM\",\n    \"asml\": \"ASML\",\n    \"arm holdings\": \"ARM\",\n    \"applied materials\": \"AMAT\",\n    \"ibm\": \"IBM\",\n    \"dell\": \"DELL\",\n\n    # --- FINANCE ---\n    \"jpmorgan\": \"JPM\",\n    \"jp morgan\": \"JPM\",\n    \"jpmorgan chase\": \"JPM\",\n    \"goldman sachs\": \"GS\",\n    \"morgan stanley\": \"MS\",\n    \"bank of america\": \"BAC\",\n    \"wells fargo\": \"WFC\",\n    \"citigroup\": \"C\",            # 1-Buchstabe-Ticker, Override-Privileg\n    \"visa\": \"V\",                 # 1-Buchstabe-Ticker, Override-Privileg\n    \"mastercard\": \"MA\",\n    \"paypal\": \"PYPL\",\n    \"robinhood\": \"HOOD\",\n    \"coinbase\": \"COIN\",\n    \"blackrock\": \"BLK\",\n    \"charles schwab\": \"SCHW\",\n\n    # --- RETAIL & CONSUMER ---\n    \"walmart\": \"WMT\",\n    \"costco\": \"COST\",\n    \"home depot\": \"HD\",\n    \"lowes\": \"LOW\",\n    \"nike\": \"NKE\",\n    \"starbucks\": \"SBUX\",\n    \"mcdonalds\": \"MCD\",\n    \"coca cola\": \"KO\",\n    \"coca-cola\": \"KO\",\n    \"pepsi\": \"PEP\",\n    \"pepsico\": \"PEP\",\n    \"procter and gamble\": \"PG\",  # nach &-Normalisierung\n    \"p and g\": \"PG\",\n    \"estee lauder\": \"EL\",\n    \"lululemon\": \"LULU\",\n    \"ford\": \"F\",                 # 1-Buchstabe-Ticker, Override-Privileg\n    \"general motors\": \"GM\",\n    \"ebay\": \"EBAY\",\n\n    # --- ENERGY & INDUSTRIAL ---\n    \"exxon\": \"XOM\",\n    \"exxon mobil\": \"XOM\",\n    \"exxonmobil\": \"XOM\",\n    \"chevron\": \"CVX\",\n    \"shell\": \"SHEL\",\n    \"boeing\": \"BA\",\n    \"lockheed martin\": \"LMT\",\n    \"raytheon\": \"RTX\",\n    \"general electric\": \"GE\",\n    \"ge aerospace\": \"GE\",\n    \"ge healthcare\": \"GEHC\",\n    \"ge vernova\": \"GEV\",\n    \"caterpillar\": \"CAT\",\n\n    # --- HEALTHCARE & PHARMA ---\n    \"pfizer\": \"PFE\",\n    \"eli lilly\": \"LLY\",\n    \"johnson and johnson\": \"JNJ\",  # nach &-Normalisierung\n    \"j and j\": \"JNJ\",\n    \"merck\": \"MRK\",\n    \"unitedhealth\": \"UNH\",\n    \"cigna\": \"CI\",\n    \"moderna\": \"MRNA\",\n    \"abbvie\": \"ABBV\",\n    \"amgen\": \"AMGN\",\n    \"gilead\": \"GILD\",\n    \"astrazeneca\": \"AZN\",\n    \"novo nordisk\": \"NVO\",\n\n    # --- TELECOM, MEDIA, SPECIALS ---\n    \"disney\": \"DIS\",\n    \"walt disney\": \"DIS\",\n    \"at and t\": \"T\",             # nach &-Normalisierung\n    \"verizon\": \"VZ\",\n    \"t-mobile\": \"TMUS\",\n    \"berkshire hathaway\": \"BRK.B\",\n    \"berkshire\": \"BRK.B\",\n}\n\n# Generische Wörter, die als Firmenname zu False-Positives führen.\n# Liste wird laufend erweitert basierend auf beobachteten Match-Fehlern.\n_NAME_BLOCKLIST = {\n    # Generische Geo/Größe-Adjektive\n    \"global\", \"international\", \"american\", \"national\", \"general\",\n    \"first\", \"new\", \"us\", \"usa\", \"united\", \"world\",\n    \"the\", \"and\", \"of\", \"for\",\n    # Penny-Stock-Falle: Ticker existiert, Wort ist aber zu häufig\n    \"block\", \"match\", \"snap\", \"square\", \"trade\", \"city\", \"state\",\n    \"here\", \"there\", \"this\", \"that\", \"them\",\n    \"viking\", \"emerging\", \"target\",\n    # Sektor-/Branchenwörter, die News oft enthalten\n    \"strategy\",  # MSTR seit Umbenennung von MicroStrategy\n    \"energy\", \"tech\", \"financial\", \"industrial\", \"consumer\",\n    \"media\", \"data\", \"research\", \"services\", \"solutions\",\n    \"systems\", \"products\", \"technologies\", \"innovations\",\n    # Rohstoff-Begriffe — Headlines über Preise, nicht über die Firmen\n    \"coffee\", \"cocoa\", \"wheat\", \"corn\", \"oil\", \"gold\", \"silver\",\n    \"copper\", \"platinum\", \"uranium\", \"lithium\", \"nickel\",\n    \"natural gas\", \"crude\",\n    # Investment-Vokabular\n    \"capital\", \"equity\", \"fund\", \"income\", \"growth\", \"value\",\n    \"dividend\", \"premium\", \"core\", \"alpha\", \"beta\",\n    # Generische Akronyme — meinen in Headlines fast immer das Konzept,\n    # nicht den gleichnamigen Ticker (z.B. \"AI\" statt C3.ai)\n    \"ai\", \"it\", \"ip\", \"ev\", \"ceo\", \"cfo\", \"cto\", \"ipo\",\n    \"api\", \"saas\", \"esg\", \"ar\", \"vr\", \"ml\",\n}\n\n# Modul-weiter Cache, vermeidet wiederholtes Parsen der SEC-Datei\n_cached_name_map: dict[str, str] | None = None\n_cached_cik_map: dict[int, str] | None = None\n\n\ndef _headers() -> dict:\n    # Kein manueller Host-Header: derselbe Helper wird fuer www.sec.gov\n    # und data.sec.gov genutzt. Ein falscher Host verursacht 403.\n    return {\n        \"User-Agent\": SEC_UA,\n        \"Accept-Encoding\": \"gzip, deflate\",\n        \"Accept\": \"application/json,text/plain,*/*\",\n    }\n\n\ndef _archive_headers() -> dict:\n    return {\n        \"User-Agent\": SEC_UA,\n        \"Accept-Encoding\": \"gzip, deflate\",\n        \"Accept\": \"application/xml,text/html,text/plain,*/*\",\n    }\n\n\ndef _get_json(url: str) -> Any:\n    r = requests.get(url, headers=_headers(), timeout=12)\n    r.raise_for_status()\n    return r.json()\n\n\ndef _get_text(url: str) -> str:\n    r = requests.get(url, headers=_archive_headers(), timeout=12)\n    r.raise_for_status()\n    return r.text\n\n\ndef _load_sec_raw_tickers() -> dict:\n    \"\"\"Lädt company_tickers.json (mit 7-Tage-Cache). Zentrale Helper-Funktion,\n    damit _load_ticker_map und get_company_name_to_ticker dieselbe Logik nutzen.\n    \"\"\"\n    DATA_DIR.mkdir(parents=True, exist_ok=True)\n    if CIK_CACHE.exists():\n        age = datetime.now(timezone.utc) - datetime.fromtimestamp(\n            CIK_CACHE.stat().st_mtime, tz=timezone.utc)\n        if age.days < 7:\n            return json.loads(CIK_CACHE.read_text(encoding=\"utf-8\"))\n\n    raw = _get_json(\"https://www.sec.gov/files/company_tickers.json\")\n    CIK_CACHE.write_text(json.dumps(raw), encoding=\"utf-8\")\n    return raw\n\n\ndef _load_ticker_map() -> dict[str, int]:\n    try:\n        raw = _load_sec_raw_tickers()\n        return {v[\"ticker\"].upper(): int(v[\"cik_str\"]) for v in raw.values()}\n    except Exception as e:\n        logger.warning(\"Ticker-Map konnte nicht geladen werden: %s\", e)\n        return {}\n\n\ndef _filing_url(cik: int, accession: str, primary_doc: str) -> str:\n    cik_plain = str(cik)\n    acc_plain = accession.replace(\"-\", \"\")\n    return f\"https://www.sec.gov/Archives/edgar/data/{cik_plain}/{acc_plain}/{primary_doc}\"\n\n\ndef _recent_filings(cik: int) -> list[dict]:\n    cik10 = str(cik).zfill(10)\n    data = _get_json(f\"https://data.sec.gov/submissions/CIK{cik10}.json\")\n    recent = data.get(\"filings\", {}).get(\"recent\", {})\n    keys = [\"form\", \"filingDate\", \"accessionNumber\", \"primaryDocument\", \"items\", \"primaryDocDescription\"]\n    n = len(recent.get(\"form\", []))\n    rows = []\n    for i in range(n):\n        rows.append({k: (recent.get(k, [None] * n)[i] if i < len(recent.get(k, [])) else None) for k in keys})\n    return rows\n\n\ndef _within_days(date_str: str, days_back: int) -> bool:\n    try:\n        d = datetime.strptime(date_str, \"%Y-%m-%d\").replace(tzinfo=timezone.utc)\n        return d >= datetime.now(timezone.utc) - timedelta(days=days_back)\n    except Exception:\n        return False\n\n\ndef _xml_text(root: ET.Element, tag: str) -> str:\n    # SEC XML nutzt Namespaces teils inkonsistent; suffix match ist robuster.\n    for el in root.iter():\n        if el.tag.lower().endswith(tag.lower()):\n            return (el.text or \"\").strip()\n    return \"\"\n\n\ndef _iter_form4_transactions(xml_text: str) -> list[dict]:\n    try:\n        root = ET.fromstring(xml_text.encode(\"utf-8\", errors=\"ignore\"))\n    except ET.ParseError:\n        return []\n\n    txns = []\n    for txn in root.iter():\n        if not txn.tag.lower().endswith(\"nonderivativetransaction\"):\n            continue\n        code = \"\"\n        shares = 0.0\n        price = 0.0\n        footnote_text = \"\"\n        for el in txn.iter():\n            name = el.tag.lower()\n            text = (el.text or \"\").strip()\n            if name.endswith(\"transactioncode\"):\n                code = text.upper()\n            elif name.endswith(\"transactionshares\"):\n                try: shares = float(text)\n                except Exception: pass\n            elif name.endswith(\"transactionpricepershare\"):\n                try: price = float(text)\n                except Exception: pass\n            elif \"footnote\" in name:\n                footnote_text += \" \" + text.lower()\n        txns.append({\"code\": code, \"shares\": shares, \"price\": price, \"value\": shares * price, \"footnotes\": footnote_text})\n    return txns\n\n\ndef _classify_form4(text: str) -> list[dict]:\n    events = []\n    for txn in _iter_form4_transactions(text):\n        code = txn[\"code\"]\n        value = txn[\"value\"]\n        shares = txn[\"shares\"]\n        foot = txn.get(\"footnotes\", \"\")\n        tenb51 = \"10b5\" in foot\n\n        # P = Open-market purchase: klar bullisher als Awards.\n        if code == \"P\" and value >= 50_000:\n            events.append({\n                \"type\": \"insider_purchase\",\n                \"bullish\": True,\n                \"bearish\": False,\n                \"confidence\": min(0.9, 0.55 + value / 2_000_000),\n                \"reason\": f\"Form 4 Insider-Kauf ${value:,.0f}\",\n            })\n        # S = Sale. Nur große Verkäufe bearish; 10b5-1 wird gedämpft.\n        elif code == \"S\" and value >= 1_000_000:\n            conf = min(0.65, 0.30 + value / 10_000_000)\n            if tenb51:\n                conf *= 0.5\n            events.append({\n                \"type\": \"insider_sale_10b5\" if tenb51 else \"insider_sale\",\n                \"bullish\": False,\n                \"bearish\": conf >= 0.35,\n                \"confidence\": round(conf, 2),\n                \"reason\": f\"Form 4 Insider-Verkauf ${value:,.0f}\" + (\" 10b5-1\" if tenb51 else \"\"),\n            })\n        # A/M/F sind meist Award, Option Exercise, Tax Withholding → nicht als Alpha-Signal werten.\n        elif code in {\"A\", \"M\", \"F\", \"G\"} and shares > 0:\n            events.append({\n                \"type\": f\"neutral_form4_{code}\",\n                \"bullish\": False,\n                \"bearish\": False,\n                \"confidence\": 0.05,\n                \"reason\": f\"Form 4 neutral Code {code}\",\n            })\n    return events\n\n\ndef _classify_8k(text: str, filing: dict) -> list[dict]:\n    low = (text[:250_000] + \" \" + str(filing.get(\"items\", \"\")) + \" \" + str(filing.get(\"primaryDocDescription\", \"\"))).lower()\n    events = []\n    for kw, conf in BEARISH_8K_KEYWORDS.items():\n        if kw in low:\n            events.append({\"type\": \"8k_bearish\", \"bullish\": False, \"bearish\": True, \"confidence\": conf, \"reason\": f\"8-K Warnsignal: {kw}\"})\n            break\n    for kw, conf in BULLISH_8K_KEYWORDS.items():\n        if kw in low:\n            events.append({\"type\": \"8k_bullish\", \"bullish\": True, \"bearish\": False, \"confidence\": conf, \"reason\": f\"8-K Katalysator: {kw}\"})\n            break\n    return events\n\n\ndef get_sec_signal(ticker: str, days_back: int = 14) -> dict:\n    if ticker in ETF_TICKERS:\n        return {**EMPTY_RESULT, \"reason\": \"ETF — kein SEC-Check\"}\n\n    try:\n        ticker_map = _load_ticker_map()\n        cik = ticker_map.get(ticker.upper())\n        if not cik:\n            return {**EMPTY_RESULT, \"reason\": \"Ticker nicht in SEC Map\"}\n\n        filings = [f for f in _recent_filings(cik) if _within_days(str(f.get(\"filingDate\", \"\")), days_back)]\n        events = []\n        checked = 0\n\n        for f in filings[:30]:\n            form = str(f.get(\"form\", \"\"))\n            if form not in {\"4\", \"8-K\"}:\n                continue\n            primary = f.get(\"primaryDocument\")\n            accession = f.get(\"accessionNumber\")\n            if not primary or not accession:\n                continue\n            checked += 1\n            try:\n                text = _get_text(_filing_url(cik, accession, primary))\n                if form == \"4\":\n                    events.extend(_classify_form4(text))\n                elif form == \"8-K\":\n                    events.extend(_classify_8k(text, f))\n            except Exception as e:\n                logger.debug(\"SEC Dokument %s %s Fehler: %s\", ticker, form, e)\n                continue\n\n        bullish_events = [e for e in events if e.get(\"bullish\")]\n        bearish_events = [e for e in events if e.get(\"bearish\")]\n        bullish_conf = max([e.get(\"confidence\", 0.0) for e in bullish_events] or [0.0])\n        bearish_conf = max([e.get(\"confidence\", 0.0) for e in bearish_events] or [0.0])\n\n        bullish = bullish_conf > bearish_conf and bullish_conf >= 0.45\n        bearish = bearish_conf > bullish_conf and bearish_conf >= 0.45\n        confidence = max(bullish_conf, bearish_conf)\n        if bullish_conf and bearish_conf:\n            confidence *= 0.65\n\n        top_events = sorted(events, key=lambda e: e.get(\"confidence\", 0), reverse=True)[:4]\n        reason = \" | \".join(e.get(\"reason\", \"\") for e in top_events) or \"Keine relevanten Filings\"\n\n        result = {\n            \"bullish\": bullish,\n            \"bearish\": bearish,\n            \"insider_buy\": any(e.get(\"type\") == \"insider_purchase\" for e in events),\n            \"insider_sell\": any(str(e.get(\"type\", \"\")).startswith(\"insider_sale\") for e in events),\n            \"reason\": reason,\n            \"confidence\": round(confidence, 2),\n            \"filings_checked\": checked,\n            \"events\": top_events,\n        }\n        if checked:\n            logger.info(\"SEC %s: %d Filings | bull=%s bear=%s | %s\", ticker, checked, bullish, bearish, reason[:80])\n        return result\n\n    except Exception as e:\n        logger.warning(\"SEC-Check %s fehlgeschlagen: %s\", ticker, e)\n        return {**EMPTY_RESULT, \"reason\": f\"SEC-Fehler: {str(e)[:60]}\"}\n\n\n# ==================== NAME → TICKER MAPPING (Funktionen) ====================\n\ndef _normalize_company_name(name: str) -> str:\n    \"\"\"'Apple Inc.' -> 'apple'\n       'BERKSHIRE HATHAWAY INC /DE/' -> 'berkshire hathaway'\n       'AT&T INC' -> 'at and t'\n       'Johnson & Johnson' -> 'johnson and johnson'\n    \"\"\"\n    s = name.lower()\n    s = s.replace(\"&\", \" and \")                 # AT&T -> at and t\n    s = re.sub(r\"/[a-z]{2,3}/\", \" \", s)         # /DE/, /MD/, /NY/ Suffixe\n    s = re.sub(r\"[^a-z0-9\\s\\-]\", \" \", s)        # Punkte, Kommas raus\n    s = re.sub(r\"\\s+\", \" \", s).strip()\n    tokens = s.split()\n    # Suffix-Tokens am Ende abschneiden, solange welche da sind\n    while tokens and tokens[-1] in _CORP_SUFFIXES:\n        tokens.pop()\n    return \" \".join(tokens)\n\n\ndef get_company_name_to_ticker() -> dict[str, str]:\n    \"\"\"Liefert Mapping 'apple' -> 'AAPL', 'microsoft' -> 'MSFT', etc.\n\n    Quelle: bereits gecachte SEC-Datei sec_company_tickers.json + Overrides.\n    Bei Konflikt gewinnt der Override. Bei doppelten SEC-Einträgen mit\n    gleichem normalisierten Namen gewinnt der erste (= meist die Haupt-Aktienklasse).\n\n    Modul-weiter Cache verhindert mehrfaches Parsen der ~1 MB SEC-Datei.\n    \"\"\"\n    global _cached_name_map\n    if _cached_name_map is not None:\n        return _cached_name_map\n\n    name_map: dict[str, str] = {}\n\n    try:\n        raw = _load_sec_raw_tickers()\n        for v in raw.values():\n            ticker = (v.get(\"ticker\") or \"\").upper().strip()\n            title = (v.get(\"title\") or \"\").strip()\n            if not ticker or not title:\n                continue\n            normalized = _normalize_company_name(title)\n            if not normalized or len(normalized) < 4:\n                continue\n            if normalized in _NAME_BLOCKLIST:\n                continue\n            # Erster gewinnt (vermeidet, dass z.B. BRK.A später BRK.B überschreibt)\n            if normalized not in name_map:\n                name_map[normalized] = ticker\n    except Exception as e:\n        logger.warning(\"SEC Name-Map konnte nicht geladen werden: %s\", e)\n\n    # Overrides drüberlegen — die haben immer Vorrang\n    name_map.update(COMPANY_NAME_OVERRIDES)\n\n    _cached_name_map = name_map\n    logger.info(\"Name->Ticker Mapping: %d Einträge geladen\", len(name_map))\n    return name_map\n\n\ndef get_cik_to_ticker_map() -> dict[int, str]:\n    \"\"\"Liefert Mapping CIK -> Ticker fuer SEC EDGAR Filings-Aufloesung.\n\n    Beispiel: 320193 -> \"AAPL\", 1318605 -> \"TSLA\"\n\n    Hintergrund: Der SEC-EDGAR-Atom-Feed identifiziert Firmen ueber CIK\n    (Central Index Key), nicht ueber Ticker. Wenn der News-Bot 8-K-Filings\n    aus dem SEC-Feed verarbeiten will, braucht er die Inverse der\n    Ticker->CIK-Map, die _load_ticker_map() bereits liefert.\n\n    Caveat: Mehrere Tickers koennen denselben CIK haben (z.B. BRK.A und BRK.B\n    teilen den Berkshire-CIK). Hier gewinnt der erste Eintrag in der SEC-Datei,\n    was praktisch oft die Klasse-A-Aktie ist. Fuer Trading-Zwecke ist das\n    suboptimal (BRK.B ist liquider), deshalb sollten kritische Faelle ueber\n    COMPANY_NAME_OVERRIDES nachgesteuert werden.\n    \"\"\"\n    global _cached_cik_map\n    if _cached_cik_map is not None:\n        return _cached_cik_map\n\n    cik_map: dict[int, str] = {}\n    try:\n        raw = _load_sec_raw_tickers()\n        for v in raw.values():\n            ticker = (v.get(\"ticker\") or \"\").upper().strip()\n            cik = v.get(\"cik_str\")\n            if not ticker or cik is None:\n                continue\n            try:\n                cik_int = int(cik)\n            except (TypeError, ValueError):\n                continue\n            # Erster gewinnt; Override-Tickers haetten hier keine Wirkung,\n            # weil die SEC-Map nur primaere Tickers liefert.\n            if cik_int not in cik_map:\n                cik_map[cik_int] = ticker\n    except Exception as e:\n        logger.warning(\"CIK->Ticker Map konnte nicht geladen werden: %s\", e)\n        return {}\n\n    _cached_cik_map = cik_map\n    logger.info(\"CIK->Ticker Mapping: %d Eintraege geladen\", len(cik_map))\n    return cik_map\n"
  },
  {
    "path": "src/sector_map.py",
    "content": "\"\"\"\nsector_map.py — Markt-/Sektorfilter für Daily-Options-Signale.\n\nZiel:\n- Keine Long-Calls gegen klaren Sektor-/Marktwind.\n- Keine Long-Puts gegen klar starke Sektor-/Marktbreite.\n- Relative Stärke/Schwäche als Feature journalisieren.\n\nDie Zuordnung ist bewusst pragmatisch und kostenlos: Sektor-ETFs + einfache Ticker-Maps.\nUnbekannte Ticker fallen auf QQQ/SPY zurück, damit das Gate nicht unbrauchbar wird.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom typing import Callable, Any\n\nfrom rules import RULES\n\n\nSECTOR_ETFS = {\n    \"technology\": \"XLK\",\n    \"semiconductors\": \"SMH\",\n    \"communication\": \"XLC\",\n    \"consumer_discretionary\": \"XLY\",\n    \"consumer_staples\": \"XLP\",\n    \"energy\": \"XLE\",\n    \"financials\": \"XLF\",\n    \"healthcare\": \"XLV\",\n    \"industrials\": \"XLI\",\n    \"materials\": \"XLB\",\n    \"real_estate\": \"XLRE\",\n    \"utilities\": \"XLU\",\n    \"small_caps\": \"IWM\",\n    \"market\": \"SPY\",\n    \"nasdaq\": \"QQQ\",\n}\n\n# Nur die häufigsten/alpha-relevanten Namen. Unbekannte fallen auf QQQ/SPY zurück.\nTICKER_TO_SECTOR = {\n    # Mega-cap tech / AI\n    \"AAPL\": \"technology\", \"MSFT\": \"technology\", \"ORCL\": \"technology\", \"CRM\": \"technology\",\n    \"ADBE\": \"technology\", \"NOW\": \"technology\", \"SNOW\": \"technology\", \"PLTR\": \"technology\",\n    \"AI\": \"technology\", \"SHOP\": \"technology\", \"DDOG\": \"technology\", \"NET\": \"technology\",\n    \"CRWD\": \"technology\", \"PANW\": \"technology\", \"ZS\": \"technology\", \"MDB\": \"technology\",\n\n    # Semis\n    \"NVDA\": \"semiconductors\", \"AMD\": \"semiconductors\", \"AVGO\": \"semiconductors\",\n    \"INTC\": \"semiconductors\", \"MU\": \"semiconductors\", \"ARM\": \"semiconductors\",\n    \"TSM\": \"semiconductors\", \"ASML\": \"semiconductors\", \"QCOM\": \"semiconductors\",\n    \"TXN\": \"semiconductors\", \"AMAT\": \"semiconductors\", \"LRCX\": \"semiconductors\",\n\n    # Communication / internet\n    \"GOOGL\": \"communication\", \"GOOG\": \"communication\", \"META\": \"communication\",\n    \"NFLX\": \"communication\", \"DIS\": \"communication\", \"ROKU\": \"communication\",\n    \"SNAP\": \"communication\", \"PINS\": \"communication\", \"SPOT\": \"communication\",\n\n    # Consumer discretionary / autos / retail\n    \"TSLA\": \"consumer_discretionary\", \"AMZN\": \"consumer_discretionary\", \"NKE\": \"consumer_discretionary\",\n    \"SBUX\": \"consumer_discretionary\", \"MCD\": \"consumer_discretionary\", \"HD\": \"consumer_discretionary\",\n    \"LOW\": \"consumer_discretionary\", \"TGT\": \"consumer_discretionary\", \"WMT\": \"consumer_staples\",\n    \"COST\": \"consumer_staples\", \"PG\": \"consumer_staples\", \"KO\": \"consumer_staples\", \"PEP\": \"consumer_staples\",\n\n    # Energy / commodities\n    \"XOM\": \"energy\", \"CVX\": \"energy\", \"OXY\": \"energy\", \"COP\": \"energy\", \"SLB\": \"energy\",\n    \"HAL\": \"energy\", \"USO\": \"energy\",\n\n    # Financials\n    \"JPM\": \"financials\", \"BAC\": \"financials\", \"C\": \"financials\", \"WFC\": \"financials\",\n    \"GS\": \"financials\", \"MS\": \"financials\", \"BLK\": \"financials\", \"SCHW\": \"financials\",\n    \"AXP\": \"financials\", \"V\": \"financials\", \"MA\": \"financials\", \"PYPL\": \"financials\",\n\n    # Healthcare / biotech\n    \"LLY\": \"healthcare\", \"PFE\": \"healthcare\", \"MRNA\": \"healthcare\", \"BMY\": \"healthcare\",\n    \"JNJ\": \"healthcare\", \"UNH\": \"healthcare\", \"HUM\": \"healthcare\", \"ABBV\": \"healthcare\",\n    \"MRK\": \"healthcare\", \"GILD\": \"healthcare\", \"REGN\": \"healthcare\", \"VRTX\": \"healthcare\",\n\n    # Industrials/materials/utilities/real estate\n    \"BA\": \"industrials\", \"CAT\": \"industrials\", \"DE\": \"industrials\", \"GE\": \"industrials\",\n    \"HON\": \"industrials\", \"UPS\": \"industrials\", \"FDX\": \"industrials\", \"LMT\": \"industrials\",\n    \"NOC\": \"industrials\", \"RTX\": \"industrials\",\n    \"FCX\": \"materials\", \"NEM\": \"materials\", \"AA\": \"materials\", \"LIN\": \"materials\",\n    \"NEE\": \"utilities\", \"DUK\": \"utilities\", \"SO\": \"utilities\",\n    \"PLD\": \"real_estate\", \"AMT\": \"real_estate\", \"O\": \"real_estate\",\n\n    # ETFs map to themselves/market context\n    \"SPY\": \"market\", \"QQQ\": \"nasdaq\", \"IWM\": \"small_caps\",\n    \"XLK\": \"technology\", \"SMH\": \"semiconductors\", \"SOXX\": \"semiconductors\",\n    \"XLE\": \"energy\", \"XLF\": \"financials\", \"XLV\": \"healthcare\", \"XLY\": \"consumer_discretionary\",\n    \"XLP\": \"consumer_staples\", \"XLI\": \"industrials\", \"XLB\": \"materials\", \"XLU\": \"utilities\",\n    \"XLRE\": \"real_estate\", \"XLC\": \"communication\",\n}\n\n\n@dataclass(frozen=True)\nclass SectorFilterResult:\n    ok: bool\n    reason: str\n    sector: str\n    sector_etf: str\n    sector_change_pct: float | None\n    market_change_pct: float | None\n    qqq_change_pct: float | None\n    relative_to_sector_pct: float | None\n    sector_vs_market_pct: float | None\n    momentum_confirmation: str\n    score_adjustment: float\n    severity: str\n\n\ndef _quote_change(symbol: str, cfg: dict, quote_fn: Callable[[str, dict], Any]) -> float | None:\n    try:\n        result = quote_fn(symbol, cfg)\n        if not result:\n            return None\n        # get_quote liefert: price, change_pct, high, low, source\n        return float(result[1])\n    except Exception:\n        return None\n\n\ndef sector_for_ticker(ticker: str) -> tuple[str, str]:\n    t = (ticker or \"\").upper().strip()\n    sector = TICKER_TO_SECTOR.get(t)\n    if not sector:\n        # Grober Fallback: unbekannte Single Stocks gegen QQQ + SPY prüfen.\n        sector = \"nasdaq\"\n    return sector, SECTOR_ETFS.get(sector, \"QQQ\")\n\n\ndef evaluate_sector_filter(ticker: str, direction: str, stock_change_pct: float,\n                           cfg: dict, quote_fn: Callable[[str, dict], Any]) -> SectorFilterResult:\n    \"\"\"\n    Bewertet Markt-/Sektorbestätigung.\n    Fail-closed nur bei klaren Konflikten; fehlende ETF-Daten führen zu Warnung, nicht zu Block.\n    \"\"\"\n    direction = (direction or \"\").upper()\n    sector, sector_etf = sector_for_ticker(ticker)\n\n    sector_change = _quote_change(sector_etf, cfg, quote_fn)\n    spy_change = _quote_change(\"SPY\", cfg, quote_fn)\n    qqq_change = _quote_change(\"QQQ\", cfg, quote_fn)\n    market_change = spy_change if spy_change is not None else qqq_change\n\n    if sector_change is None and market_change is None:\n        return SectorFilterResult(\n            ok=True, reason=\"Sektor-/Marktdaten fehlen; kein harter Block\", sector=sector,\n            sector_etf=sector_etf, sector_change_pct=None, market_change_pct=market_change,\n            qqq_change_pct=qqq_change, relative_to_sector_pct=None, sector_vs_market_pct=None,\n            momentum_confirmation=\"unknown\", score_adjustment=-3.0,\n            severity=\"warning\",\n        )\n\n    rel = None\n    if sector_change is not None:\n        rel = round(stock_change_pct - sector_change, 2)\n\n    sector_vs_market = None\n    if sector_change is not None and market_change is not None:\n        sector_vs_market = round(sector_change - market_change, 2)\n\n    reasons: list[str] = []\n    score_adj = 0.0\n    momentum_confirmation = \"neutral\"\n    ok = True\n    severity = \"ok\"\n\n    # CALL: ideal ist Aktie > Sektor, Sektor/Markt nicht klar negativ.\n    if direction == \"CALL\":\n        if sector_change is not None and sector_change < -0.60 and (rel is None or rel < 0.20):\n            ok = False\n            severity = \"block\"\n            reasons.append(f\"CALL gegen schwachen Sektor {sector_etf} {sector_change:.2f}% ohne relative Staerke\")\n        if market_change is not None and market_change < -0.80 and stock_change_pct <= 0:\n            ok = False\n            severity = \"block\"\n            reasons.append(f\"CALL gegen schwachen Markt SPY/QQQ {market_change:.2f}%\")\n        if rel is not None:\n            if rel >= RULES.sector_relative_strength_min:\n                score_adj += RULES.sector_confirms_score_bonus\n                momentum_confirmation = \"stock_outperforms_sector\"\n            elif rel < -0.40:\n                score_adj += RULES.sector_disagrees_score_malus\n                momentum_confirmation = \"stock_lags_sector\"\n        if sector_vs_market is not None:\n            if sector_vs_market >= RULES.sector_vs_market_confirm_min and direction == \"CALL\":\n                score_adj += 4.0\n                if momentum_confirmation == \"stock_outperforms_sector\":\n                    momentum_confirmation = \"stock_and_sector_outperform_market\"\n            elif sector_vs_market < -0.30:\n                score_adj -= 5.0\n        if market_change is not None and market_change < -0.40:\n            score_adj -= 4.0\n\n    # PUT: ideal ist Aktie < Sektor, Sektor/Markt nicht klar stark.\n    elif direction == \"PUT\":\n        if sector_change is not None and sector_change > 0.60 and (rel is None or rel > -0.20):\n            ok = False\n            severity = \"block\"\n            reasons.append(f\"PUT gegen starken Sektor {sector_etf} {sector_change:.2f}% ohne relative Schwaeche\")\n        if market_change is not None and market_change > 0.80 and stock_change_pct >= 0:\n            ok = False\n            severity = \"block\"\n            reasons.append(f\"PUT gegen starken Markt SPY/QQQ {market_change:.2f}%\")\n        if rel is not None:\n            if rel <= -RULES.sector_relative_strength_min:\n                score_adj += RULES.sector_confirms_score_bonus\n                momentum_confirmation = \"stock_underperforms_sector\"\n            elif rel > 0.40:\n                score_adj += RULES.sector_disagrees_score_malus\n                momentum_confirmation = \"stock_stronger_than_sector\"\n        if sector_vs_market is not None:\n            if sector_vs_market <= -RULES.sector_vs_market_confirm_min and direction == \"PUT\":\n                score_adj += 4.0\n                if momentum_confirmation == \"stock_underperforms_sector\":\n                    momentum_confirmation = \"stock_and_sector_underperform_market\"\n            elif sector_vs_market > 0.30:\n                score_adj -= 5.0\n        if market_change is not None and market_change > 0.40:\n            score_adj -= 4.0\n\n    else:\n        reasons.append(\"Unbekannte Richtung fuer Sektorfilter\")\n        score_adj -= 5.0\n        severity = \"warning\"\n\n    if not reasons:\n        reasons.append(\"ok\")\n\n    return SectorFilterResult(\n        ok=ok,\n        reason=\" | \".join(reasons),\n        sector=sector,\n        sector_etf=sector_etf,\n        sector_change_pct=round(sector_change, 2) if sector_change is not None else None,\n        market_change_pct=round(market_change, 2) if market_change is not None else None,\n        qqq_change_pct=round(qqq_change, 2) if qqq_change is not None else None,\n        relative_to_sector_pct=rel,\n        sector_vs_market_pct=sector_vs_market,\n        momentum_confirmation=momentum_confirmation,\n        score_adjustment=round(score_adj, 2),\n        severity=severity,\n    )\n"
  },
  {
    "path": "src/simple_journal.py",
    "content": "\"\"\"\nEinfaches Interface auf dem bestehenden, robusten TradingJournal.\n\"\"\"\n\nfrom trading_journal import (\n    create_run,\n    log_market_signals,\n    log_final_decision,\n    update_due_outcomes,\n    get_iv_stats\n)\n\nclass TradingJournal:\n    \"\"\"Einfaches, benutzerfreundliches Interface\"\"\"\n    \n    def __init__(self):\n        self.run_id = None\n\n    def start_run(self):\n        \"\"\"Neuen Run starten\"\"\"\n        self.run_id = create_run()\n        return self.run_id\n\n    def log_signals(self, parsed_signals, market_data, clusters=None):\n        \"\"\"Signale + Marktdaten loggen\"\"\"\n        if self.run_id is None:\n            self.start_run()\n        log_market_signals(self.run_id, parsed_signals, market_data, clusters)\n\n    def log_decision(self, result: dict):\n        \"\"\"Finale Entscheidung (Trade oder No-Trade) loggen\"\"\"\n        if self.run_id is None:\n            self.start_run()\n        log_final_decision(self.run_id, result)\n\n    def update_outcomes(self, cfg):\n        \"\"\"Fällige Outcomes updaten\"\"\"\n        return update_due_outcomes(cfg)\n\n    def get_iv_stats(self, ticker: str, current_iv: float | None = None):\n        \"\"\"IV-Rank aus eigener Historie\"\"\"\n        return get_iv_stats(ticker, current_iv)\n\n    def get_run_id(self):\n        return self.run_id\n\n\n# Singleton für einfache Nutzung\njournal = TradingJournal()\n"
  },
  {
    "path": "src/trading_journal.py",
    "content": "\"\"\"\ntrading_journal.py — Signal-/Trade-Journal und Outcome-Tracking.\n\nSpeichert jeden Lauf in SQLite:\n- Rohsignale aus News/Claude\n- Marktdaten, Optionsdaten, SEC-Daten\n- finale Report-Entscheidung\n- spätere Underlying-Outcomes für Event-Study\n\nWichtig: In GitHub Actions muss data/ persistent gemacht werden, sonst ist SQLite nach jedem Lauf weg.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport sqlite3\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\nfrom typing import Any\n\nlogger = logging.getLogger(__name__)\n\nDATA_DIR = Path(__file__).resolve().parent.parent / \"data\"\nDB_PATH = DATA_DIR / \"trading_journal.sqlite\"\n\nOUTCOME_HORIZONS = {\n    \"1H\": timedelta(hours=1),\n    \"EOD\": None,  # wird auf 21:00 UTC des Signaltags gesetzt\n    \"1D\": timedelta(days=1),\n    \"3D\": timedelta(days=3),\n    \"5D\": timedelta(days=5),\n    \"10D\": timedelta(days=10),\n}\n\n\ndef utc_now() -> datetime:\n    return datetime.now(timezone.utc)\n\n\ndef iso(dt: datetime | None = None) -> str:\n    return (dt or utc_now()).astimezone(timezone.utc).isoformat(timespec=\"seconds\")\n\n\ndef _json(obj: Any) -> str:\n    return json.dumps(obj, ensure_ascii=False, sort_keys=True, default=str)\n\n\ndef connect(db_path: Path = DB_PATH) -> sqlite3.Connection:\n    DATA_DIR.mkdir(parents=True, exist_ok=True)\n    con = sqlite3.connect(db_path, timeout=10)\n    con.row_factory = sqlite3.Row\n    con.execute(\"PRAGMA journal_mode=WAL\")\n    con.execute(\"PRAGMA busy_timeout=5000\")\n    init_db(con)\n    return con\n\n\ndef init_db(con: sqlite3.Connection) -> None:\n    con.executescript(\n        \"\"\"\n        CREATE TABLE IF NOT EXISTS runs (\n            run_id INTEGER PRIMARY KEY AUTOINCREMENT,\n            started_at TEXT NOT NULL,\n            market_date TEXT NOT NULL,\n            market_status TEXT,\n            vix TEXT,\n            raw_ticker_signals TEXT,\n            article_count INTEGER,\n            cluster_count INTEGER,\n            no_trade INTEGER DEFAULT 0,\n            no_trade_reason TEXT,\n            final_ticker TEXT,\n            final_direction TEXT,\n            final_payload_json TEXT\n        );\n\n        CREATE TABLE IF NOT EXISTS signals (\n            signal_id INTEGER PRIMARY KEY AUTOINCREMENT,\n            run_id INTEGER NOT NULL,\n            created_at TEXT NOT NULL,\n            ticker TEXT NOT NULL,\n            direction TEXT NOT NULL,\n            signal_strength TEXT,\n            horizon TEXT,\n            dte_days INTEGER,\n            cluster_json TEXT,\n            market_json TEXT,\n            option_json TEXT,\n            sec_json TEXT,\n            price REAL,\n            change_pct REAL,\n            rel_vol TEXT,\n            score REAL,\n            raw_signal_score REAL,\n            gate_adjusted_score REAL,\n            news_confidence_score REAL,\n            news_sentiment_score REAL,\n            news_sentiment_source TEXT,\n            score_reason TEXT,\n            liquidity_fail INTEGER,\n            liquidity_reason TEXT,\n            ev_ok INTEGER,\n            ev_pct REAL,\n            ev_dollars REAL,\n            conservative_entry REAL,\n            data_quality_ok INTEGER,\n            data_quality_reason TEXT,\n            no_trade_reason TEXT,\n            quote_source TEXT,\n            option_source TEXT,\n            realized_vol_20d REAL,\n            option_iv REAL,\n            iv_to_rv REAL,\n            exit_slippage_points REAL,\n            earnings_iv_ok INTEGER,\n            earnings_iv_reason TEXT,\n            sector TEXT,\n            sector_etf TEXT,\n            sector_change_pct REAL,\n            market_change_pct REAL,\n            relative_to_sector_pct REAL,\n            sector_filter_ok INTEGER,\n            sector_filter_reason TEXT,\n            sentiment_price_label TEXT,\n            sentiment_price_score_adjustment REAL,\n            data_quality_score REAL,\n            price_spike_pct REAL,\n            iv_rank REAL,\n            iv_percentile REAL,\n            iv_history_count INTEGER,\n            iv_rank_reason TEXT,\n            iv_cold_start INTEGER,\n            sector_vs_market_pct REAL,\n            sector_momentum_confirmation TEXT,\n            time_stop_hours INTEGER,\n            time_stop_required_move_pct REAL,\n            time_stop_rule TEXT,\n            selected_trade INTEGER DEFAULT 0,\n            FOREIGN KEY(run_id) REFERENCES runs(run_id)\n        );\n\n        CREATE TABLE IF NOT EXISTS outcomes (\n            outcome_id INTEGER PRIMARY KEY AUTOINCREMENT,\n            signal_id INTEGER NOT NULL,\n            horizon TEXT NOT NULL,\n            due_at TEXT NOT NULL,\n            checked_at TEXT,\n            start_price REAL,\n            end_price REAL,\n            underlying_return_pct REAL,\n            direction_return_pct REAL,\n            status TEXT DEFAULT 'open',\n            FOREIGN KEY(signal_id) REFERENCES signals(signal_id),\n            UNIQUE(signal_id, horizon)\n        );\n\n        CREATE TABLE IF NOT EXISTS option_iv_history (\n            iv_id INTEGER PRIMARY KEY AUTOINCREMENT,\n            market_date TEXT NOT NULL,\n            created_at TEXT NOT NULL,\n            run_id INTEGER,\n            signal_id INTEGER,\n            ticker TEXT NOT NULL,\n            direction TEXT,\n            expiration TEXT,\n            strike REAL,\n            dte_actual INTEGER,\n            option_iv REAL NOT NULL,\n            realized_vol_20d REAL,\n            iv_to_rv REAL,\n            source TEXT DEFAULT 'tradier',\n            UNIQUE(market_date, ticker, direction, expiration, strike)\n        );\n\n        CREATE INDEX IF NOT EXISTS idx_signals_ticker ON signals(ticker);\n        CREATE INDEX IF NOT EXISTS idx_outcomes_due ON outcomes(status, due_at);\n        CREATE INDEX IF NOT EXISTS idx_iv_history_ticker ON option_iv_history(ticker, created_at);\n        \"\"\"\n    )\n    _ensure_columns(con, \"signals\", {\n        \"raw_signal_score\": \"REAL\",\n        \"gate_adjusted_score\": \"REAL\",\n        \"news_confidence_score\": \"REAL\",\n        \"news_sentiment_score\": \"REAL\",\n        \"news_sentiment_source\": \"TEXT\",\n        \"data_quality_ok\": \"INTEGER\",\n        \"data_quality_reason\": \"TEXT\",\n        \"no_trade_reason\": \"TEXT\",\n        \"quote_source\": \"TEXT\",\n        \"option_source\": \"TEXT\",\n        \"realized_vol_20d\": \"REAL\",\n        \"option_iv\": \"REAL\",\n        \"iv_to_rv\": \"REAL\",\n        \"exit_slippage_points\": \"REAL\",\n        \"earnings_iv_ok\": \"INTEGER\",\n        \"earnings_iv_reason\": \"TEXT\",\n        \"sector\": \"TEXT\",\n        \"sector_etf\": \"TEXT\",\n        \"sector_change_pct\": \"REAL\",\n        \"market_change_pct\": \"REAL\",\n        \"relative_to_sector_pct\": \"REAL\",\n        \"sector_filter_ok\": \"INTEGER\",\n        \"sector_filter_reason\": \"TEXT\",\n        \"sentiment_price_label\": \"TEXT\",\n        \"sentiment_price_score_adjustment\": \"REAL\",\n        \"data_quality_score\": \"REAL\",\n        \"price_spike_pct\": \"REAL\",\n        \"iv_rank\": \"REAL\",\n        \"iv_percentile\": \"REAL\",\n        \"iv_history_count\": \"INTEGER\",\n        \"iv_rank_reason\": \"TEXT\",\n        \"iv_cold_start\": \"INTEGER\",\n        \"sector_vs_market_pct\": \"REAL\",\n        \"sector_momentum_confirmation\": \"TEXT\",\n        \"time_stop_hours\": \"INTEGER\",\n        \"time_stop_required_move_pct\": \"REAL\",\n        \"time_stop_rule\": \"TEXT\",\n    })\n    con.commit()\n\n\ndef _ensure_columns(con: sqlite3.Connection, table: str, columns: dict[str, str]) -> None:\n    existing = {row[1] for row in con.execute(f\"PRAGMA table_info({table})\").fetchall()}\n    for name, ddl in columns.items():\n        if name not in existing:\n            con.execute(f\"ALTER TABLE {table} ADD COLUMN {name} {ddl}\")\n\n\ndef create_run(market_status: str = \"\", vix: Any = None, raw_ticker_signals: str = \"\",\n               article_count: int = 0, cluster_count: int = 0) -> int:\n    con = connect()\n    now = utc_now()\n    cur = con.execute(\n        \"\"\"\n        INSERT INTO runs(started_at, market_date, market_status, vix, raw_ticker_signals,\n                         article_count, cluster_count)\n        VALUES (?, ?, ?, ?, ?, ?, ?)\n        \"\"\",\n        (iso(now), now.date().isoformat(), market_status, str(vix), raw_ticker_signals,\n         article_count, cluster_count),\n    )\n    con.commit()\n    run_id = int(cur.lastrowid)\n    con.close()\n    return run_id\n\n\ndef update_run_context(run_id: int, market_status: str = \"\", vix: Any = None,\n                       raw_ticker_signals: str = \"\", article_count: int | None = None,\n                       cluster_count: int | None = None) -> None:\n    con = connect()\n    con.execute(\n        \"\"\"\n        UPDATE runs\n        SET market_status = COALESCE(NULLIF(?, ''), market_status),\n            vix = COALESCE(NULLIF(?, ''), vix),\n            raw_ticker_signals = COALESCE(NULLIF(?, ''), raw_ticker_signals),\n            article_count = COALESCE(?, article_count),\n            cluster_count = COALESCE(?, cluster_count)\n        WHERE run_id = ?\n        \"\"\",\n        (market_status, str(vix) if vix is not None else \"\", raw_ticker_signals,\n         article_count, cluster_count, run_id),\n    )\n    con.commit()\n    con.close()\n\n\ndef _cluster_for_ticker(clusters: list[dict], ticker: str) -> dict:\n    matches = [c for c in clusters if c.get(\"ticker\") == ticker]\n    if not matches:\n        return {}\n    return sorted(matches, key=lambda c: c.get(\"confidence_score\", 0), reverse=True)[0]\n\n\ndef _parsed_signal_for_ticker(parsed_signals: list[dict], ticker: str) -> dict:\n    for s in parsed_signals:\n        if s.get(\"ticker\") == ticker:\n            return s\n    return {}\n\n\ndef log_market_signals(run_id: int, parsed_signals: list[dict], market_data: list[dict],\n                       clusters: list[dict] | None = None) -> None:\n    \"\"\"Schreibt alle geprüften Ticker inkl. Options-/SEC-/Kostenfeldern atomar.\"\"\"\n    clusters = clusters or []\n    con = connect()\n    created = utc_now()\n    signal_ids: list[tuple[int, float | None]] = []\n\n    columns = [\n        \"run_id\", \"created_at\", \"ticker\", \"direction\", \"signal_strength\", \"horizon\", \"dte_days\",\n        \"cluster_json\", \"market_json\", \"option_json\", \"sec_json\", \"price\", \"change_pct\", \"rel_vol\",\n        \"score\", \"raw_signal_score\", \"gate_adjusted_score\", \"news_confidence_score\",\n        \"news_sentiment_score\", \"news_sentiment_source\", \"score_reason\", \"liquidity_fail\",\n        \"liquidity_reason\", \"ev_ok\", \"ev_pct\",\n        \"ev_dollars\", \"conservative_entry\", \"data_quality_ok\", \"data_quality_reason\",\n        \"no_trade_reason\", \"quote_source\", \"option_source\", \"realized_vol_20d\", \"option_iv\",\n        \"iv_to_rv\", \"exit_slippage_points\", \"earnings_iv_ok\", \"earnings_iv_reason\",\n        \"sector\", \"sector_etf\", \"sector_change_pct\", \"market_change_pct\", \"relative_to_sector_pct\",\n        \"sector_filter_ok\", \"sector_filter_reason\", \"sentiment_price_label\",\n        \"sentiment_price_score_adjustment\", \"data_quality_score\", \"price_spike_pct\",\n        \"iv_rank\", \"iv_percentile\", \"iv_history_count\", \"iv_rank_reason\",\n        \"iv_cold_start\", \"sector_vs_market_pct\", \"sector_momentum_confirmation\",\n        \"time_stop_hours\", \"time_stop_required_move_pct\", \"time_stop_rule\",\n    ]\n    placeholders = \", \".join([\"?\"] * len(columns))\n    sql = f\"INSERT INTO signals({', '.join(columns)}) VALUES ({placeholders})\"\n\n    with con:\n        for d in market_data:\n            ticker = d.get(\"ticker\", \"\")\n            ps = _parsed_signal_for_ticker(parsed_signals, ticker)\n            opt = d.get(\"options\") or {}\n            sec = {\n                \"sec_bullish\": d.get(\"sec_bullish\"),\n                \"sec_bearish\": d.get(\"sec_bearish\"),\n                \"sec_insider\": d.get(\"sec_insider\"),\n                \"sec_reason\": d.get(\"sec_reason\"),\n                \"sec_confidence\": d.get(\"sec_confidence\"),\n            }\n            cluster = _cluster_for_ticker(clusters, ticker)\n            values = [\n                run_id, iso(created), ticker, d.get(\"news_direction\") or ps.get(\"direction\"),\n                ps.get(\"score\"), ps.get(\"horizon\"), ps.get(\"dte_days\"),\n                _json(cluster), _json(d), _json(opt), _json(sec),\n                d.get(\"price\"), d.get(\"change_pct\"), str(d.get(\"rel_vol\")), d.get(\"score\"),\n                d.get(\"raw_signal_score\"), d.get(\"gate_adjusted_score\"),\n                d.get(\"news_confidence_score\", cluster.get(\"confidence_score\")),\n                d.get(\"news_sentiment_score\", cluster.get(\"sentiment_score\")),\n                d.get(\"news_sentiment_source\", cluster.get(\"sentiment_source\")),\n                d.get(\"_score_reason\"), 1 if d.get(\"_liquidity_fail\") else 0,\n                d.get(\"_liquidity_reason\", \"\"), 1 if opt.get(\"ev_ok\") else 0,\n                opt.get(\"ev_pct\"), opt.get(\"ev_dollars\"), opt.get(\"conservative_entry\"),\n                1 if d.get(\"_data_quality_ok\") else 0, d.get(\"_data_quality_reason\", \"\"),\n                d.get(\"_no_trade_reason\", \"\"), d.get(\"_src_quote\", \"\"), opt.get(\"option_source\", \"\"),\n                d.get(\"realized_vol_20d\"), opt.get(\"iv_decimal\"), opt.get(\"iv_to_rv\"),\n                opt.get(\"exit_slippage_points\"), 1 if opt.get(\"earnings_iv_ok\", True) else 0,\n                opt.get(\"earnings_iv_reason\", \"\"),\n                d.get(\"sector\"), d.get(\"sector_etf\"), d.get(\"sector_change_pct\"),\n                d.get(\"market_change_pct\"), d.get(\"relative_to_sector_pct\"),\n                1 if d.get(\"sector_filter_ok\", True) else 0, d.get(\"sector_filter_reason\", \"\"),\n                d.get(\"sentiment_price_label\", \"\"), d.get(\"sentiment_price_score_adjustment\"),\n                d.get(\"data_quality_score\"), d.get(\"price_spike_pct\"),\n                opt.get(\"iv_rank\"), opt.get(\"iv_percentile\"), opt.get(\"iv_history_count\"),\n                opt.get(\"iv_rank_reason\", \"\"), 1 if opt.get(\"iv_cold_start\") else 0,\n                d.get(\"sector_vs_market_pct\"), d.get(\"sector_momentum_confirmation\", \"\"),\n                opt.get(\"time_stop_hours\"), opt.get(\"time_stop_required_move_pct\"),\n                opt.get(\"time_stop_rule\", \"\"),\n            ]\n            cur = con.execute(sql, values)\n            signal_id = int(cur.lastrowid)\n            signal_ids.append((signal_id, d.get(\"price\")))\n            _record_iv_snapshot(con, run_id, signal_id, ticker, d.get(\"news_direction\") or ps.get(\"direction\"), opt)\n\n        # Outcome-Zeitpunkte anlegen.\n        for signal_id, start_price in signal_ids:\n            if not start_price or start_price <= 0:\n                continue\n            for horizon, delta in OUTCOME_HORIZONS.items():\n                if delta is None:\n                    now = created\n                    due = now.replace(hour=21, minute=0, second=0, microsecond=0)\n                    if due <= now:\n                        due = now + timedelta(hours=1)\n                else:\n                    due = created + delta\n                con.execute(\n                    \"\"\"\n                    INSERT OR IGNORE INTO outcomes(signal_id, horizon, due_at, start_price)\n                    VALUES (?, ?, ?, ?)\n                    \"\"\",\n                    (signal_id, horizon, iso(due), start_price),\n                )\n\n    con.close()\n    logger.info(\"Journal: %d Signale gespeichert\", len(signal_ids))\n\ndef log_final_decision(run_id: int, result: dict) -> None:\n    con = connect()\n    no_trade = 1 if result.get(\"no_trade\") else 0\n    ticker = result.get(\"ticker\", \"\")\n    direction = result.get(\"direction\", \"\")\n    with con:\n        con.execute(\n            \"\"\"\n            UPDATE runs\n            SET no_trade=?, no_trade_reason=?, final_ticker=?, final_direction=?, final_payload_json=?\n            WHERE run_id=?\n            \"\"\",\n            (no_trade, result.get(\"no_trade_grund\", \"\"), ticker, direction, _json(result), run_id),\n        )\n        if ticker:\n            con.execute(\n                \"\"\"\n                UPDATE signals SET selected_trade = 1\n                WHERE run_id = ? AND ticker = ? AND direction = ?\n                \"\"\",\n                (run_id, ticker, direction),\n            )\n    con.close()\n\n\n\ndef _as_float(value: Any) -> float | None:\n    try:\n        if value is None or value == \"\":\n            return None\n        return float(value)\n    except (TypeError, ValueError):\n        return None\n\n\ndef _record_iv_snapshot(con: sqlite3.Connection, run_id: int, signal_id: int,\n                        ticker: str, direction: str | None, opt: dict) -> None:\n    iv = _as_float((opt or {}).get(\"iv_decimal\"))\n    if iv is None or iv <= 0:\n        return\n    strike = _as_float((opt or {}).get(\"strike\"))\n    con.execute(\n        \"\"\"\n        INSERT OR REPLACE INTO option_iv_history(\n            market_date, created_at, run_id, signal_id, ticker, direction, expiration,\n            strike, dte_actual, option_iv, realized_vol_20d, iv_to_rv, source\n        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n        \"\"\",\n        (\n            utc_now().date().isoformat(), iso(), run_id, signal_id, ticker, direction,\n            (opt or {}).get(\"expiration\"), strike, (opt or {}).get(\"dte_actual\"), iv,\n            (opt or {}).get(\"realized_vol_20d\"), (opt or {}).get(\"iv_to_rv\"),\n            (opt or {}).get(\"option_source\", \"tradier\"),\n        ),\n    )\n\n\ndef get_iv_stats(ticker: str, current_iv: float | None, min_samples: int = 2) -> dict:\n    \"\"\"\n    Eigener IV-Rank aus bereits journalisierten Options-IVs.\n    Keine Yahoo-/Underlying-Naeherung. Wenn Historie zu kurz ist, wird nur diagnostiziert.\n    \"\"\"\n    iv = _as_float(current_iv)\n    if iv is None or iv <= 0:\n        return {\n            \"iv_rank\": None,\n            \"iv_percentile\": None,\n            \"iv_history_count\": 0,\n            \"iv_rank_reason\": \"IV fehlt\",\n        }\n\n    con = connect()\n    rows = con.execute(\n        \"\"\"\n        SELECT option_iv FROM option_iv_history\n        WHERE ticker = ? AND option_iv > 0\n        ORDER BY created_at DESC\n        LIMIT 260\n        \"\"\",\n        (ticker.upper(),),\n    ).fetchall()\n    con.close()\n\n    values = [float(r[0]) for r in rows if r[0] is not None and float(r[0]) > 0]\n    n = len(values)\n    if n < min_samples:\n        return {\n            \"iv_rank\": None,\n            \"iv_percentile\": None,\n            \"iv_history_count\": n,\n            \"iv_rank_reason\": f\"IV-Historie zu kurz: {n} Samples\",\n        }\n\n    lo = min(values)\n    hi = max(values)\n    if hi <= lo:\n        iv_rank = 50.0\n    else:\n        iv_rank = max(0.0, min(100.0, (iv - lo) / (hi - lo) * 100.0))\n    percentile = sum(1 for v in values if v <= iv) / n * 100.0\n    return {\n        \"iv_rank\": round(iv_rank, 2),\n        \"iv_percentile\": round(percentile, 2),\n        \"iv_history_count\": n,\n        \"iv_rank_reason\": f\"eigene Journal-Historie n={n}\",\n    }\n\n\ndef update_due_outcomes(cfg: dict, max_updates: int = 50) -> int:\n    \"\"\"\n    Aktualisiert fällige Outcomes mit aktuellem Underlying-Preis.\n    Wird bei jedem Lauf aufgerufen.\n    \"\"\"\n    con = connect()\n    due_rows = con.execute(\n        \"\"\"\n        SELECT o.outcome_id, o.signal_id, o.horizon, o.start_price,\n               s.ticker, s.direction\n        FROM outcomes o\n        JOIN signals s ON s.signal_id = o.signal_id\n        WHERE o.status = 'open' AND o.due_at <= ?\n        ORDER BY o.due_at ASC\n        LIMIT ?\n        \"\"\",\n        (iso(), max_updates),\n    ).fetchall()\n\n    if not due_rows:\n        con.close()\n        return 0\n\n    try:\n        from market_data import get_quote\n    except Exception as e:\n        logger.warning(\"Outcome-Update ohne market_data nicht möglich: %s\", e)\n        con.close()\n        return 0\n\n    updated = 0\n    quote_cache: dict[str, float] = {}\n    for row in due_rows:\n        ticker = row[\"ticker\"]\n        if ticker not in quote_cache:\n            price, *_ = get_quote(ticker, cfg)\n            quote_cache[ticker] = price\n        end_price = quote_cache[ticker]\n        start_price = row[\"start_price\"]\n        if not end_price or not start_price or start_price <= 0:\n            continue\n        ret = round((end_price - start_price) / start_price * 100.0, 3)\n        direction_ret = ret if row[\"direction\"] == \"CALL\" else -ret\n        con.execute(\n            \"\"\"\n            UPDATE outcomes\n            SET checked_at=?, end_price=?, underlying_return_pct=?,\n                direction_return_pct=?, status='done'\n            WHERE outcome_id=?\n            \"\"\",\n            (iso(), end_price, ret, direction_ret, row[\"outcome_id\"]),\n        )\n        updated += 1\n\n    con.commit()\n    con.close()\n    if updated:\n        logger.info(\"Journal: %d Outcomes aktualisiert\", updated)\n    return updated\n"
  },
  {
    "path": "src/universe.py",
    "content": "\"\"\"\nuniverse.py — kostenloses dynamisches US-Ticker-Universum.\n\nQuelle: Nasdaq Trader Symbol Directory.\n- nasdaqlisted.txt\n- otherlisted.txt\n\nFail-safe: Wenn Download/Parse scheitert, nutzt news_analyzer.py die übergebene Fallback-Liste.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport csv\nimport json\nimport logging\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\n\nimport requests\n\nlogger = logging.getLogger(__name__)\n\nDATA_DIR = Path(__file__).resolve().parent.parent / \"data\"\nCACHE_FILE = DATA_DIR / \"universe_cache.json\"\nCACHE_TTL_HOURS = 24\n\nNASDAQ_LISTED_URL = \"https://www.nasdaqtrader.com/dynamic/SymDir/nasdaqlisted.txt\"\nOTHER_LISTED_URL = \"https://www.nasdaqtrader.com/dynamic/SymDir/otherlisted.txt\"\n\nSTATIC_ETFS = {\n    \"SPY\", \"QQQ\", \"IWM\", \"DIA\", \"GLD\", \"SLV\", \"USO\", \"TLT\", \"GDX\",\n    \"XLE\", \"XLF\", \"XLK\", \"XLV\", \"XLI\", \"XLU\", \"XLP\", \"XLY\", \"XLB\", \"XLRE\",\n}\n\n\ndef _is_cache_fresh(path: Path) -> bool:\n    if not path.exists():\n        return False\n    age = datetime.now(timezone.utc) - datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)\n    return age < timedelta(hours=CACHE_TTL_HOURS)\n\n\ndef _download_text(url: str) -> str:\n    r = requests.get(url, timeout=10, headers={\"User-Agent\": \"daily-options-report/1.0\"})\n    r.raise_for_status()\n    return r.text\n\n\ndef _parse_pipe_table(text: str, symbol_field: str) -> set[str]:\n    result: set[str] = set()\n    rows = [line for line in text.splitlines() if line and not line.startswith(\"File Creation Time\")]\n    reader = csv.DictReader(rows, delimiter=\"|\")\n    for row in reader:\n        sym = (row.get(symbol_field) or \"\").strip().upper()\n        if not sym or sym == \"File Creation Time\":\n            continue\n        # Ausschluss von Test-Issues und Sonder-Symbolen, die RSS oft falsch triggert.\n        if row.get(\"Test Issue\", \"N\").strip().upper() == \"Y\":\n            continue\n        if row.get(\"ETF\", \"N\").strip().upper() == \"Y\":\n            # Makro-ETFs separat kontrolliert behalten.\n            if sym not in STATIC_ETFS:\n                continue\n        if \"$\" in sym or \".\" in sym or \"^\" in sym or \"/\" in sym:\n            continue\n        if 1 <= len(sym) <= 5 and sym.isalpha():\n            result.add(sym)\n    return result\n\n\ndef refresh_universe() -> set[str]:\n    DATA_DIR.mkdir(parents=True, exist_ok=True)\n    tickers: set[str] = set()\n    nasdaq_text = _download_text(NASDAQ_LISTED_URL)\n    other_text = _download_text(OTHER_LISTED_URL)\n    tickers |= _parse_pipe_table(nasdaq_text, \"Symbol\")\n    tickers |= _parse_pipe_table(other_text, \"ACT Symbol\")\n    tickers |= STATIC_ETFS\n    payload = {\n        \"created_at\": datetime.now(timezone.utc).isoformat(),\n        \"count\": len(tickers),\n        \"tickers\": sorted(tickers),\n    }\n    CACHE_FILE.write_text(json.dumps(payload, indent=2), encoding=\"utf-8\")\n    logger.info(\"Ticker-Universum aktualisiert: %d Symbole\", len(tickers))\n    return tickers\n\n\ndef get_known_tickers(fallback: set[str] | None = None) -> set[str]:\n    fallback = fallback or set()\n    try:\n        if _is_cache_fresh(CACHE_FILE):\n            payload = json.loads(CACHE_FILE.read_text(encoding=\"utf-8\"))\n            cached = set(payload.get(\"tickers\", []))\n            if cached:\n                return cached | STATIC_ETFS | fallback\n        return refresh_universe() | fallback\n    except Exception as e:\n        logger.warning(\"Ticker-Universum Fallback aktiv: %s\", e)\n        return fallback | STATIC_ETFS\n"
  }
]