main 2939eebacbc9 cached
95 files
1.6 MB
497.8k tokens
219 symbols
8 requests
Download .txt
Showing preview only (1,662K chars total). Download the full file or copy to clipboard to get everything.
Repository: pcctradinginc-alt/Adaptive-Asymmetry-Scanner
Branch: main
Commit: 2939eebacbc9
Files: 95
Total size: 1.6 MB

Directory structure:
gitextract_pqm5dysu/

├── .github/
│   └── workflows/
│       └── scanner.yml
├── .gitignore
├── README.md
├── config.yaml
├── feedback.py
├── modules/
│   ├── __init__.py
│   ├── alpha_sources.py
│   ├── config.py
│   ├── data_ingestion.py
│   ├── data_validator.py
│   ├── deep_analysis.py
│   ├── email_reporter.py
│   ├── finbert_sentiment.py
│   ├── intraday_delta.py
│   ├── macro_context.py
│   ├── mirofish_simulation.py
│   ├── mismatch_scorer.py
│   ├── news_fetcher.py
│   ├── options_designer.py
│   ├── premium_signals.py
│   ├── prescreener.py
│   ├── quasi_ml.py
│   ├── reddit_signals.py
│   ├── reporter.py
│   ├── risk_gates.py
│   ├── rl_agent.py
│   ├── rl_environment.py
│   ├── sentiment_tracker.py
│   ├── trade_scorer.py
│   └── universe.py
├── outputs/
│   ├── daily_reports/
│   │   ├── 2026-04-11.json
│   │   ├── 2026-04-11.md
│   │   ├── 2026-04-12.json
│   │   ├── 2026-04-12.md
│   │   ├── 2026-04-13.json
│   │   ├── 2026-04-13.md
│   │   ├── 2026-04-14.json
│   │   ├── 2026-04-14.md
│   │   ├── 2026-04-15.json
│   │   ├── 2026-04-15.md
│   │   ├── 2026-04-16.json
│   │   ├── 2026-04-16.md
│   │   ├── 2026-04-17.json
│   │   ├── 2026-04-17.md
│   │   ├── 2026-04-20.json
│   │   ├── 2026-04-20.md
│   │   ├── 2026-04-21.json
│   │   ├── 2026-04-21.md
│   │   ├── 2026-04-22.json
│   │   ├── 2026-04-22.md
│   │   ├── 2026-04-23.json
│   │   ├── 2026-04-23.md
│   │   ├── 2026-04-24.json
│   │   ├── 2026-04-24.md
│   │   ├── 2026-04-27.json
│   │   ├── 2026-04-27.md
│   │   ├── 2026-04-28.json
│   │   ├── 2026-04-28.md
│   │   ├── 2026-04-29.json
│   │   ├── 2026-04-29.md
│   │   ├── 2026-04-30.json
│   │   ├── 2026-04-30.md
│   │   ├── 2026-05-01.json
│   │   ├── 2026-05-01.md
│   │   ├── 2026-05-04.json
│   │   ├── 2026-05-04.md
│   │   ├── 2026-05-05.json
│   │   ├── 2026-05-05.md
│   │   ├── 2026-05-06.json
│   │   ├── 2026-05-06.md
│   │   ├── 2026-05-07.json
│   │   ├── 2026-05-07.md
│   │   ├── 2026-05-08.json
│   │   ├── 2026-05-08.md
│   │   ├── 2026-05-11.json
│   │   ├── 2026-05-11.md
│   │   ├── 2026-05-12.json
│   │   ├── 2026-05-12.md
│   │   ├── 2026-05-13.json
│   │   ├── 2026-05-13.md
│   │   ├── 2026-05-14.json
│   │   ├── 2026-05-14.md
│   │   ├── 2026-05-17.json
│   │   ├── 2026-05-17.md
│   │   ├── 2026-05-18.json
│   │   ├── 2026-05-18.md
│   │   ├── 2026-05-19.json
│   │   ├── 2026-05-19.md
│   │   ├── 2026-05-20.json
│   │   └── 2026-05-20.md
│   ├── history.json
│   └── models/
│       └── .gitkeep
├── pipeline.py
├── requirements.txt
└── tests/
    └── test_pipeline.py

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/workflows/scanner.yml
================================================
name: Adaptive Asymmetry Scanner

on:
  schedule:
    - cron: '30 13 * * 1-5'  # 13:30 UTC = 15:30 MEZ (entspricht US-Marktöffnung)
  workflow_dispatch:

jobs:
  scan:
    runs-on: ubuntu-latest
    timeout-minutes: 45        # Hartes Limit: kein Run > 45 Min

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          fetch-depth: 1

      # ── FinBERT Model Cache ──────────────────────────────────────────────
      # Wichtig: Cache persistiert zwischen Runs → kein 440MB Download täglich
      - name: Cache FinBERT Model
        uses: actions/cache@v4
        id: finbert-cache
        with:
          path: /tmp/finbert_cache
          key: finbert-prosusai-v1
          restore-keys: finbert-prosusai-

      # ── Python + pip Cache ───────────────────────────────────────────────
      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'

      - name: Install dependencies
        run: pip install -r requirements.txt

      # ── Scanner ──────────────────────────────────────────────────────────
      - name: Run Scanner
        env:
          ANTHROPIC_API_KEY:     ${{ secrets.ANTHROPIC_API_KEY }}
          TRADIER_API_KEY:       ${{ secrets.TRADIER_API_KEY }}
          NEWS_API_KEY:          ${{ secrets.NEWS_API_KEY }}
          GMAIL_SENDER:          ${{ secrets.GMAIL_SENDER }}
          GMAIL_APP_PW:          ${{ secrets.GMAIL_APP_PW }}
          NOTIFY_EMAIL:          ${{ secrets.NOTIFY_EMAIL }}
          FINNHUB_API_KEY:       ${{ secrets.FINNHUB_API_KEY }}
          ALPHA_VANTAGE_API_KEY: ${{ secrets.ALPHA_VANTAGE_API_KEY }}
          FLASH_ALPHA_API_KEY:   ${{ secrets.FLASH_ALPHA_API_KEY }}
          EULERPOOL_API_KEY:     ${{ secrets.EULERPOOL_API_KEY }}
          HF_HUB_CACHE:          /tmp/finbert_cache
          TRANSFORMERS_CACHE:    /tmp/finbert_cache
          HF_HOME:               /tmp/finbert_cache
        run: python pipeline.py

      # ── Feedback Loop ────────────────────────────────────────────────────
      - name: Run Feedback Loop
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          TRADIER_API_KEY:   ${{ secrets.TRADIER_API_KEY }}
        run: python feedback.py

      # ── Git Commit ───────────────────────────────────────────────────────
      - name: Commit daily report
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          git config user.name  "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add outputs/
          git diff --cached --quiet && echo "Keine Änderungen" && exit 0
          git commit -m "chore: daily report $(date +%Y-%m-%d)"
          git push https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git HEAD:main


================================================
FILE: .gitignore
================================================
.env
__pycache__/
*.pyc
.pytest_cache/
.venv/
*.egg-info/

# FinBERT-Modell-Cache (417 MB – nicht in Git)
outputs/models/finbert/

# PPO-Modell MIT commit (klein, <1 MB)
# outputs/models/ppo_options_agent.zip  ← NICHT ignorieren


================================================
FILE: README.md
================================================
# Adaptive Asymmetry-Scanner v3.5

Ein vollautomatischer News-to-Options-Scanner, der täglich Informations-Asymmetrien im US-Aktienmarkt identifiziert und konkrete Options-Vorschläge generiert.

---

## Kernkonzept

Das System sucht nicht nach "guten Nachrichten", sondern nach **Underreactions**: Fundamentale Nachrichten mit einem 3–6-Monats-Impact, auf die der Markt innerhalb der ersten 48 Stunden statistisch zu schwach reagiert hat. Dieser Mismatch zwischen fundamentaler Stärke und Preisbewegung ist der eigentliche Alpha-Hebel.

```
News-Stärke (Impact 0-10) minus Marktreaktion (Z-Score × 5) = Mismatch-Score
```

Je höher der Mismatch, desto wahrscheinlicher eine verzögerte Einpreisung.

---

## 7-Stufen-Pipeline

```
1. Daten-Ingestion      → News (NewsAPI/RSS) + yfinance Hard-Filter
2. Prescreening         → Claude Haiku: Rauschen vs. strukturelle Änderung
3. Deep Analysis        → Claude Sonnet: Asymmetry Reasoning + Bear Case
4. Mismatch-Score       → Z-Score der 48h-Bewegung vs. Impact
5. MiroFish-Simulation  → 10.000 Monte-Carlo-Pfade über 120 Tage
6. Quasi-ML Scoring     → Selbstlernende Gewichtung aus history.json
7. Options-Design       → IV-Rank-basierte Strategie via Tradier/yfinance
```

---

## Schnellstart

### 1. Repository klonen

```bash
git clone https://github.com/DEIN-USERNAME/news-mirofish.git
cd news-mirofish
```

### 2. Abhängigkeiten installieren

```bash
pip install -r requirements.txt
```

### 3. API-Keys konfigurieren

```bash
cp .env.example .env
# .env öffnen und Keys eintragen
```

Lokal:
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
export NEWS_API_KEY="..."
export TRADIER_API_KEY="..."   # optional, Fallback auf yfinance
```

### 4. Ersten Lauf starten

```bash
python pipeline.py
```

### 5. Feedback-Loop manuell ausführen

```bash
python feedback.py
```

---

## GitHub Actions Setup

### Secrets konfigurieren

Im GitHub-Repo unter **Settings → Secrets and variables → Actions**:

| Secret | Beschreibung |
|---|---|
| `ANTHROPIC_API_KEY` | Claude API Key (Pflicht) |
| `NEWS_API_KEY` | NewsAPI Key (empfohlen) |
| `TRADIER_API_KEY` | Tradier API Key (optional) |

### Automatischer Trigger

Die Pipeline läuft automatisch **Mo–Fr um 14:30 MEZ** (12:30 UTC).  
Manueller Trigger: GitHub → Actions → "Adaptive Asymmetry-Scanner" → "Run workflow".

---

## Projektstruktur

```
news-mirofish/
│
├── pipeline.py              # Haupt-Orchestrator (7-Stufen-Flow)
├── feedback.py              # Wöchentlicher Lern-Loop
├── config.yaml              # Alle Parameter zentral
├── requirements.txt
├── .env.example
│
├── modules/
│   ├── data_ingestion.py    # Stufe 1: News + Hard-Filter + EPS-Drift
│   ├── prescreener.py       # Stufe 2: Claude Haiku Batch-Filter
│   ├── deep_analysis.py     # Stufe 3: Claude Sonnet Asymmetry-Reasoning
│   ├── mismatch_scorer.py   # Stufe 4: Z-Score + Mismatch-Formel
│   ├── mirofish_simulation.py # Stufe 5: Monte-Carlo 10.000 Pfade
│   ├── quasi_ml.py          # Stufe 6: Adaptive Bin-Scoring
│   ├── options_designer.py  # Stufe 7: IV-Analyse + Kontrakt-Auswahl
│   ├── risk_gates.py        # VIX-Check, Earnings-Gate, Liquidität
│   └── reporter.py          # JSON + Markdown Report
│
├── outputs/
│   ├── history.json         # Persistente Feature-Stats + Trades (im Git)
│   └── daily_reports/
│       ├── YYYY-MM-DD.json  # Maschinenlesbar
│       └── YYYY-MM-DD.md    # Menschenlesbar
│
├── tests/
│   └── test_pipeline.py     # Pytest-Suite
│
└── .github/
    └── workflows/
        └── scanner.yml      # GitHub Actions
