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