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} {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} · VIX {vix_str} · 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 · {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 & 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}% |
<b>Model-Target:</b> +{model_move:.1f}% |
<b>Edge:</b> <span style="color:{edge_color};font-weight:bold;">{edge_sign}</span>
<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 & 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 > 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 < {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} · {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} · 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
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
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.