```

---

## Risk-Gates (Sicherheitslayer)

Alle Gates blockieren den Trade automatisch:

| Gate | Bedingung |
|---|---|
| VIX-Gate | VIX > 35 → gesamte Pipeline bricht ab |
| Earnings-Gate | Earnings < 7 Tage → Ticker blockiert |
| Bear-Case-Gate | bear_case_severity > 7 → Ticker blockiert |
| Liquiditäts-Gate | Open Interest < 100 → Kontrakt abgelehnt |
| Spread-Gate | Bid-Ask-Ratio > 10% → Kontrakt abgelehnt |

---

## Strategie-Logik

| IV-Rank | Richtung | Strategie |
|---|---|---|
| < 50 | Bullish | Long Call (DTE 120–200, Delta ~0.65) |
| ≥ 50 | Bullish | Bull Call Spread |
| < 50 | Bearish | Long Put |
| ≥ 50 | Bearish | Bear Put Spread |

---

## Quasi-ML Selbstlern-System

`history.json` speichert für jede Feature-Kombination den historischen Durchschnitts-Return:

```
FinalScore = Σ(Bin_Avg_Return_i × Current_Weight_i)
```

Nach jedem abgeschlossenen Trade (≥ 130 Tage) werden:
1. Die Bin-Durchschnitte aktualisiert (laufender Ø)
2. Die Feature-Gewichte via Pearson-Korrelation neu kalibriert

Je mehr Trades, desto präziser das Scoring.

---

## Tests

```bash
pytest tests/ -v
```

---

## Haftungsausschluss

Dieses System generiert ** keine Anlageberatung **. Alle Vorschläge sind rein algorithmischer Natur und dienen ausschließlich zu Forschungs- und Lernzwecken. Der Einsatz von echtem Kapital auf Basis dieser Ausgaben erfolgt auf eigenes Risiko. Options-Handel kann zum vollständigen Verlust des eingesetzten Kapitals führen.

**Empfehlung:** Mindestens 6 Monate Papier-Trading (nur Logs, kein echtes Kapital) bevor reale Positionen eröffnet werden.


================================================
FILE: config.yaml
================================================
# Adaptive Asymmetry-Scanner – Konfiguration v5.0

pipeline:
  confidence_gate: 0.70
  n_simulation_paths: 10000
  simulation_days: 120
  min_impact_threshold: 4
  max_intraday_move: 0.07    # NEU: 7% → Signal verwerfen wenn zu spät

filters:
  min_market_cap: 2_000_000_000
  min_avg_volume: 1_000_000
  universe: "sp500_nasdaq100"

models:
  prescreener: "claude-haiku-4-5-20251001"
  deep_analysis: "claude-sonnet-4-6"

risk:
  vix_threshold: 35.0
  earnings_buffer_days: 7
  min_open_interest: 100
  max_bid_ask_ratio: 0.10
  max_bear_case_severity: 8

options:
  # DTE-Tiers werden in options_designer.py (DTE_TIERS) gesteuert.
  # time_to_materialization aus deep_analysis bestimmt das DTE-Minimum (v9.0):
  #   "4-8 Wochen"  → dte_floor=14  (Short-Term erlaubt)
  #   "2-3 Monate"  → dte_floor=55  (Mid-Term Minimum)
  #   "6 Monate"    → dte_floor=140 (Long-Term Minimum)
  delta_target_low:  0.55
  delta_target_high: 0.75
  target_move_pct:   0.10
  min_roi_after_spread: 0.15

learning:
  learning_rate: 0.05
  min_bin_count:  3
  close_after_days: 45         # Geändert: 130 → 45

eps_drift:
  noise_threshold:    0.02
  relevant_threshold: 0.05
  massive_threshold:  0.10

# RL-System
rl:
  enabled: true
  model_path: "outputs/models/ppo_options_agent.zip"
  min_trades_for_training: 3
  timesteps_per_update: 2000

# FinBERT
finbert:
  enabled: true
  model_name: "ProsusAI/finbert"
  cache_dir: "/tmp/finbert_cache"
  max_headlines: 8
  max_tokens: 128

# Reddit
reddit:
  enabled: true
  subreddits: ["wallstreetbets", "stocks", "investing", "options"]
  max_posts_per_ticker: 50
  time_filter: "day"
  request_delay: 0.5

# Externe API-Quellen
data_sources:

  tradier:
    enabled: true             # false → yfinance Fallback für Option Chains
    base_url: "https://api.tradier.com/v1"
    timeout: 10               # Sekunden pro Request
    # Scope: options_designer.py (Chain + IV-Rank) + feedback.py (P&L-Tracking)
    # Key:   TRADIER_API_KEY als GitHub Secret (nie hier eintragen!)

  alpha_vantage:
    enabled: true             # ALPHA_VANTAGE_API_KEY als Secret
    max_daily_calls: 25       # Free Tier Limit
    eps_tolerance: 0.10       # 10% Abweichung erlaubt

  finnhub:
    enabled: true             # FINNHUB_API_KEY als Secret
    use_for_earnings: true    # Earnings-Gate via Finnhub statt yfinance

  fda:
    enabled: true             # Kein Key nötig
    days_back: 7
    sectors: ["Healthcare", "Biotechnology", "Pharmaceuticals"]

  sec_insider:
    enabled: true             # Kein Key nötig
    days_back: 14
    min_cluster_size: 2       # Mindestens 2 Insider für Cluster-Signal

  flash_alpha:
    enabled: true             # FLASH_ALPHA_API_KEY als Secret
    max_daily_calls: 5        # KRITISCH: Nicht überschreiten!
    top_n: 2                  # Nur für Top-2 Signale

  eulerpool:
    enabled: true             # EULERPOOL_API_KEY als Secret
    top_n: 2                  # Nur für Top-2 Signale
    iv_crush_threshold: 80    # IV-Percentile ab dem Warnung ausgegeben wird


================================================
FILE: feedback.py
================================================
"""
feedback.py – Adaptive Lern-Loop v5.0

Änderungen v5.0:
    - Tradier Live-API als primäre Datenquelle für Optionspreise (P&L-Tracking)
      Endpoint: /v1/markets/options/chains (Optionspreis via Strike-Filter)
      Endpoint: /v1/markets/quotes        (Aktienkurs Real-Time)
    - get_current_price():        Tradier Primary → yfinance Fallback
    - get_current_option_price(): Tradier Primary → yfinance Fallback
    - compute_outcome():          strategy-Parameter für saubere Call/Put-Erkennung
    - TRADIER_API_KEY via os.environ (bereits als GitHub Secret hinterlegt)
    - Warum wichtig: RL-Agent trainiert auf Outcomes — falsche Preise (yfinance
      ~15min delayed) führen zu fehlerhaften Lern-Signalen für den PPO-Agenten.

Änderungen v4.0:
    - Nach Trade-Close: PPO-Agent wird auf neuem closed_trade nachtrainiert
    - RL-Training: Inkrementelles Update (Continual Learning)
    - Bestehende Fixes M-04, M-05 bleiben erhalten
"""

import json
import logging
import os
import sys
from datetime import datetime
from pathlib import Path

import numpy as np
import requests
import yfinance as yf
from scipy import stats

from modules.config import cfg

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)],
)
log = logging.getLogger(__name__)

HISTORY_PATH       = Path("outputs/history.json")
MIN_TRADE_AGE_DAYS = 7

TRADIER_BASE    = "https://api.tradier.com/v1"
TRADIER_TIMEOUT = 10


# ── Tradier Hilfsfunktionen ───────────────────────────────────────────────────

def _tradier_headers() -> dict:
    """Authorization-Header für Tradier Live-API."""
    api_key = os.environ.get("TRADIER_API_KEY", "")
    return {
        "Authorization": f"Bearer {api_key}",
        "Accept":        "application/json",
    }


def _use_tradier() -> bool:
    """Gibt True zurück wenn TRADIER_API_KEY gesetzt ist."""
    return bool(os.environ.get("TRADIER_API_KEY", "").strip())


# ── History I/O ───────────────────────────────────────────────────────────────

def load_history() -> dict:
    if not HISTORY_PATH.exists():
        log.error("history.json nicht gefunden.")
        sys.exit(1)
    with open(HISTORY_PATH) as f:
        return json.load(f)


def save_history(history: dict) -> None:
    with open(HISTORY_PATH, "w") as f:
        json.dump(history, f, indent=2, default=str)
    log.info("history.json aktualisiert.")


# ── Preis-Abruf: Aktienkurs ───────────────────────────────────────────────────

def get_current_price(ticker: str) -> float:
    """
    Aktueller Aktienkurs: Tradier Primary → yfinance Fallback.

    Tradier /v1/markets/quotes liefert Real-Time-Kurse ohne Delay.
    yfinance als Fallback wenn Tradier nicht erreichbar.
    """
    if _use_tradier():
        price = _tradier_stock_price(ticker)
        if price > 0:
            return price
        log.debug(f"[{ticker}] Tradier Aktienkurs fehlgeschlagen → yfinance")

    # yfinance Fallback
    try:
        info = yf.Ticker(ticker).info
        return float(info.get("currentPrice") or info.get("regularMarketPrice") or 0)
    except Exception:
        return 0.0


def _tradier_stock_price(ticker: str) -> float:
    """
    Aktienkurs via Tradier /v1/markets/quotes.

    Response-Struktur:
        {"quotes": {"quote": {"last": 150.25, "bid": ..., "ask": ...}}}
    """
    try:
        resp = requests.get(
            f"{TRADIER_BASE}/markets/quotes",
            params={"symbols": ticker, "greeks": "false"},
            headers=_tradier_headers(),
            timeout=TRADIER_TIMEOUT,
        )
        resp.raise_for_status()
        data  = resp.json()
        quote = data.get("quotes", {}).get("quote", {})

        # Mehrere Symbole → Liste; einzelnes Symbol → Dict
        if isinstance(quote, list):
            quote = next((q for q in quote if q.get("symbol") == ticker), {})

        # "last" bevorzugt; Fallback auf Mid aus Bid/Ask
        last = quote.get("last")
        if last and float(last) > 0:
            return float(last)

        bid = float(quote.get("bid") or 0)
        ask = float(quote.get("ask") or 0)
        if bid > 0 and ask > 0:
            return round((bid + ask) / 2, 4)

        return 0.0

    except Exception as e:
        log.debug(f"Tradier Aktienkurs [{ticker}]: {e}")
        return 0.0


# ── Preis-Abruf: Optionspreis ─────────────────────────────────────────────────

def get_current_option_price(
    ticker: str, option: dict, strategy: str = ""
) -> float:
    """
    Aktueller Mid-Price einer Options-Position: Tradier Primary → yfinance Fallback.

    Args:
        ticker:   Ticker-Symbol (z.B. "AAPL")
        option:   Option-Dict aus history.json (strike, expiry, ...)
        strategy: Trade-Strategie (z.B. "LONG_CALL", "BEAR_PUT_SPREAD")
                  → bestimmt ob Call oder Put gesucht wird.
                  Leer → versucht Call zuerst, dann Put.
    """
    if not option:
        return 0.0
    strike = option.get("strike")
    expiry = option.get("expiry")
    if not strike or not expiry:
        return 0.0

    # Option-Type aus Strategie ableiten
    option_type = _option_type_from_strategy(strategy)

    # ── Versuch 1: Tradier ────────────────────────────────────────────────────
    if _use_tradier():
        price = _tradier_option_price(ticker, strike, expiry, option_type)
        if price > 0:
            log.debug(
                f"[{ticker}] Tradier Options-Mid: strike={strike} "
                f"expiry={expiry} → ${price:.2f}"
            )
            return price
        log.debug(f"[{ticker}] Tradier Options-Preis fehlgeschlagen → yfinance")

    # ── Versuch 2: yfinance Fallback ──────────────────────────────────────────
    return _yfinance_option_price(ticker, strike, expiry)


def _option_type_from_strategy(strategy: str) -> str:
    """
    Leitet "call" oder "put" aus der Trade-Strategie ab.

    "LONG_CALL", "BULL_CALL_SPREAD" → "call"
    "LONG_PUT",  "BEAR_PUT_SPREAD"  → "put"
    ""                              → "call" (Standard-Fallback; wird in
                                      _tradier_option_price auch als Put versucht)
    """
    s = strategy.upper()
    if "PUT" in s or "BEAR" in s:
        return "put"
    return "call"  # Default: Call (häufiger Fall)


def _tradier_option_price(
    ticker: str, strike: float, expiry: str, option_type: str
) -> float:
    """
    Options-Mid-Price via Tradier /v1/markets/options/chains.

    Filtert die Chain nach Strike ± 0.01 und option_type.
    Wenn option_type="call" und nichts gefunden → versucht "put" (Fallback
    bei alten Trades ohne Strategy-Info in history.json).
    """
    def _fetch_mid(o_type: str) -> float:
        try:
            resp = requests.get(
                f"{TRADIER_BASE}/markets/options/chains",
                params={
                    "symbol":     ticker,
                    "expiration": expiry,
                    "greeks":     "false",
                },
                headers=_tradier_headers(),
                timeout=TRADIER_TIMEOUT,
            )
            resp.raise_for_status()
            data    = resp.json()
            options = data.get("options", {}).get("option", []) or []

            # Einzelner Kontrakt kommt als Dict
            if isinstance(options, dict):
                options = [options]

            for o in options:
                if o.get("option_type") != o_type:
                    continue
                # Strike-Vergleich mit Float-Toleranz
                if abs(float(o.get("strike", 0)) - float(strike)) > 0.01:
                    continue

                bid = float(o.get("bid") or 0)
                ask = float(o.get("ask") or 0)
                if bid > 0 and ask > 0:
                    return round((bid + ask) / 2, 4)
                # Nur Ask vorhanden
                if ask > 0:
                    return float(ask)

            return 0.0

        except Exception as e:
            log.debug(f"Tradier Options-Chain [{ticker} {expiry}]: {e}")
            return 0.0

    # Primärer Versuch
    price = _fetch_mid(option_type)
    if price > 0:
        return price

    # Fallback: anderer Option-Type (für alte Trades ohne Strategy-Info)
    other_type = "put" if option_type == "call" else "call"
    return _fetch_mid(other_type)


def _yfinance_option_price(
    ticker: str, strike: float, expiry: str
) -> float:
    """Options-Mid-Price via yfinance (Fallback, unveränderte v4.0-Logik)."""
    try:
        t = yf.Ticker(ticker)
        if expiry not in t.options:
            return 0.0
        chain   = t.option_chain(expiry)
        # Versuche Calls zuerst, dann Puts
        for opts in [chain.calls, chain.puts]:
            matches = opts[(opts["strike"] == strike) & (opts["ask"] > 0)]
            if not matches.empty:
                row = matches.iloc[0]
                return float((row["bid"] + row["ask"]) / 2)
        return 0.0
    except Exception as e:
        log.debug(f"yfinance Options-Preis Fehler für {ticker}: {e}")
        return 0.0


# ── Outcome-Berechnung ────────────────────────────────────────────────────────

def compute_outcome(trade: dict, current_stock_price: float) -> float:
    """
    Berechnet Trade-Outcome (Return) für das RL-Training.

    Reihenfolge:
    1. Echter Options-P&L (wenn entry_last bekannt → Tradier/yfinance Preis)
    2. Delta-approximierter Return (Leverage-Schätzung)
    3. Reiner Stock-Return als letzter Fallback
    """
    ticker      = trade["ticker"]
    option      = trade.get("option", {})
    strategy    = trade.get("strategy", "")   # v5.0: für Option-Type-Erkennung
    sim         = trade.get("simulation", {})
    entry_stock = sim.get("current_price", 0)

    stock_return = 0.0
    if entry_stock > 0 and current_stock_price > 0:
        stock_return = (current_stock_price - entry_stock) / entry_stock

    entry_last = option.get("last", 0) if option else 0
    if entry_last > 0:
        # v5.0: strategy wird weitergegeben für saubere Call/Put-Erkennung
        current_option = get_current_option_price(ticker, option, strategy)
        if current_option > 0:
            options_return = (current_option - entry_last) / entry_last
            log.info(
                f"    Options-P&L: entry=${entry_last:.2f} → "
                f"current=${current_option:.2f} = {options_return:+.2%}"
            )
            return options_return

    if entry_last > 0 and entry_stock > 0:
        leverage      = (entry_stock / entry_last) * 0.65
        approx_return = stock_return * leverage
        log.info(f"    Delta-approx: {stock_return:+.2%} × {leverage:.1f} = {approx_return:+.2%}")
        return approx_return

    log.info(f"    Stock-Return Fallback: {stock_return:+.2%}")
    return stock_return


# ── Bin-Updates (Legacy, für Backward-Kompatibilität) ─────────────────────────

def update_bin(stats_dict: dict, feature: str, bin_label: str, outcome: float) -> None:
    bin_data = stats_dict.setdefault(feature, {}).setdefault(
        bin_label, {"count": 0, "avg_return": 0.0}
    )
    old_avg = bin_data["avg_return"]
    old_cnt = bin_data["count"]
    new_cnt = old_cnt + 1
    new_avg = (old_avg * old_cnt + outcome) / new_cnt
    bin_data["count"]      = new_cnt
    bin_data["avg_return"] = round(new_avg, 6)


# ── RL-Training ───────────────────────────────────────────────────────────────

def retrain_rl_agent(history: dict) -> None:
    """
    Trainiert den PPO-Agenten inkrementell auf allen closed_trades.

    Continual Learning: 2.000 Steps pro Feedback-Lauf (~5s auf CPU).
    GitHub-Actions-tauglich: Modell als .zip committed, nächster Run nutzt es.
    """
    try:
        from modules.rl_agent import train_agent
    except ImportError as e:
        log.warning(f"RL-Agent nicht importierbar: {e} → Training übersprungen")
        return

    closed = history.get("closed_trades", [])
    if len(closed) < 5:
        log.info(
            f"Nur {len(closed)} closed_trades → RL-Training übersprungen "
            f"(Minimum: 5)."
        )
        return

    log.info(f"Starte RL-Nachtraining auf {len(closed)} closed_trades...")
    success = train_agent(
        history         = history,
        total_timesteps = 2_000,
        force_retrain   = False,
    )

    if success:
        log.info("RL-Agent erfolgreich nachtrainiert.")
    else:
        log.warning("RL-Nachtraining fehlgeschlagen (nicht kritisch).")


# ── Pearson-Gewichte (Legacy-Support) ────────────────────────────────────────

def compute_pearson_weights(history: dict) -> dict:
    closed = history.get("closed_trades", [])
    if len(closed) < 5:
        return history.get("model_weights", {"impact": 0.35, "mismatch": 0.45, "eps_drift": 0.20})

    outcomes, impacts, mismatches, drifts = [], [], [], []
    for t in closed:
        outcome = t.get("outcome")
        if outcome is None:
            continue
        feat = t.get("features", {})
        outcomes.append(outcome)
        impacts.append(_bin_to_num("impact",    feat.get("bin_impact",    "mid")))
        mismatches.append(_bin_to_num("mismatch", feat.get("bin_mismatch",  "good")))
        drifts.append(_bin_to_num("eps_drift", feat.get("bin_eps_drift", "noise")))

    if len(outcomes) < 5:
        return history.get("model_weights", {})

    outcomes_arr = np.array(outcomes)
    correlations = {}
    for name, arr in [("impact", np.array(impacts)),
                       ("mismatch", np.array(mismatches)),
                       ("eps_drift", np.array(drifts))]:
        r, _ = stats.pearsonr(arr, outcomes_arr)
        correlations[name] = max(r, 0)

    total = sum(correlations.values()) or 1.0
    old_w = history.get("model_weights", {})
    new_w = {}
    for feat, corr in correlations.items():
        raw_new     = corr / total
        old         = old_w.get(feat, 1/3)
        new_w[feat] = round(old + cfg.learning.learning_rate * (raw_new - old), 4)

    total_w = sum(new_w.values())
    return {k: round(v / total_w, 4) for k, v in new_w.items()}


def _bin_to_num(feature: str, bin_label: str) -> float:
    mapping = {
        "impact":    {"low": 0.0, "mid": 0.5, "high": 1.0},
        "mismatch":  {"weak": 0.0, "good": 0.5, "strong": 1.0},
        "eps_drift": {"noise": 0.0, "relevant": 0.5, "massive": 1.0},
    }
    return mapping.get(feature, {}).get(bin_label, 0.5)


# ── Haupt-Loop ────────────────────────────────────────────────────────────────

def main() -> None:
    log.info("=== Feedback-Loop v5.0 gestartet ===")
    log.info(f"Tradier: {'aktiv' if _use_tradier() else 'KEIN KEY → yfinance Fallback'}")

    history      = load_history()
    today        = datetime.utcnow()
    active       = history.get("active_trades", [])
    still_active = []
    newly_closed = 0

    for trade in active:
        ticker     = trade["ticker"]
        entry_date = datetime.strptime(trade["entry_date"][:10], "%Y-%m-%d")
        age_days   = (today - entry_date).days

        if age_days < MIN_TRADE_AGE_DAYS:
            log.info(f"  [{ticker}] Alter={age_days}d < {MIN_TRADE_AGE_DAYS} → zu jung.")
            still_active.append(trade)
            continue

        current = get_current_price(ticker)
        if current <= 0:
            still_active.append(trade)
            continue

        outcome = compute_outcome(trade, current)
        log.info(f"  [{ticker}] Alter={age_days}d Outcome={outcome:+.2%}")

        # Legacy Bin-Updates (für Backward-Kompatibilität mit QuasiML)
        feat = trade.get("features", {})
        for f_name, bin_key in [("impact",    "bin_impact"),
                                  ("mismatch",  "bin_mismatch"),
                                  ("eps_drift", "bin_eps_drift")]:
            bin_label = feat.get(bin_key)
            if bin_label:
                update_bin(history["feature_stats"], f_name, bin_label, outcome)

        if age_days >= cfg.learning.close_after_days:
            trade["outcome"]      = round(outcome, 4)
            trade["close_date"]   = today.strftime("%Y-%m-%d")
            trade["close_price"]  = current
            history.setdefault("closed_trades", []).append(trade)
            log.info(f"  [{ticker}] Trade abgeschlossen (Return={outcome:+.2%})")
            newly_closed += 1
        else:
            trade["last_price"]     = current
            trade["current_return"] = round(outcome, 4)
            still_active.append(trade)

    history["active_trades"] = still_active
    history["model_weights"] = compute_pearson_weights(history)

    save_history(history)

    if newly_closed > 0:
        log.info(f"{newly_closed} neue closed_trades → starte RL-Nachtraining...")
        retrain_rl_agent(history)
    else:
        log.info("Keine neuen closed_trades → RL-Training übersprungen.")

    log.info("=== Feedback-Loop abgeschlossen ===")


if __name__ == "__main__":
    main()


================================================
FILE: modules/__init__.py
================================================


================================================
FILE: modules/alpha_sources.py
================================================
"""
modules/alpha_sources.py – Alternative Alpha-Quellen v9.0

Änderungen v9.0:
    #15 Put/Call-Skew + Dealer-Gamma-Schätzung als neue Signalquellen.

        fetch_options_skew(ticker, current_price):
            Berechnet Put/Call IV-Skew aus der Options-Chain.
            Hoher Skew (Puts teurer als Calls) = Markt ist bearish positioniert.
            Niedriger Skew = Markt sieht wenig Downside = bullish neutral.
            Nutzt Tradier (wenn verfügbar) sonst yfinance.

        estimate_dealer_gamma(ticker, current_price):
            Schätzt Dealer-Gamma-Exposure aus Open Interest.
            Negative Gamma: Dealer müssen in Richtung bewegen → Volatilität verstärkt.
            Positive Gamma: Dealer dämpfen Bewegungen → Mean Reversion wahrscheinlicher.
            Wichtig für: Interpreation ob ein Move sich beschleunigt oder abbricht.

        enrich_with_alpha_sources() jetzt auch mit Skew + Gamma-Signal.

Integriert FDA API, SEC Insider-Käufe und Finnhub Earnings-Kalender.

API-Limits:
  - FDA:     Unbegrenzt (offiziell, kein Key nötig)
  - SEC:     Unbegrenzt (offiziell, kein Key nötig)
  - Finnhub: 60 Calls/Minute auf Free Tier (API-Key nötig)
  - Tradier: Wie konfiguriert (für Skew-Berechnung, optional)
"""

from __future__ import annotations
import logging
import os
import re
import time
from datetime import datetime, timedelta, date
from typing import Optional

import requests

log = logging.getLogger(__name__)

_HEADERS = {"User-Agent": "newstoption-scanner/4.1 research@pcctrading.com"}

# v9.0 #15: Skew-Schwellen
SKEW_BEARISH_THRESHOLD = 1.20   # Puts > 20% teurer als Calls → bearishes Signal
SKEW_BULLISH_THRESHOLD = 0.85   # Puts > 15% billiger als Calls → bullishes Signal
SKEW_LOOKBACK_DTE_MIN  = 20     # Minimum DTE für Skew-Messung
SKEW_LOOKBACK_DTE_MAX  = 50     # Maximum DTE für Skew-Messung (30-50d ist der Standard)


# ── FDA API ───────────────────────────────────────────────────────────────────

def fetch_fda_events(company_name: str, days_back: int = 7) -> list[dict]:
    """
    Ruft FDA-Ereignisse für ein Unternehmen ab.
    Quelle: https://api.fda.gov/drug/event.json
    """
    try:
        since = (datetime.utcnow() - timedelta(days=days_back)).strftime("%Y%m%d")
        url   = (
            f"https://api.fda.gov/drug/event.json"
            f"?search=receivedate:[{since}+TO+99991231]"
            f"+AND+companynumb:{company_name.replace(' ', '+')}"
            f"&limit=5"
        )
        resp = requests.get(url, headers=_HEADERS, timeout=10)

        if resp.status_code == 404:
            return []

        resp.raise_for_status()
        results = resp.json().get("results", [])

        events = []
        for r in results:
            date = r.get("receivedate", "")
            desc = r.get("primarysource", {}).get("reportercountry", "")
            events.append({
                "date":        date,
                "type":        "fda_adverse_event",
                "description": f"FDA Adverse Event Report ({desc})",
                "source":      "FDA",
            })

        return events

    except Exception as e:
        log.debug(f"FDA API Fehler für {company_name}: {e}")
        return []


def fetch_fda_drug_approvals(days_back: int = 7) -> list[dict]:
    """
    Ruft aktuelle FDA Drug Approvals ab (nicht ticker-spezifisch).
    Quelle: https://api.fda.gov/drug/drugsfda.json
    """
    try:
        since = (datetime.utcnow() - timedelta(days=days_back)).strftime("%Y%m%d")
        url   = (
            f"https://api.fda.gov/drug/drugsfda.json"
            f"?search=submissions.submission_status_date:[{since}+TO+99991231]"
            f"+AND+submissions.submission_type:ORIG"
            f"&limit=10"
        )
        resp = requests.get(url, headers=_HEADERS, timeout=10)

        if resp.status_code == 404:
            return []

        resp.raise_for_status()
        results = resp.json().get("results", [])

        approvals = []
        for r in results:
            sponsor = r.get("sponsor_name", "").upper()
            drugs   = [p.get("brand_name", "") for p in r.get("products", [])[:2]]
            approvals.append({
                "sponsor":     sponsor,
                "drugs":       drugs,
                "type":        "fda_approval",
                "description": f"FDA Approval: {', '.join(drugs)}",
                "source":      "FDA",
            })

        return approvals

    except Exception as e:
        log.debug(f"FDA Approvals Fehler: {e}")
        return []


def match_fda_to_ticker(ticker: str, company_info: dict, days_back: int = 7) -> list[str]:
    """Sucht FDA-Events für einen Ticker und gibt Headlines zurück."""
    company_name = company_info.get("shortName", "") or company_info.get("longName", "")
    if not company_name:
        return []

    name_short = company_name.split()[0] if company_name else ""
    if len(name_short) < 3:
        return []

    events    = fetch_fda_events(name_short, days_back)
    approvals = fetch_fda_drug_approvals(days_back)

    headlines = []

    for e in events:
        headlines.append(f"FDA {e['type'].replace('_', ' ').title()}: {e['description']}")

    for a in approvals:
        if name_short.upper() in a.get("sponsor", ""):
            headlines.append(f"FDA APPROVAL: {a['description']} by {a['sponsor']}")

    if headlines:
        log.info(f"  [{ticker}] FDA: {len(headlines)} Events gefunden")

    return headlines


# ── SEC Insider-Käufe ─────────────────────────────────────────────────────────

def fetch_sec_insider_trades(ticker: str, days_back: int = 14) -> list[dict]:
    """
    Ruft Insider-Käufe für einen Ticker via SEC EDGAR ab.
    """
    try:
        search_url = (
            f"https://efts.sec.gov/LATEST/search-index?q=%22{ticker}%22"
            f"&dateRange=custom&startdt="
            f"{(datetime.utcnow() - timedelta(days=days_back)).strftime('%Y-%m-%d')}"
            f"&forms=4"
        )
        resp = requests.get(search_url, headers=_HEADERS, timeout=10)

        if resp.status_code != 200:
            return _fetch_sec_form4_fallback(ticker, days_back)

        hits   = resp.json().get("hits", {}).get("hits", [])
        trades = []

        for hit in hits[:10]:
            src = hit.get("_source", {})
            trades.append({
                "date":        src.get("period_of_report", ""),
                "insider":     src.get("display_names", ["Unknown"])[0]
                               if src.get("display_names") else "Unknown",
                "filing_url":  src.get("file_date", ""),
                "form":        "Form 4",
                "source":      "SEC",
            })

        return trades

    except Exception as e:
        log.debug(f"SEC EDGAR Fehler für {ticker}: {e}")
        return _fetch_sec_form4_fallback(ticker, days_back)


def _fetch_sec_form4_fallback(ticker: str, days_back: int) -> list[dict]:
    try:
        url = (
            f"https://efts.sec.gov/LATEST/search-index?q=%22{ticker}%22"
            f"&forms=4&dateRange=custom"
            f"&startdt={(datetime.utcnow()-timedelta(days=days_back)).strftime('%Y-%m-%d')}"
        )
        resp = requests.get(url, headers=_HEADERS, timeout=8)
        if resp.status_code != 200:
            return []

        hits   = resp.json().get("hits", {}).get("hits", [])
        result = []
        for h in hits[:5]:
            s = h.get("_source", {})
            result.append({
                "date":    s.get("file_date", ""),
                "insider": (s.get("display_names") or ["Unknown"])[0],
                "form":    "Form 4",
                "source":  "SEC",
            })
        return result
    except Exception:
        return []


def detect_insider_cluster(ticker: str, days_back: int = 14) -> dict:
    """
    Erkennt Cluster-Insider-Käufe: Mehrere verschiedene Insider kaufen
    innerhalb von 72 Stunden → starkes Signal.
    """
    trades = fetch_sec_insider_trades(ticker, days_back)

    if not trades:
        return {"cluster_detected": False, "insider_count": 0, "trades": []}

    unique_insiders = set(t["insider"] for t in trades)
    cluster         = len(unique_insiders) >= 2

    result = {
        "cluster_detected": cluster,
        "insider_count":    len(unique_insiders),
        "trades":           trades[:5],
        "headline":         "",
    }

    if cluster:
        result["headline"] = (
            f"SEC Form 4: {len(unique_insiders)} Insider kaufen {ticker} "
            f"innerhalb {days_back} Tagen (Cluster-Signal)"
        )
        log.info(
            f"  [{ticker}] SEC Insider-Cluster: "
            f"{len(unique_insiders)} Insider, {len(trades)} Trades"
        )

    return result


# ── Finnhub Earnings-Kalender ─────────────────────────────────────────────────

def get_earnings_date_finnhub(ticker: str) -> Optional[str]:
    """Ruft das nächste Earnings-Datum via Finnhub ab."""
    finnhub_key = os.getenv("FINNHUB_API_KEY", "")
    if not finnhub_key:
        return None

    try:
        today   = datetime.utcnow()
        to_date = today + timedelta(days=30)
        url     = (
            f"https://finnhub.io/api/v1/calendar/earnings"
            f"?from={today.strftime('%Y-%m-%d')}"
            f"&to={to_date.strftime('%Y-%m-%d')}"
            f"&symbol={ticker}"
            f"&token={finnhub_key}"
        )
        resp = requests.get(url, timeout=8)
        resp.raise_for_status()

        earnings_calendar = resp.json().get("earningsCalendar", [])
        if not earnings_calendar:
            return None

        dates = sorted([e["date"] for e in earnings_calendar if e.get("date")])
        return dates[0] if dates else None

    except Exception as e:
        log.debug(f"Finnhub Earnings Fehler für {ticker}: {e}")
        return None


def has_earnings_within_days(
    ticker:      str,
    buffer_days: int  = 7,
    use_finnhub: bool = True,
) -> tuple[bool, Optional[str]]:
    """Prüft ob Earnings innerhalb der nächsten buffer_days liegen."""
    earnings_date = None

    if use_finnhub and os.getenv("FINNHUB_API_KEY"):
        earnings_date = get_earnings_date_finnhub(ticker)

    if not earnings_date:
        try:
            import yfinance as yf
            info        = yf.Ticker(ticker).info
            earnings_ts = info.get("earningsTimestamp")
            if earnings_ts:
                earnings_date = datetime.fromtimestamp(earnings_ts).strftime("%Y-%m-%d")
        except Exception:
            pass

    if not earnings_date:
        return False, None

    try:
        earnings_dt = datetime.strptime(earnings_date, "%Y-%m-%d")
        # date.today() statt datetime.utcnow() — konsistent mit risk_gates.py.
        # datetime.utcnow() hat UTC-Offset-Fehler (0d vs 1d je nach Tageszeit).
        days_until  = (earnings_dt.date() - date.today()).days

        if 0 <= days_until <= buffer_days:
            log.info(
                f"  [{ticker}] EARNINGS-GATE: Earnings in {days_until}d "
                f"({earnings_date}) → Hard-Block."
            )
            return True, earnings_date

        return False, earnings_date

    except Exception:
        return False, None


# ── v9.0 #15: Put/Call-Skew ───────────────────────────────────────────────────

def fetch_options_skew(ticker: str, current_price: float) -> dict:
    """
    Berechnet Put/Call IV-Skew aus der 30-50 DTE Options-Chain.

    Methode: ATM-Put-IV / ATM-Call-IV für das nächste Expiry im 20-50d Fenster.
    Skew > 1.20: Markt ist bearish (Puts teurer → erhöhter Downside-Schutz)
    Skew < 0.85: Markt sieht kaum Downside (bullish neutral)
    Skew ~1.0:   Ausgeglichen

    Nutzt yfinance (Tradier-Integration via TRADIER_API_KEY wenn verfügbar).

    Returns:
        {
            "skew_ratio":      float,   # put_iv / call_iv
            "put_iv":          float,
            "call_iv":         float,
            "expiry":          str,
            "signal":          "bearish_skew" | "neutral" | "bullish_skew",
            "headline":        str,     # Für News-Liste
            "data_available":  bool,
        }
    """
    result_empty = {
        "skew_ratio": 1.0, "put_iv": 0.0, "call_iv": 0.0,
        "expiry": "", "signal": "neutral", "headline": "",
        "data_available": False,
    }

    if current_price <= 0:
        return result_empty

    try:
        # Primär: Tradier (genauere IV-Daten)
        tradier_key = os.environ.get("TRADIER_API_KEY", "").strip()
        if tradier_key:
            result = _fetch_skew_tradier(ticker, current_price, tradier_key)
            if result and result.get("data_available"):
                log.info(
                    f"  [{ticker}] Put/Call Skew (Tradier): "
                    f"ratio={result['skew_ratio']:.2f} signal={result['signal']}"
                )
                return result

        # Fallback: yfinance
        result = _fetch_skew_yfinance(ticker, current_price)
        if result and result.get("data_available"):
            log.info(
                f"  [{ticker}] Put/Call Skew (yfinance): "
                f"ratio={result['skew_ratio']:.2f} signal={result['signal']}"
            )
        return result if result else result_empty

    except Exception as e:
        log.debug(f"  [{ticker}] Skew-Fehler: {e}")
        return result_empty


def _fetch_skew_yfinance(ticker: str, current_price: float) -> Optional[dict]:
    """yfinance-basierte Skew-Berechnung."""
    try:
        import yfinance as yf
        from datetime import timezone
        from datetime import datetime as _dt

        t = yf.Ticker(ticker)
        now = _dt.now(timezone.utc)

        target_expiry = None
        for exp in (t.options or []):
            try:
                exp_dt = _dt.strptime(exp, "%Y-%m-%d").replace(tzinfo=timezone.utc)
                dte    = (exp_dt - now).days
                if SKEW_LOOKBACK_DTE_MIN <= dte <= SKEW_LOOKBACK_DTE_MAX:
                    target_expiry = exp
                    break
            except Exception:
                continue

        if not target_expiry:
            return None

        chain = t.option_chain(target_expiry)

        # ATM-Strike (nächster Strike zum aktuellen Preis)
        atm_strike = None
        for s in sorted(chain.calls["strike"].tolist(), key=lambda x: abs(x - current_price)):
            atm_strike = s
            break

        if not atm_strike:
            return None

        call_rows = chain.calls[chain.calls["strike"] == atm_strike]
        put_rows  = chain.puts[chain.puts["strike"] == atm_strike]

        if call_rows.empty or put_rows.empty:
            return None

        call_iv = float(call_rows["impliedVolatility"].iloc[0])
        put_iv  = float(put_rows["impliedVolatility"].iloc[0])

        if call_iv <= 0.01 or put_iv <= 0.01:
            return None

        skew_ratio = put_iv / call_iv
        return _build_skew_result(ticker, skew_ratio, put_iv, call_iv, target_expiry)

    except Exception as e:
        log.debug(f"  [{ticker}] yfinance Skew Fehler: {e}")
        return None


def _fetch_skew_tradier(ticker: str, current_price: float, api_key: str) -> Optional[dict]:
    """Tradier-basierte Skew-Berechnung (höhere IV-Qualität)."""
    try:
        from datetime import timezone
        from datetime import datetime as _dt

        headers = {
            "Authorization": f"Bearer {api_key}",
            "Accept":        "application/json",
        }
        resp = requests.get(
            "https://api.tradier.com/v1/markets/options/expirations",
            params={"symbol": ticker, "includeAllRoots": "true"},
            headers=headers, timeout=10,
        )
        resp.raise_for_status()
        all_dates = resp.json().get("expirations", {}).get("date", []) or []
        if isinstance(all_dates, str):
            all_dates = [all_dates]

        now = _dt.now(timezone.utc)
        target_expiry = None
        for d in sorted(all_dates):
            try:
                exp_dt = _dt.strptime(d, "%Y-%m-%d").replace(tzinfo=timezone.utc)
                dte    = (exp_dt - now).days
                if SKEW_LOOKBACK_DTE_MIN <= dte <= SKEW_LOOKBACK_DTE_MAX:
                    target_expiry = d
                    break
            except Exception:
                continue

        if not target_expiry:
            return None

        chain_resp = requests.get(
            "https://api.tradier.com/v1/markets/options/chains",
            params={"symbol": ticker, "expiration": target_expiry, "greeks": "true"},
            headers=headers, timeout=10,
        )
        chain_resp.raise_for_status()
        options = chain_resp.json().get("options", {}).get("option", []) or []
        if isinstance(options, dict):
            options = [options]

        call_iv_atm, put_iv_atm = None, None
        best_call_dist, best_put_dist = float("inf"), float("inf")

        for o in options:
            strike = float(o.get("strike", 0))
            dist   = abs(strike - current_price)
            greeks = o.get("greeks") or {}
            iv     = greeks.get("mid_iv") or greeks.get("smv_vol") or 0.0
            if not isinstance(iv, (int, float)) or iv <= 0.01:
                continue

            if o.get("option_type") == "call" and dist < best_call_dist:
                best_call_dist = dist
                call_iv_atm    = float(iv)
            elif o.get("option_type") == "put" and dist < best_put_dist:
                best_put_dist = dist
                put_iv_atm    = float(iv)

        if not call_iv_atm or not put_iv_atm:
            return None

        skew_ratio = put_iv_atm / call_iv_atm
        return _build_skew_result(ticker, skew_ratio, put_iv_atm, call_iv_atm, target_expiry)

    except Exception as e:
        log.debug(f"  [{ticker}] Tradier Skew Fehler: {e}")
        return None


def _build_skew_result(
    ticker: str, skew_ratio: float, put_iv: float, call_iv: float, expiry: str
) -> dict:
    """Erstellt das Skew-Result-Dict mit Signal und Headline."""
    if skew_ratio >= SKEW_BEARISH_THRESHOLD:
        signal   = "bearish_skew"
        headline = (
            f"Options-Skew {ticker}: Puts {skew_ratio:.1%} teurer als Calls "
            f"(put_iv={put_iv:.1%} vs call_iv={call_iv:.1%}) — Markt sichert Downside ab"
        )
    elif skew_ratio <= SKEW_BULLISH_THRESHOLD:
        signal   = "bullish_skew"
        headline = (
            f"Options-Skew {ticker}: Calls relativ zu Puts günstig "
            f"(skew={skew_ratio:.2f}) — Markt erwartet wenig Downside"
        )
    else:
        signal   = "neutral"
        headline = ""

    return {
        "skew_ratio":     round(skew_ratio, 3),
        "put_iv":         round(put_iv, 4),
        "call_iv":        round(call_iv, 4),
        "expiry":         expiry,
        "signal":         signal,
        "headline":       headline,
        "data_available": True,
    }


# ── v9.0 #15: Dealer-Gamma-Schätzung ─────────────────────────────────────────

def estimate_dealer_gamma(ticker: str, current_price: float) -> dict:
    """
    Schätzt die Netto-Dealer-Gamma-Position aus Open Interest.

    Vereinfachte Methode (ohne Live Market-Maker-Daten):
    - Calls: Dealer sind typischerweise SHORT Calls (haben negative Gamma)
      → hoher Call-OI nahe ATM → Dealer müssen kaufen wenn Preis steigt (Gamma hedging)
    - Puts: Dealer sind typischerweise SHORT Puts (haben negative Gamma)
      → hoher Put-OI nahe ATM → Dealer müssen verkaufen wenn Preis fällt

    Netto-Gamma-Schätzung: Call-OI × Γ_call - Put-OI × Γ_put
    Γ ≈ N'(d1) / (S × σ × √T) — für ATM Optionen vereinfacht proportional zu 1/σ

    Positive Netto-Gamma: Dealer dämpfen Bewegungen (mean-reversion wahrscheinlicher)
    Negative Netto-Gamma: Dealer verstärken Bewegungen (trending wahrscheinlicher)

    Returns:
        {
            "net_gamma_sign":  "positive" | "negative" | "neutral",
            "call_oi_atm":     int,
            "put_oi_atm":      int,
            "oi_ratio":        float,   # call_oi / put_oi
            "signal":          str,
            "headline":        str,
            "data_available":  bool,
        }
    """
    result_empty = {
        "net_gamma_sign": "neutral", "call_oi_atm": 0, "put_oi_atm": 0,
        "oi_ratio": 1.0, "signal": "neutral", "headline": "",
        "data_available": False,
    }

    if current_price <= 0:
        return result_empty

    try:
        import yfinance as yf
        from datetime import timezone
        from datetime import datetime as _dt

        t   = yf.Ticker(ticker)
        now = _dt.now(timezone.utc)

        target_expiry = None
        for exp in (t.options or []):
            try:
                exp_dt = _dt.strptime(exp, "%Y-%m-%d").replace(tzinfo=timezone.utc)
                dte    = (exp_dt - now).days
                if 14 <= dte <= 45:
                    target_expiry = exp
                    break
            except Exception:
                continue

        if not target_expiry:
            return result_empty

        chain = t.option_chain(target_expiry)

        # ATM-Bereich: ±5% vom aktuellen Preis
        atm_low  = current_price * 0.95
        atm_high = current_price * 1.05

        calls_atm = chain.calls[
            (chain.calls["strike"] >= atm_low) &
            (chain.calls["strike"] <= atm_high)
        ]
        puts_atm = chain.puts[
            (chain.puts["strike"] >= atm_low) &
            (chain.puts["strike"] <= atm_high)
        ]

        call_oi = int(calls_atm["openInterest"].sum()) if not calls_atm.empty else 0
        put_oi  = int(puts_atm["openInterest"].sum())  if not puts_atm.empty  else 0

        if call_oi + put_oi < 100:
            return result_empty

        oi_ratio = call_oi / put_oi if put_oi > 0 else 2.0

        # Dealer-Gamma-Interpretation:
        # Hoher Call-OI ATM → Dealer short viele Calls → müssen bei Anstieg kaufen
        # = negative Dealer-Gamma (bei Calls) → verstärkt Aufwärtsbewegung
        # Für Trading-Signal: hoher put_oi/call_oi-Ratio → viel Absicherung
        # = Markt ist bearish positioniert aber gut abgesichert
        if oi_ratio > 1.5:
            net_gamma_sign = "positive"  # Mehr Calls, Dealer dämpfen Anstieg etwas
            signal         = "gamma_neutral_to_positive"
            headline       = (
                f"Dealer-Gamma {ticker}: Call-OI ({call_oi:,}) dominiert Put-OI ({put_oi:,}) "
                f"ATM — Dealer-Hedging könnte Aufwärtsbewegungen leicht dämpfen"
            )
        elif oi_ratio < 0.70:
            net_gamma_sign = "negative"  # Mehr Puts, Dealer-Hedging verstärkt Abwärts
            signal         = "gamma_bearish_pressure"
            headline       = (
                f"Dealer-Gamma {ticker}: Put-OI ({put_oi:,}) dominiert ATM "
                f"(Call/Put-Ratio={oi_ratio:.2f}) — Dealer-Hedging kann Abwärtsbewegungen verstärken"
            )
        else:
            net_gamma_sign = "neutral"
            signal         = "neutral"
            headline       = ""

        log.info(
            f"  [{ticker}] Dealer-Gamma: call_oi={call_oi} put_oi={put_oi} "
            f"ratio={oi_ratio:.2f} → {net_gamma_sign}"
        )

        return {
            "net_gamma_sign": net_gamma_sign,
            "call_oi_atm":    call_oi,
            "put_oi_atm":     put_oi,
            "oi_ratio":       round(oi_ratio, 3),
            "signal":         signal,
            "headline":       headline,
            "data_available": True,
        }

    except Exception as e:
        log.debug(f"  [{ticker}] Dealer-Gamma Fehler: {e}")
        return result_empty


# ── Kombinierter Alpha-Enrichment ─────────────────────────────────────────────

def enrich_with_alpha_sources(candidate: dict) -> dict:
    """
    Reichert einen Pipeline-Kandidaten mit FDA, SEC, Finnhub,
    Put/Call-Skew und Dealer-Gamma-Daten an.

    v9.0: Skew und Gamma werden als neue alpha_signals gespeichert
    und auffällige Werte als Headlines in candidate["news"] aufgenommen.
    """
    ticker = candidate.get("ticker", "")
    info   = candidate.get("info", {})

    current_price = float(
        info.get("currentPrice") or
        info.get("regularMarketPrice") or 0
    )

    alpha_signals = {
        "fda_headlines":     [],
        "sec_insider":       {},
        "earnings_date":     None,
        "has_near_earnings": False,
        "options_skew":      {},
        "dealer_gamma":      {},
    }

    # 1. FDA (nur für Healthcare/Biotech)
    sector = info.get("sector", "")
    if sector in ("Healthcare", "Biotechnology", "Pharmaceuticals"):
        fda_headlines = match_fda_to_ticker(ticker, info)
        alpha_signals["fda_headlines"] = fda_headlines
        if fda_headlines:
            candidate.setdefault("news", [])
            candidate["news"] = fda_headlines + candidate["news"]

    # 2. SEC Insider
    insider_data = detect_insider_cluster(ticker)
    alpha_signals["sec_insider"] = insider_data
    if insider_data.get("headline"):
        candidate.setdefault("news", [])
        candidate["news"] = [insider_data["headline"]] + candidate["news"]

    # 3. Finnhub Earnings
    has_earnings, earnings_date = has_earnings_within_days(ticker)
    alpha_signals["earnings_date"]     = earnings_date
    alpha_signals["has_near_earnings"] = has_earnings
    candidate["has_near_earnings"]     = has_earnings

    # 4. v9.0 #15: Put/Call-Skew
    if current_price > 0:
        skew_data = fetch_options_skew(ticker, current_price)
        alpha_signals["options_skew"] = skew_data
        if skew_data.get("headline"):
            candidate.setdefault("news", [])
            candidate["news"] = candidate["news"] + [skew_data["headline"]]

    # 5. v9.0 #15: Dealer-Gamma-Schätzung
    if current_price > 0:
        gamma_data = estimate_dealer_gamma(ticker, current_price)
        alpha_signals["dealer_gamma"] = gamma_data
        if gamma_data.get("headline"):
            candidate.setdefault("news", [])
            candidate["news"] = candidate["news"] + [gamma_data["headline"]]

    candidate["alpha_signals"] = alpha_signals
    return candidate


================================================
FILE: modules/config.py
================================================
import yaml
import os
from box import ConfigBox

# Pfad zur config.yaml (eine Ebene höher als dieser Ordner)
config_path = os.path.join(os.path.dirname(__file__), "..", "config.yaml")

with open(config_path, "r") as f:
    # ConfigBox erlaubt den Zugriff via cfg.filters.min_market_cap statt ['filters']['min_market_cap']
    cfg = ConfigBox(yaml.safe_load(f))


================================================
FILE: modules/data_ingestion.py
================================================
"""
modules/data_ingestion.py v7.1

v7.1: Short Interest als Feature
    yfinance liefert shortPercentOfFloat bereits im info-Dict.
    Kein zusätzlicher API-Call nötig.
    Short Float > 15% → asymmetrisches Upside bei positiver News (Squeeze-Potential).
    Wird als candidate["short_interest"] gespeichert.

v7.0:
Fix 1: Parallel-Requests statt sequenziell
    Vorher: 493 Ticker × ~0.85s = ~7 Minuten
    Jetzt:  ThreadPoolExecutor mit 20 Workers = ~30-45 Sekunden

Fix 2: Haiku↔Sonnet Konsistenz-Check
    Prescreening-Begründung wird an Deep Analysis übergeben.
"""

from __future__ import annotations
import logging
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
from typing import Optional

import requests
import yfinance as yf

from modules.config   import cfg
from modules.universe import get_universe

log = logging.getLogger(__name__)

MIN_MARKET_CAP_USD    = 2_000_000_000
MIN_AVG_VOLUME        = 1_000_000
MIN_DOLLAR_VOLUME_USD = 10_000_000
RV_BASE_THRESHOLD     = 0.6   # Basis-Schwelle (skaliert mit VIX dynamisch)
MAX_WORKERS           = 20   # Parallel Threads — empirisch für Yahoo Finance

# v7.1: Short Interest Schwellenwerte
SHORT_INTEREST_HIGH   = 0.15   # 15% Short Float → "high" (Squeeze-Potential)
SHORT_INTEREST_MED    = 0.08   # 8% Short Float → "elevated"


class DataIngestion:

    def __init__(self, history: dict | None = None):
        self.history      = history or {}
        self.news_api_key = os.getenv("NEWS_API_KEY", "")

    def _get_current_vix(self) -> float:
        """VIX einmal holen — nicht pro Ticker wiederholen."""
        try:
            import yfinance as _yf
            val = _yf.Ticker("^VIX").fast_info.last_price
            return float(val or 20.0)
        except Exception:
            return 20.0

    def run(self) -> list[dict]:
        tickers = get_universe()
        log.info(f"Stufe 1: Hard-Filter auf {len(tickers)} Ticker "
                 f"(parallel, {MAX_WORKERS} Workers)")

        vix_current = self._get_current_vix()
        log.info(f"  RV-Filter: VIX={vix_current:.1f} → Schwelle={0.6 * max(0.5, min(1.5, vix_current/20.0)):.3f}")

        stats = {
            "total": len(tickers), "no_data": 0, "market_cap": 0,
            "avg_volume": 0, "dollar_volume": 0, "rel_volume": 0,
            "no_news": 0, "passed": 0,
        }

        candidates = []
        # Parallel-Requests — massiv schneller als sequenziell
        with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
            futures = {
                executor.submit(self._evaluate_ticker, ticker, {}, vix_current): ticker
                for ticker in tickers
            }
            for future in as_completed(futures):
                try:
                    result, ticker_stats = future.result()
                    # Stats thread-safe aggregieren
                    for k, v in ticker_stats.items():
                        stats[k] = stats.get(k, 0) + v
                    if result:
                        candidates.append(result)
                except Exception as e:
                    log.debug(f"Future-Fehler: {e}")
                    stats["no_data"] += 1

        self._log_filter_stats(stats)
        log.info(f"  → {len(candidates)} Kandidaten nach Hard-Filter")
        return candidates

    def _evaluate_ticker(
        self, ticker: str, _stats: dict, vix_current: float = 20.0
    ) -> tuple[Optional[dict], dict]:
        """Evaluiert einen Ticker. Gibt (result, stats_delta) zurück."""
        local_stats = {
            "no_data": 0, "market_cap": 0, "avg_volume": 0,
            "dollar_volume": 0, "rel_volume": 0, "no_news": 0, "passed": 0,
        }
        try:
            # Retry bei 401 (yfinance Crumb-Invalidierung durch parallele Requests)
            info = None
            for _attempt in range(2):
                try:
                    t    = yf.Ticker(ticker)
                    info = t.info
                    if info and isinstance(info, dict):
                        break
                except Exception as _e:
                    if _attempt == 0:
                        time.sleep(0.4)   # kurze Pause vor Retry
                    else:
                        raise

            if not info or not isinstance(info, dict):
                local_stats["no_data"] += 1
                return None, local_stats

            market_cap = info.get("marketCap") or 0
            if market_cap < MIN_MARKET_CAP_USD:
                local_stats["market_cap"] += 1
                return None, local_stats

            avg_vol = info.get("averageVolume") or info.get("averageVolume10days") or 0
            if avg_vol < MIN_AVG_VOLUME:
                local_stats["avg_volume"] += 1
                return None, local_stats

            current_price = (
                info.get("currentPrice") or
                info.get("regularMarketPrice") or
                info.get("previousClose") or 0
            )
            if current_price * avg_vol < MIN_DOLLAR_VOLUME_USD:
                local_stats["dollar_volume"] += 1
                return None, local_stats

            volume_today = info.get("volume") or info.get("regularMarketVolume") or 0
            rel_volume   = volume_today / avg_vol if avg_vol > 0 and volume_today > 0 else 0.0
            # Dynamischer RV-Schwellenwert: skaliert mit VIX (einmal in run() geholt)
            vix_avg = 20.0   # historischer Durchschnitt
            # Schwelle sinkt bei ruhigem Markt, steigt bei Panik
            rv_threshold = RV_BASE_THRESHOLD * max(0.5, min(1.5, vix_current / vix_avg))
            rv_threshold = round(rv_threshold, 2)

            # News-Override: Ticker mit starker News-Aktivität trotz niedrigem RV erlauben
            news = self._fetch_news(ticker, info)
            news_count   = len(news) if news else 0
            news_override = (news_count >= 3 and rel_volume >= 0.25)

            if rel_volume < rv_threshold and not news_override:
                local_stats["rel_volume"] += 1
                return None, local_stats

            if not news:
                local_stats["no_news"] += 1
                return None, local_stats

            # ── v7.1: Short Interest extrahieren (kostenlos, bereits in info) ─
            short_pct   = info.get("shortPercentOfFloat") or info.get("shortRatio") or 0.0
            short_float = float(short_pct) if short_pct else 0.0
            # yfinance gibt manchmal als Ratio (0.15) oder Prozent (15.0)
            if short_float > 1.0:
                short_float = short_float / 100.0   # 15.0 → 0.15

            if short_float >= SHORT_INTEREST_HIGH:
                short_label = "high"
            elif short_float >= SHORT_INTEREST_MED:
                short_label = "elevated"
            else:
                short_label = "normal"

            local_stats["passed"] += 1
            dollar_volume = current_price * avg_vol
            log.info(
                f"  [{ticker}] ✅ Cap=${market_cap/1e9:.1f}B "
                f"AvgVol={avg_vol/1e6:.1f}M "
                f"$Vol=${dollar_volume/1e6:.0f}M "
                f"RV={rel_volume:.2f} News={len(news)}"
                f"{f' Short={short_float:.0%}({short_label})' if short_float >= SHORT_INTEREST_MED else ''}"
            )

            return {
                "ticker":        ticker,
                "info":          info,
                "news":          news,
                "market_cap":    market_cap,
                "avg_volume":    avg_vol,
                "dollar_volume": dollar_volume,
                "rel_volume":    round(rel_volume, 3),
                "current_price": current_price,
                "features":      {},
                # v7.1: Short Interest
                "short_interest": {
                    "short_float_pct": round(short_float, 4),
                    "label":           short_label,
                },
            }, local_stats

        except Exception as e:
            log.debug(f"  [{ticker}] Fehler: {e}")
            local_stats["no_data"] += 1
            return None, local_stats

    def _log_filter_stats(self, stats: dict) -> None:
        total  = stats["total"]
        passed = stats["passed"]
        log.info("=" * 55)
        log.info(f"HARD-FILTER ERGEBNIS: {passed}/{total} Ticker bestanden")
        log.info("-" * 55)
        log.info(f"  ❌ Kein Data/Fehler:         {stats['no_data']:>4}  ({stats['no_data']/total*100:.1f}%)")
        log.info(f"  ❌ Market Cap < 2 Mrd.:      {stats['market_cap']:>4}  ({stats['market_cap']/total*100:.1f}%)")
        log.info(f"  ❌ Avg Volume < 1M:          {stats['avg_volume']:>4}  ({stats['avg_volume']/total*100:.1f}%)")
        log.info(f"  ❌ Dollar-Vol < $10M:         {stats['dollar_volume']:>4}  ({stats['dollar_volume']/total*100:.1f}%)")
        log.info(f"  ❌ Rel. Volume < Schwelle:  {stats['rel_volume']:>4}  ({stats['rel_volume']/total*100:.1f}%)")
        log.info(f"  ❌ Keine News:                {stats['no_news']:>4}  ({stats['no_news']/total*100:.1f}%)")
        log.info(f"  ✅ Bestanden:                {passed:>4}  ({passed/total*100:.1f}%)")
        log.info("=" * 55)
        if passed < 10:
            log.warning(f"Nur {passed} Kandidaten — wenig Material für Prescreening.")
        elif passed > 80:
            log.warning(f"{passed} Kandidaten — sehr viel, API-Kosten beachten.")

    def _fetch_news(self, ticker: str, info: dict) -> list[str]:
        finnhub_key = os.getenv("FINNHUB_API_KEY", "")
        if finnhub_key:
            news = self._fetch_finnhub_news(ticker, finnhub_key)
            if news:
                return news
        if self.news_api_key:
            company_name = info.get("longName", ticker).split()[0]
            news = self._fetch_newsapi(ticker, company_name)
            if news:
                return news
        return self._fetch_yfinance_news(ticker)

    def _fetch_finnhub_news(self, ticker: str, api_key: str) -> list[str]:
        try:
            since = (datetime.utcnow() - timedelta(days=2)).strftime("%Y-%m-%d")
            today = datetime.utcnow().strftime("%Y-%m-%d")
            resp  = requests.get(
                "https://finnhub.io/api/v1/company-news",
                params={"symbol": ticker, "from": since, "to": today, "token": api_key},
                timeout=8,
            )
            resp.raise_for_status()
            articles = resp.json()
            return [a["headline"] for a in (articles or [])[:5] if a.get("headline")]
        except Exception:
            return []

    def _fetch_newsapi(self, ticker: str, company_name: str) -> list[str]:
        try:
            since = (datetime.utcnow() - timedelta(days=2)).strftime("%Y-%m-%d")
            resp  = requests.get(
                "https://newsapi.org/v2/everything",
                params={
                    "q": f'"{ticker}" OR "{company_name}"', "from": since,
                    "sortBy": "publishedAt", "pageSize": 5,
                    "apiKey": self.news_api_key, "language": "en",
                },
                timeout=8,
            )
            resp.raise_for_status()
            return [a["title"] for a in resp.json().get("articles", []) if a.get("title")]
        except Exception:
            return []

    def _fetch_yfinance_news(self, ticker: str) -> list[str]:
        try:
            t     = yf.Ticker(ticker)
            news  = t.news or []
            since = datetime.utcnow() - timedelta(hours=48)
            return [
                n["title"] for n in news[:5]
                if n.get("title") and
                datetime.fromtimestamp(n.get("providerPublishTime", 0)) >= since
            ]
        except Exception:
            return []


================================================
FILE: modules/data_validator.py
================================================
"""
modules/data_validator.py v6.0

Fix 5: SEC EDGAR XBRL für EPS (statt veraltetem yfinance forwardEps)
    EDGAR liefert geprüfte EPS-Daten direkt aus 10-Q/10-K Filings.
    Kostenlos, kein API-Key nötig.
    URL: https://data.sec.gov/api/xbrl/companyfacts/{CIK}.json

Fix 6: Echte Vega-Kalkulation im ROI-Gate
    Bisher: Vega ignoriert → ROI-Schätzung zu optimistisch bei hohem IV-Rank
    Jetzt:  Black-Scholes Vega berechnet → IV-Mean-Reversion-Verlust eingepreist
    Formel: Vega-P&L = Vega × ΔIV (erwartete IV-Reduktion nach Event)
"""

from __future__ import annotations
import logging
import math
import os
import time
from typing import Optional

import requests
import yfinance as yf

log = logging.getLogger(__name__)

# Alpha Vantage Rate-Limit
_AV_BASE       = "https://www.alphavantage.co/query"
_AV_DELAY      = 12.5
_last_av_call  = 0.0

# SEC EDGAR
_SEC_BASE      = "https://data.sec.gov"
_SEC_HEADERS   = {
    "User-Agent": "newstoption-scanner/5.0 research@pcctrading.com",
    "Accept-Encoding": "gzip, deflate",
}
_cik_cache: dict[str, str] = {}


# ── Fix 5: SEC EDGAR XBRL EPS ────────────────────────────────────────────────

def fetch_eps_sec_edgar(ticker: str) -> Optional[float]:
    """
    Ruft EPS (TTM) direkt aus SEC EDGAR XBRL-Daten ab.
    Quelle: 10-Q / 10-K Filings — geprüfte Zahlen, nicht veraltet.
    Kein API-Key nötig.

    Ablauf:
    1. CIK (Central Index Key) für Ticker lookup via EDGAR Tickers API
    2. EPS-Daten aus companyfacts API (XBRL Tag: us-gaap/EarningsPerShareBasic)
    3. TTM berechnen aus letzten 4 Quartals-Filings
    """
    try:
        cik = _get_cik(ticker)
        if not cik:
            return None

        url  = f"{_SEC_BASE}/api/xbrl/companyfacts/CIK{cik}.json"
        resp = requests.get(url, headers=_SEC_HEADERS, timeout=15)

        if resp.status_code != 200:
            return None

        facts = resp.json()
        us_gaap = facts.get("facts", {}).get("us-gaap", {})

        # EPS Basic (oder Diluted als Fallback)
        for tag in ("EarningsPerShareBasic", "EarningsPerShareDiluted"):
            eps_data = us_gaap.get(tag, {})
            if not eps_data:
                continue

            # Nur USD-Einheit, Quartals-Filings (10-Q)
            units = eps_data.get("units", {}).get("USD/shares", [])
            if not units:
                continue

            # Neueste 4 Quartale für TTM
            quarterly = [
                u for u in units
                if u.get("form") in ("10-Q", "10-K") and u.get("val") is not None
            ]

            if not quarterly:
                continue

            # Neueste 4 Einträge
            quarterly.sort(key=lambda x: x.get("end", ""), reverse=True)
            ttm_eps = sum(u["val"] for u in quarterly[:4])

            log.info(
                f"  [{ticker}] SEC EDGAR EPS: TTM={ttm_eps:.2f} "
                f"(aus {min(4, len(quarterly))} Quartalen, Tag={tag})"
            )
            return round(ttm_eps, 4)

        return None

    except Exception as e:
        log.debug(f"SEC EDGAR EPS Fehler für {ticker}: {e}")
        return None


def _get_cik(ticker: str) -> Optional[str]:
    """Lookup CIK (Central Index Key) für einen Ticker via EDGAR."""
    if ticker in _cik_cache:
        return _cik_cache[ticker]

    try:
        resp = requests.get(
            f"{_SEC_BASE}/files/company_tickers.json",
            headers=_SEC_HEADERS,
            timeout=10,
        )
        if resp.status_code != 200:
            return None

        data = resp.json()
        for _, company in data.items():
            if company.get("ticker", "").upper() == ticker.upper():
                cik = str(company["cik_str"]).zfill(10)
                _cik_cache[ticker] = cik
                return cik

        return None

    except Exception as e:
        log.debug(f"CIK-Lookup Fehler für {ticker}: {e}")
        return None


def cross_check_eps_edgar(
    ticker: str,
    yfinance_eps: float,
    tolerance: float = 0.10,
) -> dict:
    """
    Cross-Check: yfinance EPS vs. SEC EDGAR (offizielle Filings).

    Priorität:
    1. SEC EDGAR (geprüfte Zahlen) — primäre Quelle
    2. Alpha Vantage (Fallback wenn EDGAR fehlschlägt)
    3. yfinance allein (wenn beides fehlschlägt)

    Returns:
        {
            "sec_eps":         float or None,
            "yf_eps":          float,
            "deviation_pct":   float or None,
            "consistent":      bool,
            "confidence":      "high" | "medium" | "low",
            "source":          "SEC_EDGAR" | "ALPHA_VANTAGE" | "YFINANCE_ONLY",
            "data_anomaly":    bool,  # True wenn >20% Abweichung → Claude warnen
        }
    """
    sec_eps = fetch_eps_sec_edgar(ticker)
    source  = "YFINANCE_ONLY"

    if sec_eps is None:
        # Fallback: Alpha Vantage
        sec_eps = _fetch_eps_alphavantage(ticker)
        if sec_eps is not None:
            source = "ALPHA_VANTAGE"
    else:
        source = "SEC_EDGAR"

    if sec_eps is None:
        return {
            "sec_eps": None, "yf_eps": yfinance_eps,
            "deviation_pct": None, "consistent": True,
            "confidence": "medium", "source": "YFINANCE_ONLY",
            "data_anomaly": False,
        }

    if yfinance_eps == 0 or yfinance_eps is None:
        return {
            "sec_eps": sec_eps, "yf_eps": yfinance_eps,
            "deviation_pct": None, "consistent": False,
            "confidence": "low", "source": source,
            "data_anomaly": True,
        }

    deviation = abs(sec_eps - yfinance_eps) / abs(yfinance_eps)
    consistent  = deviation <= tolerance
    data_anomaly = deviation > 0.20   # >20% → Claude im Prompt warnen

    confidence = "high" if deviation < 0.02 else ("medium" if consistent else "low")

    if data_anomaly:
        log.warning(
            f"  [{ticker}] DATA ANOMALY: yfinance={yfinance_eps:.2f} vs "
            f"{source}={sec_eps:.2f} Abweichung={deviation:.1%} > 20% "
            f"→ Signal-Qualität reduziert."
        )
    elif not consistent:
        log.warning(
            f"  [{ticker}] EPS-Inkonsistenz: yf={yfinance_eps:.2f} "
            f"vs {source}={sec_eps:.2f} ({deviation:.1%})"
        )

    return {
        "sec_eps":       round(sec_eps, 4),
        "yf_eps":        round(yfinance_eps, 4),
        "deviation_pct": round(deviation, 4),
        "consistent":    consistent,
        "confidence":    confidence,
        "source":        source,
        "data_anomaly":  data_anomaly,
    }


def _fetch_eps_alphavantage(ticker: str) -> Optional[float]:
    global _last_av_call
    api_key = os.getenv("ALPHA_VANTAGE_API_KEY", "")
    if not api_key:
        return None
    elapsed = time.time() - _last_av_call
    if elapsed < _AV_DELAY:
        time.sleep(_AV_DELAY - elapsed)
    _last_av_call = time.time()
    try:
        resp = requests.get(
            _AV_BASE,
            params={"function": "OVERVIEW", "symbol": ticker, "apikey": api_key},
            timeout=10,
        )
        data    = resp.json()
        eps_str = data.get("EPS", "")
        if eps_str and eps_str not in ("None", "-", ""):
            return float(eps_str)
        return None
    except Exception:
        return None


# ── Fix 6: Echte Vega-Kalkulation im ROI-Gate ────────────────────────────────

def compute_option_roi_with_vega(option: dict, simulation: dict) -> dict:
    """
    Fix 6: ROI-Berechnung mit Vega-Adjustment.

    Bisher ignoriert: IV Mean-Reversion nach News-Event.
    Typisches Muster:
      - News → IV springt +30-50% (Fear/Greed)
      - 2-4 Wochen später: IV normalisiert sich -20-30%
      - Long Call verliert durch Vega auch wenn Aktie steigt

    Formel:
      roi_delta_approx = stock_move × delta × leverage
      vega_loss        = vega × expected_iv_drop
      roi_net          = roi_delta_approx - spread_cost - vega_loss

    Delta und Vega aus Black-Scholes (vereinfacht, ohne Zinsterm).
    """
    bid     = option.get("bid", 0.0) or 0.0
    ask     = option.get("ask", 0.0) or 0.0
    strike  = option.get("strike", 0.0) or 0.0
    iv      = option.get("implied_vol", 0.30) or 0.30
    dte     = option.get("dte", 120) or 120

    if ask <= 0:
        return _roi_empty()

    spread_pct = (ask - bid) / ask if ask > 0 else 0.0

    current = simulation.get("current_price", 0.0) or 0.0
    target  = simulation.get("target_price", 0.0) or 0.0
    iv_rank = simulation.get("iv_rank", 50.0) or 50.0

    if current <= 0 or target <= current:
        roi_gross = 0.0
        vega_loss = 0.0
    else:
        T = dte / 365.0   # Zeit in Jahren

        # Black-Scholes Delta und Vega
        delta_bs, vega_bs = _bs_delta_vega(current, strike, iv, T)

        # Erwarteter Kursgewinn
        expected_move = (target - current) / current
        leverage      = current / ask if ask > 0 else 1.0

        # Delta-approximierter ROI
        roi_delta = expected_move * delta_bs * leverage

        # Vega-Verlust durch IV Mean-Reversion
        # Faustregel: Bei IV-Rank > 70% → IV fällt nach Event ~20-30%
        # Bei IV-Rank 50-70% → IV fällt ~10-15%
        # Bei IV-Rank < 50% → IV-Crush unwahrscheinlich, ~0-5%
        if iv_rank >= 70:
            expected_iv_drop = 0.25   # 25% IV-Reduktion
        elif iv_rank >= 50:
            expected_iv_drop = 0.12
        else:
            expected_iv_drop = 0.05

        # Vega-P&L: Vega × ΔIV × leverage
        # Vega in $ pro 1 Punkt IV, normalisiert auf Option-Preis
        vega_normalized = vega_bs * iv * expected_iv_drop * leverage
        vega_loss       = max(vega_normalized, 0.0)

        roi_gross = roi_delta
        log.debug(
            f"    ROI-Detail: delta={delta_bs:.2f} vega={vega_bs:.4f} "
            f"iv_drop={expected_iv_drop:.0%} vega_loss={vega_loss:.3f}"
        )

    # Spread-Kosten (Round-trip)
    roi_net = roi_gross - (spread_pct * 2) - vega_loss

    min_roi   = float(os.getenv("MIN_OPTION_ROI", "0.15"))
    passes    = roi_net >= min_roi

    if not passes:
        log.info(
            f"    ROI-GATE: gross={roi_gross:.1%} "
            f"spread={spread_pct:.1%} vega_loss={vega_loss:.1%} "
            f"net={roi_net:.1%} < {min_roi:.0%} → abgelehnt."
        )

    return {
        "roi_gross":          round(roi_gross, 4),
        "roi_net":            round(roi_net, 4),
        "spread_pct":         round(spread_pct, 4),
        "vega_loss":          round(vega_loss, 4),
        "passes_roi_gate":    passes,
        "min_roi_threshold":  min_roi,
        "iv_rank_used":       iv_rank,
    }


def _bs_delta_vega(S: float, K: float, sigma: float, T: float) -> tuple[float, float]:
    """
    Black-Scholes Delta und Vega für eine Call-Option.
    Vereinfacht: r=0 (für kurze Berechnungszwecke ausreichend).

    Args:
        S:     Aktueller Kurs
        K:     Strike
        sigma: Implizite Volatilität (z.B. 0.30 = 30%)
        T:     Zeit in Jahren

    Returns:
        (delta, vega)
    """
    if sigma <= 0 or T <= 0 or S <= 0 or K <= 0:
        return 0.5, 0.0

    try:
        d1    = (math.log(S / K) + 0.5 * sigma ** 2 * T) / (sigma * math.sqrt(T))
        delta = _norm_cdf(d1)
        vega  = S * _norm_pdf(d1) * math.sqrt(T)
        return round(delta, 4), round(vega, 4)
    except Exception:
        return 0.5, 0.0


def _norm_cdf(x: float) -> float:
    """Kumulative Normalverteilung."""
    return (1.0 + math.erf(x / math.sqrt(2.0))) / 2.0


def _norm_pdf(x: float) -> float:
    """Normalverteilungs-PDF."""
    return math.exp(-0.5 * x ** 2) / math.sqrt(2.0 * math.pi)


def _roi_empty() -> dict:
    return {
        "roi_gross": 0.0, "roi_net": 0.0,
        "spread_pct": 0.0, "vega_loss": 0.0,
        "passes_roi_gate": False,
        "min_roi_threshold": 0.15,
        "iv_rank_used": 50.0,
    }


def validate_candidate_data(candidate: dict) -> dict:
    """Kombinierte Daten-Validierung: SEC EDGAR EPS + Confidence-Score."""
    info   = candidate.get("info", {})
    ticker = candidate.get("ticker", "")
    yf_eps = info.get("trailingEps") or info.get("forwardEps") or 0.0

    eps_check = cross_check_eps_edgar(ticker, yf_eps)

    candidate["data_validation"] = {"eps_cross_check": eps_check}
    candidate["data_confidence"]  = eps_check["confidence"]
    candidate["data_anomaly"]     = eps_check.get("data_anomaly", False)

    return candidate

# Backward-Kompatibilitäts-Alias (pipeline.py importiert compute_option_roi)
compute_option_roi = compute_option_roi_with_vega


================================================
FILE: modules/deep_analysis.py
================================================
"""
modules/deep_analysis.py v9.0

Änderungen v9.0:
    #10 catalyst_confidence (0-10) als neues JSON-Feld.
        Separater Score für "Wie sicher ist der Catalyst einzutreten?"
        Unabhängig vom Gesamt-Impact — ein Catalyst kann materialreich sein
        (Impact=8) aber unsicher (confidence=3). Wird in RL-Observation
        und Email ausgegeben.

    #12 Mega-Cap-Filter im System-Prompt.
        Bei Marktkapitalisierung > $200 Mrd. expliziter Hinweis auf
        Informationseffizienz. Impact > 6 bei Mega-Caps erfordert
        besonders starke Begründung. Marktkapitalisierung wird im
        Analyse-Template angezeigt.

    #3  mc_hit_rate wird im returned result dict gespeichert,
        damit options_designer.py darauf zugreifen kann ohne
        erneuten quick_mc-Lookup.

Änderungen v8.2:
    - FIX: _get_48h_move() nutzt period='10d' und vergleicht volle Handelstage.

Änderungen v8.1:
    - Analysedatum im SYSTEM_PROMPT + ANALYSIS_TEMPLATE
    - asymmetry_reasoning: Genau 3 Sätze, max 500 Zeichen
"""

import json
import logging
import os
from datetime import datetime, timedelta
from typing import Optional

import anthropic
import yfinance as yf

from modules.config        import cfg
from modules.macro_context import get_macro_context

log = logging.getLogger(__name__)

# v9.0 #12: Mega-Cap-Schwelle in USD
MEGA_CAP_THRESHOLD = 200_000_000_000  # $200 Mrd.

SYSTEM_PROMPT = """Du bist ein skeptischer Quant-Analyst mit Fokus auf mittelfristige Optionsstrategien (2-6 Monate). Das aktuelle Jahr ist {current_year}. Alle Jahreszahlen in deinen Analysen müssen ≥ {current_year} sein. Ignoriere historische Jahreszahlen aus deinen Trainingsdaten — orientiere dich ausschließlich am Analysedatum im Prompt.

PFLICHT-ABLAUF — in dieser Reihenfolge, keine Ausnahme:

SCHRITT 1 — RED TEAM (zuerst immer):
Finde die 3 stärksten Argumente GEGEN diesen Trade.
Denke wie ein Short-Seller. Was könnte das Signal zerstören?
Typische Red Flags: Überbewertung, Sektor-Gegenwind, fragliche Datenqualität,
Makro-Risiko, IV-Crush, Katalysator bereits eingepreist.

SCHRITT 2 — STATISTIK-CHECK:
Ist die MC Hit-Rate realistisch gegeben historischer Volatilität?
Warnung wenn Hit-Rate > 80% (Modell möglicherweise zu optimistisch).

SCHRITT 3 — MAKRO-KONTEXT:
Passt das Signal zum aktuellen Zinsumfeld?
Rezessives Umfeld (invertierte Kurve) → erhöhte Skepsis bei BULLISH-Signalen.

SCHRITT 4 — MEGA-CAP-CHECK (wenn Marktkapitalisierung > $200 Mrd.):
Mega-Cap-Warnung: Bei Aktien mit Marktkapitalisierung > $200 Mrd. ist die
Informationseffizienz extrem hoch. Große institutionelle Desk-Coverage bedeutet,
dass öffentliche Informationen innerhalb von Minuten eingepreist werden.
Strukturelle Underreactions bei Mega-Caps sind deutlich seltener als bei Mid-Caps.
Impact > 6 bei Mega-Caps erfordert einen sehr konkreten, nicht-öffentlichen Informationsvorsprung.
Im Zweifel: Impact auf 5 begrenzen wenn nur öffentliche Informationen vorliegen.

SCHRITT 5 — ERST JETZT: Finale Bewertung.
Im Zweifel BEARISH. Nur eindeutige strukturelle Signale verdienen Impact > 7.

Antworte ausschließlich mit validem JSON."""

ANALYSIS_TEMPLATE = """=== ANALYSEDATUM: {analysis_date} (WICHTIG: Alle Jahreszahlen müssen ≥ {analysis_year} sein) ===

=== MAKRO-KONTEXT ===
{macro_context}

=== TICKER: {ticker} ===
Aktueller Preis: ${current_price:.2f}
Marktkapitalisierung: {market_cap_str}{mega_cap_flag}
Sektor: {sector}
Haiku-Prescreening: {prescreen_reason} [Kategorie: {prescreen_category}]
WICHTIG: Wenn deine Bewertung der Direction von der Haiku-Einschätzung abweicht,
erkläre explizit warum im asymmetry_reasoning.
EPS (yfinance): {forward_eps} | EPS (SEC EDGAR): {sec_eps}
EPS-Abweichung: {eps_deviation}
48h-Preisbewegung: {move_48h:+.1%}

=== QUICK MONTE CARLO (Vorfilter) ===
Hit-Rate: {mc_hit_rate:.1%} ({mc_paths} Pfade, {mc_days}d)
Interpretation: {mc_interpretation}

=== NEWS (letzte 48h) ===
{news_text}

{data_anomaly_warning}

=== DEINE AUFGABE ===
Folge dem Pflicht-Ablauf: Red Team → Statistik → Makro → {mega_cap_step}→ Finale Bewertung.

Antworte NUR mit diesem JSON:
{{
    "red_team": {{
        "argument_1": "<Stärkstes Argument gegen den Trade — Min 2 vollständige Sätze, mindestens 200 Zeichen, konkret>",
        "argument_2": "<Zweitstärkstes Argument>",
        "argument_3": "<Drittstärkstes Argument>",
        "red_team_verdict": "VETO" oder "PASSIERT"
    }},
    "stats_check": {{
        "mc_assessment": "<Ist {mc_hit_rate:.0%} realistisch?>",
        "concern_level": "low" oder "medium" oder "high"
    }},
    "impact": <0-10>,
    "surprise": <0-10>,
    "direction": "BULLISH" oder "BEARISH",
    "bear_case_severity": <0-10>,
    "time_to_materialization": "4-8 Wochen" oder "2-3 Monate" oder "6 Monate",
    "catalyst_confidence": <0-10>,
    "asymmetry_reasoning": "<Genau 3 vollständige Sätze — warum der Markt unterreagiert hat. Maximal 500 Zeichen. Kein Satz darf abgebrochen werden>",
    "catalyst": "<Spezifischer Katalysator>",
    "bear_case": "<Stärkstes Gegenargument>",
    "macro_assessment": "<Bewertung im aktuellen Makro-Umfeld>",
    "data_confidence": "high" oder "medium" oder "low"
}}"""


def _format_market_cap(market_cap: Optional[int]) -> str:
    if not market_cap or market_cap <= 0:
        return "Unbekannt"
    if market_cap >= 1_000_000_000_000:
        return f"${market_cap/1_000_000_000_000:.1f}B (Trillion)"
    if market_cap >= 1_000_000_000:
        return f"${market_cap/1_000_000_000:.1f} Mrd."
    return f"${market_cap/1_000_000:.0f} Mio."


class DeepAnalysis:

    def __init__(self):
        self.client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
        self._macro = get_macro_context()
        if self._macro["data_available"]:
            log.info(
                f"Makro: {self._macro['macro_regime']} | "
                f"YC={self._macro.get('yield_curve_desc', 'n/a')}"
            )

    def run(self, shortlist: list[dict]) -> list[dict]:
        analyses = []
        for candidate in shortlist:
            analysis = self._analyze(candidate)
            if not analysis:
                continue

            red_team = analysis.get("red_team", {})
            arg1 = (red_team.get("argument_1", "") or "").lower()
            narrativ_mismatch = any(w in arg1 for w in [
                "narrativ-mismatch", "narrative mismatch", "trifft das geschäftsmodell",
                "falsches narrativ", "datenfehler in der vorselektion",
                "grundlegendes missverständnis", "trifft nicht zu"
            ])
            if narrativ_mismatch and red_team.get("red_team_verdict") != "VETO":
                log.warning(
                    f"  [{candidate['ticker']}] AUTO-VETO: Narrativ-Mismatch erkannt → "
                    f"'{arg1[:60]}'"
                )
                red_team["red_team_verdict"] = "VETO"

            if red_team.get("red_team_verdict") == "VETO":
                log.info(
                    f"  [{candidate['ticker']}] RED TEAM VETO → verworfen. "
                    f"Grund: {red_team.get('argument_1', 'n/a')}"
                )
                continue

            stats = analysis.get("stats_check", {})
            if stats.get("concern_level") == "high" and analysis.get("impact", 0) > 6:
                original = analysis["impact"]
                analysis["impact"] = 6
                log.info(
                    f"  [{candidate['ticker']}] Stats-Concern HIGH: "
                    f"Impact {original} → 6 gedeckelt"
                )

            haiku_reason  = candidate.get("prescreen_reason", "").lower()
            sonnet_dir    = analysis.get("direction", "")
            haiku_bullish = any(w in haiku_reason for w in
                ["positiv", "erhöht", "wachstum", "deal", "akquisition",
                 "expansion", "gewinn", "stieg", "prognose"])
            if haiku_bullish and sonnet_dir == "BEARISH":
                log.warning(
                    f"  [{candidate['ticker']}] ⚠️ WIDERSPRUCH: "
                    f"Haiku=BULLISH ({haiku_reason[:50]}) "
                    f"aber Sonnet=BEARISH → Impact={analysis['impact']} "
                    f"gedeckelt auf max 6"
                )
                if analysis.get("impact", 0) > 6:
                    analysis["impact"] = 6
                analysis["direction_conflict"] = True
            else:
                analysis["direction_conflict"] = False

            cat_conf = analysis.get("catalyst_confidence")
            log.info(
                f"  [{candidate['ticker']}] "
                f"Impact={analysis['impact']} "
                f"Surprise={analysis['surprise']} "
                f"Direction={analysis['direction']} "
                f"CatalystConf={cat_conf}/10 "
                f"TTM={analysis.get('time_to_materialization','?')} "
                f"RedTeam={red_team.get('red_team_verdict', '?')} "
                f"{'⚠️ KONFLIKT' if analysis.get('direction_conflict') else '✅'}"
            )

            analyses.append({**candidate, "deep_analysis": analysis})

        return analyses

    def _analyze(self, candidate: dict) -> Optional[dict]:
        ticker  = candidate.get("ticker", "")
        info    = candidate.get("info", {})
        news    = candidate.get("news", [])

        current_price = float(
            info.get("currentPrice") or
            info.get("regularMarketPrice") or 0
        )
        forward_eps  = info.get("forwardEps") or info.get("trailingEps") or 0.0
        sector       = info.get("sector", "Unknown")
        market_cap   = info.get("marketCap") or 0
        move_48h     = self._get_48h_move(ticker)

        eps_check     = candidate.get("data_validation", {}).get("eps_cross_check", {})
        sec_eps       = eps_check.get("sec_eps", "n/a")
        dev_pct       = eps_check.get("deviation_pct")
        eps_deviation = f"{dev_pct:.1%}" if dev_pct is not None else "n/a"

        data_anomaly    = candidate.get("data_anomaly", False)
        anomaly_warning = ""
        if data_anomaly:
            anomaly_warning = (
                "⚠️ DATA ANOMALY: EPS-Daten weichen >20% ab. "
                "Red Team sollte Datenqualität als Argument 1 nennen. "
                "data_confidence muss 'low' sein."
            )

        qmc         = candidate.get("quick_mc", {})
        mc_hit_rate = qmc.get("hit_rate", 0.0)
        mc_paths    = qmc.get("n_paths", 0)
        mc_days     = qmc.get("n_days", 30)

        if mc_hit_rate == 0:
            mc_interpretation = "Kein Quick MC durchgeführt — keine Statistik verfügbar."
        elif mc_hit_rate > 0.80:
            mc_interpretation = "WARNUNG: >80% Hit-Rate ist ungewöhnlich hoch — Modell möglicherweise zu optimistisch."
        elif mc_hit_rate > 0.60:
            mc_interpretation = "Solide statistische Basis — realistisch für 2-6M Horizont."
        else:
            mc_interpretation = f"Nur {mc_hit_rate:.0%} — knapp über Minimum-Gate, erhöhte Vorsicht."

        # v9.0 #12: Mega-Cap-Hinweis
        market_cap_str = _format_market_cap(market_cap)
        is_mega_cap    = market_cap >= MEGA_CAP_THRESHOLD
        mega_cap_flag  = (
            f"\n⚠️ MEGA-CAP: Informationseffizienz sehr hoch — Impact > 6 erfordert konkreten Edge!"
            if is_mega_cap else ""
        )
        mega_cap_step = "Mega-Cap-Check → " if is_mega_cap else ""

        news_text = "\n".join(f"- {h}" for h in news[:8]) if news else "Keine News."

        macro_text = (
            self._macro.get("claude_context", "Makro: nicht verfügbar")
            if self._macro.get("data_available")
            else "Makro-Kontext: FRED nicht erreichbar."
        )

        prescreen_reason   = candidate.get("prescreen_reason", "n/a")
        prescreen_category = candidate.get("prescreen_category", "n/a")

        from datetime import datetime as _dt
        _today = _dt.now()
        prompt = ANALYSIS_TEMPLATE.format(
            analysis_date      = _today.strftime("%d.%m.%Y"),
            analysis_year      = _today.year,
            macro_context      = macro_text,
            ticker             = ticker,
            current_price      = current_price,
            market_cap_str     = market_cap_str,
            mega_cap_flag      = mega_cap_flag,
            mega_cap_step      = mega_cap_step,
            sector             = sector,
            prescreen_reason   = prescreen_reason,
            prescreen_category = prescreen_category,
            forward_eps        = forward_eps,
            sec_eps            = sec_eps,
            eps_deviation      = eps_deviation,
            move_48h           = move_48h,
            mc_hit_rate        = mc_hit_rate,
            mc_paths           = mc_paths,
            mc_days            = mc_days,
            mc_interpretation  = mc_interpretation,
            news_text          = news_text,
            data_anomaly_warning = anomaly_warning,
        )

        try:
            response = self.client.messages.create(
                model      = cfg.models.deep_analysis,
                max_tokens = 1600,
                system     = SYSTEM_PROMPT.format(current_year=_today.year),
                messages   = [{"role": "user", "content": prompt}],
            )
            raw = response.content[0].text.strip()

            if "```" in raw:
                parts = raw.split("```")
                raw   = parts[1].lstrip("json").strip() if len(parts) > 1 else raw
            if not raw.startswith("{"):
                idx = raw.find("{")
                if idx != -1:
                    raw = raw[idx:]

            try:
                result = json.loads(raw)
            except json.JSONDecodeError as je:
                log.warning(f"  [{ticker}] JSON teilweise abgeschnitten: {je} → Reparatur-Versuch")
                last_comma = raw.rfind('",')
                cutoff = max(last_comma, 0)
                if cutoff > 100:
                    raw_fixed = raw[:cutoff] + '"}'
                    try:
                        result = json.loads(raw_fixed)
                        log.info(f"  [{ticker}] JSON repariert (gekürzt auf {cutoff} Zeichen)")
                    except Exception:
                        log.warning(f"  [{ticker}] JSON nicht reparierbar → Fallback-Response")
                        result = {
                            "red_team": {"argument_1": "JSON-Parse-Fehler", "red_team_verdict": "PASSIERT"},
                            "stats_check": {"mc_assessment": "n/a", "concern_level": "medium"},
                            "impact": 3, "surprise": 3, "direction": "BULLISH",
                            "bear_case_severity": 5,
                            "time_to_materialization": "2-3 Monate",
                            "catalyst_confidence": 5,
                            "asymmetry_reasoning": "JSON-Parse-Fehler — manuelle Prüfung empfohlen",
                            "catalyst": "n/a", "bear_case": "n/a",
                            "macro_assessment": "n/a", "data_confidence": "low"
                        }
                else:
                    raise

            # Sicherstellen dass catalyst_confidence vorhanden ist (Fallback: 5)
            if "catalyst_confidence" not in result:
                result["catalyst_confidence"] = 5

            result["macro_regime"]  = self._macro.get("macro_regime", "unknown")
            result["macro_context"] = {
                "yield_curve": self._macro.get("yield_curve_spread"),
                "regime":      self._macro.get("macro_regime"),
            }
            # v9.0 #3: mc_hit_rate im Result speichern für options_designer
            result["mc_hit_rate"]  = mc_hit_rate
            result["is_mega_cap"]  = is_mega_cap
            result["market_cap"]   = market_cap

            return result

        except Exception as e:
            log.error(f"  [{ticker}] Deep Analysis Fehler: {e}")
            return None

    # ── FIX v8.2: 48h-Move Timing ────────────────────────────────────────────
    def _get_48h_move(self, ticker: str) -> float:
        """
        Berechnet die Preisbewegung der letzten 2 vollen Handelstage.

        FIX v8.2: Nutzt period='10d' und vergleicht volle Handelstage:
          close[-2] = gestriger Close (letzter abgeschlossener Tag)
          close[-4] = vor-3-Tage-Close (48h-Fenster)
        """
        try:
            hist = yf.Ticker(ticker).history(period="10d")
            close = hist["Close"]
            if hasattr(close, "iloc"):
                close = close.squeeze()
            if len(close) < 5:
                return 0.0
            return float((close.iloc[-2] - close.iloc[-4]) / close.iloc[-4])
        except Exception:
            return 0.0


================================================
FILE: modules/email_reporter.py
================================================
"""
modules/email_reporter.py v9.0

Änderungen v9.0:
    #5  Greeks-Block im Trade-Card:
        Delta (P(ITM)-Approximation), Theta/Tag, Vega-Exposure, Breakeven.
        Trader sieht jetzt nicht nur ROI, sondern auch wie empfindlich
        der Trade auf Seitwärtsbewegung und IV-Crush reagiert.

    #7  MC-Probabilitäten im Report:
        Hit-Rate aus Monte Carlo (P(Kurs > Ziel)) und Catalyst-Confidence
        werden als separater Block angezeigt. Macht Wahrscheinlichkeits-
        grundlage für den ROI transparent.

Änderungen v8.2:
    - Integration der Exit-Regeln (Take-Profit, Stop-Loss, Time-Exit)
    - Textlimit: [:700] für Best Argument For/Against
    - Score-Schwelle für Email: >= 50
    - Fix: pipeline_stats werden auch im Kein-Trade-Fall durchgereicht
"""

import logging
import os
import smtplib
from datetime import datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

log = logging.getLogger(__name__)


def send_status_email(pipeline_stats: dict, today: str) -> None:
    trades  = pipeline_stats.get("trades", 0)
    subject = (
        f"Adaptive Asymmetry-Scanner – Trade Empfehlung – {today}"
        if trades > 0
        else f"Adaptive Asymmetry-Scanner – Kein Trade – {today}"
    )
    html = _build_status_email(pipeline_stats, today)
    _send_smtp(subject, html)


def send_email(proposals: list[dict], today: str, pipeline_stats: dict | None = None) -> None:
    pipeline_stats = pipeline_stats or {}
    proposals = [p for p in proposals
                 if p.get("trade_score", {}).get("total", 0) >= 50]
    if proposals:
        html    = _build_trade_email(proposals, today)
        subject = f"Adaptive Asymmetry-Scanner – Trade Empfehlung – {today}"
    else:
        stats   = {**pipeline_stats, "trades": 0}
        html    = _build_status_email(stats, today)
        subject = f"Adaptive Asymmetry-Scanner – Kein Trade – {today}"
    _send_smtp(subject, html)


def _build_status_email(stats: dict, today: str) -> str:
    vix         = stats.get("vix")
    trades      = stats.get("trades", 0)
    header_col  = "#16a34a" if trades > 0 else "#0f172a"
    status_icon = "🎯" if trades > 0 else "📊"
    status_text = "Trade Empfehlung" if trades > 0 else "Kein Trade heute"

    funnel = [
        (f"{stats.get('universe', 0)} Ticker im Universum", "📋", True),
        (f"{stats.get('candidates', 0)} nach Hard-Filter (Cap>2B, Vol>1M)", "🔍", stats.get("candidates", 0) > 0),
        (f"{stats.get('prescreened', 0)} nach Prescreening (Haiku)", "🤖", stats.get("prescreened", 0) > 0),
        (f"{stats.get('roi_precheck', 0)} nach ROI Pre-Check", "💰", stats.get("roi_precheck", 0) > 0),
        (f"{stats.get('analyzed', 0)} nach Deep Analysis (Sonnet)", "🧠", stats.get("analyzed", 0) > 0),
        (f"{stats.get('quick_mc', 0)} nach Quick Monte Carlo", "🎲", stats.get("quick_mc", 0) > 0),
        (f"{trades} finale Trade-Vorschläge", "🏆" if trades > 0 else "❌", trades > 0),
    ]

    rows = ""
    for label, icon, active in funnel:
        bg    = "#f0fdf4" if active else "#fef2f2"
        color = "#16a34a" if active else "#dc2626"
        rows += f"""<tr><td style="padding:9px 16px;font-size:13px;color:{color};background:{bg};border-bottom:1px solid #e2e8f0;">{icon}&nbsp; {label}</td></tr>"""

    vix_str = f"{float(vix):.2f}" if vix else "–"

    return f"""<!DOCTYPE html><html><body style="font-family:Arial,sans-serif;margin:0;padding:0;background:#f8fafc;">
<div style="max-width:620px;margin:30px auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.1);">
  <div style="background:{header_col};padding:28px 32px;">
    <div style="font-size:28px;margin-bottom:6px;">{status_icon}</div>
    <div style="color:#fff;font-size:22px;font-weight:bold;">Adaptive Asymmetry-Scanner</div>
    <div style="color:rgba(255,255,255,0.85);font-size:16px;margin-top:4px;">{status_text}</div>
    <div style="color:rgba(255,255,255,0.6);font-size:13px;margin-top:6px;">{today} &nbsp;·&nbsp; VIX {vix_str} &nbsp;·&nbsp; v9.0</div>
  </div>
  <div style="padding:24px 32px;">
    <table style="width:100%;border-collapse:collapse;border-radius:8px;overflow:hidden;border:1px solid #e2e8f0;">{rows}</table>
  </div>
  <div style="padding:14px 32px;background:#f8fafc;border-top:1px solid #e2e8f0;font-size:11px;color:#94a3b8;text-align:center;">
    Adaptive Asymmetry-Scanner v9.0 &nbsp;·&nbsp; {datetime.utcnow().strftime('%H:%M UTC')}
  </div>
</div></body></html>"""


def _build_trade_email(proposals: list[dict], today: str) -> str:
    cards = ""
    for i, p in enumerate(proposals, 1):
        ticker   = p.get("ticker", "?")
        strategy = p.get("strategy", "?")
        da       = p.get("deep_analysis", {}) or {}
        sim      = p.get("simulation", {}) or {}
        option   = p.get("option", {}) or {}
        roi      = p.get("roi_analysis", {}) or {}
        exit_r   = p.get("exit_rules", {}) or {}
        ts       = p.get("trade_score", {}) or {}

        ts_total = ts.get("total", 0)
        ts_grade = ts.get("grade", "–")
        best_for = (ts.get("best_argument_for", "") or "")[:700]
        best_ag  = (ts.get("best_argument_against", "") or "")[:700]

        score_color = "#16a34a" if ts_total >= 75 else "#ca8a04" if ts_total >= 60 else "#ea580c"

        # ── v9.0 #5: Greeks-Block ─────────────────────────────────────────────
        delta          = roi.get("delta", 0) or 0
        theta_day_pct  = roi.get("theta_daily_pct", 0) or 0
        vega_loss      = roi.get("vega_loss", 0) or 0
        breakeven      = roi.get("breakeven", 0) or 0
        breakeven_pct  = roi.get("breakeven_pct", 0) or 0
        mc_weight      = roi.get("mc_weight", 0) or 0
        ttm            = p.get("time_to_maturation", da.get("time_to_materialization", "–"))

        theta_color = "#dc2626" if theta_day_pct > 0.025 else "#ca8a04" if theta_day_pct > 0.015 else "#16a34a"
        delta_str   = f"{delta:.2f}" if delta else "–"
        theta_str   = f"{theta_day_pct:.1%}/Tag" if theta_day_pct else "–"
        be_str      = f"${breakeven:.2f} (+{breakeven_pct:.1%})" if breakeven else "–"
        vega_str    = f"{vega_loss:.1%}" if vega_loss else "–"

        greeks_html = f"""
        <div style="margin-top:12px;padding:10px 14px;background:#f0f9ff;border:1px solid #bae6fd;border-radius:6px;font-size:12px;">
          <b style="color:#0369a1;">📐 Greeks &amp; Optionsmechanik</b>
          <table style="width:100%;margin-top:6px;font-size:12px;color:#0c4a6e;border-collapse:collapse;">
            <tr>
              <td style="padding:2px 8px 2px 0;"><b>Delta:</b> {delta_str}</td>
              <td style="padding:2px 8px 2px 0;"><b>Theta/Tag:</b> <span style="color:{theta_color};">{theta_str}</span></td>
            </tr>
            <tr>
              <td style="padding:2px 8px 2px 0;"><b>Breakeven:</b> {be_str}</td>
              <td style="padding:2px 8px 2px 0;"><b>Vega-Exposure:</b> {vega_str}</td>
            </tr>
          </table>
        </div>"""

        # ── v9.0 #7 / v10.0 #5: MC-Wahrscheinlichkeits-Block + Implied Move ────
        mc_hit_rate_pct = p.get("mc_hit_rate", 0) or 0
        cat_conf        = da.get("catalyst_confidence", None)
        dte_val         = option.get("dte", "–")
        catalyst_type   = p.get("catalyst_type", "OTHER")
        implied_move    = p.get("implied_move_pct")   # z.B. 8.2 (%)
        model_move      = p.get("model_move_pct", 0)  # z.B. 12.4 (%)
        edge_implied    = p.get("edge_vs_implied")     # z.B. 4.2 (%)

        mc_color = "#16a34a" if mc_hit_rate_pct >= 0.65 else "#ca8a04" if mc_hit_rate_pct >= 0.50 else "#dc2626"
        cat_str  = f"{cat_conf}/10" if cat_conf is not None else "–"

        # Implied Move Row
        if implied_move is not None:
            if edge_implied is not None and edge_implied > 5.0:
                edge_color = "#16a34a"
                edge_sign  = f"+{edge_implied:.1f}%"
            elif edge_implied is not None and edge_implied < -2.0:
                edge_color = "#dc2626"
                edge_sign  = f"{edge_implied:.1f}%"
            else:
                edge_color = "#ca8a04"
                edge_sign  = f"{edge_implied:+.1f}%" if edge_implied is not None else "–"
            implied_row = f"""
            <tr>
              <td style="padding:4px 8px 2px 0;" colspan="2">
                <b>Market-Implied:</b> ±{implied_move:.1f}% &nbsp;|&nbsp;
                <b>Model-Target:</b> +{model_move:.1f}% &nbsp;|&nbsp;
                <b>Edge:</b> <span style="color:{edge_color};font-weight:bold;">{edge_sign}</span>
                &nbsp;<span style="color:#64748b;font-size:11px;">({catalyst_type})</span>
              </td>
            </tr>"""
        else:
            implied_row = ""

        prob_html = f"""
        <div style="margin-top:8px;padding:10px 14px;background:#fefce8;border:1px solid #fde68a;border-radius:6px;font-size:12px;">
          <b style="color:#92400e;">📊 Wahrscheinlichkeiten &amp; Katalysator</b>
          <table style="width:100%;margin-top:6px;font-size:12px;color:#78350f;border-collapse:collapse;">
            <tr>
              <td style="padding:2px 8px 2px 0;"><b>MC Hit-Rate:</b> <span style="color:{mc_color};font-weight:bold;">{mc_hit_rate_pct:.0%}</span> (P Kurs &gt; Ziel)</td>
              <td style="padding:2px 8px 2px 0;"><b>Catalyst-Konfidenz:</b> {cat_str}</td>
            </tr>
            <tr>
              <td style="padding:2px 8px 2px 0;"><b>Thesis-Horizont:</b> {ttm}</td>
              <td style="padding:2px 8px 2px 0;"><b>Option-Laufzeit:</b> {dte_val}d</td>
            </tr>
            {implied_row}
          </table>
        </div>"""

        # ── Exit-Regeln Block ─────────────────────────────────────────────────
        exit_html = ""
        if exit_r and exit_r.get("entry_cost", 0) > 0:
            exit_html = f"""
            <div style="margin-top:10px;padding:12px;background:#fff7ed;border:1px dashed #ed8936;border-radius:6px;font-size:12px;">
                <b style="color:#c05621;">🚪 EXIT-STRATEGIE (pro Kontrakt)</b><br>
                <table style="width:100%;margin-top:5px;font-size:12px;color:#7b341e;">
                    <tr><td><b>Entry:</b> ${exit_r['entry_cost']:.2f}</td><td><b>Stop-Loss:</b> <span style="color:#dc2626;">${exit_r['stop_loss_price']:.2f}</span></td></tr>
                    <tr><td><b>TP 50%:</b> ${exit_r['take_profit_price']:.2f}</td><td><b>TP 100%:</b> ${exit_r['full_profit_price']:.2f}</td></tr>
                    <tr><td colspan="2"><b>Time-Exit:</b> {exit_r['time_exit_date']} (wenn Gewinn &lt; {exit_r['time_exit_min_profit_pct']}%)</td></tr>
                </table>
            </div>"""

        cards += f"""
        <div style="border:1px solid #e2e8f0;border-radius:10px;padding:20px;margin-bottom:24px;background:#fff;">
          <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
            <span style="font-size:22px;font-weight:bold;color:#0f172a;">#{i} {ticker}</span>
            <span style="background:{score_color};color:#fff;padding:4px 14px;border-radius:20px;font-size:13px;font-weight:600;">
              {ts_grade}&nbsp;·&nbsp;{ts_total}/100
            </span>
          </div>

          <div style="background:#f8fafc;border-radius:6px;padding:10px 14px;margin-bottom:14px;font-size:12px;color:#334155;border-left:3px solid {score_color};">
            <span style="color:#16a34a;font-weight:600;">✅ Für:</span> {best_for}<br>
            <span style="color:#dc2626;font-weight:600;">⚠️ Gegen:</span> {best_ag}
          </div>

          <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:13px;">
            <div><b>Strategie:</b> {strategy}</div>
            <div><b>Richtung:</b> {da.get('direction','–')}</div>
            <div><b>IV-Rank:</b> {p.get('iv_rank','–')}%</div>
            <div><b>Ziel:</b> ${sim.get('target_price',0):.2f}</div>
            <div><b>Strike:</b> ${option.get('strike','–')}</div>
            <div><b>Expiry:</b> {option.get('expiry','–')} ({option.get('dte','–')}d)</div>
            <div><b>Bid/Ask:</b> ${option.get('bid','–')} / ${option.get('ask','–')}</div>
            <div><b>ROI netto:</b> <span style="color:#16a34a;font-weight:600;">{roi.get('roi_net',0):.1%}</span></div>
          </div>

          {greeks_html}
          {prob_html}
          {exit_html}
        </div>"""

    return f"""<!DOCTYPE html><html><body style="font-family:Arial,sans-serif;background:#f8fafc;margin:0;padding:0;">
<div style="max-width:640px;margin:30px auto;background:#fff;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,0.1);overflow:hidden;">
  <div style="background:#16a34a;padding:28px 32px;">
    <div style="font-size:28px;margin-bottom:6px;">🎯</div>
    <div style="color:#fff;font-size:22px;font-weight:bold;">Adaptive Asymmetry-Scanner</div>
    <div style="color:rgba(255,255,255,0.85);font-size:16px;margin-top:4px;">Trade Empfehlung — {len(proposals)} Signal(e)</div>
    <div style="color:rgba(255,255,255,0.6);font-size:13px;margin-top:6px;">{today} &nbsp;·&nbsp; v9.0</div>
  </div>
  <div style="padding:24px 32px;">{cards}</div>
</div></body></html>"""


def _send_smtp(subject: str, html: str) -> None:
    sender   = os.getenv("GMAIL_SENDER", "")
    password = os.getenv("GMAIL_APP_PW", "")
    receiver = os.getenv("NOTIFY_EMAIL", sender)
    if not sender or not password:
        return
    msg = MIMEMultipart("alternative")
    msg["Subject"], msg["From"], msg["To"] = subject, sender, receiver
    msg.attach(MIMEText(html, "html"))
    try:
        with smtplib.SMTP_SSL("smtp.gmail.com", 465) as smtp:
            smtp.login(sender, password)
            smtp.sendmail(sender, receiver, msg.as_string())
        log.info(f"Email gesendet: {subject}")
    except Exception as e:
        log.error(f"SMTP-Fehler: {e}")


================================================
FILE: modules/finbert_sentiment.py
================================================
"""
modules/finbert_sentiment.py – FinBERT-Sentiment als Feature-Spalte

FIX: Cache-Verzeichnis auf /tmp statt outputs/models/finbert/
     → verhindert dass das 417 MB Modell in Git landet.
     /tmp wird bei jedem GitHub-Actions-Run neu befüllt (Download ~30s).
"""

from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Optional

import numpy as np

log = logging.getLogger(__name__)

_MODEL_NAME = "ProsusAI/finbert"

# FIX: /tmp statt outputs/models/finbert/
# /tmp ist in GitHub Actions verfügbar und wird NICHT committed
_CACHE_DIR = Path(os.environ.get("FINBERT_CACHE", "/tmp/finbert_cache"))

# Lazy-Loaded Globals
_tokenizer = None
_model     = None


def _load_model():
    """Lazy-Load FinBERT einmalig."""
    global _tokenizer, _model

    if _tokenizer is not None:
        return True

    try:
        from transformers import AutoTokenizer, AutoModelForSequenceClassification
        import torch

        log.info(f"Lade FinBERT '{_MODEL_NAME}' (Cache: {_CACHE_DIR})...")
        _CACHE_DIR.mkdir(parents=True, exist_ok=True)

        _tokenizer = AutoTokenizer.from_pretrained(
            _MODEL_NAME, cache_dir=str(_CACHE_DIR),
        )
        _model = AutoModelForSequenceClassification.from_pretrained(
            _MODEL_NAME, cache_dir=str(_CACHE_DIR),
        )
        _model.eval()
        log.info("FinBERT erfolgreich geladen.")
        return True

    except Exception as e:
        log.warning(f"FinBERT nicht ladbar: {e} → Fallback 0.0")
        return False


def score_headlines(headlines: list[str]) -> dict:
    """
    Berechnet FinBERT-Sentiment für eine Liste von Headlines.
    Gibt neutral (0.0) zurück wenn Modell nicht verfügbar.
    """
    if not headlines:
        return _neutral_result()

    if not _load_model():
        return _neutral_result()

    try:
        import torch

        texts = [h[:256] for h in headlines[:8]]
        inputs = _tokenizer(
            texts, padding=True, truncation=True,
            max_length=128, return_tensors="pt",
        )

        with torch.no_grad():
            outputs = _model(**inputs)
            probs = torch.softmax(outputs.logits, dim=-1).numpy()

        # FinBERT Labels: [positive, negative, neutral]
        pos_probs  = probs[:, 0]
        neg_probs  = probs[:, 1]
        scores     = pos_probs - neg_probs
        mean_score = float(np.mean(scores))

        mean_probs = np.mean(probs, axis=0)
        label_idx  = int(np.argmax(mean_probs))
        labels     = ["positive", "negative", "neutral"]
        label      = labels[label_idx]
        confidence = float(mean_probs[label_idx])

        return {
            "sentiment_score":      round(mean_score, 4),
            "sentiment_label":      label,
            "sentiment_confidence": round(confidence, 4),
        }

    except Exception as e:
        log.warning(f"FinBERT Inference-Fehler: {e}")
        return _neutral_result()


def _neutral_result() -> dict:
    return {
        "sentiment_score":      0.0,
        "sentiment_label":      "neutral",
        "sentiment_confidence": 0.0,
    }


def score_candidate(candidate: dict) -> dict:
    return score_headlines(candidate.get("news", []))


================================================
FILE: modules/intraday_delta.py
================================================
"""
modules/intraday_delta.py – Intraday-Delta seit News-Veröffentlichung

Priorität 1: Verhindert "Late-to-the-party"-Trades.

Logik:
  Wenn eine Aktie seit Veröffentlichung der relevanten News bereits X% gestiegen
  ist, ist die Informations-Asymmetrie eingepreist. Der Scanner hätte zu spät
  reagiert und kauft in die Stärke hinein.

Schwellenwert: max_intraday_move aus config.yaml (default: 0.07 = 7%)
  - Unter 7%: Signal bleibt aktiv (Markt hat noch nicht vollständig reagiert)
  - Über 7%: Signal wird verworfen (zu spät, Asymmetrie weg)

Datenquelle: yfinance intraday (1m-Daten des heutigen Tages)
             Kein API-Key nötig.
"""

from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Optional

import yfinance as yf

log = logging.getLogger(__name__)

# Standardschwelle: 7% Move seit Tagesbeginn → Signal verwerfen
DEFAULT_MAX_MOVE = 0.07


def get_intraday_move(ticker: str) -> dict:
    """
    Berechnet den heutigen Intraday-Move vom Eröffnungskurs zum aktuellen Kurs.

    Returns:
        {
            "move_pct": float,        # z.B. 0.082 = +8.2% seit Eröffnung
            "open_price": float,
            "current_price": float,
            "data_available": bool
        }
    """
    try:
        t    = yf.Ticker(ticker)
        hist = t.history(period="1d", interval="5m")

        if hist.empty or len(hist) < 2:
            return _no_data()

        open_price    = float(hist["Open"].iloc[0])
        current_price = float(hist["Close"].iloc[-1])

        if open_price <= 0:
            return _no_data()

        move_pct = (current_price - open_price) / open_price

        log.debug(
            f"  [{ticker}] Intraday: open={open_price:.2f} "
            f"current={current_price:.2f} move={move_pct:+.2%}"
        )

        return {
            "move_pct":      round(move_pct, 4),
            "open_price":    round(open_price, 2),
            "current_price": round(current_price, 2),
            "data_available": True,
        }

    except Exception as e:
        log.debug(f"  [{ticker}] Intraday-Fehler: {e}")
        return _no_data()


def is_already_moved(
    ticker: str,
    direction: str = "BULLISH",
    max_move: float = DEFAULT_MAX_MOVE,
) -> tuple[bool, dict]:
    """
    Prüft ob eine Aktie bereits zu stark in die erwartete Richtung gelaufen ist.

    Args:
        ticker:    Aktien-Ticker
        direction: "BULLISH" oder "BEARISH"
        max_move:  Maximaler erlaubter Move (default: 7%)

    Returns:
        (already_moved: bool, delta_info: dict)
        already_moved=True → Signal verwerfen, zu spät
    """
    delta = get_intraday_move(ticker)

    if not delta["data_available"]:
        # Keine Daten → konservativ: Signal nicht verwerfen
        return False, delta

    move = delta["move_pct"]

    # Bullish: Wenn Aktie schon stark gestiegen → zu spät
    if direction == "BULLISH" and move > max_move:
        log.info(
            f"  [{ticker}] INTRADAY-GATE: move={move:+.2%} > {max_move:.0%} "
            f"(BULLISH) → Signal zu spät, verworfen."
        )
        return True, delta

    # Bearish: Wenn Aktie schon stark gefallen → zu spät
    if direction == "BEARISH" and move < -max_move:
        log.info(
            f"  [{ticker}] INTRADAY-GATE: move={move:+.2%} < -{max_move:.0%} "
            f"(BEARISH) → Signal zu spät, verworfen."
        )
        return True, delta

    log.info(
        f"  [{ticker}] Intraday-Move={move:+.2%} → noch Asymmetrie vorhanden."
    )
    return False, delta


def filter_by_intraday_delta(
    signals: list[dict],
    max_move: float = DEFAULT_MAX_MOVE,
) -> list[dict]:
    """
    Filtert eine Liste von Pipeline-Signalen nach dem Intraday-Delta.
    Wird nach Stufe 4 (Mismatch-Score) und vor Stufe 5 (Simulation) aufgerufen.

    Fügt `intraday_delta` zu jedem Signal-Dict hinzu.
    Entfernt Signale bei denen der Move zu groß ist.
    """
    filtered = []
    for s in signals:
        ticker    = s.get("ticker", "")
        direction = s.get("deep_analysis", {}).get("direction", "BULLISH")

        already_moved, delta_info = is_already_moved(ticker, direction, max_move)

        s["intraday_delta"] = delta_info

        if already_moved:
            continue

        filtered.append(s)

    log.info(
        f"Intraday-Delta-Filter: {len(signals)} → {len(filtered)} Signale "
        f"({len(signals)-len(filtered)} zu spät)"
    )
    return filtered


def _no_data() -> dict:
    return {
        "move_pct":       0.0,
        "open_price":     0.0,
        "current_price":  0.0,
        "data_available": False,
    }


================================================
FILE: modules/macro_context.py
================================================
"""
modules/macro_context.py – Makro-Kontext für Claude-Prompt

Fix 3: ISM Einkaufsmanagerindex + 10Y-2Y Yield Curve via FRED API.
       Kein API-Key nötig (FRED public data).
       Wird als Kontext-Variable in deep_analysis.py Prompt injiziert.

Wichtig: Kein Hard-Gate — nur Kontext für Claude.
Begründung: ISM und Yield Curve sind zu träge für Hard-Gates.
            Sie geben Claude aber wichtigen Hintergrund für die
            Bewertung von 2-6 Monats-Signalen.

FRED Endpoints (kostenlos, kein Key für Basic-Daten):
    ISM Manufacturing PMI: MANEMP Proxy via M-PMI
    10Y-2Y Spread:         T10Y2Y
    Fed Funds Rate:        FEDFUNDS (als Zinskontext)
"""

from __future__ import annotations
import logging
from datetime import datetime, timedelta
from functools import lru_cache
from typing import Optional

import requests

try:
    import yfinance as yf
    _YF_AVAILABLE = True
except ImportError:
    _YF_AVAILABLE = False

log = logging.getLogger(__name__)

_FRED_BASE    = "https://fred.stlouisfed.org/graph/fredgraph.csv"
_HEADERS      = {"User-Agent": "newstoption-scanner/5.0 research@pcctrading.com"}

# Cache: Makro-Daten täglich einmal laden (nicht für jeden Ticker)
_macro_cache: dict = {}
_cache_date:  str  = ""


def get_macro_context() -> dict:
    """
    Lädt Makro-Kontext-Daten einmal pro Tag (gecached).

    Returns:
        {
            "yield_curve_spread": float,   # 10Y-2Y in %
            "yield_curve_regime": str,     # "normal" | "flat" | "inverted"
            "ism_proxy":          float,   # 10Y Zins als Proxy (kein Key nötig)
            "fed_funds_rate":     float,
            "macro_regime":       str,     # "expansive" | "neutral" | "recessionary"
            "claude_context":     str,     # Fertig formulierter Kontext-String für Claude
            "data_available":     bool,
        }
    """
    global _macro_cache, _cache_date

    today = datetime.utcnow().strftime("%Y-%m-%d")
    if _cache_date == today and _macro_cache:
        return _macro_cache

    result = _fetch_macro_data()
    _macro_cache = result
    _cache_date  = today
    return result


def _fetch_macro_data() -> dict:
    """Lädt aktuelle Makro-Daten von FRED."""
    t10y2y    = _fetch_fred_series("T10Y2Y")    # 10Y-2Y Spread
    fedfunds  = _fetch_fred_series("FEDFUNDS")  # Fed Funds Rate
    t10y      = _fetch_fred_series("GS10")      # 10Y Treasury

    # Yield Curve Regime
    if t10y2y is not None:
        if t10y2y > 0.5:
            yc_regime = "normal"
            yc_desc   = f"+{t10y2y:.2f}% (Kurve steil, expansives Umfeld)"
        elif t10y2y > -0.25:
            yc_regime = "flat"
            yc_desc   = f"{t10y2y:.2f}% (Kurve flach, Übergangsphase)"
        else:
            yc_regime = "inverted"
            yc_desc   = f"{t10y2y:.2f}% (Kurve invertiert, Rezessionsrisiko)"
    else:
        yc_regime = "unknown"
        yc_desc   = "Nicht verfügbar"

    # Makro-Gesamtregime
    if yc_regime == "normal":
        macro_regime = "expansive"
        regime_desc  = "Expansiv — Makro begünstigt Long-Positionen"
    elif yc_regime == "flat":
        macro_regime = "neutral"
        regime_desc  = "Neutral — Makro weder Rücken- noch Gegenwind"
    else:
        macro_regime = "recessionary"
        regime_desc  = "Rezessiv — Makro kann positives Alpha dämpfen"

    # VIX Term Structure
    vix_ts = get_vix_term_structure()

    # Fertiger Kontext-String für Claude-Prompt
    claude_context = _build_claude_context(
        t10y2y, fedfunds, t10y, yc_desc, regime_desc, vix_ts
    )

    result = {
        "yield_curve_spread": t10y2y,
        "yield_curve_regime": yc_regime,
        "yield_curve_desc":   yc_desc,
        "fed_funds_rate":     fedfunds,
        "t10y_rate":          t10y,
        "macro_regime":       macro_regime,
        "macro_regime_desc":  regime_desc,
        "vix_term_structure": vix_ts,
        "claude_context":     claude_context,
        "data_available":     t10y2y is not None,
        "fetched_at":         datetime.utcnow().isoformat(),
    }

    fed_str = f"{fedfunds:.2f}%" if fedfunds is not None else "Nicht verfügbar"
    log.info(
        f"Makro-Kontext geladen: Yield Curve={yc_desc} | "
        f"Fed={fed_str} | Regime={macro_regime}"
    )

    return result


def _fetch_fred_series(series_id: str, last_n: int = 1) -> Optional[float]:
    """
    Lädt den aktuellsten Wert einer FRED-Zeitreihe.
    Nutzt das CSV-Endpoint (kein API-Key nötig).
    """
    try:
        since = (datetime.utcnow() - timedelta(days=30)).strftime("%Y-%m-%d")
        resp  = requests.get(
            _FRED_BASE,
            params={
                "id":              series_id,
                "vintage_date":    datetime.utcnow().strftime("%Y-%m-%d"),
                "observation_start": since,
            },
            headers=_HEADERS,
            timeout=10,
        )

        if resp.status_code != 200:
            return None

        lines = [l for l in resp.text.strip().split("\n") if l and not l.startswith("DATE")]
        if not lines:
            return None

        # Letzter nicht-leerer Wert
        for line in reversed(lines):
            parts = line.split(",")
            if len(parts) >= 2 and parts[1].strip() not in (".", ""):
                try:
                    return float(parts[1].strip())
                except ValueError:
                    continue

        return None

    except Exception as e:
        log.debug(f"FRED {series_id} Fehler: {e}")
        return None


def _build_claude_context(
    t10y2y:      Optional[float],
    fedfunds:    Optional[float],
    t10y:        Optional[float],
    yc_desc:     str,
    regime_desc: str,
    vix_ts:      Optional[dict] = None,
) -> str:
    """
    Baut den Makro-Kontext-String für den Claude-Prompt.
    Klar formuliert damit Claude ihn richtig interpretiert.
    """
    lines = ["=== MAKRO-KONTEXT FÜR MITTELFRISTIGE BEWERTUNG (2-6 Monate) ==="]

    if t10y2y is not None:
        lines.append(f"Zinskurve (10Y-2Y): {yc_desc}")

    if fedfunds is not None:
        lines.append(f"Fed Funds Rate: {fedfunds:.2f}%")

    if t10y is not None:
        lines.append(f"10Y Treasury: {t10y:.2f}%")

    lines.append(f"Regime-Einschätzung: {regime_desc}")

    if vix_ts and vix_ts.get("available"):
        lines.append(f"Volatilitäts-Struktur: {vix_ts['regime']}")

    lines.append("")
    lines.append(
        "ANWEISUNG: Berücksichtige diesen Makro-Kontext bei der Bewertung. "
        "In einem rezessiven Umfeld (invertierte Kurve) sind positive "
        "Einzel-Aktien-Signale auf 2-6 Monate mit erhöhter Skepsis zu bewerten, "
        "da makroökonomischer Gegenwind das fundamentale Alpha oft überlagert. "
        "In einem expansiven Umfeld können strukturelle Underreactions "
        "stärker gewichtet werden. "
        "Bei VIX Backwardation (kurzfristige Angst hoch) bevorzuge Spreads "
        "gegenüber nackten Long-Calls um Vol-Crush-Risiko nach Events zu begrenzen."
    )

    return "\n".join(lines)


def get_vix_term_structure() -> dict:
    """
    Analysiert die VIX Term Structure (Contango vs. Backwardation).

    Nutzt kostenlose yfinance-Ticker:
      ^VIX9D  = 9-Tage-VIX  (sehr kurzfristig)
      ^VIX    = 30-Tage-VIX (Standard)
      ^VIX3M  = 3-Monats-VIX

    Struktur:
      Contango:      VIX9D < VIX < VIX3M → Markt ruhig, IV sinkt erwartet
                     → Optionskäufer vorteilhaft, Vol-Crush-Risiko gering
      Backwardation: VIX9D > VIX > VIX3M → kurzfristige Angst hoch
                     → erhöhtes Vol-Crush-Risiko nach Event → Spreads bevorzugen
      Flat:          Keine klare Richtung

    Returns:
        {
            "vix9d":      float,
            "vix30":      float,
            "vix3m":      float,
            "structure":  "contango" | "backwardation" | "flat",
            "slope":      float,   # (vix3m - vix9d) / vix9d, positiv = Contango
            "regime":     str,     # Kurzbeschreibung für Prompt
            "available":  bool,
        }
    """
    empty = {
        "vix9d": None, "vix30": None, "vix3m": None,
        "structure": "unknown", "slope": 0.0,
        "regime": "VIX Term Structure nicht verfügbar",
        "available": False,
    }

    if not _YF_AVAILABLE:
        return empty

    try:
        tickers = yf.download(
            ["^VIX9D", "^VIX", "^VIX3M"],
            period="2d", progress=False, auto_adjust=True,
        )
        close = tickers["Close"] if "Close" in tickers.columns else tickers

        def _last(sym: str) -> Optional[float]:
            if sym in close.columns:
                vals = close[sym].dropna()
                return float(vals.iloc[-1]) if not vals.empty else None
            return None

        vix9d = _last("^VIX9D")
        vix30 = _last("^VIX")
        vix3m = _last("^VIX3M")

        if not all([vix9d, vix30, vix3m]):
            return empty

        slope = (vix3m - vix9d) / vix9d  # positiv = Contango

        if slope > 0.05:
            structure = "contango"
            regime    = (
                f"VIX Contango (9d={vix9d:.1f} → 3M={vix3m:.1f}, slope=+{slope:.0%}): "
                f"Markt erwartet sinkende Volatilität → Long-Vol-Strategien weniger riskant"
            )
        elif slope < -0.05:
            structure = "backwardation"
            regime    = (
                f"VIX Backwardation (9d={vix9d:.1f} → 3M={vix3m:.1f}, slope={slope:.0%}): "
                f"Kurzfristige Angst erhöht → Vol-Crush nach Event wahrscheinlicher → Spreads bevorzugen"
            )
        else:
            structure = "flat"
            regime    = (
                f"VIX Term Structure flach (9d={vix9d:.1f}, 3M={vix3m:.1f}): "
                f"Kein eindeutiges Volatilitäts-Signal"
            )

        log.info(f"VIX Term Structure: {structure} | slope={slope:.1%} | {vix9d:.1f}/{vix30:.1f}/{vix3m:.1f}")

        return {
            "vix9d":     round(vix9d, 2),
            "vix30":     round(vix30, 2),
            "vix3m":     round(vix3m, 2),
            "structure": structure,
            "slope":     round(slope, 4),
            "regime":    regime,
            "available": True,
        }

    except Exception as e:
        log.debug(f"VIX Term Structure Fehler: {e}")
        return empty


def get_macro_regime_multiplier() -> float:
    """
    Gibt einen Multiplikator (0.7-1.2) für den Mismatch-Score zurück.
    Rezessives Umfeld → Signal schwächer gewichten.
    Expansives Umfeld → Signal stärker gewichten.

    Wird in mismatch_scorer.py genutzt.
    """
    ctx = get_macro_context()
    regime = ctx.get("macro_regime", "neutral")

    multipliers = {
        "expansive":    1.10,   # +10% Vertrauen in Signale
        "neutral":      1.00,   # neutral
        "recessionary": 0.80,   # -20% Vertrauen bei invertierter Kurve
        "unknown":      1.00,
    }
    return multipliers.get(regime, 1.00)


================================================
FILE: modules/mirofish_simulation.py
================================================
"""
modules/mirofish_simulation.py v8.2

Monte Carlo Simulation mit historischer yfinance-Kalibrierung.

Kernidee:
    sigma (Volatilität) kommt aus echten historischen Preisdaten.
    base_alpha (Signal-Drift) = historische Drift + Signal-Bonus.
    Das macht Hit-Rates realistisch und ticker-spezifisch.

v8.2 Kalibrierungs-Fix:
    Problem: Hit-Rates lagen bei 97% für praktisch jedes Signal.
             Der Cap (85%/95%) griff IMMER → alle Signale sahen gleich aus.

    Ursache: max_signal_alpha=0.004 → bei Impact=6/Surprise=5 akkumulierten
             sich ~19% Alpha-Drift über 120 Tage. Target war nur +8%.
             → Trivial erreichbar, auch bei schwachen Signalen.

    Fix (4 Stellschrauben):
      1. max_signal_alpha: 0.004 → 0.001 (realistischer Informationsvorsprung)
      2. NARRATIVE_DECAY: verdoppelt (News-Signal verblasst schneller)
      3. HIT_RATE_CAP: 85%/95% → 75% (kein einzelnes Signal > 75%)
      4. Target: volatilitäts-adaptiv statt pauschal +8%
         Formel: target = current * (1 + max(0.08, 0.5 * σ * √days))
         → Volatile Aktien brauchen grösseren Move für "Hit"

    Erwartete Hit-Rates nach Fix: 35-70% je nach Signal-Stärke und Vola.
"""

from __future__ import annotations
import logging
import math
import numpy as np
import yfinance as yf
from functools import lru_cache

log = logging.getLogger(__name__)

# ── v8.2: Verdoppelte Decay-Rates ────────────────────────────────────────────
# Vorher: short=0.015, medium=0.008, long=0.004
# Begründung: Empirisch zerfallen News-Signale schneller als v8.0 annahm.
# Typischer Nachrichtenzyklus: 3-5 Tage Peak, danach rapider Abfall.
NARRATIVE_DECAY = {
    "short":  0.030,   # Signal verpufft in ~30 Tagen (war: ~65 Tage)
    "medium": 0.016,   # Signal verpufft in ~60 Tagen (war: ~125 Tage)
    "long":   0.008,   # Signal verpufft in ~120 Tagen (war: ~250 Tage)
}

DEFAULT_SIGMA   = 0.020   # Fallback wenn yfinance nicht erreichbar
QUICK_MC_PATHS  = 5_000
FINAL_MC_PATHS  = 10_000

# ── v8.2: Einheitlicher Cap auf 75% ─────────────────────────────────────────
# Vorher: Short=85%, Long=95%
# Begründung: Kein einzelnes News-Signal rechtfertigt >75% Konfidenz.
# Selbst perfekte Insider-Information hat Execution-Risiko, Makro-Risiko,
# und Timing-Unsicherheit. 75% ist die Obergrenze für "sehr starkes Signal".
HIT_RATE_CAP_SHORT = 0.75
HIT_RATE_CAP_LONG  = 0.75

# ── v8.2: Reduzierter Signal-Alpha ──────────────────────────────────────────
# Vorher: 0.004 → bei Impact=6/Surprise=5 = 0.22%/Tag = ~19% über 120d
# Jetzt:  0.001 → bei Impact=6/Surprise=5 = 0.055%/Tag = ~4.5% über 120d
# Begründung: Ein News-Signal gibt keinen 0.4%/Tag-Vorsprung.
# Selbst der stärkste fundamentale Katalysator liefert ~0.1%/Tag Alpha.
MAX_SIGNAL_ALPHA = 0.001


@lru_cache(maxsize=128)
def _get_hist_params(ticker: str) -> tuple[float, float]:
    """
    Holt historische Volatilität und Drift von yfinance.
    Gecacht pro Ticker — wird nur einmal pro Run abgerufen.
    Nutzt yf.download() für effizientere Batch-Verarbeitung.
    
    Returns:
        (sigma, mu) — tägliche Vola und täglicher Drift
    """
    try:
        hist = yf.download(ticker, period="6mo", progress=False, auto_adjust=True)
        if hist.empty or len(hist) < 30:
            log.debug(f"  [{ticker}] Hist-Daten zu wenig → Default sigma")
            return DEFAULT_SIGMA, 0.0

        close = hist["Close"]
        if hasattr(close, "iloc"):
            close = close.squeeze()  # MultiIndex → Series

        returns = close.pct_change().dropna()
        sigma   = float(returns.std())
        mu      = float(returns.mean())

        # Sanity checks
        if not (0.005 <= sigma <= 0.15):
            sigma = DEFAULT_SIGMA
        if not (-0.005 <= mu <= 0.005):
            mu = 0.0

        log.debug(f"  [{ticker}] Hist: σ={sigma:.4f} μ={mu:.5f} ({len(returns)} Tage)")
        return sigma, mu

    except Exception as e:
        log.debug(f"  [{ticker}] yfinance Hist-Fehler: {e} → Default")
        return DEFAULT_SIGMA, 0.0


def preload_hist_params(tickers: list[str]) -> None:
    """
    Lädt historische Parameter für alle Ticker parallel vor.
    Spart Zeit weil MC-Runs sofort auf Cache treffen.
    Wird in pipeline.py nach Hard-Filter aufgerufen.
    """
    from concurrent.futures import ThreadPoolExecutor, as_completed
    log.info(f"Preload historische Daten für {len(tickers)} Ticker...")
    
    with ThreadPoolExecutor(max_workers=10) as ex:
        futures = {ex.submit(_get_hist_params, t): t for t in tickers}
        done = 0
        for f in as_completed(futures):
            done += 1
            try:
                f.result()
            except Exception:
                pass
    log.info(f"  Historische Daten geladen ({done} Ticker gecacht)")


def _compute_dynamic_target(current: float, sigma: float, days: int) -> float:
    """
    v8.2: Volatilitäts-adaptives Kursziel.

    Vorher: pauschal current * 1.08 (+8% für alle)
    Problem: +8% ist für eine Low-Vol-Aktie (σ=0.01) ein grosser Move,
             aber für eine High-Vol-Aktie (σ=0.04) trivial.

    Jetzt: target = current * (1 + max(0.08, 0.5 * σ * √days))
      - Low-Vol  (σ=0.01, 120d): max(0.08, 0.055) = 8.0%  (unchanged)
      - Normal   (σ=0.02, 120d): max(0.08, 0.110) = 11.0%
      - High-Vol (σ=0.04, 120d): max(0.08, 0.219) = 21.9%

    Ergebnis: Volatile Aktien brauchen stärkere Signale um hohe Hit-Rates
    zu erreichen. Das verhindert falsch-positive "starke" Signale bei
    naturgemäss volatilen Tech/Biotech-Titeln.
    """
    sigma_move = 0.5 * sigma * math.sqrt(days)
    target_pct = max(0.08, sigma_move)
    return current * (1.0 + target_pct)


class MirofishSimulation:

    def __init__(self):
        self.rng = np.random.default_rng()

    def run_for_dte(
        self,
        candidate:    dict,
        days_to_expiry: int            = 120,
        min_hit_rate:   float | None   = None,  # Override interner Threshold (Pre-MC)
    ) -> dict | None:
        ticker   = candidate.get("ticker", "")
        features = candidate.get("features", {}) or {}
        da       = candidate.get("deep_analysis", {}) or {}
        sim_data = candidate.get("simulation", {}) or {}

        current = float(
            sim_data.get("current_price") or
            candidate.get("current_price") or
            candidate.get("info", {}).get("currentPrice") or
            candidate.get("info", {}).get("regularMarketPrice") or 0
        )
        if current <= 0:
            try:
                info    = yf.Ticker(ticker).info
                current = float(
                    info.get("currentPrice") or
                    info.get("regularMarketPrice") or
                    info.get("previousClose") or 0
                )
            except Exception:
                pass
        if current <= 0:
            log.warning(f"  [{ticker}] Kein Preis verfügbar → MC übersprungen")
            return None

        # ── Historische Parameter von yfinance ───────────────────────────────
        sigma, hist_mu = _get_hist_params(ticker)

        # ── Signal-Alpha (News-Drift) ─────────────────────────────────────────
        impact   = float(da.get("impact", 5) or 5)
        surprise = float(da.get("surprise", 5) or 5)
        ttm      = da.get("time_to_materialization", "medium") or "medium"

        # TTM-String normalisieren (Claude gibt manchmal verschiedene Formate)
        ttm_lower = ttm.lower()
        if "woche" in ttm_lower or "4-8" in ttm_lower:
            ttm_key = "short"
        elif "6 monat" in ttm_lower:
            ttm_key = "long"
        else:
            ttm_key = "medium"

        # Geometrisches Mittel: beide Dimensionen müssen stark sein
        signal_strength = math.sqrt((impact / 10.0) * (surprise / 10.0))

        # v8.2: Signal-Bonus reduziert — max 0.1%/Tag bei perfektem Signal
        signal_alpha = signal_strength * MAX_SIGNAL_ALPHA

        # Gesamt-Alpha: historischer Trend + Signal-Bonus
        base_alpha = hist_mu + signal_alpha
        decay_rate = NARRATIVE_DECAY.get(ttm_key, 0.016)

        # v8.2: Volatilitäts-adaptives Target
        target  = _compute_dynamic_target(current, sigma, days_to_expiry)
        target_pct = (target / current - 1.0) * 100

        n_paths   = QUICK_MC_PATHS if days_to_expiry <= 45 else FINAL_MC_PATHS
        # min_hit_rate überschreibt den internen Threshold (genutzt für Pre-MC Gate
        # mit niedrigerer Schwelle, ohne das Quick/Final-MC zu beeinflussen)
        threshold = min_hit_rate if min_hit_rate is not None else (
            0.45 if days_to_expiry <= 45 else 0.50
        )

        log.info(
            f"  [{ticker}] MC-Kalibrierung: "
            f"σ={sigma:.3f} μ={hist_mu:.4f} "
            f"signal={signal_strength:.2f} α={base_alpha:.5f} "
            f"target=+{target_pct:.1f}% decay={decay_rate:.3f} "
            f"({days_to_expiry}d, {n_paths} Pfade)"
        )

        # ── Monte Carlo ───────────────────────────────────────────────────────
        paths_hit = 0
        for _ in range(n_paths):
            price = current
            hit   = False
            for d in range(days_to_expiry):
                # Alpha nimmt exponentiell ab (Narrative verblasst)
                daily_alpha = base_alpha * math.exp(-decay_rate * d)
                daily_ret   = daily_alpha + sigma * self.rng.standard_normal()
                price      *= (1.0 + daily_ret)
                if price >= target:
                    hit = True
                    break
            if hit:
                paths_hit += 1

        hit_rate = paths_hit / n_paths

        # v8.2: Einheitlicher Cap auf 75%
        hit_rate_cap = HIT_RATE_CAP_SHORT if days_to_expiry <= 45 else HIT_RATE_CAP_LONG
        if hit_rate >= hit_rate_cap:
            log.warning(
                f"  [{ticker}] Hit-Rate={hit_rate:.1%} → Cap auf {hit_rate_cap:.0%}"
            )
            hit_rate = hit_rate_cap

        stderr = math.sqrt(hit_rate * (1 - hit_rate) / n_paths)

        log.info(
            f"  [{ticker}] Simulation ({days_to_expiry}d, {n_paths} Pfade): "
            f"Hit={hit_rate:.1%} ±{stderr:.1%} "
            f"({'PASS' if hit_rate >= threshold else 'FAIL'})"
        )

        if hit_rate < threshold:
            log.info(f"  [{ticker}] Hit-Rate {hit_rate:.1%} < {threshold:.0%} → verworfen")
            return None

        return {
            **candidate,
            "simulation": {
                "current_price": round(current, 2),
                "target_price":  round(target, 2),
                "hit_rate":      round(hit_rate, 4),
                "n_paths":       n_paths,
                "days":          days_to_expiry,
                "sigma":         round(sigma, 4),
                "alpha":         round(base_alpha, 5),
            }
        }

    def _get_market_params(self, ticker: str) -> tuple[float, float, float]:
        """Für ROI Pre-Check: gibt (sigma, current, target) zurück."""
        sigma, _ = _get_hist_params(ticker)
        try:
            info    = yf.Ticker(ticker).info
            current = float(
                info.get("currentPrice") or
                info.get("regularMarketPrice") or 0
            )
        except Exception:
            current = 0.0
        target = _compute_dynamic_target(current, sigma, 120)
        return sigma, current, target


def compute_time_value_efficiency(roi_net: float, dte: int) -> dict:
    """ROI pro Tag und annualisierter ROI (nur wenn realistisch)."""
    dte = max(int(dte or 1), 1)
    roi_per_day = (roi_net / dte) * 100  # in %

    try:
        ann = float((1 + roi_net) ** (365 / dte) - 1)
        # Nur anzeigen wenn realistisch (<500% p.a.)
        ann_roi = round(ann, 4) if abs(ann) < 5.0 else None
    except Exception:
        ann_roi = None

    return {
        "roi_per_day_pct": round(roi_per_day, 4),
        "annualized_roi":  ann_roi,
        "dte":             dte,
    }


================================================
FILE: modules/mismatch_scorer.py
================================================
"""
Stufe 5: Normalisierter Mismatch-Score (Quant-Validierung)

Fixes:
  H-03: Negative Mismatch-Werte (Markt hat überreagiert) wurden als "weak"
        klassifiziert und weiterverarbeitet. Fix: expliziter Filter, der
        Überreaktionen vor der Simulation aussortiert.
  M-01: EPS-Drift-Bins nutzten hardcodierte Werte statt config.yaml.
        Fix: cfg.eps_drift.* Thresholds.

  v8.2 FIX-A: price_move_48h wurde NIRGENDS im Produktionscode gesetzt.
        r_2d war IMMER 0 → Z-Score IMMER 0 → Mismatch = Impact.
        Fix: Mismatch-Scorer berechnet 48h-Move selbst via yfinance.

  v8.2 FIX-B: EPS-Drift wurde unter falschem Key gelesen.
        a.get("eps_drift", {}).get("drift", 0.0) → Feld existiert nie.
        Korrekter Pfad: a["data_validation"]["eps_cross_check"]["deviation_pct"]
"""

import logging
import numpy as np
import yfinance as yf

from modules.config import cfg

log = logging.getLogger(__name__)


def _bin_impact(impact: float) -> str:
    if impact <= 4:
        return "low"
    if impact <= 7:
        return "mid"
    return "high"


def _bin_mismatch(mismatch: float) -> str:
    """
    FIX H-03: Negative Werte bedeuten Überreaktion (Markt hat mehr reagiert
    als die News rechtfertigen). Diese werden NICHT mehr als "weak" behandelt
    – sie werden durch den expliziten Filter in _score() bereits entfernt.
    Der verbleibende Wertebereich ist immer ≥ 0.
    """
    if mismatch < 3:
        return "weak"
    if mismatch <= 6:
        return "good"
    return "strong"


def _bin_eps_drift(drift: float) -> str:
    """FIX M-01: Thresholds aus config.yaml statt hartcodiert."""
    abs_drift = abs(drift)
    if abs_drift > cfg.eps_drift.massive_threshold:
        return "massive"
    if abs_drift > cfg.eps_drift.relevant_threshold:
        return "relevant"
    return "noise"


class MismatchScorer:

    def run(self, analyses: list[dict]) -> list[dict]:
        scored = []
        for a in analyses:
            result = self._score(a)
            if result:
                scored.append(result)
        return scored

    def _score(self, a: dict) -> dict | None:
        ticker = a["ticker"]
        da     = a.get("deep_analysis", {})
        impact = da.get("impact", 0)

        # ── FIX v8.2-A: 48h-Move selbst berechnen ────────────────────────────
        # Vorher: r_2d = abs(a.get("price_move_48h", 0))
        # Problem: "price_move_48h" wurde NIRGENDS im Produktionscode gesetzt.
        #          Nur in Tests als Mock-Daten vorhanden.
        #          → r_2d war IMMER 0 → Z-Score IMMER 0 → Mismatch = Impact.
        # Jetzt:   Eigene Berechnung, gleiche Logik wie deep_analysis v8.2.
        r_2d = abs(self._compute_48h_move(ticker))

        sigma = self._compute_sigma(ticker)
        if sigma == 0:
            log.warning(f"  [{ticker}] σ30d = 0, übersprungen.")
            return None

        z_score  = r_2d / sigma
        mismatch = impact - (z_score * 5)

        # FIX H-03: Negative Mismatch explizit filtern.
        # Negatives Mismatch = Markt hat MEHR reagiert als die News rechtfertigen
        # = Überreaktion. Das ist das Gegenteil des gesuchten Signals (Underreaction).
        # Solche Ticker werden nicht weiter verarbeitet.
        if mismatch <= 0:
            log.info(
                f"  [{ticker}] Mismatch={mismatch:.2f} ≤ 0 "
                f"(Impact={impact}, Z={z_score:.2f}, 48h-Move={r_2d:.3f}) "
                f"→ Markt hat überreagiert, gefiltert."
            )
            return None

        # ── FIX v8.2-B: EPS-Drift aus korrektem Pfad lesen ───────────────────
        # Vorher: eps_drift_val = a.get("eps_drift", {}).get("drift", 0.0)
        # Problem: Feld "eps_drift" mit Subkey "drift" existiert nie.
        #          data_validator.py schreibt unter:
        #          candidate["data_validation"]["eps_cross_check"]["deviation_pct"]
        # Jetzt:   Korrekter Zugriffspfad.
        eps_check     = a.get("data_validation", {}).get("eps_cross_check", {})
        eps_drift_val = eps_check.get("deviation_pct", 0.0) or 0.0

        features = {
            "impact":        impact,
            "surprise":      da.get("surprise", 0),
            "mismatch":      round(mismatch, 3),
            "z_score":       round(z_score, 3),
            "sigma_30d":     round(sigma, 4),
            "price_move_48h": round(r_2d, 4),   # NEU: für Reports & Debugging
            "eps_drift":     round(eps_drift_val, 4),
            "bin_impact":    _bin_impact(impact),
            "bin_mismatch":  _bin_mismatch(mismatch),
            "bin_eps_drift": _bin_eps_drift(eps_drift_val),
        }

        log.info(
            f"  [{ticker}] Mismatch={mismatch:.2f} "
            f"Z={z_score:.2f} σ={sigma:.4f} "
            f"48h-Move={r_2d:.3f} EPS-Drift={eps_drift_val:.4f}"
        )

        return {**a, "features": features}

    def _compute_sigma(self, ticker: str) -> float:
        """30-Tages Standardabweichung der täglichen Returns."""
        try:
            hist = yf.Ticker(ticker).history(period="35d")
            if len(hist) < 10:
                return 0.0
            returns = hist["Close"].pct_change().dropna()
            return float(np.std(returns))
        except Exception as e:
            log.debug(f"Sigma-Berechnung Fehler für {ticker}: {e}")
            return 0.0

    def _compute_48h_move(self, ticker: str) -> float:
        """
        Berechnet die Preisbewegung der letzten 2 vollen Handelstage.

        Nutzt period='10d' und vergleicht volle Handelstage:
          close[-2] = gestriger Close (letzter abgeschlossener Tag)
          close[-4] = vor-3-Tage-Close (48h-Fenster)

        Gleiche Logik wie deep_analysis.py v8.2 _get_48h_move().
        """
        try:
            hist  = yf.Ticker(ticker).history(period="10d")
            close = hist["Close"]
            if hasattr(close, "iloc"):
                close = close.squeeze()
            if len(close) < 5:
                return 0.0
            return float((close.iloc[-2] - close.iloc[-4]) / close.iloc[-4])
        except Exception:
            return 0.0


================================================
FILE: modules/news_fetcher.py
================================================
"""
modules/news_fetcher.py – Ticker-spezifische News via Finnhub

Fix 4: Ersetzt globale RSS-Feeds durch ticker-spezifisches Finnhub Company News API.

Vorher (RSS-Problem):
  - Reuters/CNBC RSS werden nach Ticker-Erwähnung durchsucht
  - Produziert False-Positives (z.B. ON Semiconductor matcht "ON sale")
  - Generisch, nicht ticker-spezifisch
  - Zeitlich unscharf (RSS-Posts haben kein exaktes Timestamp)

Jetzt (Finnhub):
  - /company-news Endpoint liefert NUR News für den spezifischen Ticker
  - Timestamp auf Minuten genau → ermöglicht echten Intraday-Delta
  - Kein False-Positive-Problem mehr
  - Free Tier: 60 Calls/Minute (ausreichend für 20 Ticker/Tag)
  - Fallback auf RSS wenn kein Finnhub-Key

API: https://finnhub.io/docs/api/company-news
Key: FINNHUB_API_KEY als GitHub Secret
"""

from __future__ import annotations
import logging
import os
import time
from datetime import datetime, timedelta
from typing import Optional

import feedparser
import requests

log = logging.getLogger(__name__)

_FINNHUB_BASE   = "https://finnhub.io/api/v1"
_FINNHUB_DELAY  = 1.0   # Sekunden zwischen Calls (max 60/min)
_last_call_time = 0.0

# Fallback RSS-Feeds (nur wenn kein Finnhub-Key)
_RSS_FEEDS = [
    "https://feeds.reuters.com/reuters/businessNews",
    "https://www.cnbc.com/id/100003114/device/rss/rss.html",
]

# Kurzticker die NICHT per RSS gematcht werden (False-Positive-Gefahr)
_RSS_UNSAFE = frozenset({
    "ON", "IT", "OR", "ARE", "BE", "TO", "DO", "GO", "SO", "RE",
    "AI", "GE", "AM", "PM", "IS", "AS", "AT", "BY", "IN", "OF",
    "A", "V", "C", "F", "K", "D", "L", "O",
})


def fetch_company_news(
    ticker: str,
    days_back: int = 2,
    max_articles: int = 5,
) -> list[dict]:
    """
    Ruft ticker-spezifische News via Finnhub ab.

    Args:
        ticker:       Aktien-Ticker (z.B. "AAPL")
        days_back:    Wie viele Tage zurück (default: 2 = 48h)
        max_articles: Maximale Anzahl zurückgegebener Artikel

    Returns:
        [{"title": str, "datetime": int (unix), "source": str, "url": str}]
        Sortiert nach Datum (neueste zuerst).
    """
    finnhub_key = os.getenv("FINNHUB_API_KEY", "")

    if finnhub_key:
        news = _fetch_finnhub(ticker, days_back, max_articles, finnhub_key)
        if news:
            return news
        log.debug(f"  [{ticker}] Finnhub: keine News → RSS-Fallback")

    return _fetch_rss_fallback(ticker, max_articles)


def fetch_news_headlines(ticker: str, days_back: int = 2) -> list[str]:
    """
    Convenience-Wrapper: Gibt nur Headlines als String-Liste zurück.
    Kompatibel mit dem bisherigen news-Format in data_ingestion.py.
    """
    news = fetch_company_news(ticker, days_back)
    return [n["title"] for n in news if n.get("title")]


def get_news_with_timestamps(ticker: str, days_back: int = 2) -> list[dict]:
    """
    Gibt News MIT Unix-Timestamps zurück.
    Wird für Intraday-Delta-Berechnung genutzt:
    Wenn die News älter als 4h ist und die Aktie schon +5% gestiegen →
    Alpha möglicherweise eingepreist.
    """
    return fetch_company_news(ticker, days_back)


def compute_news_age_hours(news_items: list[dict]) -> Optional[float]:
    """
    Berechnet das Alter der neuesten News in Stunden.
    Nur möglich mit Finnhub (hat Timestamps).

    Returns:
        Float (Stunden) oder None wenn kein Timestamp verfügbar.
    """
    timestamps = [
        n.get("datetime", 0)
        for n in news_items
        if n.get("datetime", 0) > 0
    ]
    if not timestamps:
        return None

    newest_ts   = max(timestamps)
    newest_dt   = datetime.fromtimestamp(newest_ts)
    age_hours   = (datetime.utcnow() - newest_dt).total_seconds() / 3600

    return round(age_hours, 1)


# ── Finnhub ───────────────────────────────────────────────────────────────────

def _rate_limit():
    global _last_call_time
    elapsed = time.time() - _last_call_time
    if elapsed < _FINNHUB_DELAY:
        time.sleep(_FINNHUB_DELAY - elapsed)
    _last_call_time = time.time()


def _fetch_finnhub(
    ticker: str,
    days_back: int,
    max_articles: int,
    api_key: str,
) -> list[dict]:
    """Ruft Company News von Finnhub ab."""
    _rate_limit()

    try:
        today = datetime.utcnow()
        since = today - timedelta(days=days_back)

        resp = requests.get(
            f"{_FINNHUB_BASE}/company-news",
            params={
                "symbol": ticker,
                "from":   since.strftime("%Y-%m-%d"),
                "to":     today.strftime("%Y-%m-%d"),
                "token":  api_key,
            },
            timeout=10,
        )
        resp.raise_for_status()
        articles = resp.json()

        if not isinstance(articles, list):
            return []

        # Neueste zuerst, max N zurückgeben
        articles.sort(key=lambda x: x.get("datetime", 0), reverse=True)
        result = []
        for a in articles[:max_articles]:
            result.append({
                "title":    a.get("headline", ""),
                "datetime": a.get("datetime", 0),
                "source":   a.get("source", "Finnhub"),
                "url":      a.get("url", ""),
                "summary":  a.get("summary", "")[:200],
            })

        if result:
            log.debug(f"  [{ticker}] Finnhub: {len(result)} News-Artikel")

        return result

    except requests.exceptions.HTTPError as e:
        if e.response.status_code == 429:
            log.warning(f"Finnhub Rate-Limit (429) für {ticker} → warte 60s")
            time.sleep(60)
        else:
            log.debug(f"  [{ticker}] Finnhub HTTP-Fehler: {e}")
        return []
    except Exception as e:
        log.debug(f"  [{ticker}] Finnhub Fehler: {e}")
        return []


# ── RSS Fallback ──────────────────────────────────────────────────────────────

import re
_PATTERNS: dict[str, re.Pattern] = {}


def _get_pattern(ticker: str) -> Optional[re.Pattern]:
    """Word-Boundary-Pattern, None für unsichere Kurzticker."""
    if len(ticker) < 3 or ticker.upper() in _RSS_UNSAFE:
        return None
    if ticker not in _PATTERNS:
        _PATTERNS[ticker] = re.compile(
            rf"\b{re.escape(ticker)}\b", re.IGNORECASE
        )
    return _PATTERNS[ticker]


def _fetch_rss_fallback(ticker: str, max_articles: int) -> list[dict]:
    """RSS-Fallback wenn kein Finnhub-Key. Weniger präzise."""
    pattern = _get_pattern(ticker)
    if pattern is None:
        log.debug(f"  [{ticker}] RSS: Kurzticker übersprungen (False-Positive-Risiko)")
        return []

    results = []
    for feed_url in _RSS_FEEDS:
        try:
            feed = feedparser.parse(feed_url)
            for entry in feed.entries:
                title = entry.get("title", "")
                if pattern.search(title):
                    results.append({
                        "title":    title,
                        "datetime": 0,   # RSS hat keinen zuverlässigen Timestamp
                        "source":   "RSS",
                        "url":      entry.get("link", ""),
                        "summary":  "",
                    })
                    if len(results) >= max_articles:
                        return results
        except Exception as e:
            log.debug(f"RSS Fehler ({feed_url}): {e}")

    return results


================================================
FILE: modules/options_designer.py
================================================
"""
modules/options_designer.py v9.0

Änderungen v9.0:
    #1  DTE-Architektur: Erste passende Tier-Logik ersetzt durch Catalyst-aligned DTE.
        time_to_materialization aus deep_analysis bestimmt jetzt das DTE-Minimum.
        Tiers unterhalb des Minimums werden übersprungen (nicht nur vega_loss-Check).

    #2  IV-Spread-Gate: 85% → 52%.
        BULL_CALL_SPREAD wird ab IV-Rank ≥ 52% gewählt (nicht erst bei 85%).
        Bei normaler IV (< 52%) bleibt LONG_CALL, weil Vega-Exposure akzeptabel.

    #3  Probability-weighted ROI: roi_delta × mc_hit_rate.
        MC Hit-Rate aus quick_mc wird als Wahrscheinlichkeitsgewicht genutzt.
        Ergebnis: 135%-ROI bei 0.55 Hit-Rate → ~74% realistischer Erwartungswert.

    #6  time_to_materialization wird jetzt explizit aus deep_analysis
        ausgelesen und an _compute_roi + Proposal weitergegeben.

    #13 Theta-Decay-Gate: theta_daily_pct > 3%/Tag bei Short-Term → erzwingt
        Upgrade auf Mid-Term. Verhindert dass Options mit >3% Tagesverlust
        durch Zeitwert bei Short-Term akzeptiert werden.

Änderungen v8.1:
    1. Adaptives Strike-Fenster bei hohem Aktienkurs
    2. Delta-Filter im Tradier-Pfad

Änderungen v8.0:
    - Tradier Live-API als primäre Datenquelle
    - yfinance Fallback

Änderungen v7.5:
    - IV-Rank Kalibrierung
"""

from __future__ import annotations
import logging
import math
import os
import numpy as np
import pandas as pd
import requests
from datetime import datetime, timezone
from typing import Optional

import yfinance as yf

from modules.config        import cfg
from modules.macro_context import get_macro_regime_multiplier, get_macro_context

log = logging.getLogger(__name__)

# IV-Gate: ab diesem Wert wird SPREAD statt LONG_CALL gewählt
# v9.0: 85% → 52% (Spread schützt bereits bei mittlerer IV)
IV_SPREAD_GATE = 52.0

# IV-Gate für Scoring-Log (behalten für Kompatibilität)
IV_RANK_GATE = IV_SPREAD_GATE

DTE_TIERS = [
    {"label": "Short-Term", "dte_min": 14,  "dte_max": 60,  "min_roi": 0.15},
    {"label": "Mid-Term",   "dte_min": 61,  "dte_max": 149, "min_roi": 0.12},
    {"label": "Long-Term",  "dte_min": 150, "dte_max": 365, "min_roi": 0.10},
]

# v9.0 #6: time_to_materialization → DTE-Minimum
# Verhindert 16-DTE-Option bei 3-Monats-Thesis
TTM_TO_DTE_MIN: dict[str, int] = {
    "4-8 Wochen":  45,   # Min 45d: Short-Thesis braucht trotzdem Puffer
    "2-3 Monate":  55,   # Mid-Term Minimum (kein 16-DTE!)
    "6 Monate":   140,   # Long-Term Minimum
}

# v9.0 #13: Theta-Decay-Gate
# Wenn Zeitwertverlust > X%/Tag des Prämienpreises → Short-Term überspringen
THETA_DAILY_PCT_GATE = 0.030   # 3 % pro Tag

# v10.0 #2: Event-typ-spezifische IV-Crush-Schätzungen (Basis-Rate, vor IV-Rank-Scaling)
# Earnings:  IV crush hoch (42%/18%/6%), Event fix terminiert
# FDA:       Sehr hoch (60%/22%/8%), binäres Outcome kollabiert Post-Event
# M&A:       Mittel (25%/10%/3%), bereits teilweise eingepreist
# Insider:   Niedrig (10%/4%/1%), kein konkretes Event-Datum
# Other:     Standard (~22%/10%/3%)
# Scaling:   × (0.5 + iv_rank/200) — bei iv_rank=50: ×0.75, bei iv_rank=100: ×1.0
EVENT_IV_CRUSH: dict[str, dict[str, float]] = {
    "EARNINGS": {"short": 0.42, "mid": 0.18, "long": 0.06},
    "FDA":      {"short": 0.60, "mid": 0.22, "long": 0.08},
    "MA":       {"short": 0.25, "mid": 0.10, "long": 0.03},
    "INSIDER":  {"short": 0.10, "mid": 0.04, "long": 0.01},
    "OTHER":    {"short": 0.22, "mid": 0.10, "long": 0.03},
}

SECTOR_ETF = {
    "Technology": "XLK", "Healthcare": "XLV", "Biotechnology": "XBI",
    "Financial Services": "XLF", "Financials": "XLF", "Energy": "XLE",
    "Consumer Cyclical": "XLY", "Consumer Defensive": "XLP",
    "Industrials": "XLI", "Basic Materials": "XLB",
    "Real Estate": "XLRE", "Utilities": "XLU",
    "Communication Services": "XLC", "default": "SPY",
}

RELATIVE_STRENGTH_MIN = -0.15  # -0.08→-0.15: weniger aggressiv, rettet Underreaction-Signale

TRADIER_BASE    = "https://api.tradier.com/v1"
TRADIER_TIMEOUT = 10

MIN_STRIKE_DOLLAR_RANGE = 15.0


def _classify_catalyst_type(s: dict) -> str:
    """
    Klassifiziert den Katalysator-Typ aus alpha_signals + deep_analysis.
    Priorität: FDA > Earnings > M&A > Insider > Other
    Wird in _compute_roi() für event-spezifischen IV-Crush genutzt.
    """
    alpha        = s.get("alpha_signals", {}) or {}
    da           = s.get("deep_analysis", {}) or {}
    catalyst_txt = (da.get("catalyst", "") or "").lower()

    if alpha.get("fda_catalyst"):
        return "FDA"
    # eps_drift ist ein Float — nur als EARNINGS klassifizieren wenn signifikant (>5%)
    eps_drift_significant = abs(float(alpha.get("eps_drift") or 0)) > 0.05
    if eps_drift_significant or any(k in catalyst_txt for k in ("earnings", " eps", "ergebnis", "guidance")):
        return "EARNINGS"
    if any(k in catalyst_txt for k in ("merger", "acquisition", "takeover", "buyout", "übernahme", "deal", "m&a")):
        return "MA"
    if alpha.get("insider_cluster") or "insider" in catalyst_txt:
        return "INSIDER"
    return "OTHER"


# ── Tradier Hilfsfunktionen ───────────────────────────────────────────────────

def _tradier_headers() -> dict:
    api_key = os.environ.get("TRADIER_API_KEY", "")
    return {
        "Authorization": f"Bearer {api_key}",
        "Accept":        "application/json",
    }


def _tradier_expirations(symbol: str) -> list[str]:
    try:
        resp = requests.get(
            f"{TRADIER_BASE}/markets/options/expirations",
            params={"symbol": symbol, "includeAllRoots": "true"},
            headers=_tradier_headers(),
            timeout=TRADIER_TIMEOUT,
        )
        resp.raise_for_status()
        data  = resp.json()
        dates = data.get("expirations", {}).get("date", []) or []
        if isinstance(dates, str):
            dates = [dates]
        return sorted(dates)
    except Exception as e:
        log.debug(f"Tradier Expirations [{symbol}]: {e}")
        return []


def _tradier_chain(symbol: str, expiration: str) -> list[dict]:
    try:
        resp = requests.get(
            f"{TRADIER_BASE}/markets/options/chains",
            params={
                "symbol":     symbol,
                "expiration": expiration,
                "greeks":     "true",
            },
            headers=_tradier_headers(),
            timeout=TRADIER_TIMEOUT,
        )
        resp.raise_for_status()
        data    = resp.json()
        options = data.get("options", {}).get("option", []) or []
        if isinstance(options, dict):
            options = [options]
        return options
    except Exception as e:
        log.debug(f"Tradier Chain [{symbol} {expiration}]: {e}")
        return []


def _tradier_chain_to_df(options: list[dict], option_type: str) -> pd.DataFrame:
    rows = []
    for o in options:
        if o.get("option_type") != option_type:
            continue
        greeks = o.get("greeks") or {}

        iv = (
            greeks.get("mid_iv")
            or greeks.get("smv_vol")
            or 0.30
        )
        if not isinstance(iv, (int, float)) or iv <= 0.01:
            iv = 0.30

        delta = greeks.get("delta")
        delta = float(delta) if isinstance(delta, (int, float)) else 0.0

        rows.append({
            "strike":            float(o.get("strike", 0)),
            "bid":               float(o.get("bid") or 0),
            "ask":               float(o.get("ask") or 0),
            "openInterest":      int(o.get("open_interest") or 0),
            "impliedVolatility": float(iv),
            "delta":             delta,
            "volume":            int(o.get("volume") or 0),
            "_option_symbol":    o.get("symbol", ""),
        })

    if not rows:
        return pd.DataFrame()

    return pd.DataFrame(rows)


# ── Strike-Fenster Berechnung ─────────────────────────────────────────────────

def _strike_window(current: float, dte_min: int, dte_max: int) -> tuple[float, float]:
    """
    Berechnet OTM/ITM-Multiplikatoren für das Strike-Fenster.

    v8.1: Adaptiv bei hohem Aktienkurs.
    Mindestens MIN_STRIKE_DOLLAR_RANGE ($15) Spielraum pro Seite.

    Beispiele:
        AME  $310, Short-Term: 3% = $9.3  → zu eng → $15 = 4.8% → otm=1.048
        AAPL $210, Short-Term: 3% = $6.3  → zu eng → $15 = 7.1% → otm=1.071
        NVDA $900, Short-Term: 3% = $27.0 → ok      → otm=1.03 bleibt
    """
    days_mid = (dte_min + dte_max) / 2
    if days_mid <= 60:
        base_otm, base_itm = 1.03, 0.97
    elif days_mid <= 149:
        base_otm, base_itm = 1.08, 0.96
    else:
        base_otm, base_itm = 1.12, 0.95

    if current > 0 and current * (base_otm - 1.0) < MIN_STRIKE_DOLLAR_RANGE:
        adj     = MIN_STRIKE_DOLLAR_RANGE / current
        otm_max = round(1.0 + adj, 4)
        itm_max = round(1.0 - adj, 4)
        log.debug(
            f"  Strike-Fenster adaptiv: ${current:.0f} × {base_otm-1:.0%} "
            f"= ${current*(base_otm-1):.1f} < ${MIN_STRIKE_DOLLAR_RANGE} "
            f"→ otm={otm_max:.4f} itm={itm_max:.4f}"
        )
    else:
        otm_max, itm_max = base_otm, base_itm

    return otm_max, itm_max


# ── Haupt-Klasse ──────────────────────────────────────────────────────────────

class OptionsDesigner:

    def __init__(self, gates):
        self.gates = gates
        self._use_tradier = bool(os.environ.get("TRADIER_API_KEY", "").strip())
        if self._use_tradier:
            log.info("OptionsDesigner: Tradier Live-API aktiv (Primary)")
        else:
            log.warning("OptionsDesigner: TRADIER_API_KEY fehlt → yfinance Fallback")
        # VIX Term Structure: einmal laden, gecacht für alle Ticker dieses Runs
        self._vix_ts = get_macro_context().get("vix_term_structure", {})

    def run(self, signals: list[dict]) -> list[dict]:
        proposals = []
        for s in signals:
            ticker = s.get("ticker", "")
            if not self._bear_case_ok(s):
                continue
            t_obj = yf.Ticker(ticker)
            if not self._sector_momentum_ok(s, t=t_obj):
                log.info(f"  [{ticker}] SECTOR-GATE → verworfen")
                continue
            proposal = self._design_with_adaptive_dte(s, t_obj)
            if proposal:
                proposals.append(proposal)
        return proposals

    def _design_with_adaptive_dte(self, s: dict, t=None) -> Optional[dict]:
        ticker    = s["ticker"]
        direction = s.get("deep_analysis", {}).get("direction", "BULLISH")
        sim       = s.get("simulation", {})
        current   = sim.get("current_price", 0)

        if current <= 0:
            return None

        if self.gates.has_upcoming_earnings(ticker):
            log.info(f"  [{ticker}] EARNINGS-GATE → blockiert")
            return None

        if t is None:
            t = yf.Ticker(ticker)
        iv_rank = self._get_iv_rank(ticker, t)

        # v9.0 #6: time_to_materialization → DTE-Minimum
        da  = s.get("deep_analysis", {})
        ttm = da.get("time_to_materialization", "4-8 Wochen") or "4-8 Wochen"
        dte_floor = TTM_TO_DTE_MIN.get(ttm, 14)
        log.info(
            f"  [{ticker}] TTM='{ttm}' → DTE-Minimum={dte_floor}d "
            f"(Catalyst-aligned DTE Gate)"
        )

        # v9.0 #3: MC Hit-Rate für probability-weighted ROI
        # v10.0 #6: Kein unterer Clamp mehr — 0.30 war künstlich und verbesserte
        # schlechte Signale. Mirofish filtert bereits < threshold (0.45/0.50).
        qmc         = s.get("quick_mc", {}) or {}
        mc_hit_rate = float(qmc.get("hit_rate", 0.65) or 0.65)
        mc_hit_rate = min(0.95, mc_hit_rate)   # nur obere Grenze, kein Floor

        # v10.0 #2: Katalysator-Typ für event-spezifischen IV-Crush
        catalyst_type = _classify_catalyst_type(s)

        # Dealer-Gamma aus alpha_signals (bereits in v9.0 berechnet)
        dealer_gamma = (
            s.get("alpha_signals", {}).get("dealer_gamma", {}) or {}
        )

        results_per_tier = []

        for tier in DTE_TIERS:
            label = tier["label"]

            # v9.0 #1: Überspringe Tiers die unter dem Catalyst-DTE-Minimum liegen.
            # Korrekte Bedingung: dte_min des Tiers < dte_floor
            # Beispiel: "2-3 Monate" → dte_floor=55
            #   Short-Term dte_min=14 < 55 → übersprungen ✓
            #   Mid-Term   dte_min=61 < 55 → False → evaluiert  ✓
            if tier["dte_min"] < dte_floor:
                log.info(
                    f"  [{ticker}] {label}: dte_min={tier['dte_min']}d < "
                    f"dte_floor={dte_floor}d (Thesis='{ttm}') → übersprungen"
                )
                continue

            strategy = self._select_strategy(ticker, direction, iv_rank, dealer_gamma)
            option   = self._find_option_for_dte(
                ticker, strategy, current, tier["dte_min"], tier["dte_max"], t
            )

            if not option:
                log.info(f"  [{ticker}] {label}: kein Kontrakt verfügbar")
                if "SPREAD" in strategy:
                    fallback = "LONG_CALL" if "BULL" in strategy else "LONG_PUT"
                    option   = self._find_option_for_dte(
                        ticker, fallback, current, tier["dte_min"], tier["dte_max"], t
                    )
                    if option:
                        strategy = fallback
                        log.info(f"  [{ticker}] {label}: Fallback → {fallback}")
                if not option:
                    continue

            roi = self._compute_roi(option, sim, iv_rank, tier, strategy, mc_hit_rate, catalyst_type)

            try:
                dte_safe       = max(int(option.get("dte") or 1), 1)
                roi_net_safe   = float(roi["roi_net"].real if isinstance(roi["roi_net"], complex) else roi["roi_net"])
                annualized_roi = float((1 + roi_net_safe) ** (365 / dte_safe) - 1)
                annualized_roi = min(annualized_roi, 9.99)
            except Exception:
                annualized_roi = 0.0

            log.info(
                f"  [{ticker}] {label} ({int(option['dte'] or 0)}d): "
                f"ROI={roi['roi_net']:.1%} (mc_weighted) "
                f"theta={roi.get('theta_daily_pct', 0):.1%}/d "
                f"(ann.={annualized_roi:.1%}) "
                f"{'✅ PASS' if roi['passes_roi_gate'] else '❌ FAIL'}"
            )

            results_per_tier.append({
                "tier": label, "dte": option["dte"],
                "option": option, "roi": roi,
                "annualized_roi": round(annualized_roi, 4),
                "strategy": strategy,
            })

            # v9.0 #13: Theta-Decay-Gate — bei Short-Term und hohem Theta → upgrade
            if label == "Short-Term":
                theta_pct = roi.get("theta_daily_pct", 0.0)
                vega_loss = roi.get("vega_loss", 0)

                if theta_pct > THETA_DAILY_PCT_GATE:
                    log.info(
                        f"  [{ticker}] {label}: Theta={theta_pct:.1%}/d > "
                        f"{THETA_DAILY_PCT_GATE:.0%} Gate → Zeitwertverlust zu hoch, "
                        f"versuche längere Laufzeit"
                    )
                    continue

                if vega_loss > 0.35:
                    log.info(
                        f"  [{ticker}] {label}: Vega-Loss={vega_loss:.0%} > 35% "
                        f"→ zu hohes IV-Crush-Risiko, versuche längere Laufzeit"
                    )
                    continue

            if roi["passes_roi_gate"]:
                tier_idx = DTE_TIERS.index(tier)
                if tier_idx > 0:
                    prev_label = DTE_TIERS[0]["label"]
                    prev_roi   = results_per_tier[0]["roi"]["roi_net"] if results_per_tier else None
                    if prev_roi is not None:
                        log.info(
                            f"  [{ticker}] ⚡ LAUFZEIT-RETTUNG: "
                            f"{prev_label} ROI={prev_roi:.1%} (FAIL) → "
                            f"{label} ROI={roi['roi_net']:.1%} (PASS) — "
                            f"Trade akzeptiert mit {option['dte']}d Laufzeit"
                        )

                # v10.0 #5: Market-Implied Expected Move (ATM Straddle)
                implied_move = self._get_atm_straddle(ticker, current, option["expiry"], t)
                model_move   = (
                    (sim.get("target_price", 0) - current) / current
                    if current > 0 and sim.get("target_price", 0) > current else 0.0
                )
                edge_vs_implied = (model_move - implied_move) if implied_move is not None else None
                if implied_move is not None:
                    log.info(
                        f"  [{ticker}] Market-Implied: ±{implied_move:.1%} | "
                        f"Model: +{model_move:.1%} | "
                        f"Edge: {edge_vs_implied:+.1%} "
                        f"({'✅ Edge vorhanden' if edge_vs_implied > 0 else '⚠️ kein Edge'})"
                    )

                return {
                    "ticker":              ticker,
                    "strategy":            strategy,
                    "iv_rank":             iv_rank,
                    "iv_gate_applied":     iv_rank >= IV_SPREAD_GATE,
                    "direction":           direction,
                    "option":              option,
                    "roi_analysis":        roi,
                    "dte_tier":            label,
                    "annualized_roi":      round(annualized_roi, 4),
                    "all_tiers_tried":     results_per_tier,
                    "features":            s.get("features", {}),
                    "simulation":          s.get("simulation", {}),
                    "deep_analysis":       s.get("deep_analysis", {}),
                    "sector_momentum":     s.get("sector_momentum", {}),
                    "final_score":         s.get("final_score", 0),
                    "mc_hit_rate":         mc_hit_rate,
                    "time_to_maturation":  ttm,
                    "sector":              s.get("info", {}).get("sector", ""),
                    "catalyst_type":       catalyst_type,
                    "implied_move_pct":    round(implied_move * 100, 2) if implied_move is not None else None,
                    "model_move_pct":      round(model_move * 100, 2),
                    "edge_vs_implied":     round(edge_vs_implied * 100, 2) if edge_vs_implied is not None else None,
                }

        tried = ", ".join(
            f"{r['tier']}={r['roi']['roi_net']:.1%}" for r in results_per_tier
        )
        log.info(f"  [{ticker}] Alle Laufzeiten unter ROI-Gate: {tried} → verworfen")
        return None

    def _select_strategy(
        self,
        ticker:       str,
        direction:    str,
        iv_rank:      float,
        dealer_gamma: dict | None = None,
    ) -> str:
        """
        Strategie-Auswahl mit drei Signalen:
        1. IV-Rank: ≥ 52% → Spread (Vega-Schutz)
        2. Dealer-Gamma: negative Gamma → aggressiver (LONG_CALL auch bei mittlerer IV)
        3. Kombination: negative Gamma + niedrige IV → LONG_CALL
                        positive Gamma + hohe IV   → SPREAD (doppelter Schutz)
        """
        dealer_gamma  = dealer_gamma or {}
        gamma_sign    = dealer_gamma.get("net_gamma_sign", "neutral")
        gamma_ok      = dealer_gamma.get("data_available", False)

        is_bullish = direction == "BULLISH"

        if gamma_ok and gamma_sign == "negative":
            effective_gate = min(IV_SPREAD_GATE + 13, 65.0)
            reason = f"Dealer-Gamma negativ (Trend-Verstärkung) → IV-Gate {effective_gate:.0f}%"
        elif gamma_ok and gamma_sign == "positive":
            effective_gate = max(IV_SPREAD_GATE - 12, 40.0)
            reason = f"Dealer-Gamma positiv (Mean-Reversion) → IV-Gate {effective_gate:.0f}%"
        else:
            effective_gate = IV_SPREAD_GATE
            reason = f"Dealer-Gamma neutral/unbekannt → Standard IV-Gate {IV_SPREAD_GATE:.0f}%"

        # VIX Term Structure: Backwardation → kurzfristige Angst hoch →
        # Vol-Crush nach Event wahrscheinlicher → Spread bevorzugen (Gate -8%)
        vix_structure = self._vix_ts.get("structure", "unknown") if self._vix_ts else "unknown"
        if vix_structure == "backwardation":
            effective_gate = max(effective_gate - 8.0, 35.0)
            reason += f" | VIX Backwardation → Gate -{8:.0f}% ({effective_gate:.0f}%)"
        elif vix_structure == "contango":
            pass  # Contango: kein Adjustment — Standardlogik reicht

        if iv_rank >= effective_gate:
            s = "BULL_CALL_SPREAD" if is_bullish else "BEAR_PUT_SPREAD"
            log.info(
                f"  [{ticker}] IV={iv_rank:.0f}% ≥ {effective_gate:.0f}% → {s} "
                f"(Spread | {reason})"
            )
        else:
            s = "LONG_CALL" if is_bullish else "LONG_PUT"
            log.info(
                f"  [{ticker}] IV={iv_rank:.0f}% < {effective_gate:.0f}% → {s} "
                f"(Naked | {reason})"
            )
        return s

    def _compute_roi(
        self,
        option:        dict,
        sim:           dict,
        iv_rank:       float,
        tier:          dict,
        strategy:      str   = "",
        mc_hit_rate:   float = 0.65,   # v9.0 #3: probability weight
        catalyst_type: str   = "OTHER", # v10.0 #2: event-spezifischer IV-Crush
    ) -> dict:
        bid     = option.get("bid", 0) or 0
        ask     = option.get("ask", 0) or 0
        strike  = option.get("strike", 0) or 0
        iv      = option.get("implied_vol", 0.30) or 0.30
        dte     = int(option.get("dte", 120) or 120)
        current = sim.get("current_price", 0) or 0
        target  = sim.get("target_price", 0) or 0
        min_roi = tier["min_roi"]

        is_spread = "SPREAD" in strategy
        cost = option.get("net_debit", ask) if is_spread else ask

        if cost <= 0 or current <= 0:
            return {"roi_net": 0.0, "passes_roi_gate": False,
                    "roi_gross": 0.0, "spread_pct": 0.0,
                    "vega_loss": 0.0, "theta_daily_pct": 0.0,
                    "breakeven": 0.0, "breakeven_pct": 0.0,
                    "delta": 0.0, "min_roi_threshold": min_roi}

        if iv < 0.05 or iv > 3.0:
            iv = 0.30

        spread_pct = (ask - bid) / ask if ask > 0 else 0.0
        T          = max(dte / 365.0, 1 / 365.0)  # mindestens 1 Tag

        tradier_delta = option.get("delta")
        if tradier_delta and 0.01 <= abs(float(tradier_delta)) <= 0.99:
            delta = float(tradier_delta)
            vega  = 0.0
            try:
                d1   = (math.log(current / strike) + 0.5 * iv**2 * T) / (iv * math.sqrt(T))
                nd1  = math.exp(-0.5 * d1**2) / math.sqrt(2 * math.pi)
                vega = current * nd1 * math.sqrt(T)
            except Exception:
                nd1 = 0.0
        else:
            try:
                d1   = (math.log(current / strike) + 0.5 * iv**2 * T) / (iv * math.sqrt(T))
                nd1  = math.exp(-0.5 * d1**2) / math.sqrt(2 * math.pi)
                delta = (1.0 + math.erf(d1 / math.sqrt(2.0))) / 2.0
                vega  = current * nd1 * math.sqrt(T)
            except Exception:
                delta, vega, nd1 = 0.5, 0.0, 0.0

        if is_spread:
            delta *= 0.80

        # v9.0 #13: Theta-Decay (vereinfachte BS-Formel, r≈0)
        # theta_per_year = -current * N'(d1) * sigma / (2 * sqrt(T))
        # = -vega * sigma / (2 * T)
        try:
            theta_per_year = -(current * nd1 * iv) / (2.0 * math.sqrt(T))
            theta_daily    = theta_per_year / 365.0
            theta_daily_pct = abs(theta_daily) / cost if cost > 0 else 0.0
        except Exception:
            theta_daily     = 0.0
            theta_daily_pct = 0.0

        # Breakeven bei Expiry
        breakeven     = strike + ask if not is_spread else strike + cost
        breakeven_pct = (breakeven - current) / current if current > 0 else 0.0

        leverage      = current / cost if cost > 0 else 1.0
        expected_move = (target - current) / current if target > current else 0.0

        # v9.0 #3 / v10.0 #6: Probability-weighted ROI, kein unterer Clamp mehr
        mc_weight = min(0.95, mc_hit_rate)
        roi_delta = expected_move * delta * leverage * mc_weight

        # v10.0 #2: Event-typ-spezifischer IV-Crush + IV-Rank-Scaling
        # Basis-Rate aus EVENT_IV_CRUSH, dann skaliert mit IV-Rank:
        # iv_rank=0  → 0.50× (kaum IV, kaum Crush)
        # iv_rank=50 → 0.75× (mittlere IV)
        # iv_rank=100→ 1.00× (maximale IV, maximaler Crush)
        crush_rates  = EVENT_IV_CRUSH.get(catalyst_type, EVENT_IV_CRUSH["OTHER"])
        dte_key      = "short" if dte <= 60 else ("mid" if dte <= 149 else "long")
        iv_drop_base = crush_rates[dte_key]
        iv_rank_scale = 0.5 + (iv_rank / 200.0)

        # VIX Term Structure: Backwardation → erhöhter Crush (×1.20)
        #                     Contango      → geringerer Crush (×0.85, Markt ruhig)
        vix_structure = self._vix_ts.get("structure", "unknown") if self._vix_ts else "unknown"
        vix_crush_scale = 1.20 if vix_structure == "backwardation" else (
                          0.85 if vix_structure == "contango" else 1.00)

        iv_drop = iv_drop_base * iv_rank_scale * vix_crush_scale

        vega_loss = min((vega * iv * iv_drop) / cost, 0.50) if cost > 0 else 0.0
        roi_net   = roi_delta - (spread_pct * 2) - vega_loss
        passes    = roi_net >= min_roi

        def _safe_float(v):
            if isinstance(v, complex): return float(v.real)
            try: return float(v)
            except: return 0.0

        return {
            "roi_gross":         round(_safe_float(roi_delta), 4),
            "roi_net":           round(_safe_float(roi_net), 4),
            "spread_pct":        round(_safe_float(spread_pct), 4),
            "vega_loss":         round(_safe_float(vega_loss), 4),
            "theta_daily":       round(_safe_float(theta_daily), 4),
            "theta_daily_pct":   round(_safe_float(theta_daily_pct), 4),
            "delta":             round(_safe_float(delta), 4),
            "breakeven":         round(_safe_float(breakeven), 2),
            "breakeven_pct":     round(_safe_float(breakeven_pct), 4),
            "iv_drop_assumed":   _safe_float(iv_drop),
            "iv_drop_base":      _safe_float(iv_drop_base),
            "catalyst_type":     catalyst_type,
            "cost_basis":        round(float(cost), 4),
            "mc_weight":         round(mc_weight, 3),
            "is_spread":         is_spread,
            "passes_roi_gate":   passes,
            "min_roi_threshold": min_roi,
            "dte":               int(dte),
        }

    # ── Option Chain Abruf: Tradier Primary, yfinance Fallback ───────────────

    def _find_option_for_dte(
        self, ticker: str, strategy: str, current: float,
        dte_min: int, dte_max: int,
        t: Optional[object] = None,
    ) -> Optional[dict]:
        if self._use_tradier:
            result = self._find_option_tradier(ticker, strategy, current, dte_min, dte_max)
            if result is not None:
                return result
            log.debug(f"  [{ticker}] Tradier Chain leer → yfinance Fallback")

        return self._find_option_yfinance(ticker, strategy, current, dte_min, dte_max, t)

    def _find_option_tradier(
        self, ticker: str, strategy: str, current: float,
        dte_min: int, dte_max: int,
    ) -> Optional[dict]:
        try:
            all_dates = _tradier_expirations(ticker)
            if not all_dates:
                return None

            dates = [d for d in all_dates if dte_min <= self._days_to(d) <= dte_max]
            if not dates:
                return None

            best_expiry = dates[0]
            is_call     = "CALL" in strategy or "BULL" in strategy
            option_type = "call" if is_call else "put"

            raw = _tradier_chain(ticker, best_expiry)
            if not raw:
                return None

            opts = _tradier_chain_to_df(raw, option_type)
            if opts.empty:
                return None

            otm_max, itm_max = _strike_window(current, dte_min, dte_max)

            min_oi = max(
                getattr(getattr(cfg, "risk", None), "min_open_interest", 100), 50
            )
            filtered = opts[
                (opts["strike"] >= current * itm_max) &
                (opts["strike"] <= current * otm_max) &
                (opts["openInterest"] >= min_oi)
            ].copy()

            if filtered.empty:
                return None

            filtered["spread_ratio"] = (
                (filtered["ask"] - filtered["bid"]) /
                filtered["ask"].clip(lower=0.01)
            )
            filtered = filtered[filtered["spread_ratio"] <= cfg.risk.max_bid_ask_ratio]
            if filtered.empty:
                return None

            delta_min = getattr(getattr(cfg, "options", None), "delta_target_low",  0.50)
            delta_max = getattr(getattr(cfg, "options", None), "delta_target_high", 0.75)
            if "delta" in filtered.columns:
                delta_filtered = filtered[
                    (filtered["delta"].abs() >= delta_min) &
                    (filtered["delta"].abs() <= delta_max)
                ]
                if not delta_filtered.empty:
                    filtered = delta_filtered
                else:
                    log.debug(
                        f"  [{ticker}] Delta-Filter ({delta_min:.2f}–{delta_max:.2f}): "
                        f"kein Match → alle behalten"
                    )

            best = filtered.sort_values("openInterest", ascending=False).iloc[0]
            dte  = self._days_to(best_expiry)

            result = {
                "expiry":        best_expiry,
                "strike":        float(best["strike"]),
                "bid":           float(best["bid"]),
                "ask":           float(best["ask"]),
                "open_interest": int(best["openInterest"]),
                "implied_vol":   float(best["impliedVolatility"]),
                "spread_ratio":  round(float(best["spread_ratio"]), 4),
                "dte":           int(dte),
                "delta":         float(best.get("delta", 0)),
                "data_source":   "tradier",
            }

            log.info(
                f"  [{ticker}] Tradier Chain: expiry={best_expiry} "
                f"strike={result['strike']:.1f} IV={result['implied_vol']:.1%} "
                f"delta={result['delta']:.2f} OI={result['open_interest']}"
            )

            if "SPREAD" in strategy:
                spread_leg = self._find_spread_leg(opts, best["strike"])
                result["spread_leg"] = spread_leg
                if spread_leg:
                    result["net_debit"] = round(
                        result["ask"] - spread_leg.get("bid", 0), 2
                    )
                    if spread_leg.get("bid", 0) <= 0:
                        log.debug(f"  [{ticker}] Short-Leg hat keine Liquidität → kein Spread")
                        result.pop("spread_leg", None)
                        result.pop("net_debit", None)

            return result

        except Exception as e:
            log.debug(f"Tradier _find_option [{ticker}] {dte_min}-{dte_max}d: {e}")
            return None

    def _find_option_yfinance(
        self, ticker: str, strategy: str, current: float,
        dte_min: int, dte_max: int,
        t: Optional[object] = None,
    ) -> Optional[dict]:
        try:
            if t is None:
                t = yf.Ticker(ticker)
            dates = [
                d for d in (t.options or [])
                if dte_min <= self._days_to(d) <= dte_max
            ]
            if not dates:
                return None

            best_expiry = dates[0]
            chain       = t.option_chain(best_expiry)
            is_call     = "CALL" in strategy or "BULL" in strategy
            opts        = chain.calls if is_call else chain.puts

            otm_max, itm_max = _strike_window(current, dte_min, dte_max)

            filtered = opts[
                (opts["strike"] >= current * itm_max) &
                (opts["strike"] <= current * otm_max) &
                (opts["openInterest"] >= max(
                    getattr(getattr(cfg, "risk", None), "min_open_interest", 100), 50
                ))
            ].copy()

            if filtered.empty:
                return None

            filtered["spread_ratio"] = (
                (filtered["ask"] - filtered["bid"]) /
                filtered["ask"].clip(lower=0.01)
            )
            filtered = filtered[filtered["spread_ratio"] <= cfg.risk.max_bid_ask_ratio]
            if filtered.empty:
                return None

            best = filtered.sort_values("openInterest", ascending=False).iloc[0]
            dte  = self._days_to(best_expiry)

            result = {
                "expiry":        best_expiry,
                "strike":        float(best["strike"]),
                "bid":           float(best["bid"]),
                "ask":           float(best["ask"]),
                "open_interest": int(best["openInterest"]),
                "implied_vol":   float(best.get("impliedVolatility", 0.30)),
                "spread_ratio":  round(float(best["spread_ratio"]), 4),
                "dte":           int(dte),
                "data_source":   "yfinance",
            }

            if "SPREAD" in strategy:
                spread_leg = self._find_spread_leg(opts, best["strike"])
                result["spread_leg"] = spread_leg
                if spread_leg:
                    result["net_debit"] = round(
                        result["ask"] - spread_leg.get("bid", 0), 2
                    )
                    if spread_leg.get("bid", 0) <= 0:
                        log.debug(f"  [{ticker}] Short-Leg hat keine Liquidität → kein Spread")
                        result.pop("spread_leg", None)
                        result.pop("net_debit", None)

            return result

        except Exception as e:
            log.debug(f"yfinance _find_option [{ticker}] {dte_min}-{dte_max}d: {e}")
            return None

    def _find_spread_leg(self, opts: pd.DataFrame, long_strike: float) -> Optional[dict]:
        candidates = opts[
            (opts["strike"] >= long_strike * 1.05) &
            (opts["strike"] <= long_strike * 1.20)
        ]
        if candidates.empty:
            return None
        best = candidates.iloc[
            (candidates["strike"] - long_strike * 1.10).abs().argsort()
        ].iloc[0]
        return {"strike": float(best["strike"]),
                "bid": float(best["bid"]), "ask": float(best["ask"])}

    # ── ATM Straddle (Market-Implied Expected Move) ───────────────────────────

    def _get_atm_straddle(
        self, ticker: str, current: float, expiry: str, t=None
    ) -> Optional[float]:
        """
        Berechnet den ATM Straddle-Preis (Call Ask + Put Ask) für eine Expiry.
        Gibt ihn als Anteil des Aktienkurses zurück (= Market-Implied Expected Move).

        Beispiel: Straddle $10 bei Kurs $100 → implied_move = 0.10 (±10%).
        """
        # Tradier: Chain bereits für diese Expiry gecacht
        if self._use_tradier:
            try:
                raw = _tradier_chain(ticker, expiry)
                if raw:
                    atm_call = min(
                        (o for o in raw if o.get("option_type") == "call"),
                        key=lambda o: abs(float(o.get("strike", 1e9)) - current),
                        default=None,
                    )
                    atm_put = min(
                        (o for o in raw if o.get("option_type") == "put"),
                        key=lambda o: abs(float(o.get("strike", 1e9)) - current),
                        default=None,
                    )
                    if atm_call and atm_put:
                        ca = float(atm_call.get("ask", 0) or 0)
                        pa = float(atm_put.get("ask", 0) or 0)
                        if ca > 0 and pa > 0 and current > 0:
                            return (ca + pa) / current
            except Exception as e:
                log.debug(f"  [{ticker}] ATM Straddle Tradier Fehler: {e}")

        # yfinance Fallback
        try:
            if t is None:
                t = yf.Ticker(ticker)
            chain = t.option_chain(expiry)
            c = chain.calls.iloc[(chain.calls["strike"] - current).abs().argsort()].iloc[0]
            p = chain.puts.iloc[(chain.puts["strike"] - current).abs().argsort()].iloc[0]
            ca = float(c.get("ask", 0) or 0)
            pa = float(p.get("ask", 0) or 0)
            if ca > 0 and pa > 0 and current > 0:
                return (ca + pa) / current
        except Exception as e:
            log.debug(f"  [{ticker}] ATM Straddle yfinance Fehler: {e}")

        return None

    # ── IV-Rank ───────────────────────────────────────────────────────────────

    def _get_iv_rank(self, ticker: str, t: Optional[object] = None) -> float:
        try:
            if t is None:
                t = yf.Ticker(ticker)

            rv_score = 50.0
            info     = t.info
            current  = float(info.get("currentPrice") or info.get("regularMarketPrice") or 0)

            hist = t.history(period="1y")
            if not hist.empty and len(hist) >= 60:
                rets    = hist["Close"].pct_change().dropna()
                roll_rv = rets.rolling(21).std().dropna() * (252 ** 0.5)
                if len(roll_rv) >= 20:
                    rv_current = float(roll_rv.iloc[-1])
                    rv_min     = float(roll_rv.quantile(0.05))
                    rv_max     = float(roll_rv.quantile(0.95))
                    if rv_max > rv_min:
                        rv_score = ((rv_current - rv_min) / (rv_max - rv_min)) * 100
                        rv_score = max(0.0, min(100.0, rv_score))

            if current <= 0:
                return round(rv_score, 1)

            term_score = 20.0
            iv_pts     = self._get_term_structure_iv(ticker, current, t)

            if len(iv_pts) >= 2:
                iv_pts.sort()
                iv_short = iv_pts[0][1]
                iv_long  = iv_pts[-1][1]
                if iv_long > 0:
                    slope      = (iv_short / iv_long) - 1.0
                    term_score = max(0.0, min(80.0, (slope + 0.05) * 100))

            combined = round(rv_score * 0.80 + term_score * 0.20, 1)
            log.info(
                f"  [{ticker}] IV-Rank: rv={rv_score:.0f} term={term_score:.0f} "
                f"→ combined={combined:.0f} "
                f"({'SPREAD' if combined >= IV_SPREAD_GATE else 'LONG'})"
            )
            return combined

        except Exception:
            return 50.0

    def _get_term_structure_iv(
        self, ticker: str, current: float, t=None
    ) -> list[tuple[int, float]]:
        if self._use_tradier:
            iv_pts = self._term_structure_tradier(ticker, current)
            if len(iv_pts) >= 2:
                return iv_pts
            log.debug(f"  [{ticker}] Term-Structure Tradier unvollständig → yfinance")
        return self._term_structure_yfinance(ticker, current, t)

    def _term_structure_tradier(
        self, ticker: str, current: float
    ) -> list[tuple[int, float]]:
        iv_pts = []
        try:
            dates = _tradier_expirations(ticker)
            for d in dates[:3]:
                dte = self._days_to(d)
                if dte < 7:
                    continue
                raw = _tradier_chain(ticker, d)
                if not raw:
                    continue
                atm_ivs = []
                for o in raw:
                    if o.get("option_type") != "call":
                        continue
                    strike = float(o.get("strike", 0))
                    if not (current * 0.93 <= strike <= current * 1.07):
                        continue
                    greeks = o.get("greeks") or {}
                    iv = greeks.get("mid_iv") or greeks.get("smv_vol")
                    if iv and isinstance(iv, (int, float)) and iv > 0.05:
                        atm_ivs.append(float(iv))
                if atm_ivs:
                    iv_pts.append((dte, float(np.median(atm_ivs))))
        except Exception as e:
            log.debug(f"  [{ticker}] Term-Structure Tradier Fehler: {e}")
        return iv_pts

    def _term_structure_yfinance(
        self, ticker: str, current: float, t=None
    ) -> list[tuple[int, float]]:
        iv_pts = []
        try:
            if t is None:
                t = yf.Ticker(ticker)
            dates = t.options or []
            for d in dates[:3]:
                try:
                    dte = self._days_to(d)
                    if dte < 7:
                        continue
                    ch  = t.option_chain(d)
                    atm = ch.calls[
                        (ch.calls["strike"] >= current * 0.93) &
                        (ch.calls["strike"] <= current * 1.07) &
                        (ch.calls["impliedVolatility"] > 0.05)
                    ]
                    if not atm.empty:
                        iv_pts.append((dte, float(atm["impliedVolatility"].median())))
                except Exception:
                    continue
        except Exception as e:
            log.debug(f"  [{ticker}] Term-Structure yfinance Fehler: {e}")
        return iv_pts

    # ── Sektor-Momentum ───────────────────────────────────────────────────────

    def _sector_momentum_ok(self, s: dict, t=None) -> bool:
        ticker    = s.get("ticker", "")
        sector    = s.get("info", {}).get("sector", "default")
        direction = s.get("deep_analysis", {}).get("direction", "BULLISH")
        etf       = SECTOR_ETF.get(sector, SECTOR_ETF["default"])
        try:
            ticker_obj = t if t is not None else yf.Ticker(ticker)
            sh = ticker_obj.history(period="35d")
            eh = yf.Ticker(etf).history(period="35d")
            if sh.empty or eh.empty or len(sh) < 5:
                return True
            sr = float((sh["Close"].iloc[-1] - sh["Close"].iloc[0]) / sh["Close"].iloc[0])
            er = float((eh["Close"].iloc[-1] - eh["Close"].iloc[0]) / eh["Close"].iloc[0])
            rs = sr - er
            s["sector_momentum"] = {"etf": etf, "rel_strength": round(rs, 4)}
            return rs >= RELATIVE_STRENGTH_MIN if direction == "BULLISH" else rs <= -RELATIVE_STRENGTH_MIN
        except Exception:
            return True

    def _bear_case_ok(self, s: dict) -> bool:
        sev = s.get("deep_analysis", {}).get("bear_case_severity", 0)
        thr = getattr(getattr(cfg, "risk", None), "max_bear_case_severity", 8)
        if sev >= thr:
            log.info(f"  [{s['ticker']}] BEAR-CASE={sev} ≥ {thr} → blockiert")
            return False
        return True

    def _days_to(self, expiry_str: str) -> int:
        try:
            expiry = datetime.strptime(expiry_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
            now    = datetime.now(timezone.utc)
            return max(0, (expiry - now).days)
        except Exception:
            return 0


================================================
FILE: modules/premium_signals.py
================================================
"""
modules/premium_signals.py – FLASH Alpha & Eulerpool (nur Top-Kandidaten)

Priorität 5: Dealer Positioning, GEX, Vol Surface — nur für finale Kandidaten.

Budget-Regeln (KRITISCH):
  FLASH Alpha: Max 5 API-Calls/Tag → NUR für Top-2 Signale nach RL-Scoring
  Eulerpool:   Free Tier begrenzt  → NUR für Top-2 Signale nach RL-Scoring

Diese Signale laufen NACH Stufe 6 (RL-Scoring), VOR Stufe 7 (Options-Design).
Sie können ein Signal final boosten oder verwerfen.
"""

from __future__ import annotations
import logging
import os
from typing import Optional

import requests

log = logging.getLogger(__name__)

# Globaler Call-Zähler für FLASH Alpha (max 5/Tag)
_flash_calls
Download .txt
gitextract_pqm5dysu/

├── .github/
│   └── workflows/
│       └── scanner.yml
├── .gitignore
├── README.md
├── config.yaml
├── feedback.py
├── modules/
│   ├── __init__.py
│   ├── alpha_sources.py
│   ├── config.py
│   ├── data_ingestion.py
│   ├── data_validator.py
│   ├── deep_analysis.py
│   ├── email_reporter.py
│   ├── finbert_sentiment.py
│   ├── intraday_delta.py
│   ├── macro_context.py
│   ├── mirofish_simulation.py
│   ├── mismatch_scorer.py
│   ├── news_fetcher.py
│   ├── options_designer.py
│   ├── premium_signals.py
│   ├── prescreener.py
│   ├── quasi_ml.py
│   ├── reddit_signals.py
│   ├── reporter.py
│   ├── risk_gates.py
│   ├── rl_agent.py
│   ├── rl_environment.py
│   ├── sentiment_tracker.py
│   ├── trade_scorer.py
│   └── universe.py
├── outputs/
│   ├── daily_reports/
│   │   ├── 2026-04-11.json
│   │   ├── 2026-04-11.md
│   │   ├── 2026-04-12.json
│   │   ├── 2026-04-12.md
│   │   ├── 2026-04-13.json
│   │   ├── 2026-04-13.md
│   │   ├── 2026-04-14.json
│   │   ├── 2026-04-14.md
│   │   ├── 2026-04-15.json
│   │   ├── 2026-04-15.md
│   │   ├── 2026-04-16.json
│   │   ├── 2026-04-16.md
│   │   ├── 2026-04-17.json
│   │   ├── 2026-04-17.md
│   │   ├── 2026-04-20.json
│   │   ├── 2026-04-20.md
│   │   ├── 2026-04-21.json
│   │   ├── 2026-04-21.md
│   │   ├── 2026-04-22.json
│   │   ├── 2026-04-22.md
│   │   ├── 2026-04-23.json
│   │   ├── 2026-04-23.md
│   │   ├── 2026-04-24.json
│   │   ├── 2026-04-24.md
│   │   ├── 2026-04-27.json
│   │   ├── 2026-04-27.md
│   │   ├── 2026-04-28.json
│   │   ├── 2026-04-28.md
│   │   ├── 2026-04-29.json
│   │   ├── 2026-04-29.md
│   │   ├── 2026-04-30.json
│   │   ├── 2026-04-30.md
│   │   ├── 2026-05-01.json
│   │   ├── 2026-05-01.md
│   │   ├── 2026-05-04.json
│   │   ├── 2026-05-04.md
│   │   ├── 2026-05-05.json
│   │   ├── 2026-05-05.md
│   │   ├── 2026-05-06.json
│   │   ├── 2026-05-06.md
│   │   ├── 2026-05-07.json
│   │   ├── 2026-05-07.md
│   │   ├── 2026-05-08.json
│   │   ├── 2026-05-08.md
│   │   ├── 2026-05-11.json
│   │   ├── 2026-05-11.md
│   │   ├── 2026-05-12.json
│   │   ├── 2026-05-12.md
│   │   ├── 2026-05-13.json
│   │   ├── 2026-05-13.md
│   │   ├── 2026-05-14.json
│   │   ├── 2026-05-14.md
│   │   ├── 2026-05-17.json
│   │   ├── 2026-05-17.md
│   │   ├── 2026-05-18.json
│   │   ├── 2026-05-18.md
│   │   ├── 2026-05-19.json
│   │   ├── 2026-05-19.md
│   │   ├── 2026-05-20.json
│   │   └── 2026-05-20.md
│   ├── history.json
│   └── models/
│       └── .gitkeep
├── pipeline.py
├── requirements.txt
└── tests/
    └── test_pipeline.py
Download .txt
SYMBOL INDEX (219 symbols across 26 files)

FILE: feedback.py
  function _tradier_headers (line 51) | def _tradier_headers() -> dict:
  function _use_tradier (line 60) | def _use_tradier() -> bool:
  function load_history (line 67) | def load_history() -> dict:
  function save_history (line 75) | def save_history(history: dict) -> None:
  function get_current_price (line 83) | def get_current_price(ticker: str) -> float:
  function _tradier_stock_price (line 104) | def _tradier_stock_price(ticker: str) -> float:
  function get_current_option_price (line 145) | def get_current_option_price(
  function _option_type_from_strategy (line 183) | def _option_type_from_strategy(strategy: str) -> str:
  function _tradier_option_price (line 198) | def _tradier_option_price(
  function _yfinance_option_price (line 259) | def _yfinance_option_price(
  function compute_outcome (line 282) | def compute_outcome(trade: dict, current_stock_price: float) -> float:
  function update_bin (line 325) | def update_bin(stats_dict: dict, feature: str, bin_label: str, outcome: ...
  function retrain_rl_agent (line 339) | def retrain_rl_agent(history: dict) -> None:
  function compute_pearson_weights (line 375) | def compute_pearson_weights(history: dict) -> dict:
  function _bin_to_num (line 414) | def _bin_to_num(feature: str, bin_label: str) -> float:
  function main (line 425) | def main() -> None:

FILE: modules/alpha_sources.py
  function fetch_fda_events (line 53) | def fetch_fda_events(company_name: str, days_back: int = 7) -> list[dict]:
  function fetch_fda_drug_approvals (line 92) | def fetch_fda_drug_approvals(days_back: int = 7) -> list[dict]:
  function match_fda_to_ticker (line 132) | def match_fda_to_ticker(ticker: str, company_info: dict, days_back: int ...
  function fetch_sec_insider_trades (line 162) | def fetch_sec_insider_trades(ticker: str, days_back: int = 14) -> list[d...
  function _fetch_sec_form4_fallback (line 199) | def _fetch_sec_form4_fallback(ticker: str, days_back: int) -> list[dict]:
  function detect_insider_cluster (line 225) | def detect_insider_cluster(ticker: str, days_back: int = 14) -> dict:
  function get_earnings_date_finnhub (line 260) | def get_earnings_date_finnhub(ticker: str) -> Optional[str]:
  function has_earnings_within_days (line 291) | def has_earnings_within_days(
  function fetch_options_skew (line 336) | def fetch_options_skew(ticker: str, current_price: float) -> dict:
  function _fetch_skew_yfinance (line 393) | def _fetch_skew_yfinance(ticker: str, current_price: float) -> Optional[...
  function _fetch_skew_tradier (line 448) | def _fetch_skew_tradier(ticker: str, current_price: float, api_key: str)...
  function _build_skew_result (line 522) | def _build_skew_result(
  function estimate_dealer_gamma (line 555) | def estimate_dealer_gamma(ticker: str, current_price: float) -> dict:
  function enrich_with_alpha_sources (line 682) | def enrich_with_alpha_sources(candidate: dict) -> dict:

FILE: modules/data_ingestion.py
  class DataIngestion (line 46) | class DataIngestion:
    method __init__ (line 48) | def __init__(self, history: dict | None = None):
    method _get_current_vix (line 52) | def _get_current_vix(self) -> float:
    method run (line 61) | def run(self) -> list[dict]:
    method _evaluate_ticker (line 98) | def _evaluate_ticker(
    method _log_filter_stats (line 211) | def _log_filter_stats(self, stats: dict) -> None:
    method _fetch_news (line 230) | def _fetch_news(self, ticker: str, info: dict) -> list[str]:
    method _fetch_finnhub_news (line 243) | def _fetch_finnhub_news(self, ticker: str, api_key: str) -> list[str]:
    method _fetch_newsapi (line 258) | def _fetch_newsapi(self, ticker: str, company_name: str) -> list[str]:
    method _fetch_yfinance_news (line 275) | def _fetch_yfinance_news(self, ticker: str) -> list[str]:

FILE: modules/data_validator.py
  function fetch_eps_sec_edgar (line 43) | def fetch_eps_sec_edgar(ticker: str) -> Optional[float]:
  function _get_cik (line 105) | def _get_cik(ticker: str) -> Optional[str]:
  function cross_check_eps_edgar (line 133) | def cross_check_eps_edgar(
  function _fetch_eps_alphavantage (line 213) | def _fetch_eps_alphavantage(ticker: str) -> Optional[float]:
  function compute_option_roi_with_vega (line 239) | def compute_option_roi_with_vega(option: dict, simulation: dict) -> dict:
  function _bs_delta_vega (line 333) | def _bs_delta_vega(S: float, K: float, sigma: float, T: float) -> tuple[...
  function _norm_cdf (line 359) | def _norm_cdf(x: float) -> float:
  function _norm_pdf (line 364) | def _norm_pdf(x: float) -> float:
  function _roi_empty (line 369) | def _roi_empty() -> dict:
  function validate_candidate_data (line 379) | def validate_candidate_data(candidate: dict) -> dict:

FILE: modules/deep_analysis.py
  function _format_market_cap (line 131) | def _format_market_cap(market_cap: Optional[int]) -> str:
  class DeepAnalysis (line 141) | class DeepAnalysis:
    method __init__ (line 143) | def __init__(self):
    method run (line 152) | def run(self, shortlist: list[dict]) -> list[dict]:
    method _analyze (line 223) | def _analyze(self, candidate: dict) -> Optional[dict]:
    method _get_48h_move (line 376) | def _get_48h_move(self, ticker: str) -> float:

FILE: modules/email_reporter.py
  function send_status_email (line 32) | def send_status_email(pipeline_stats: dict, today: str) -> None:
  function send_email (line 43) | def send_email(proposals: list[dict], today: str, pipeline_stats: dict |...
  function _build_status_email (line 57) | def _build_status_email(stats: dict, today: str) -> str:
  function _build_trade_email (line 99) | def _build_trade_email(proposals: list[dict], today: str) -> str:
  function _send_smtp (line 254) | def _send_smtp(subject: str, html: str) -> None:

FILE: modules/finbert_sentiment.py
  function _load_model (line 30) | def _load_model():
  function score_headlines (line 59) | def score_headlines(headlines: list[str]) -> dict:
  function _neutral_result (line 106) | def _neutral_result() -> dict:
  function score_candidate (line 114) | def score_candidate(candidate: dict) -> dict:

FILE: modules/intraday_delta.py
  function get_intraday_move (line 32) | def get_intraday_move(ticker: str) -> dict:
  function is_already_moved (line 76) | def is_already_moved(
  function filter_by_intraday_delta (line 123) | def filter_by_intraday_delta(
  function _no_data (line 155) | def _no_data() -> dict:

FILE: modules/macro_context.py
  function get_macro_context (line 43) | def get_macro_context() -> dict:
  function _fetch_macro_data (line 70) | def _fetch_macro_data() -> dict:
  function _fetch_fred_series (line 133) | def _fetch_fred_series(series_id: str, last_n: int = 1) -> Optional[float]:
  function _build_claude_context (line 174) | def _build_claude_context(
  function get_vix_term_structure (line 217) | def get_vix_term_structure() -> dict:
  function get_macro_regime_multiplier (line 312) | def get_macro_regime_multiplier() -> float:

FILE: modules/mirofish_simulation.py
  function _get_hist_params (line 70) | def _get_hist_params(ticker: str) -> tuple[float, float]:
  function preload_hist_params (line 107) | def preload_hist_params(tickers: list[str]) -> None:
  function _compute_dynamic_target (line 128) | def _compute_dynamic_target(current: float, sigma: float, days: int) -> ...
  class MirofishSimulation (line 150) | class MirofishSimulation:
    method __init__ (line 152) | def __init__(self):
    method run_for_dte (line 155) | def run_for_dte(
    method _get_market_params (line 283) | def _get_market_params(self, ticker: str) -> tuple[float, float, float]:
  function compute_time_value_efficiency (line 298) | def compute_time_value_efficiency(roi_net: float, dte: int) -> dict:

FILE: modules/mismatch_scorer.py
  function _bin_impact (line 29) | def _bin_impact(impact: float) -> str:
  function _bin_mismatch (line 37) | def _bin_mismatch(mismatch: float) -> str:
  function _bin_eps_drift (line 51) | def _bin_eps_drift(drift: float) -> str:
  class MismatchScorer (line 61) | class MismatchScorer:
    method run (line 63) | def run(self, analyses: list[dict]) -> list[dict]:
    method _score (line 71) | def _score(self, a: dict) -> dict | None:
    method _compute_sigma (line 134) | def _compute_sigma(self, ticker: str) -> float:
    method _compute_48h_move (line 146) | def _compute_48h_move(self, ticker: str) -> float:

FILE: modules/news_fetcher.py
  function fetch_company_news (line 53) | def fetch_company_news(
  function fetch_news_headlines (line 81) | def fetch_news_headlines(ticker: str, days_back: int = 2) -> list[str]:
  function get_news_with_timestamps (line 90) | def get_news_with_timestamps(ticker: str, days_back: int = 2) -> list[di...
  function compute_news_age_hours (line 100) | def compute_news_age_hours(news_items: list[dict]) -> Optional[float]:
  function _rate_limit (line 125) | def _rate_limit():
  function _fetch_finnhub (line 133) | def _fetch_finnhub(
  function _get_pattern (line 197) | def _get_pattern(ticker: str) -> Optional[re.Pattern]:
  function _fetch_rss_fallback (line 208) | def _fetch_rss_fallback(ticker: str, max_articles: int) -> list[dict]:

FILE: modules/options_designer.py
  function _classify_catalyst_type (line 110) | def _classify_catalyst_type(s: dict) -> str:
  function _tradier_headers (line 135) | def _tradier_headers() -> dict:
  function _tradier_expirations (line 143) | def _tradier_expirations(symbol: str) -> list[str]:
  function _tradier_chain (line 162) | def _tradier_chain(symbol: str, expiration: str) -> list[dict]:
  function _tradier_chain_to_df (line 185) | def _tradier_chain_to_df(options: list[dict], option_type: str) -> pd.Da...
  function _strike_window (line 222) | def _strike_window(current: float, dte_min: int, dte_max: int) -> tuple[...
  class OptionsDesigner (line 259) | class OptionsDesigner:
    method __init__ (line 261) | def __init__(self, gates):
    method run (line 271) | def run(self, signals: list[dict]) -> list[dict]:
    method _design_with_adaptive_dte (line 286) | def _design_with_adaptive_dte(self, s: dict, t=None) -> Optional[dict]:
    method _select_strategy (line 466) | def _select_strategy(
    method _compute_roi (line 519) | def _compute_roi(
    method _find_option_for_dte (line 648) | def _find_option_for_dte(
    method _find_option_tradier (line 661) | def _find_option_tradier(
    method _find_option_yfinance (line 763) | def _find_option_yfinance(
    method _find_spread_leg (line 837) | def _find_spread_leg(self, opts: pd.DataFrame, long_strike: float) -> ...
    method _get_atm_straddle (line 852) | def _get_atm_straddle(
    method _get_iv_rank (line 902) | def _get_iv_rank(self, ticker: str, t: Optional[object] = None) -> float:
    method _get_term_structure_iv (line 948) | def _get_term_structure_iv(
    method _term_structure_tradier (line 958) | def _term_structure_tradier(
    method _term_structure_yfinance (line 988) | def _term_structure_yfinance(
    method _sector_momentum_ok (line 1017) | def _sector_momentum_ok(self, s: dict, t=None) -> bool:
    method _bear_case_ok (line 1036) | def _bear_case_ok(self, s: dict) -> bool:
    method _days_to (line 1044) | def _days_to(self, expiry_str: str) -> int:

FILE: modules/premium_signals.py
  function fetch_flash_alpha (line 30) | def fetch_flash_alpha(ticker: str) -> dict:
  function _compute_dealer_score (line 125) | def _compute_dealer_score(
  function _flash_empty (line 156) | def _flash_empty(ticker: str) -> dict:
  function fetch_eulerpool_vol_surface (line 172) | def fetch_eulerpool_vol_surface(ticker: str) -> dict:
  function _assess_iv_crush_risk (line 246) | def _assess_iv_crush_risk(iv_percentile: float, iv_skew: float) -> str:
  function _compute_flow_bias (line 265) | def _compute_flow_bias(net_call_flow: float, net_put_flow: float) -> float:
  function _eulerpool_empty (line 277) | def _eulerpool_empty(ticker: str) -> dict:
  function enrich_top_candidates (line 295) | def enrich_top_candidates(signals: list[dict], top_n: int = 2) -> list[d...

FILE: modules/prescreener.py
  class Prescreener (line 74) | class Prescreener:
    method __init__ (line 76) | def __init__(self):
    method run (line 79) | def run(self, candidates: list[dict]) -> list[dict]:
    method _has_options_liquidity (line 151) | def _has_options_liquidity(self, ticker: str) -> bool:
    method _call_with_retry (line 164) | def _call_with_retry(self, batch: list[dict]) -> list | None:

FILE: modules/quasi_ml.py
  class QuasiML (line 12) | class QuasiML:
    method __init__ (line 13) | def __init__(self, history: dict):
    method run (line 20) | def run(self, simulated: list[dict]) -> list[dict]:
    method _compute_final_score (line 29) | def _compute_final_score(self, s: dict) -> float:
    method _get_bin_avg_return (line 44) | def _get_bin_avg_return(self, feature: str, bin_label: str) -> float:
    method _prior_return (line 53) | def _prior_return(self, feature: str, bin_label: str) -> float:
    method _fallback_score (line 61) | def _fallback_score(self, features: dict) -> float:

FILE: modules/reddit_signals.py
  function fetch_ticker_mentions (line 70) | def fetch_ticker_mentions(
  function _fetch_subreddit_posts (line 118) | def _fetch_subreddit_posts(
  function _score_post (line 170) | def _score_post(post: dict) -> dict:
  function _compute_sentiment (line 197) | def _compute_sentiment(scored_posts: list[dict]) -> float:
  function _compute_options_intent (line 221) | def _compute_options_intent(scored_posts: list[dict]) -> float:
  function _empty_result (line 233) | def _empty_result() -> dict:
  function enrich_candidate (line 242) | def enrich_candidate(candidate: dict) -> dict:

FILE: modules/reporter.py
  function compute_exit_rules (line 21) | def compute_exit_rules(proposal: dict) -> dict:
  function _empty_exit_rules (line 91) | def _empty_exit_rules() -> dict:
  class Reporter (line 101) | class Reporter:
    method __init__ (line 102) | def __init__(self, reports_dir: Path):
    method save (line 106) | def save(self, today: str, proposals: list[dict], history: dict) -> None:
    method _save_json (line 115) | def _save_json(self, today: str, proposals: list[dict]) -> None:
    method _save_markdown (line 124) | def _save_markdown(self, today: str, proposals: list[dict], history: d...

FILE: modules/risk_gates.py
  class RiskGates (line 31) | class RiskGates:
    method __init__ (line 33) | def __init__(self):
    method global_ok (line 36) | def global_ok(self) -> bool:
    method _fetch_vix (line 63) | def _fetch_vix(self) -> Optional[float]:
    method has_upcoming_earnings (line 104) | def has_upcoming_earnings(self, ticker: str) -> bool:

FILE: modules/rl_agent.py
  function train_agent (line 46) | def train_agent(
  function _create_new_model (line 104) | def _create_new_model(env: OptionsRLEnv):
  class RLScorer (line 128) | class RLScorer:
    method __init__ (line 136) | def __init__(self, history: dict):
    method _load_model (line 141) | def _load_model(self) -> None:
    method run (line 157) | def run(self, simulated: list[dict]) -> list[dict]:
    method _compute_raw_score (line 214) | def _compute_raw_score(self, s: dict) -> float:
    method _quasi_ml_fallback (line 247) | def _quasi_ml_fallback(self, simulated: list[dict]) -> list[dict]:

FILE: modules/rl_environment.py
  function features_to_obs (line 63) | def features_to_obs(
  class OptionsRLEnv (line 103) | class OptionsRLEnv(gym.Env):
    method __init__ (line 113) | def __init__(self, trade_data: list[dict]):
    method reset (line 131) | def reset(self, seed=None, options=None):
    method step (line 137) | def step(self, action: int):
    method _get_obs (line 170) | def _get_obs(self) -> np.ndarray:
    method render (line 183) | def render(self):
  function build_env_from_history (line 187) | def build_env_from_history(history: dict) -> Optional[OptionsRLEnv]:

FILE: modules/sentiment_tracker.py
  function update_sentiment_history (line 41) | def update_sentiment_history(
  function get_sentiment_drift (line 90) | def get_sentiment_drift(
  function enrich_with_sentiment_drift (line 177) | def enrich_with_sentiment_drift(
  function get_accumulation_candidates (line 226) | def get_accumulation_candidates(history: dict) -> list[str]:

FILE: modules/trade_scorer.py
  function compute_trade_score (line 62) | def compute_trade_score(proposal: dict) -> dict:
  function rank_proposals (line 311) | def rank_proposals(proposals: list[dict]) -> list[dict]:

FILE: modules/universe.py
  function _fetch_sp500 (line 113) | def _fetch_sp500() -> list[str]:
  function _fetch_nasdaq100 (line 133) | def _fetch_nasdaq100() -> list[str]:
  function _clean (line 157) | def _clean(tickers: list[str]) -> list[str]:
  function get_universe (line 178) | def get_universe(universe: str = "") -> list[str]:

FILE: pipeline.py
  function get_mc_threshold (line 89) | def get_mc_threshold(vix) -> float:
  function reject (line 109) | def reject(reason: str, ticker: str | None = None) -> None:
  function validate_strict (line 122) | def validate_strict(c: dict):
  function validate_for_simulation (line 141) | def validate_for_simulation(c: dict):
  function validate_mc_result (line 153) | def validate_mc_result(result: dict):
  function filter_correlated_proposals (line 166) | def filter_correlated_proposals(
  function load_history (line 223) | def load_history() -> dict:
  function save_history (line 240) | def save_history(history: dict) -> None:
  function main (line 246) | def main() -> None:

FILE: tests/test_pipeline.py
  function empty_history (line 20) | def empty_history():
  function sample_candidate (line 34) | def sample_candidate():
  function sample_analysis (line 62) | def sample_analysis(sample_candidate):
  class TestMismatchScorer (line 82) | class TestMismatchScorer:
    method test_high_impact_low_move_gives_high_mismatch (line 83) | def test_high_impact_low_move_gives_high_mismatch(self):
    method test_low_impact_high_move_gives_negative_mismatch_filtered (line 109) | def test_low_impact_high_move_gives_negative_mismatch_filtered(self):
    method test_moderate_impact_moderate_move (line 131) | def test_moderate_impact_moderate_move(self):
    method test_high_impact_tiny_move_passes (line 153) | def test_high_impact_tiny_move_passes(self):
    method test_zero_sigma_returns_none (line 178) | def test_zero_sigma_returns_none(self):
    method test_missing_data_validation_defaults_to_zero (line 195) | def test_missing_data_validation_defaults_to_zero(self):
  class TestQuasiML (line 218) | class TestQuasiML:
    method test_fallback_scoring_without_history (line 219) | def test_fallback_scoring_without_history(self, empty_history):
    method test_signals_sorted_by_score (line 237) | def test_signals_sorted_by_score(self, empty_history):
  class TestRiskGates (line 250) | class TestRiskGates:
    method test_vix_below_threshold_passes (line 251) | def test_vix_below_threshold_passes(self):
    method test_vix_above_threshold_blocks (line 257) | def test_vix_above_threshold_blocks(self):
  class TestMirofishSimulation (line 266) | class TestMirofishSimulation:
    method test_strong_signal_passes_gate (line 267) | def test_strong_signal_passes_gate(self):
    method test_zero_price_returns_none (line 286) | def test_zero_price_returns_none(self):
  class TestFeedbackLoop (line 302) | class TestFeedbackLoop:
    method test_bin_update_running_average (line 303) | def test_bin_update_running_average(self):
    method test_bin_to_num_mapping (line 311) | def test_bin_to_num_mapping(self):
Condensed preview — 95 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,764K chars).
[
  {
    "path": ".github/workflows/scanner.yml",
    "chars": 2907,
    "preview": "name: Adaptive Asymmetry Scanner\n\non:\n  schedule:\n    - cron: '30 13 * * 1-5'  # 13:30 UTC = 15:30 MEZ (entspricht US-Ma"
  },
  {
    "path": ".gitignore",
    "chars": 229,
    "preview": ".env\n__pycache__/\n*.pyc\n.pytest_cache/\n.venv/\n*.egg-info/\n\n# FinBERT-Modell-Cache (417 MB – nicht in Git)\noutputs/models"
  },
  {
    "path": "README.md",
    "chars": 5109,
    "preview": "# Adaptive Asymmetry-Scanner v3.5\n\nEin vollautomatischer News-to-Options-Scanner, der täglich Informations-Asymmetrien i"
  },
  {
    "path": "config.yaml",
    "chars": 3030,
    "preview": "# Adaptive Asymmetry-Scanner – Konfiguration v5.0\n\npipeline:\n  confidence_gate: 0.70\n  n_simulation_paths: 10000\n  simul"
  },
  {
    "path": "feedback.py",
    "chars": 16885,
    "preview": "\"\"\"\nfeedback.py – Adaptive Lern-Loop v5.0\n\nÄnderungen v5.0:\n    - Tradier Live-API als primäre Datenquelle für Optionspr"
  },
  {
    "path": "modules/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "modules/alpha_sources.py",
    "chars": 26119,
    "preview": "\"\"\"\nmodules/alpha_sources.py – Alternative Alpha-Quellen v9.0\n\nÄnderungen v9.0:\n    #15 Put/Call-Skew + Dealer-Gamma-Sch"
  },
  {
    "path": "modules/config.py",
    "chars": 361,
    "preview": "import yaml\nimport os\nfrom box import ConfigBox\n\n# Pfad zur config.yaml (eine Ebene höher als dieser Ordner)\nconfig_path"
  },
  {
    "path": "modules/data_ingestion.py",
    "chars": 11761,
    "preview": "\"\"\"\nmodules/data_ingestion.py v7.1\n\nv7.1: Short Interest als Feature\n    yfinance liefert shortPercentOfFloat bereits im"
  },
  {
    "path": "modules/data_validator.py",
    "chars": 12466,
    "preview": "\"\"\"\nmodules/data_validator.py v6.0\n\nFix 5: SEC EDGAR XBRL für EPS (statt veraltetem yfinance forwardEps)\n    EDGAR liefe"
  },
  {
    "path": "modules/deep_analysis.py",
    "chars": 16674,
    "preview": "\"\"\"\nmodules/deep_analysis.py v9.0\n\nÄnderungen v9.0:\n    #10 catalyst_confidence (0-10) als neues JSON-Feld.\n        Sepa"
  },
  {
    "path": "modules/email_reporter.py",
    "chars": 13895,
    "preview": "\"\"\"\nmodules/email_reporter.py v9.0\n\nÄnderungen v9.0:\n    #5  Greeks-Block im Trade-Card:\n        Delta (P(ITM)-Approxima"
  },
  {
    "path": "modules/finbert_sentiment.py",
    "chars": 3228,
    "preview": "\"\"\"\nmodules/finbert_sentiment.py – FinBERT-Sentiment als Feature-Spalte\n\nFIX: Cache-Verzeichnis auf /tmp statt outputs/m"
  },
  {
    "path": "modules/intraday_delta.py",
    "chars": 4634,
    "preview": "\"\"\"\nmodules/intraday_delta.py – Intraday-Delta seit News-Veröffentlichung\n\nPriorität 1: Verhindert \"Late-to-the-party\"-T"
  },
  {
    "path": "modules/macro_context.py",
    "chars": 10908,
    "preview": "\"\"\"\nmodules/macro_context.py – Makro-Kontext für Claude-Prompt\n\nFix 3: ISM Einkaufsmanagerindex + 10Y-2Y Yield Curve via"
  },
  {
    "path": "modules/mirofish_simulation.py",
    "chars": 11915,
    "preview": "\"\"\"\nmodules/mirofish_simulation.py v8.2\n\nMonte Carlo Simulation mit historischer yfinance-Kalibrierung.\n\nKernidee:\n    s"
  },
  {
    "path": "modules/mismatch_scorer.py",
    "chars": 6093,
    "preview": "\"\"\"\nStufe 5: Normalisierter Mismatch-Score (Quant-Validierung)\n\nFixes:\n  H-03: Negative Mismatch-Werte (Markt hat überre"
  },
  {
    "path": "modules/news_fetcher.py",
    "chars": 7266,
    "preview": "\"\"\"\nmodules/news_fetcher.py – Ticker-spezifische News via Finnhub\n\nFix 4: Ersetzt globale RSS-Feeds durch ticker-spezifi"
  },
  {
    "path": "modules/options_designer.py",
    "chars": 43269,
    "preview": "\"\"\"\nmodules/options_designer.py v9.0\n\nÄnderungen v9.0:\n    #1  DTE-Architektur: Erste passende Tier-Logik ersetzt durch "
  },
  {
    "path": "modules/premium_signals.py",
    "chars": 11799,
    "preview": "\"\"\"\nmodules/premium_signals.py – FLASH Alpha & Eulerpool (nur Top-Kandidaten)\n\nPriorität 5: Dealer Positioning, GEX, Vol"
  },
  {
    "path": "modules/prescreener.py",
    "chars": 8362,
    "preview": "\"\"\"\nmodules/prescreener.py v7.0 – Verschärfter Haiku-Prefilter\n\nProblem vorher: Haiku liess 17+ Ticker durch → Deep Anal"
  },
  {
    "path": "modules/quasi_ml.py",
    "chars": 2703,
    "preview": "\"\"\"\nStufe 6: Adaptive Quasi-ML Scoring (Selbstlern-Kern)\n- Unverändert vom Original (keine Bugs gefunden)\n- cfg: MIN_BIN"
  },
  {
    "path": "modules/reddit_signals.py",
    "chars": 8063,
    "preview": "\"\"\"\nmodules/reddit_signals.py – Reddit als zweite News-Quelle\n\nInspiriert von: Mattbusel/Reddit-Options-Trader-ROT\nQuell"
  },
  {
    "path": "modules/reporter.py",
    "chars": 9964,
    "preview": "\"\"\"\nReporter: Speichert tägliche Trade-Vorschläge als JSON + Markdown\n- outputs/daily_reports/YYYY-MM-DD.json\n- outputs/"
  },
  {
    "path": "modules/risk_gates.py",
    "chars": 4643,
    "preview": "\"\"\"\nmodules/risk_gates.py – Fixes\n\nFix 1: VIX Timeout → Fallback statt Pipeline-Abbruch\n       Begründung: Bei yfinance "
  },
  {
    "path": "modules/rl_agent.py",
    "chars": 8537,
    "preview": "\"\"\"\nmodules/rl_agent.py – PPO-Agent (Stable-Baselines3) für Options-Scoring\n\nErsetzt QuasiML komplett in Stufe 6 der Pip"
  },
  {
    "path": "modules/rl_environment.py",
    "chars": 7191,
    "preview": "\"\"\"\nmodules/rl_environment.py – Gymnasium-Environment für Options-Scoring v9.0\n\nÄnderungen v9.0:\n    #11 Observation Spa"
  },
  {
    "path": "modules/sentiment_tracker.py",
    "chars": 7857,
    "preview": "\"\"\"\nmodules/sentiment_tracker.py – Sentiment-Drift über Zeit\n\nFix 4: Speichert täglich FinBERT-Scores pro Ticker in hist"
  },
  {
    "path": "modules/trade_scorer.py",
    "chars": 11975,
    "preview": "\"\"\"\nmodules/trade_scorer.py  —  Adaptive Asymmetry-Scanner v9.0\n\nScore-System (0-100 Punkte total):\n\nA. SIGNAL-QUALITÄT "
  },
  {
    "path": "modules/universe.py",
    "chars": 9122,
    "preview": "\"\"\"\nmodules/universe.py – Dynamisches Ticker-Universum\n\nFix: Veraltete/delistete Ticker aus _SP500_STATIC entfernt.\n    "
  },
  {
    "path": "outputs/daily_reports/2026-04-11.json",
    "chars": 2211,
    "preview": "{\n  \"date\": \"2026-04-11\",\n  \"proposals\": [\n    {\n      \"ticker\": \"LLY\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\": "
  },
  {
    "path": "outputs/daily_reports/2026-04-11.md",
    "chars": 1814,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-04-11\n\n**Generiert:** 2026-04-11 06:56 UTC\n**Trade-Vorschläge:** 1\n\n---\n## 1. LLY – "
  },
  {
    "path": "outputs/daily_reports/2026-04-12.json",
    "chars": 2288,
    "preview": "{\n  \"date\": \"2026-04-12\",\n  \"proposals\": [\n    {\n      \"ticker\": \"ON\",\n      \"strategy\": \"BULL_CALL_SPREAD\",\n      \"iv_r"
  },
  {
    "path": "outputs/daily_reports/2026-04-12.md",
    "chars": 1860,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-04-12\n\n**Generiert:** 2026-04-12 11:57 UTC\n**Trade-Vorschläge:** 1\n\n---\n## 1. ON – B"
  },
  {
    "path": "outputs/daily_reports/2026-04-13.json",
    "chars": 45,
    "preview": "{\n  \"date\": \"2026-04-13\",\n  \"proposals\": []\n}"
  },
  {
    "path": "outputs/daily_reports/2026-04-13.md",
    "chars": 304,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-04-13\n\n**Generiert:** 2026-04-13 18:31 UTC\n**Trade-Vorschläge:** 0\n\n_Kein Signal heu"
  },
  {
    "path": "outputs/daily_reports/2026-04-14.json",
    "chars": 45,
    "preview": "{\n  \"date\": \"2026-04-14\",\n  \"proposals\": []\n}"
  },
  {
    "path": "outputs/daily_reports/2026-04-14.md",
    "chars": 304,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-04-14\n\n**Generiert:** 2026-04-14 19:13 UTC\n**Trade-Vorschläge:** 0\n\n_Kein Signal heu"
  },
  {
    "path": "outputs/daily_reports/2026-04-15.json",
    "chars": 37314,
    "preview": "{\n  \"date\": \"2026-04-15\",\n  \"proposals\": [\n    {\n      \"ticker\": \"AVGO\",\n      \"strategy\": \"BULL_CALL_SPREAD\",\n      \"iv"
  },
  {
    "path": "outputs/daily_reports/2026-04-15.md",
    "chars": 6109,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-04-15\n\n**Generiert:** 2026-04-15 20:55 UTC\n**Trade-Vorschläge:** 5\n\n---\n## 1. AVGO –"
  },
  {
    "path": "outputs/daily_reports/2026-04-16.json",
    "chars": 7404,
    "preview": "{\n  \"date\": \"2026-04-16\",\n  \"proposals\": [\n    {\n      \"ticker\": \"ORCL\",\n      \"strategy\": \"BULL_CALL_SPREAD\",\n      \"iv"
  },
  {
    "path": "outputs/daily_reports/2026-04-16.md",
    "chars": 1445,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-04-16\n\n**Generiert:** 2026-04-16 18:53 UTC\n**Trade-Vorschläge:** 1\n\n---\n## 1. ORCL –"
  },
  {
    "path": "outputs/daily_reports/2026-04-17.json",
    "chars": 7015,
    "preview": "{\n  \"date\": \"2026-04-17\",\n  \"proposals\": [\n    {\n      \"ticker\": \"SCHW\",\n      \"strategy\": \"BULL_CALL_SPREAD\",\n      \"iv"
  },
  {
    "path": "outputs/daily_reports/2026-04-17.md",
    "chars": 1426,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-04-17\n\n**Generiert:** 2026-04-17 17:42 UTC\n**Trade-Vorschläge:** 1\n\n---\n## 1. SCHW –"
  },
  {
    "path": "outputs/daily_reports/2026-04-20.json",
    "chars": 16734,
    "preview": "{\n  \"date\": \"2026-04-20\",\n  \"proposals\": [\n    {\n      \"ticker\": \"HOOD\",\n      \"strategy\": \"BULL_CALL_SPREAD\",\n      \"iv"
  },
  {
    "path": "outputs/daily_reports/2026-04-20.md",
    "chars": 2679,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-04-20\n\n**Generiert:** 2026-04-20 19:07 UTC\n**Trade-Vorschläge:** 2\n\n---\n## 1. HOOD –"
  },
  {
    "path": "outputs/daily_reports/2026-04-21.json",
    "chars": 8850,
    "preview": "{\n  \"date\": \"2026-04-21\",\n  \"proposals\": [\n    {\n      \"ticker\": \"AEP\",\n      \"strategy\": \"BULL_CALL_SPREAD\",\n      \"iv_"
  },
  {
    "path": "outputs/daily_reports/2026-04-21.md",
    "chars": 1436,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-04-21\n\n**Generiert:** 2026-04-21 18:33 UTC\n**Trade-Vorschläge:** 1\n\n---\n## 1. AEP – "
  },
  {
    "path": "outputs/daily_reports/2026-04-22.json",
    "chars": 26180,
    "preview": "{\n  \"date\": \"2026-04-22\",\n  \"proposals\": [\n    {\n      \"ticker\": \"APA\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\": "
  },
  {
    "path": "outputs/daily_reports/2026-04-22.md",
    "chars": 3568,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-04-22\n\n**Generiert:** 2026-04-22 19:35 UTC\n**Trade-Vorschläge:** 3\n\n---\n## 1. APA – "
  },
  {
    "path": "outputs/daily_reports/2026-04-23.json",
    "chars": 36467,
    "preview": "{\n  \"date\": \"2026-04-23\",\n  \"proposals\": [\n    {\n      \"ticker\": \"BX\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\": 6"
  },
  {
    "path": "outputs/daily_reports/2026-04-23.md",
    "chars": 5460,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-04-23\n\n**Generiert:** 2026-04-23 18:47 UTC\n**Trade-Vorschläge:** 5\n\n---\n## 1. BX – L"
  },
  {
    "path": "outputs/daily_reports/2026-04-24.json",
    "chars": 38360,
    "preview": "{\n  \"date\": \"2026-04-24\",\n  \"proposals\": [\n    {\n      \"ticker\": \"AMAT\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\":"
  },
  {
    "path": "outputs/daily_reports/2026-04-24.md",
    "chars": 5505,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-04-24\n\n**Generiert:** 2026-04-24 17:14 UTC\n**Trade-Vorschläge:** 5\n\n---\n## 1. AMAT –"
  },
  {
    "path": "outputs/daily_reports/2026-04-27.json",
    "chars": 14941,
    "preview": "{\n  \"date\": \"2026-04-27\",\n  \"proposals\": [\n    {\n      \"ticker\": \"BKR\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\": "
  },
  {
    "path": "outputs/daily_reports/2026-04-27.md",
    "chars": 2393,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-04-27\n\n**Generiert:** 2026-04-27 15:26 UTC\n**Trade-Vorschläge:** 2\n\n---\n## 1. BKR – "
  },
  {
    "path": "outputs/daily_reports/2026-04-28.json",
    "chars": 16099,
    "preview": "{\n  \"date\": \"2026-04-28\",\n  \"proposals\": [\n    {\n      \"ticker\": \"AMAT\",\n      \"strategy\": \"LONG_PUT\",\n      \"iv_rank\": "
  },
  {
    "path": "outputs/daily_reports/2026-04-28.md",
    "chars": 4116,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-04-28\n\n**Generiert:** 2026-04-28 19:32 UTC\n**Trade-Vorschläge:** 2\n\n---\n## 1. AMAT –"
  },
  {
    "path": "outputs/daily_reports/2026-04-29.json",
    "chars": 23736,
    "preview": "{\n  \"date\": \"2026-04-29\",\n  \"proposals\": [\n    {\n      \"ticker\": \"BKR\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\": "
  },
  {
    "path": "outputs/daily_reports/2026-04-29.md",
    "chars": 5883,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-04-29\n\n**Generiert:** 2026-04-29 17:48 UTC\n**Trade-Vorschläge:** 3\n\n---\n## 1. BKR – "
  },
  {
    "path": "outputs/daily_reports/2026-04-30.json",
    "chars": 15707,
    "preview": "{\n  \"date\": \"2026-04-30\",\n  \"proposals\": [\n    {\n      \"ticker\": \"AMZN\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\":"
  },
  {
    "path": "outputs/daily_reports/2026-04-30.md",
    "chars": 3947,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-04-30\n\n**Generiert:** 2026-04-30 15:32 UTC\n**Trade-Vorschläge:** 2\n\n---\n## 1. AMZN –"
  },
  {
    "path": "outputs/daily_reports/2026-05-01.json",
    "chars": 45,
    "preview": "{\n  \"date\": \"2026-05-01\",\n  \"proposals\": []\n}"
  },
  {
    "path": "outputs/daily_reports/2026-05-01.md",
    "chars": 304,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-05-01\n\n**Generiert:** 2026-05-01 14:38 UTC\n**Trade-Vorschläge:** 0\n\n_Kein Signal heu"
  },
  {
    "path": "outputs/daily_reports/2026-05-04.json",
    "chars": 7752,
    "preview": "{\n  \"date\": \"2026-05-04\",\n  \"proposals\": [\n    {\n      \"ticker\": \"AMAT\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\":"
  },
  {
    "path": "outputs/daily_reports/2026-05-04.md",
    "chars": 2059,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-05-04\n\n**Generiert:** 2026-05-04 15:32 UTC\n**Trade-Vorschläge:** 1\n\n---\n## 1. AMAT –"
  },
  {
    "path": "outputs/daily_reports/2026-05-05.json",
    "chars": 7813,
    "preview": "{\n  \"date\": \"2026-05-05\",\n  \"proposals\": [\n    {\n      \"ticker\": \"AMD\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\": "
  },
  {
    "path": "outputs/daily_reports/2026-05-05.md",
    "chars": 2090,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-05-05\n\n**Generiert:** 2026-05-05 18:17 UTC\n**Trade-Vorschläge:** 1\n\n---\n## 1. AMD – "
  },
  {
    "path": "outputs/daily_reports/2026-05-06.json",
    "chars": 15539,
    "preview": "{\n  \"date\": \"2026-05-06\",\n  \"proposals\": [\n    {\n      \"ticker\": \"GOOG\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\":"
  },
  {
    "path": "outputs/daily_reports/2026-05-06.md",
    "chars": 3913,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-05-06\n\n**Generiert:** 2026-05-06 15:48 UTC\n**Trade-Vorschläge:** 2\n\n---\n## 1. GOOG –"
  },
  {
    "path": "outputs/daily_reports/2026-05-07.json",
    "chars": 8342,
    "preview": "{\n  \"date\": \"2026-05-07\",\n  \"proposals\": [\n    {\n      \"ticker\": \"ALB\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\": "
  },
  {
    "path": "outputs/daily_reports/2026-05-07.md",
    "chars": 2082,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-05-07\n\n**Generiert:** 2026-05-07 15:51 UTC\n**Trade-Vorschläge:** 1\n\n---\n## 1. ALB – "
  },
  {
    "path": "outputs/daily_reports/2026-05-08.json",
    "chars": 7642,
    "preview": "{\n  \"date\": \"2026-05-08\",\n  \"proposals\": [\n    {\n      \"ticker\": \"ABNB\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\":"
  },
  {
    "path": "outputs/daily_reports/2026-05-08.md",
    "chars": 2094,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-05-08\n\n**Generiert:** 2026-05-08 15:11 UTC\n**Trade-Vorschläge:** 1\n\n---\n## 1. ABNB –"
  },
  {
    "path": "outputs/daily_reports/2026-05-11.json",
    "chars": 7682,
    "preview": "{\n  \"date\": \"2026-05-11\",\n  \"proposals\": [\n    {\n      \"ticker\": \"AVGO\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\":"
  },
  {
    "path": "outputs/daily_reports/2026-05-11.md",
    "chars": 2060,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-05-11\n\n**Generiert:** 2026-05-11 16:19 UTC\n**Trade-Vorschläge:** 1\n\n---\n## 1. AVGO –"
  },
  {
    "path": "outputs/daily_reports/2026-05-12.json",
    "chars": 25307,
    "preview": "{\n  \"date\": \"2026-05-12\",\n  \"proposals\": [\n    {\n      \"ticker\": \"GOOG\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\":"
  },
  {
    "path": "outputs/daily_reports/2026-05-12.md",
    "chars": 6115,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-05-12\n\n**Generiert:** 2026-05-12 17:13 UTC\n**Trade-Vorschläge:** 3\n\n---\n## 1. GOOG –"
  },
  {
    "path": "outputs/daily_reports/2026-05-13.json",
    "chars": 7811,
    "preview": "{\n  \"date\": \"2026-05-13\",\n  \"proposals\": [\n    {\n      \"ticker\": \"META\",\n      \"strategy\": \"LONG_PUT\",\n      \"iv_rank\": "
  },
  {
    "path": "outputs/daily_reports/2026-05-13.md",
    "chars": 2121,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-05-13\n\n**Generiert:** 2026-05-13 16:11 UTC\n**Trade-Vorschläge:** 1\n\n---\n## 1. META –"
  },
  {
    "path": "outputs/daily_reports/2026-05-14.json",
    "chars": 24282,
    "preview": "{\n  \"date\": \"2026-05-14\",\n  \"proposals\": [\n    {\n      \"ticker\": \"CDNS\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\":"
  },
  {
    "path": "outputs/daily_reports/2026-05-14.md",
    "chars": 5851,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-05-14\n\n**Generiert:** 2026-05-14 07:57 UTC\n**Trade-Vorschläge:** 3\n\n---\n## 1. CDNS –"
  },
  {
    "path": "outputs/daily_reports/2026-05-17.json",
    "chars": 39599,
    "preview": "{\n  \"date\": \"2026-05-17\",\n  \"proposals\": [\n    {\n      \"ticker\": \"BKR\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\": "
  },
  {
    "path": "outputs/daily_reports/2026-05-17.md",
    "chars": 9811,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-05-17\n\n**Generiert:** 2026-05-17 06:57 UTC\n**Trade-Vorschläge:** 5\n\n---\n## 1. BKR – "
  },
  {
    "path": "outputs/daily_reports/2026-05-18.json",
    "chars": 31679,
    "preview": "{\n  \"date\": \"2026-05-18\",\n  \"proposals\": [\n    {\n      \"ticker\": \"GOOG\",\n      \"strategy\": \"LONG_CALL\",\n      \"iv_rank\":"
  },
  {
    "path": "outputs/daily_reports/2026-05-18.md",
    "chars": 7761,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-05-18\n\n**Generiert:** 2026-05-18 16:49 UTC\n**Trade-Vorschläge:** 4\n\n---\n## 1. GOOG –"
  },
  {
    "path": "outputs/daily_reports/2026-05-19.json",
    "chars": 9939,
    "preview": "{\n  \"date\": \"2026-05-19\",\n  \"proposals\": [\n    {\n      \"ticker\": \"GOOGL\",\n      \"strategy\": \"BULL_CALL_SPREAD\",\n      \"i"
  },
  {
    "path": "outputs/daily_reports/2026-05-19.md",
    "chars": 2259,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-05-19\n\n**Generiert:** 2026-05-19 19:10 UTC\n**Trade-Vorschläge:** 1\n\n---\n## 1. GOOGL "
  },
  {
    "path": "outputs/daily_reports/2026-05-20.json",
    "chars": 47449,
    "preview": "{\n  \"date\": \"2026-05-20\",\n  \"proposals\": [\n    {\n      \"ticker\": \"DDOG\",\n      \"strategy\": \"BULL_CALL_SPREAD\",\n      \"iv"
  },
  {
    "path": "outputs/daily_reports/2026-05-20.md",
    "chars": 10577,
    "preview": "# Adaptive Asymmetry-Scanner – 2026-05-20\n\n**Generiert:** 2026-05-20 16:55 UTC\n**Trade-Vorschläge:** 5\n\n---\n## 1. DDOG –"
  },
  {
    "path": "outputs/history.json",
    "chars": 705459,
    "preview": "{\n  \"feature_stats\": {\n    \"impact\": {\n      \"low\": {\n        \"count\": 377,\n        \"avg_return\": 0.598737\n      },\n    "
  },
  {
    "path": "outputs/models/.gitkeep",
    "chars": 1,
    "preview": "\n"
  },
  {
    "path": "pipeline.py",
    "chars": 26817,
    "preview": "\"\"\"\npipeline.py v8.3 – Early Sector-Momentum-Check\n\nv8.3 Änderungen:\n  - NEU Stufe 2c: Sector-Momentum-Check VOR ROI Pre"
  },
  {
    "path": "requirements.txt",
    "chars": 373,
    "preview": "anthropic>=0.28.0\nyfinance>=0.2.40\npandas>=2.2.0\nnumpy>=1.26.0\nscikit-learn>=1.4.0\nscipy>=1.13.0\nrequests>=2.31.0\nfeedpa"
  },
  {
    "path": "tests/test_pipeline.py",
    "chars": 12124,
    "preview": "\"\"\"\nTests für den Adaptive Asymmetry-Scanner\nAusführen: pytest tests/ -v\n\nv8.2: Mismatch-Scorer-Tests aktualisiert:\n  - "
  }
]

About this extraction

This page contains the full source code of the pcctradinginc-alt/Adaptive-Asymmetry-Scanner GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 95 files (1.6 MB), approximately 497.8k tokens, and a symbol index with 219 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!