[
  {
    "path": ".github/workflows/backtest.yml",
    "content": "name: Backtest\n\non:\n  schedule:\n    - cron: '0 2 * * 0'\n  workflow_dispatch:\n\njobs:\n  backtest:\n    runs-on: ubuntu-latest\n    timeout-minutes: 30\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: '3.11'\n          cache: 'pip'\n      - name: Install deps\n        run: pip install -r requirements.txt\n      - name: Run Backtest\n        env:\n          GMAIL_USER: ${{ secrets.GMAIL_USER }}\n          GMAIL_PASSWORD: ${{ secrets.GMAIL_PASSWORD }}\n          RECIPIENT_EMAIL: ${{ secrets.RECIPIENT_EMAIL }}\n        run: python scripts/backtest.py\n"
  },
  {
    "path": ".github/workflows/daily_light.yml",
    "content": "name: Daily Light Scan\n\non:\n  schedule:\n    - cron: '30 14 * * 1-5'\n    - cron: '30 21 * * 1-5'\n  workflow_dispatch:\n\njobs:\n  scan:\n    runs-on: ubuntu-latest\n    timeout-minutes: 15\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-python@v5\n        with:\n          python-version: '3.11'\n          cache: 'pip'\n\n      - name: Install deps\n        run: pip install -r requirements.txt\n\n      - name: Restore DB cache\n        uses: actions/cache@v4\n        with:\n          path: data/scanner.db\n          # [LÖSUNG 1] Key auf v2 geändert, um die alte inkompatible DB zu verwerfen\n          key: scanner-db-v2-${{ github.run_id }}\n          restore-keys: |\n            scanner-db-v2-\n\n      - name: Run Daily Light\n        env:\n          GMAIL_USER: ${{ secrets.GMAIL_USER }}\n          GMAIL_PASSWORD: ${{ secrets.GMAIL_PASSWORD }}\n          RECIPIENT_EMAIL: ${{ secrets.RECIPIENT_EMAIL }}\n          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n          TRADIER_API_KEY: ${{ secrets.TRADIER_API_KEY }}\n          TRADIER_ACCOUNT_ID: ${{ secrets.TRADIER_ACCOUNT_ID }}\n          \n          # SEC Compliance\n          EDGAR_USER_AGENT: ${{ secrets.EDGAR_USER_AGENT }}\n          \n          # Ticker-Cache Pfad\n          TICKER_DB_PATH: \"data/scanner.db\"\n          \n        run: |\n          mkdir -p data\n          python scripts/daily_scan.py --run-mode daily_light\n\n      - name: Save DB cache\n        if: always()\n        uses: actions/cache/save@v4\n        with:\n          path: data/scanner.db\n          key: scanner-db-v2-${{ github.run_id }}\n"
  },
  {
    "path": ".github/workflows/keepalive.yml",
    "content": "name: Keepalive\n\non:\n  schedule:\n    - cron: '0 0 1 */2 *'\n  workflow_dispatch:\n\njobs:\n  keepalive:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - run: echo \"Repo aktiv. Datum $(date)\"\n"
  },
  {
    "path": ".github/workflows/main.yml",
    "content": "name: Smart Money Scanner (Manual Run)\n\non:\n  workflow_dispatch:\n    inputs:\n      run_mode:\n        description: 'Welcher Modus soll laufen?'\n        required: true\n        default: 'daily_light'\n        type: choice\n        options:\n          - daily_light\n          - weekly_full\n          - thirteenf\n          - weekly_review\n\njobs:\n  scan:\n    runs-on: ubuntu-latest\n    timeout-minutes: 45\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-python@v5\n        with:\n          python-version: '3.11'\n          cache: 'pip'\n\n      - name: Install deps\n        run: pip install -r requirements.txt\n\n      - name: Restore DB cache\n        uses: actions/cache@v4\n        with:\n          path: data/scanner.db\n          key: scanner-db-${{ github.run_number }}\n          restore-keys: |\n            scanner-db-\n\n      - name: Run Scanner (${{ github.event.inputs.run_mode }})\n        env:\n          GMAIL_USER: ${{ secrets.GMAIL_USER }}\n          GMAIL_PASSWORD: ${{ secrets.GMAIL_PASSWORD }}\n          RECIPIENT_EMAIL: ${{ secrets.RECIPIENT_EMAIL }}\n          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n          TRADIER_API_KEY: ${{ secrets.TRADIER_API_KEY }}\n          TRADIER_ACCOUNT_ID: ${{ secrets.TRADIER_ACCOUNT_ID }}\n        run: python scripts/daily_scan.py --run-mode ${{ github.event.inputs.run_mode }}\n\n      - name: Save DB cache\n        if: always()\n        uses: actions/cache/save@v4\n        with:\n          path: data/scanner.db\n          key: scanner-db-${{ github.run_number }}\n"
  },
  {
    "path": ".github/workflows/thirteenf_dedicated.yml",
    "content": "name: 13F Dedicated Scan\n\non:\n  schedule:\n    - cron: '0 6 15 2,5,8,11 *'\n  workflow_dispatch:\n\njobs:\n  scan:\n    runs-on: ubuntu-latest\n    timeout-minutes: 45\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: '3.11'\n          cache: 'pip'\n      - name: Install deps\n        run: pip install -r requirements.txt\n      - name: Restore DB cache\n        uses: actions/cache@v4\n        with:\n          path: data/scanner.db\n          key: scanner-db-${{ github.run_number }}\n          restore-keys: |\n            scanner-db-\n      - name: Run 13F Dedicated\n        env:\n          GMAIL_USER: ${{ secrets.GMAIL_USER }}\n          GMAIL_PASSWORD: ${{ secrets.GMAIL_PASSWORD }}\n          RECIPIENT_EMAIL: ${{ secrets.RECIPIENT_EMAIL }}\n          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n          TRADIER_API_KEY: ${{ secrets.TRADIER_API_KEY }}\n          TRADIER_ACCOUNT_ID: ${{ secrets.TRADIER_ACCOUNT_ID }}\n          # Diese Zeile wurde hinzugefügt:\n          EDGAR_USER_AGENT: ${{ secrets.EDGAR_USER_AGENT }}\n        run: python scripts/daily_scan.py --run-mode thirteenf\n      - name: Save DB cache\n        if: always()\n        uses: actions/cache/save@v4\n        with:\n          path: data/scanner.db\n          key: scanner-db-${{ github.run_number }}\n"
  },
  {
    "path": ".github/workflows/weekly_full.yml",
    "content": "name: Weekly Full Scan\n\non:\n  schedule:\n    - cron: '0 5 * * 1'\n  workflow_dispatch:\n\njobs:\n  scan:\n    runs-on: ubuntu-latest\n    timeout-minutes: 30\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: '3.11'\n          cache: 'pip'\n      - name: Install deps\n        run: pip install -r requirements.txt\n      - name: Restore DB cache\n        uses: actions/cache@v4\n        with:\n          path: data/scanner.db\n          key: scanner-db-${{ github.run_number }}\n          restore-keys: |\n            scanner-db-\n      - name: Run Weekly Full\n        env:\n          GMAIL_USER: ${{ secrets.GMAIL_USER }}\n          GMAIL_PASSWORD: ${{ secrets.GMAIL_PASSWORD }}\n          RECIPIENT_EMAIL: ${{ secrets.RECIPIENT_EMAIL }}\n          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n          TRADIER_API_KEY: ${{ secrets.TRADIER_API_KEY }}\n          TRADIER_ACCOUNT_ID: ${{ secrets.TRADIER_ACCOUNT_ID }}\n        run: python scripts/daily_scan.py --run-mode weekly_full\n      - name: Save DB cache\n        if: always()\n        uses: actions/cache/save@v4\n        with:\n          path: data/scanner.db\n          key: scanner-db-${{ github.run_number }}\n"
  },
  {
    "path": ".github/workflows/weekly_review.yml",
    "content": "name: Weekly Review\n\non:\n  schedule:\n    - cron: '0 17 * * 0'\n  workflow_dispatch:\n\njobs:\n  review:\n    runs-on: ubuntu-latest\n    timeout-minutes: 20\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: '3.11'\n          cache: 'pip'\n      - name: Install deps\n        run: pip install -r requirements.txt\n      - name: Restore DB cache\n        uses: actions/cache@v4\n        with:\n          path: data/scanner.db\n          key: scanner-db-${{ github.run_number }}\n          restore-keys: |\n            scanner-db-\n      - name: Run Weekly Review\n        env:\n          GMAIL_USER: ${{ secrets.GMAIL_USER }}\n          GMAIL_PASSWORD: ${{ secrets.GMAIL_PASSWORD }}\n          RECIPIENT_EMAIL: ${{ secrets.RECIPIENT_EMAIL }}\n          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n        run: python scripts/daily_scan.py --run-mode weekly_review\n      - name: Save DB cache\n        if: always()\n        uses: actions/cache/save@v4\n        with:\n          path: data/scanner.db\n          key: scanner-db-${{ github.run_number }}\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2026 Smart Money Scanner v2\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "Smart Money Scanner v2\n\n**SEC Insider + Institutional + Politiker-Trade Scanner mit KI-Analyse**\nOptimiert für 90–180 Tage Call-Optionen basierend auf institutioneller Conviction.\n\nVollautomatisch via GitHub Actions. Tradier Pro für Real-Time Options-Daten.\n\n---\n\n## 🎯 Kern-These\n\nGroße institutionelle Käufe (Hedgefonds, Insider, Politiker) die sich **über mehrere Wochen und Quartale wiederholen** signalisieren echte mittelfristige Überzeugung. Diese lässt sich mit 90–180 Tage Calls mit gutem Risk/Reward abbilden — wenn:\n\n1. **IV-Rank ≤ 50** (billige Optionen, dynamischer Threshold)\n2. **Datierter Katalysator in Laufzeit** (z.B. Earnings in 60 Tagen)\n3. **Systematisches Muster, nicht Rebalancing** (3 Quartale in Folge)\n4. **Strikte Exit-Regeln** (≤21d raus, +80% TP, −45% SL)\n\n## 🏗️ Architektur\n\n**Modularer Aufbau:** Jedes Modul ist eigenständig und ersetzbar.\n\n```\nsrc/\n├── ingest/          # Datenquellen (Form4, 13F, 8K, Gov, News)\n├── enrich/          # Anreicherung (Preis, Catalyst, Options-PreFilter)\n├── score/           # Scoring (Signal-Builder, Merger, Filter, Trends)\n├── ai/              # Claude-Analyse + Outcome-Tracking\n├── execution/       # Tradier API + Exit-Manager\n├── alerts/          # Email-Versand\n└── utils/           # Logger, Storage, Retry\n```\n\n## 📅 Cron Schedule\n\n| Workflow | Zeit (UTC) | Was passiert |\n|----------|-----------|--------------|\n| Daily Light | Mo-Fr 14:30, 21:30 | Form4 Update + Exit-Check |\n| Weekly Full | Mo 05:00 | Volle Pipeline + Claude |\n| 13F Dedicated | 15. Feb/Mai/Aug/Nov | Multi-Quartals-Trend Analyse |\n| Weekly Review | So 17:00 | Claude Meta-Analyse |\n| Backtest | So 02:00 | Performance-Validierung |\n\n\n## ⚠️ Disclaimer\n\nKein Finanzrat. Eigene Due Diligence erforderlich. Code ist Open Source und ohne Gewährleistung.\n\n## 📝 Lizenz\n\nMIT License — see LICENSE file\n"
  },
  {
    "path": "config/fund_weights.yaml",
    "content": "# config/fund_weights.yaml\n# Score 0-50. Wird auto-kalibriert basierend auf Outcomes.\n# Funds mit Score < 15 werden komplett ignoriert.\n\nfunds:\n  Berkshire Hathaway:\n    score: 48\n    category: value_conviction\n    comment: Stärkster langfristiger Track Record\n\n  Pershing Square:\n    score: 42\n    category: activist_conviction\n    comment: Hohe Conviction + klare These\n\n  Elliott Management:\n    score: 40\n    category: activist\n    comment: Sehr stark bei 13D/G\n\n  Starboard Value:\n    score: 38\n    category: activist\n\n  Icahn Enterprises:\n    score: 36\n    category: activist\n\n  Coatue Management:\n    score: 32\n    category: growth_tech\n\n  Situational Awareness LP:\n    score: 35\n    category: ai_infrastructure\n    comment: \"Leo Aschenbrenner - AI-fokussiert\"\n\n  D1 Capital Partners:\n    score: 30\n    category: growth\n\n  Tiger Global:\n    score: 25\n    category: growth\n    note: Nur bei sehr großen neuen Positionen (>4% Portfolio)\n\n  ARK Invest:\n    score: 18\n    category: momentum\n    note: Nur bei extremen Käufen\n\n  Two Sigma:\n    score: 14\n    category: quant\n    note: Oft schon eingepreist\n\nignored_funds:\n  - Vanguard\n  - BlackRock\n  - Fidelity\n  - State Street\n  - Invesco\n  - iShares\n"
  },
  {
    "path": "config/funds_to_track.yaml",
    "content": "# config/funds_to_track.yaml\n# CIK-Nummern für SEC EDGAR 13F-Abfragen\n\nfunds:\n  - name: Berkshire Hathaway\n    cik: \"0001067983\"\n  - name: Pershing Square\n    cik: \"0001336528\"\n  - name: Elliott Management\n    cik: \"0001791786\"\n  - name: Starboard Value\n    cik: \"0001517137\"\n  - name: Icahn Capital LP\n    cik: \"0001412093\"\n  - name: Coatue Management\n    cik: \"0001135730\"\n  - name: Situational Awareness LP\n    cik: \"0002045724\"\n  - name: D1 Capital Partners\n    cik: \"0001747057\"\n  - name: Tiger Global\n    cik: \"0001167483\"\n  - name: ARK Invest\n    cik: \"0001697748\"\n  - name: Two Sigma\n    cik: \"0001179392\"\n"
  },
  {
    "path": "config/thresholds.yaml",
    "content": "# config/thresholds.yaml\n# ZENTRALE STELLE für alle Magic Numbers\n# Änderungen hier propagieren durch das ganze System\n\n# ── SIGNAL-FILTER (Hard Gates) ───────────────────────────────────────\nsignal_filter:\n  min_fund_score: 15           # Fund muss bekannt + relevant sein\n  min_sources: 2               # Multi-Signal-Gate\n  min_conviction: 0.38         # Mindest-Überzeugungsgrad\n  max_neg_news: -0.70          # Sehr negative News blocken (lockerer als v1)\n\n# ── OPTIONS-PARAMETER (für 2-6 Monats-Calls) ─────────────────────────\noptions:\n  min_days_to_exp: 90          # Unter 90d: Theta frisst zu viel\n  max_days_to_exp: 180         # Über 180d: Liquidität sinkt\n  \n  target_otm_min_pct: 0.05     # 5% OTM minimum\n  target_otm_max_pct: 0.15     # 15% OTM maximum\n  \n  # IV-Rank: dynamischer statt hart\n  iv_rank_ideal: 35            # Bonus wenn drunter\n  iv_rank_acceptable: 50       # Conviction -0.10\n  iv_rank_risky: 70            # Conviction -0.20\n  iv_rank_kill: 70             # Hard-Kill drüber\n  \n  min_open_interest: 500\n  max_spread_pct: 4.0          # Bid/Ask-Spread\n  \n  ideal_delta_min: 0.35\n  ideal_delta_max: 0.45\n\n# ── EXIT-REGELN (NICHT VERHANDELBAR) ─────────────────────────────────\nexit_rules:\n  take_profit_pct: 80          # +80% TP\n  stop_loss_pct: -45           # -45% SL\n  partial_take_pct: 50         # 50% Position bei +50%\n  min_days_remaining: 21       # Hard Time-Exit\n  \n  exit_if_fund_sells: true\n  exit_if_insider_sells: true\n  \n  vix_emergency_exit: 35       # VIX > 35 für 2 Tage = alle Exits\n\n# ── POSITION SIZING (Kelly) ──────────────────────────────────────────\nposition_sizing:\n  use_kelly: true\n  kelly_fraction: 0.25         # Quarter-Kelly (sicherer)\n  max_position_pct: 0.05       # Max 5% pro Trade\n  min_position_pct: 0.005      # Min 0.5%\n  max_total_exposure: 0.40     # Max 40% Tech/Sektor\n  \n  # Default Win-Rate für unbekannte Funds\n  default_win_rate: 0.42\n  default_avg_win: 0.70\n  default_avg_loss: 0.42\n\n# ── 13F MULTI-QUARTALS-TREND ─────────────────────────────────────────\nthirteenf:\n  min_position_value_usd: 100000     # $100k Mindestposition\n  min_new_position_usd: 500000       # $500k für neue Pos.\n  min_increase_pct: 15.0             # 15% Aufstockung\n  min_decrease_pct: 30.0\n  min_portfolio_pct: 0.3             # 0.3% Portfolio-Anteil\n  \n  # Multi-Quartals-Bonus\n  consecutive_2_bonus: 0.15\n  consecutive_3plus_bonus: 0.30      # Stärkstes Signal!\n\n# ── INSIDER PATTERN (Form 4) ─────────────────────────────────────────\ninsider:\n  pattern_lookback_days: 90\n  min_systematic_weeks: 3\n  min_unique_buyers: 2\n  min_total_usd: 500000\n\n# ── CATALYST FINDER ──────────────────────────────────────────────────\ncatalyst:\n  earnings_too_close_days: 30        # IV bereits aufgebläht\n  earnings_optimal_min_days: 30\n  earnings_optimal_max_days: 90\n  \n  bonus_optimal: 0.20\n  bonus_acceptable: 0.05\n  penalty_too_close: -0.15\n  penalty_no_catalyst: -0.10\n\n# ── DUPLIKAT-CHECK (differenziert nach Signal-Typ) ──────────────────\nduplicates:\n  insider_buy_days: 5\n  thirteenf_days: 90               # Quartals-Filing, nie doppelt\n  eight_k_days: 0                  # Jedes Event einmalig\n  gov_buy_days: 14\n\n# ── BACKTEST PARAMETER ───────────────────────────────────────────────\nbacktest:\n  start_year: 2018\n  end_year: 2025\n  entry_delay_days: 1              # Realistisch: nächster Handelstag\n  \n  # Kosten-Modell\n  spread_cost_per_trade_pct: 0.012  # 1.2% round-trip\n  tax_rate_short_term: 0.35         # DE/AT\n  \n  # Validierung-Kriterien\n  min_win_rate: 0.40\n  min_sortino: 0.80\n  max_drawdown: -0.35\n"
  },
  {
    "path": "docs/ARCHITECTURE.md",
    "content": "# Architecture\n\n## Modulare Struktur\n\nDas System ist in 6 unabhängige Layer aufgeteilt. Jeder Layer kann\neinzeln gewartet, ersetzt und getestet werden ohne andere zu brechen.\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                    scripts/daily_scan.py                    │\n│              (Orchestrator - dünne Pipeline)                │\n└──────────────────────┬──────────────────────────────────────┘\n                       │\n       ┌───────────────┼───────────────┬──────────────┐\n       ▼               ▼               ▼              ▼\n   ┌────────┐     ┌────────┐      ┌────────┐    ┌─────────┐\n   │ ingest │ ──▶ │ enrich │  ──▶ │ score  │ ──▶│   ai    │\n   └────────┘     └────────┘      └────────┘    └─────────┘\n                                                      │\n                                                      ▼\n                                                ┌──────────┐\n                                                │execution │\n                                                └──────────┘\n                                                      │\n                                                      ▼\n                                                ┌──────────┐\n                                                │  alerts  │\n                                                └──────────┘\n                                                      │\n                          ┌───────────────────────────┴─────┐\n                          ▼                                 ▼\n                     ┌────────┐                       ┌─────────┐\n                     │ utils  │ ◀──── used by all ───▶│ config  │\n                     └────────┘                       └─────────┘\n```\n\n## Layer-Verantwortlichkeiten\n\n### `src/ingest/`\n**Was:** Holt Rohdaten von externen Quellen.\n**Wie:** Jedes Modul hat ein einfaches Interface: `fetch() -> List[Dict]`.\n**Module:**\n- `form4_fetcher` — SEC Insider mit Cluster-Detection\n- `thirteenf_fetcher` — Hedgefonds-Filings + Multi-Quartals-Trend\n- `eight_k_fetcher` — Corporate Events mit Item-Score\n- `gov_trades_fetcher` — Politiker-Trades (Quiver)\n- `news_fetcher` — Google + Yahoo RSS\n\n**Modul ersetzen:** Nur Interface beibehalten (`fetch() -> List[Dict]`),\nImplementation kann komplett anders sein.\n\n### `src/enrich/`\n**Was:** Reichert Rohdaten an.\n**Wie:** Jedes Modul nimmt einen Ticker oder Signal entgegen.\n**Module:**\n- `price_context` — Yahoo Finance Kursdaten\n- `catalyst_finder` — Earnings-Termine im Optionsfenster\n- `options_prefilter` — Tradier Pre-Filter (Hard-Gate)\n- `sentiment` — News-Sentiment via Phrasen\n- `macro_context` — Polymarket + Kalshi\n\n### `src/score/`\n**Was:** Bewertet & filtert Signale.\n**Wie:** Verwendet `Signal`-Dataclass + zentrale Thresholds.\n**Module:**\n- `fund_scorer` — Fund-Score Lookup\n- `signal_filter` — Hard-Gates + Weighted Score\n- `signal_builder` — Erzeugt + merged Signale\n\n### `src/ai/`\n**Was:** Claude-Analyse + Outcome-Tracking.\n**Module:**\n- `single_analyzer` — Claude Sonnet 4.5 Single-Signal-Analyse\n- `outcome_tracker` — 30/60/90d Returns + Auto-Kalibrierung\n\n### `src/execution/`\n**Was:** Tradier API + Position-Management.\n**Module:**\n- `tradier_client` — Wrapper für Tradier API\n- `exit_manager` — Tägliche Position-Checks (TP/SL/Time)\n\n### `src/alerts/`\n**Was:** Benachrichtigungen.\n**Module:**\n- `email_sender` — Apple-Style HTML-Mail via Gmail SMTP\n\n### `src/utils/`\n**Was:** Querschnittsfunktionen.\n**Module:**\n- `logger` — Loguru-basiert\n- `retry` — Exponential Backoff Decorator\n- `config` — YAML-Loader mit Cache\n- `storage` — SQLite-Wrapper (alle DB-Zugriffe)\n- `ticker_resolver` — CIK → Ticker\n\n## Design-Prinzipien\n\n### 1. Single Responsibility\nJedes Modul macht GENAU EINE Sache.\n\n### 2. Interface-Stabilität\nModule exponieren simple Interfaces:\n```python\n# Ingest\ndef fetch() -> List[Dict]\n\n# Enrich\ndef get_price_context(ticker: str) -> Dict\n\n# Score\ndef filter_and_rank(signals: List[Signal]) -> List[Signal]\n```\n\n### 3. Configuration over Code\nAlle Magic Numbers in `config/thresholds.yaml`. Code referenziert via\n`get_threshold(\"category\", \"key\")`.\n\n### 4. Fail-Safe\nFehler in einem Modul brechen NICHT die ganze Pipeline. Try/Except an\nstrategischen Stellen, Logging, weiter.\n\n### 5. Storage-Abstraktion\nModule greifen NICHT direkt auf SQLite zu. Alles geht über `src/utils/storage.py`.\nVorteil: Storage-Backend kann gewechselt werden (z.B. Postgres) ohne andere\nModule anzufassen.\n\n### 6. Tradier-Abstraktion\nTradier-Calls gehen NICHT direkt aus Modulen. Alles über `tradier_client.py`.\nVorteil: API-Wechsel (z.B. zu IBKR) bedeutet eine neue Datei, nicht 10.\n\n## Datenfluss\n\n```\n1. INGEST    → Form4 RSS, 13F XML, 8K RSS, Quiver API\n                ↓\n2. BUILD     → List[Dict] → List[Signal] (mit Fund-Score)\n                ↓\n3. MERGE     → Signal-Cluster nach Ticker\n                ↓\n4. FILTER    → Hard-Gates (Fund, Sources, Conviction)\n                ↓\n5. ENRICH    → Top-N: + Preis, Catalyst, Options, News\n                ↓\n6. CLAUDE    → Single-Signal Analysis → action/confidence/instrument\n                ↓\n7. PERSIST   → SQLite (signals, open_positions)\n                ↓\n8. NOTIFY    → HTML Email\n```\n\n## Erweiterbarkeit\n\n### Neue Datenquelle hinzufügen\n1. `src/ingest/my_source.py` mit `fetch() -> List[Dict]`\n2. In `daily_scan.py` step_ingest erweitern\n3. In `signal_builder.py` Builder-Funktion `build_signals_from_my_source`\n4. Done. Keine andere Komponente muss angepasst werden.\n\n### Scoring ändern\nNur `src/score/signal_filter.py` editieren. Tests laufen lassen.\n\n### Anderen LLM benutzen\nNur `src/ai/single_analyzer.py` umbauen. Interface bleibt:\n`analyze(signal, news) -> Dict`.\n\n### Andere Broker-API\n`src/execution/tradier_client.py` durch `ibkr_client.py` ersetzen, gleiches\nInterface anbieten.\n"
  },
  {
    "path": "docs/BACKTESTING.md",
    "content": "# Backtesting\n\n## Was der Backtest tut\n\nDer Backtest in `scripts/backtest.py` simuliert Call-Optionen-Trades\nauf einer Auswahl von Tickers (AAPL, MSFT, GOOGL, ...) zu pseudo-zufälligen\nDaten zwischen 2019-2025.\n\nDie Optionspreise werden via **Black-Scholes approximiert** (nicht echte\nMarktdaten), weil echte historische Optionspreise teuer sind.\n\n## Was der Backtest validiert\n\n✅ **Risk/Reward-Modell** — passt 80% TP / -45% SL?\n✅ **Exit-Disziplin** — Time-Exit bei ≤21d hält Theta-Verluste in Grenzen?\n✅ **Robustheit über Marktphasen** — funktioniert es 2020 (Crash) UND 2022 (Bear)?\n✅ **Drawdown-Profile** — bleibt Max DD unter 35%?\n\n## Was der Backtest NICHT validiert\n\n❌ **Echte Spread-Kosten** — werden nur grob approximiert\n❌ **Tatsächliche Fill-Preise** — Annahme: Mid-Preis\n❌ **Slippage bei großen Positionen**\n❌ **Fund-Score-Logik** — wir testen die *Mechanik*, nicht die *Selektion*\n\n## Ergebnisse interpretieren\n\n```\nWin-Rate:        ≥ 40%  ✓ akzeptabel\nWin-Rate:        ≥ 45%  ✓✓ gut\nWin-Rate:        ≥ 50%  ✓✓✓ exzellent\n\nSortino:         ≥ 0.80 ✓ akzeptabel\nSortino:         ≥ 1.20 ✓✓ gut\nSortino:         ≥ 2.00 ✓✓✓ exzellent\n\nMax DD:          ≥ -35% ✓ akzeptabel\nMax DD:          ≥ -25% ✓✓ gut\nMax DD:          ≥ -15% ✓✓✓ exzellent\n```\n\n## Wann Live gehen?\n\n✅ Backtest passt grobe Validierung\n✅ Mindestens 2 Wochen Paper-Trading parallel\n✅ Mindestens 5 echte Mini-Positionen (0.5%) zur Verifikation\n✅ Eine vollständige Earnings-Saison durch (Q-Update zeigt Multi-Quartals-Effekt)\n\n## Bekannte Limitierungen\n\n### 1. Datums-Auswahl\nDie 12 Test-Daten in `backtest.py` sind hardgecoded. Echte Signale wären \nin der Realität dichter und ungleichmäßiger verteilt. Du kannst sie \nselbst erweitern in `scripts/backtest.py`.\n\n### 2. IV-Annahme\nWir nehmen 30% IV durchgehend an. In der Realität schwankt IV stark\n(2020: 60-80%, 2022: 30-50%, 2024: 15-25%). Die echte Performance\nkann besser oder schlechter sein.\n\n### 3. Keine Fund-Selection\nWir simulieren keine *echten* Smart-Money-Signale, sondern nur die\nTrade-Mechanik (TP/SL/Time-Exit) auf zufälligen Daten.\nEchtes Edge entsteht durch:\n- Multi-Quartals-Trend (3+ Q in Folge bei Top-Funds) — nicht im Backtest\n- Insider-Cluster (mehrere Insider gleichzeitig kaufen) — nicht im Backtest\n\nFür eine *vollständige* Backtest-Validierung müsstest du historische \nSEC-Filings parsen + entsprechende Trades simulieren. Das ist \nsubstantieller Aufwand (1-2 Wochen Code).\n\n## Erweiterung\n\n```python\n# In scripts/backtest.py, in run_backtest():\ntest_dates = [\n    \"2019-03-15\",\n    # Hier mehr Daten ergänzen\n]\n\ntest_tickers = [\n    \"AAPL\", \n    # Hier mehr Tickers ergänzen\n]\n```\n\n## Bewährter Validierungs-Workflow\n\n1. Backtest laufen lassen → Mechanik passt?\n2. 2-4 Wochen Paper-Trading mit vollem System\n3. Win-Rate echter Signale messen → matcht Erwartung?\n4. Erst dann Live mit kleinen Positionen (0.5-1%)\n5. Nach 10-15 echten Trades: Position-Size hochsetzen falls WR stabil\n"
  },
  {
    "path": "docs/DEPLOYMENT.md",
    "content": "# Deployment Guide\n\n## Voraussetzungen\n\n- GitHub Account (Free Tier reicht — 2.000 min/Monat)\n- Gmail Account mit App-Passwort\n- Anthropic API Key (für Claude)\n- Tradier Pro Account + API Key\n\n## Schritt-für-Schritt (nur Browser nötig)\n\n### 1. Repo erstellen\n\n1. github.com → New repository\n2. Name: `smart-money-scanner-v2`\n3. Privat oder Public\n4. NICHTS initialisieren (kein README, kein .gitignore — haben wir bereits)\n5. Create repository\n\n### 2. Code hochladen\n\n**Option A: Drag & Drop**\n1. \"uploading an existing file\"\n2. Den ganzen `smart-money-scanner-v2` Ordner per Drag & Drop reinziehen\n3. Commit message: \"Initial deployment\"\n4. Commit changes\n\n**Option B: ZIP**\n1. ZIP entpacken auf deinem Rechner\n2. Alle Dateien selektieren (Strg+A)\n3. Drag & Drop in das leere Repo\n4. Commit changes\n\n### 3. Secrets konfigurieren\n\n`Settings → Secrets and variables → Actions → New repository secret`\n\n| Secret Name | Wo bekommst du das? |\n|-------------|---------------------|\n| `GMAIL_USER` | Deine Gmail-Adresse |\n| `GMAIL_PASSWORD` | App-Passwort, NICHT dein normales PW! |\n| `RECIPIENT_EMAIL` | An wen soll Mail gehen |\n| `ANTHROPIC_API_KEY` | console.anthropic.com → API Keys |\n| `TRADIER_API_KEY` | Tradier Account → API |\n| `TRADIER_ACCOUNT_ID` | Optional |\n\n#### Gmail App-Passwort erstellen\n1. myaccount.google.com → Sicherheit\n2. 2-Faktor-Authentifizierung aktivieren (falls noch nicht)\n3. \"App-Passwörter\" → Neues App-Passwort\n4. Name: \"Smart Money Scanner\"\n5. 16-stelligen Code kopieren → als `GMAIL_PASSWORD`\n\n### 4. Erster Test-Run\n\n`Actions → Daily Light Scan → Run workflow → Run workflow`\n\nNach 3-5 Min solltest du:\n- ✅ Grünen Haken bei Actions sehen\n- ✅ E-Mail erhalten haben\n\nBei Fehler:\n- Actions → fehlgeschlagener Run → Logs lesen\n- Häufigste Ursachen: Tippfehler in Secrets, falsches Gmail-Passwort\n\n### 5. Backtest VOR Live-Trading\n\n`Actions → Backtest → Run workflow`\n\nErwartete Performance:\n```\nWin-Rate:        ≥ 40%   (gut: 43-50%)\nSortino Ratio:   ≥ 0.80  (gut: 1.0+)\nMax Drawdown:    ≤ -35%  (gut: -20-25%)\n```\n\nWenn deutlich schlechter: NICHT live gehen. Erst Tuning.\n\n### 6. Cron-Schedules sind automatisch aktiv\n\nSobald Code im Main-Branch ist, laufen die Workflows automatisch:\n- Mo-Fr 14:30 + 21:30 UTC: Daily Light\n- Mo 05:00 UTC: Weekly Full\n- 15. Feb/Mai/Aug/Nov 06:00 UTC: 13F Dedicated\n- So 17:00 UTC: Weekly Review\n- So 02:00 UTC: Backtest\n- Alle 2 Monate: Keepalive (verhindert Auto-Disable)\n\n## Cost Tracking\n\n| Komponente | Verbrauch | Kosten |\n|------------|-----------|--------|\n| GitHub Actions | ~61 min/Monat | $0 (Free Tier 2000) |\n| Anthropic API | ~5-10 Calls/Woche × $0.01 | ~$2/Monat |\n| Tradier Pro | Konto bereits da | $10/Monat (Pauschal) |\n| Gmail | unbegrenzt | $0 |\n| **Total** | | **~$12/Monat** |\n\n## Wartung\n\n### Monatlich\n- Win-Rate prüfen (`docs/BACKTESTING.md`)\n- Source-Health-Warnings prüfen (kommen per Mail wenn Quelle 3+ Tage 0)\n\n### Quartalsweise\n- `config/funds_to_track.yaml`: neue Funds hinzufügen?\n- `config/fund_weights.yaml`: Auto-Kalibrierung läuft, manuell tunen falls nötig\n\n### Bei Problemen\n- Actions-Logs sind die erste Anlaufstelle\n- Daten in `data/scanner.db` bleiben durch Cache erhalten\n\n## Update-Strategie\n\n1. Lokal: in einem Branch arbeiten\n2. Test: `python -m tests.test_scoring` etc.\n3. Push als PR\n4. Mergen wenn alle Tests grün\n\nDa das System modular ist: ein einzelnes Modul anfassen ≠ Risiko für den Rest.\n"
  },
  {
    "path": "requirements.txt",
    "content": "requests>=2.31.0\npyyaml>=6.0.1\nanthropic>=0.34.0\nloguru>=0.7.2\n"
  },
  {
    "path": "scripts/backtest.py",
    "content": "#!/usr/bin/env python3\n# scripts/backtest.py\n\"\"\"\nBacktest 2018-2025 mit historischen Daten.\n\nWARNUNG: Optionspreise werden APPROXIMIERT (Black-Scholes) -\nechte historische Optionspreise sind teuer (CBOE LiveVol etc).\nFür Live-Validierung ist Paper-Trading der bessere Weg.\n\nWas es validiert:\n- Win-Rate des Signal-Modells (auf Aktien-Basis)\n- Sortino, Drawdown\n- Robustheit über verschiedene Marktphasen\n\"\"\"\nimport sys\nfrom pathlib import Path\nfrom datetime import datetime, timedelta\nimport math\nimport statistics\nimport requests\n\nsys.path.insert(0, str(Path(__file__).parent.parent))\n\nfrom src.utils.logger import logger\nfrom src.utils.config import get_threshold\n\n\nHEADERS = {\n    \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\"\n}\n\n\ndef fetch_historical_prices(ticker: str, start: str, end: str):\n    \"\"\"Yahoo historical data.\"\"\"\n    try:\n        start_ts = int(datetime.strptime(start, \"%Y-%m-%d\").timestamp())\n        end_ts = int(datetime.strptime(end, \"%Y-%m-%d\").timestamp())\n        \n        url = f\"https://query1.finance.yahoo.com/v8/finance/chart/{ticker}\"\n        params = {\n            \"period1\": start_ts,\n            \"period2\": end_ts,\n            \"interval\": \"1d\"\n        }\n        resp = requests.get(url, headers=HEADERS, params=params, timeout=15)\n        if resp.status_code != 200:\n            return None\n        \n        data = resp.json()[\"chart\"][\"result\"][0]\n        timestamps = data.get(\"timestamp\", [])\n        closes = data[\"indicators\"][\"quote\"][0].get(\"close\", [])\n        \n        return [\n            {\"date\": datetime.utcfromtimestamp(t).strftime(\"%Y-%m-%d\"), \"close\": c}\n            for t, c in zip(timestamps, closes) if c\n        ]\n    except Exception as e:\n        logger.warning(f\"Backtest fetch {ticker}: {e}\")\n        return None\n\n\ndef black_scholes_call(S: float, K: float, T: float, r: float, sigma: float) -> float:\n    \"\"\"Vereinfachter BS für Call-Approximation.\"\"\"\n    if T <= 0 or sigma <= 0:\n        return max(S - K, 0)\n    \n    d1 = (math.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * math.sqrt(T))\n    d2 = d1 - sigma * math.sqrt(T)\n    \n    # Normal CDF approximation\n    def N(x):\n        return 0.5 * (1 + math.erf(x / math.sqrt(2)))\n    \n    return S * N(d1) - K * math.exp(-r * T) * N(d2)\n\n\ndef simulate_trade(ticker: str, entry_date: str, hold_days: int = 90) -> dict:\n    \"\"\"\n    Simuliert einen Call-Trade.\n    Einfache Annahmen: 10% OTM, 120d Laufzeit, IV=30%.\n    \"\"\"\n    end_date = (datetime.strptime(entry_date, \"%Y-%m-%d\") + \n                timedelta(days=hold_days + 30)).strftime(\"%Y-%m-%d\")\n    \n    prices = fetch_historical_prices(ticker, entry_date, end_date)\n    if not prices or len(prices) < hold_days:\n        return None\n    \n    entry_price = prices[0][\"close\"]\n    strike = entry_price * 1.10\n    \n    # Entry option price (approx)\n    iv = 0.30\n    T_entry = 120 / 365\n    entry_option = black_scholes_call(entry_price, strike, T_entry, 0.04, iv)\n    \n    # Exit nach hold_days oder bei TP/SL\n    tp = get_threshold(\"exit_rules\", \"take_profit_pct\", 80) / 100\n    sl = get_threshold(\"exit_rules\", \"stop_loss_pct\", -45) / 100\n    \n    for i, p in enumerate(prices[1:hold_days + 1], start=1):\n        days_left = 120 - i\n        if days_left <= 21:\n            # Time exit\n            T_exit = days_left / 365 if days_left > 0 else 0.01\n            exit_option = black_scholes_call(p[\"close\"], strike, T_exit, 0.04, iv)\n            pnl = (exit_option - entry_option) / entry_option\n            return {\n                \"ticker\": ticker, \"entry_date\": entry_date,\n                \"exit_date\": p[\"date\"], \"exit_reason\": \"time\",\n                \"pnl\": pnl, \"days_held\": i\n            }\n        \n        T_exit = days_left / 365\n        opt_price = black_scholes_call(p[\"close\"], strike, T_exit, 0.04, iv)\n        pnl = (opt_price - entry_option) / entry_option\n        \n        if pnl >= tp:\n            return {\n                \"ticker\": ticker, \"entry_date\": entry_date,\n                \"exit_date\": p[\"date\"], \"exit_reason\": \"tp\",\n                \"pnl\": pnl, \"days_held\": i\n            }\n        if pnl <= sl:\n            return {\n                \"ticker\": ticker, \"entry_date\": entry_date,\n                \"exit_date\": p[\"date\"], \"exit_reason\": \"sl\",\n                \"pnl\": pnl, \"days_held\": i\n            }\n    \n    # End of holding period\n    final_price = prices[hold_days][\"close\"]\n    T_exit = (120 - hold_days) / 365\n    final_option = black_scholes_call(final_price, strike, T_exit, 0.04, iv)\n    pnl = (final_option - entry_option) / entry_option\n    return {\n        \"ticker\": ticker, \"entry_date\": entry_date,\n        \"exit_date\": prices[hold_days][\"date\"], \"exit_reason\": \"hold_end\",\n        \"pnl\": pnl, \"days_held\": hold_days\n    }\n\n\ndef run_backtest():\n    \"\"\"\n    Vereinfachter Backtest:\n    Simuliert Call-Trades auf Top-Tickers an pseudo-zufälligen Daten \n    der letzten 7 Jahre.\n    \"\"\"\n    logger.info(\"█\" * 60)\n    logger.info(\"BACKTEST 2019-2025\")\n    logger.info(\"█\" * 60)\n    \n    test_tickers = [\n        \"AAPL\", \"MSFT\", \"GOOGL\", \"META\", \"NVDA\", \"AMZN\",\n        \"JPM\", \"BAC\", \"WMT\", \"PG\", \"JNJ\", \"UNH\",\n        \"TSLA\", \"AVGO\", \"CRM\", \"AMD\"\n    ]\n    \n    test_dates = [\n        \"2019-03-15\", \"2019-09-13\", \"2020-02-21\", \"2020-08-14\",\n        \"2021-03-19\", \"2021-09-17\", \"2022-02-18\", \"2022-08-19\",\n        \"2023-03-17\", \"2023-09-15\", \"2024-02-16\", \"2024-08-16\",\n    ]\n    \n    results = []\n    for ticker in test_tickers:\n        for date in test_dates:\n            try:\n                r = simulate_trade(ticker, date, hold_days=90)\n                if r:\n                    results.append(r)\n            except Exception as e:\n                logger.debug(f\"Skip {ticker}@{date}: {e}\")\n    \n    if not results:\n        logger.error(\"Keine Backtest-Ergebnisse\")\n        return\n    \n    # Stats\n    pnls = [r[\"pnl\"] for r in results]\n    wins = [p for p in pnls if p > 0]\n    losses = [p for p in pnls if p < 0]\n    \n    win_rate = len(wins) / len(pnls)\n    avg_win = statistics.mean(wins) if wins else 0\n    avg_loss = statistics.mean(losses) if losses else 0\n    avg_pnl = statistics.mean(pnls)\n    \n    # Sortino (vereinfacht)\n    neg_returns = [p for p in pnls if p < 0]\n    downside_dev = statistics.stdev(neg_returns) if len(neg_returns) > 1 else 0.01\n    sortino = avg_pnl / downside_dev if downside_dev > 0 else 0\n    \n    # Drawdown (cumulative)\n    cumulative = []\n    cum = 1.0\n    for p in pnls:\n        cum *= (1 + p * 0.02)  # 2% Position-Size\n        cumulative.append(cum)\n    \n    peak = cumulative[0]\n    max_dd = 0\n    for c in cumulative:\n        if c > peak:\n            peak = c\n        dd = (c - peak) / peak\n        if dd < max_dd:\n            max_dd = dd\n    \n    # Report\n    report = f\"\"\"\n═══════════════════════════════════════════════════════\nBACKTEST RESULTS\n═══════════════════════════════════════════════════════\nTotal Trades:    {len(results)}\nWin-Rate:        {win_rate:.1%}\nAvg Win:         {avg_win:+.1%}\nAvg Loss:        {avg_loss:+.1%}\nAvg P&L:         {avg_pnl:+.1%}\nSortino Ratio:   {sortino:.2f}\nMax Drawdown:    {max_dd:.1%}\n\nExit Reasons:\n  Take Profit: {sum(1 for r in results if r['exit_reason'] == 'tp')}\n  Stop Loss:   {sum(1 for r in results if r['exit_reason'] == 'sl')}\n  Time Exit:   {sum(1 for r in results if r['exit_reason'] == 'time')}\n  Hold End:    {sum(1 for r in results if r['exit_reason'] == 'hold_end')}\n\nVALIDIERUNG:\n  Win-Rate ≥ 40%:   {'✓' if win_rate >= 0.40 else '✗'}\n  Sortino ≥ 0.80:   {'✓' if sortino >= 0.80 else '✗'}\n  Max DD ≥ -35%:    {'✓' if max_dd >= -0.35 else '✗'}\n\nNOTE: Optionspreise sind APPROXIMIERT (Black-Scholes).\n      Echte Performance kann ±15% abweichen.\n      Paper-Trading vor Live empfohlen!\n═══════════════════════════════════════════════════════\n\"\"\"\n    \n    logger.info(report)\n    \n    # Email\n    try:\n        from src.alerts.email_sender import send_email\n        html = f\"<pre style='font-family:monospace;font-size:12px;'>{report}</pre>\"\n        send_email(\"📊 Backtest Results\", html)\n    except Exception as e:\n        logger.warning(f\"Email fail: {e}\")\n\n\nif __name__ == \"__main__\":\n    run_backtest()\n"
  },
  {
    "path": "scripts/daily_scan.py",
    "content": "#!/usr/bin/env python3\n# scripts/daily_scan.py\n\"\"\"\nHAUPT-ORCHESTRATOR.\n\nRun-Modes:\n- daily_light: Form4 + Exit-Check (Mo-Fr 14:30 + 21:30 UTC)\n- weekly_full: Volle Pipeline + Claude (Mo 05:00 UTC)\n- thirteenf: 13F-fokussiert (15. Feb/Mai/Aug/Nov)\n- weekly_review: Outcome-Update (So 17:00 UTC)\n\"\"\"\nimport os\nimport sys\nimport argparse\nfrom pathlib import Path\nfrom datetime import datetime\nfrom typing import List, Dict\n\n# Pfad-Setup für Import\nsys.path.insert(0, str(Path(__file__).parent.parent))\n\nfrom src.utils.logger import logger\nfrom src.utils.storage import (\n    init_db, save_signal, log_scan, log_source_health,\n    get_source_warnings, is_duplicate, save_position\n)\nfrom src.utils.config import load as load_config, get_threshold\n\n# Ingest\nfrom src.ingest import form4_fetcher, eight_k_fetcher, gov_trades_fetcher, news_fetcher\nfrom src.ingest import thirteenf_fetcher\n\n# Enrich\nfrom src.enrich.price_context import get_price_context\nfrom src.enrich.catalyst_finder import catalyst_score\nfrom src.enrich.options_prefilter import (\n    options_prefilter,\n    conviction_modifier_for_iv,\n    post_claude_options_check,\n)\nfrom src.enrich.sentiment import calculate as calculate_sentiment\nfrom src.enrich.macro_context import get_macro_context\n\n# Score\nfrom src.score.fund_scorer import FundScorer\nfrom src.score.signal_filter import SignalFilter, Signal\nfrom src.score.signal_builder import (\n    build_signals_from_form4,\n    build_signals_from_13f,\n    build_signals_from_8k,\n    build_signals_from_gov,\n    merge_by_ticker,\n)\n\n# AI\nfrom src.ai.single_analyzer import SingleAnalyzer\nfrom src.ai.outcome_tracker import run_outcome_tracking\n\n# Execution\nfrom src.execution.exit_manager import run_exit_check\n\n# Alerts\nfrom src.alerts.email_sender import send_report\n\n\n# ── Pipeline-Steps ───────────────────────────────────────────────────\n\ndef step_ingest(scorer: FundScorer, run_mode: str) -> Dict[str, list]:\n    \"\"\"Stufe 1: Datenquellen abfragen.\"\"\"\n    logger.info(\"━\" * 60)\n    logger.info(f\"INGEST ({run_mode})\")\n    logger.info(\"━\" * 60)\n    \n    data = {\"form4\": [], \"thirteenf\": [], \"eightk\": [], \"gov\": []}\n    \n    # Form 4 - immer\n    try:\n        data[\"form4\"] = form4_fetcher.fetch()\n        log_source_health(\"form4\", len(data[\"form4\"]))\n    except Exception as e:\n        logger.error(f\"Form4: {e}\")\n        log_source_health(\"form4\", 0)\n    \n    # 8-K - außer im pure thirteenf\n    if run_mode != \"thirteenf\":\n        try:\n            data[\"eightk\"] = eight_k_fetcher.fetch()\n            log_source_health(\"8k\", len(data[\"eightk\"]))\n        except Exception as e:\n            logger.error(f\"8-K: {e}\")\n            log_source_health(\"8k\", 0)\n    \n    # Gov-Trades\n    if run_mode in (\"weekly_full\", \"thirteenf\"):\n        try:\n            data[\"gov\"] = gov_trades_fetcher.fetch()\n            log_source_health(\"gov\", len(data[\"gov\"]))\n        except Exception as e:\n            logger.error(f\"Gov: {e}\")\n            log_source_health(\"gov\", 0)\n    \n    # 13F\n    if run_mode in (\"weekly_full\", \"thirteenf\"):\n        try:\n            funds_cfg = load_config(\"funds_to_track\").get(\"funds\", [])\n            data[\"thirteenf\"] = thirteenf_fetcher.fetch(funds_cfg, scorer)\n            log_source_health(\"13f\", len(data[\"thirteenf\"]))\n        except Exception as e:\n            logger.error(f\"13F: {e}\")\n            log_source_health(\"13f\", 0)\n    \n    logger.info(\n        f\"Ingest done: Form4={len(data['form4'])}, \"\n        f\"13F={len(data['thirteenf'])}, \"\n        f\"8K={len(data['eightk'])}, \"\n        f\"Gov={len(data['gov'])}\"\n    )\n    return data\n\n\ndef step_build_signals(raw: Dict, scorer: FundScorer, sf: SignalFilter) -> List[Signal]:\n    \"\"\"Stufe 2: Signale erzeugen + mergen.\"\"\"\n    logger.info(\"━\" * 60)\n    logger.info(\"BUILD SIGNALS\")\n    logger.info(\"━\" * 60)\n    \n    all_signals: List[Signal] = []\n    all_signals += build_signals_from_form4(raw[\"form4\"], scorer, sf)\n    all_signals += build_signals_from_13f(raw[\"thirteenf\"], sf)\n    all_signals += build_signals_from_8k(raw[\"eightk\"], sf)\n    all_signals += build_signals_from_gov(raw[\"gov\"], sf)\n    \n    logger.info(f\"Built: {len(all_signals)} raw signals\")\n    \n    merged = merge_by_ticker(all_signals, sf)\n    logger.info(f\"After merge: {len(merged)} unique tickers\")\n    return merged\n\n\ndef step_filter(signals: List[Signal], sf: SignalFilter) -> List[Signal]:\n    \"\"\"Stufe 3: Hard-Gates anwenden + ranken.\"\"\"\n    logger.info(\"━\" * 60)\n    logger.info(\"FILTER & RANK\")\n    logger.info(\"━\" * 60)\n    \n    dup_cfg = load_config(\"thresholds\").get(\"duplicates\", {})\n    type_to_days = {\n        \"insider_buy\": dup_cfg.get(\"insider_buy_days\", 5),\n        \"13f_increase\": dup_cfg.get(\"thirteenf_days\", 90),\n        \"13f_new_position\": dup_cfg.get(\"thirteenf_days\", 90),\n        \"8k_event\": dup_cfg.get(\"eight_k_days\", 0),\n        \"gov_buy\": dup_cfg.get(\"gov_buy_days\", 14),\n    }\n    \n    deduped = []\n    for s in signals:\n        days = type_to_days.get(s.signal_type, 5)\n        if days == 0 or not is_duplicate(s.ticker, s.signal_type, days):\n            deduped.append(s)\n    \n    logger.info(f\"After dedup: {len(deduped)}\")\n    return sf.filter_and_rank(deduped)\n\n\ndef step_enrich(signals: List[Signal], run_mode: str) -> List[Dict]:\n    \"\"\"Stufe 4: Anreicherung mit weichem Options-Pre-Filter (Hybrid).\"\"\"\n    logger.info(\"━\" * 60)\n    logger.info(\"ENRICH\")\n    logger.info(\"━\" * 60)\n\n    top_n = 10 if run_mode in (\"weekly_full\", \"thirteenf\") else 5\n\n    # Pre-screen: scan a larger pool and prefer tickers that actually have\n    # options expirations in the 90-180d window. Micro-caps often have no\n    # listed options at all → Claude would reject them anyway, so skip them\n    # early and save API calls.\n    from src.execution.tradier_client import get_client\n    candidate_pool = signals[:max(top_n * 4, 20)]\n    tradier = get_client()\n\n    if tradier.is_configured and candidate_pool:\n        with_options, without_options = [], []\n        for s in candidate_pool:\n            expirations = tradier.get_expirations(s.ticker)\n            if expirations:\n                with_options.append(s)\n            else:\n                without_options.append(s)\n        # Prefer tickers with options; fall back to the rest if needed\n        ordered = with_options + without_options\n        logger.info(\n            f\"Options pre-screen: {len(with_options)} mit Options, \"\n            f\"{len(without_options)} ohne — aus {len(candidate_pool)} Kandidaten\"\n        )\n    else:\n        ordered = candidate_pool\n\n    top = ordered[:top_n]\n    logger.info(f\"Anreicherung der Top {len(top)} Signale\")\n    \n    enriched = []\n    for s in top:\n        d = {\n            \"ticker\": s.ticker,\n            \"signal_type\": s.signal_type,\n            \"fund_name\": s.fund_name,\n            \"fund_score\": s.fund_score,\n            \"strength\": s.strength,\n            \"conviction\": s.conviction,\n            \"consecutive_quarters\": s.consecutive_quarters,\n            \"is_clustered\": s.is_clustered,\n            \"source_count\": s.source_count,\n            \"sources\": [s.signal_type],\n            \"is_10b5\": s.raw.get(\"is_10b5\", False),\n            \"fund_category\": s.raw.get(\"fund_category\", \"fund\"),\n            **s.raw\n        }\n        \n        # Preis-Kontext\n        try:\n            d[\"price_context\"] = get_price_context(s.ticker)\n        except Exception as e:\n            logger.warning(f\"Price {s.ticker}: {e}\")\n            d[\"price_context\"] = {}\n        \n        # Catalyst\n        try:\n            cat = catalyst_score(s.ticker)\n            d[\"catalyst\"] = cat\n            d[\"catalyst_modifier\"] = cat.get(\"conviction_modifier\", 0)\n        except Exception as e:\n            logger.warning(f\"Catalyst {s.ticker}: {e}\")\n            d[\"catalyst\"] = {}\n        \n        # === Hybrid Options-Pre-Filter (weich) ===\n        try:\n            opt_result = options_prefilter(s.ticker)\n            if opt_result.get(\"passed\"):\n                d[\"options_data\"] = opt_result.get(\"options_data\")\n                d[\"iv_rank\"] = opt_result.get(\"iv_rank\", 50.0)\n                d[\"options_qualified\"] = True\n                d[\"options_summary\"] = opt_result.get(\"summary\", \"\")\n                \n                # IV-Modifier auf Conviction anwenden\n                iv_mod = conviction_modifier_for_iv(d[\"iv_rank\"])\n                d[\"conviction\"] = max(0, min(1.0, d[\"conviction\"] + iv_mod))\n            else:\n                d[\"options_qualified\"] = False\n                d[\"options_data\"] = None\n                d[\"iv_rank\"] = 50.0\n                d[\"options_summary\"] = f\"Options-Check fehlgeschlagen: {opt_result.get('kill_reason')}\"\n                logger.info(f\"  {s.ticker}: Options-PreFilter SOFT FAIL ({opt_result.get('kill_reason')})\")\n        except Exception as e:\n            logger.warning(f\"Options prefilter {s.ticker}: {e}\")\n            d[\"options_qualified\"] = False\n            d[\"options_data\"] = None\n            d[\"iv_rank\"] = 50.0\n            d[\"options_summary\"] = \"Options-Check fehlgeschlagen (Exception)\"\n        \n        # News + Sentiment\n        try:\n            news = news_fetcher.fetch(s.ticker)\n            d[\"_news\"] = news\n            d[\"news_alignment\"] = calculate_sentiment(news)\n        except Exception as e:\n            logger.warning(f\"News {s.ticker}: {e}\")\n            d[\"_news\"] = []\n            d[\"news_alignment\"] = 0.0\n        \n        # Macro\n        if run_mode == \"weekly_full\":\n            try:\n                macro = get_macro_context(s.ticker)\n                d[\"macro_context\"] = macro[\"context\"]\n                d[\"macro_summary\"] = macro[\"summary\"]\n            except Exception as e:\n                logger.warning(f\"Macro {s.ticker}: {e}\")\n                d[\"macro_context\"] = \"neutral\"\n        else:\n            d[\"macro_context\"] = \"neutral\"\n        \n        enriched.append(d)\n    \n    logger.info(f\"Enriched: {len(enriched)}\")\n    return enriched\n\n\ndef step_analyze(enriched: List[Dict]) -> List[Dict]:\n    \"\"\"Stufe 5: Claude-Analyse.\"\"\"\n    logger.info(\"━\" * 60)\n    logger.info(\"CLAUDE ANALYSIS\")\n    logger.info(\"━\" * 60)\n    \n    if not enriched:\n        return []\n    \n    try:\n        analyzer = SingleAnalyzer()\n    except ValueError as e:\n        logger.error(f\"Claude not configured: {e}\")\n        return []\n    \n    news_map = {d[\"ticker\"]: d.get(\"_news\", []) for d in enriched}\n    return analyzer.analyze_batch(enriched, news_map)\n\n\ndef step_persist_and_send(\n    analyzed: List[Dict],\n    exits: List[Dict],\n    warnings: List[Dict],\n    stats: Dict,\n    run_mode: str\n):\n    \"\"\"Stufe 6: Speichern + E-Mail senden.\"\"\"\n    logger.info(\"━\" * 60)\n    logger.info(\"PERSIST & NOTIFY\")\n    logger.info(\"━\" * 60)\n    \n    trades = [a for a in analyzed if a.get(\"action\") == \"trade\"]\n    watchlist = [a for a in analyzed if a.get(\"action\") == \"watchlist\"]\n    no_trades = [a for a in analyzed if a.get(\"action\") == \"kein_trade\"]\n    \n    # Save signals\n    for a in analyzed:\n        try:\n            sig_dict = {\n                **(a.get(\"raw_signal\") or {}),\n                \"action\": a.get(\"action\"),\n                \"confidence\": a.get(\"confidence\", 0),\n                \"reasoning\": a.get(\"reasoning\", \"\"),\n                \"suggested_instrument\": a.get(\"suggested_instrument\", \"\"),\n            }\n            save_signal(sig_dict)\n        except Exception as e:\n            logger.warning(f\"Save signal fail: {e}\")\n    \n    # Save new positions for trades – Post-Claude Options-Check\n    for t in trades:\n        ticker = t[\"ticker\"]\n        raw = t.get(\"raw_signal\", {})\n        \n        opt_result = post_claude_options_check(ticker)\n        opt = opt_result.get(\"options_data\") or {}\n        \n        if opt_result.get(\"passed\"):\n            try:\n                save_position({\n                    \"ticker\": ticker,\n                    \"signal_date\": datetime.utcnow().strftime(\"%Y-%m-%d\"),\n                    \"entry_price_stock\": opt.get(\n                        \"stock_price\",\n                        raw.get(\"price_context\", {}).get(\"price\", 0)\n                    ),\n                    \"entry_price_option\": opt.get(\"mid\", 0),\n                    \"entry_bid\": opt.get(\"bid\", 0),\n                    \"entry_ask\": opt.get(\"ask\", 0),\n                    \"strike\": opt.get(\"strike\", 0),\n                    \"expiry\": opt.get(\"expiry\", \"\"),\n                    \"quantity\": 1,\n                    \"position_size_pct\": t.get(\"position_size_pct\", 1),\n                    \"delta_entry\": opt.get(\"delta\", 0),\n                    \"vega_entry\": opt.get(\"vega\", 0),\n                    \"theta_entry\": opt.get(\"theta\", 0),\n                })\n            except Exception as e:\n                logger.warning(f\"Save position fail {ticker}: {e}\")\n        else:\n            logger.warning(f\"Post-Claude Options-Check failed for trade {ticker} – skipping position save\")\n    \n    sent = send_report(\n        trades=trades,\n        watchlist=watchlist,\n        exits=exits,\n        no_trades=no_trades,\n        warnings=warnings,\n        stats=stats,\n        run_mode=run_mode,\n    )\n    \n    log_scan(\n        found=stats.get(\"total\", 0),\n        sent=len(trades) + len(watchlist),\n        status=\"success\" if sent else \"email_failed\",\n        run_mode=run_mode,\n    )\n\n\n# ── Run-Mode Entry Points ────────────────────────────────────────────\n\ndef run_daily_light():\n    logger.info(\"█\" * 60)\n    logger.info(f\"DAILY LIGHT — {datetime.utcnow():%Y-%m-%d %H:%M UTC}\")\n    logger.info(\"█\" * 60)\n    \n    init_db()\n    scorer = FundScorer()\n    sf = SignalFilter()\n    \n    exits = []\n    try:\n        exits = run_exit_check()\n    except Exception as e:\n        logger.error(f\"Exit check: {e}\")\n    \n    raw = step_ingest(scorer, \"daily_light\")\n    signals = step_build_signals(raw, scorer, sf)\n    filtered = step_filter(signals, sf)\n    enriched = step_enrich(filtered, \"daily_light\")\n    analyzed = step_analyze(enriched)\n    \n    warnings = get_source_warnings()\n    stats = {\n        \"total\": len(signals),\n        \"filtered\": len(filtered),\n        \"analyzed\": len(analyzed),\n    }\n    \n    step_persist_and_send(analyzed, exits, warnings, stats, \"daily_light\")\n\n\ndef run_weekly_full():\n    logger.info(\"█\" * 60)\n    logger.info(f\"WEEKLY FULL — {datetime.utcnow():%Y-%m-%d %H:%M UTC}\")\n    logger.info(\"█\" * 60)\n    \n    init_db()\n    scorer = FundScorer()\n    sf = SignalFilter()\n    \n    exits = run_exit_check()\n    raw = step_ingest(scorer, \"weekly_full\")\n    signals = step_build_signals(raw, scorer, sf)\n    filtered = step_filter(signals, sf)\n    enriched = step_enrich(filtered, \"weekly_full\")\n    analyzed = step_analyze(enriched)\n    \n    warnings = get_source_warnings()\n    stats = {\n        \"total\": len(signals),\n        \"filtered\": len(filtered),\n        \"analyzed\": len(analyzed),\n    }\n    \n    step_persist_and_send(analyzed, exits, warnings, stats, \"weekly_full\")\n\n\ndef run_thirteenf():\n    logger.info(\"█\" * 60)\n    logger.info(f\"13F DEDICATED — {datetime.utcnow():%Y-%m-%d %H:%M UTC}\")\n    logger.info(\"█\" * 60)\n    \n    init_db()\n    scorer = FundScorer()\n    sf = SignalFilter()\n    \n    exits = run_exit_check()\n    raw = step_ingest(scorer, \"thirteenf\")\n    signals = step_build_signals(raw, scorer, sf)\n    filtered = step_filter(signals, sf)\n    enriched = step_enrich(filtered, \"thirteenf\")\n    analyzed = step_analyze(enriched)\n    \n    warnings = get_source_warnings()\n    stats = {\n        \"total\": len(signals),\n        \"filtered\": len(filtered),\n        \"analyzed\": len(analyzed),\n    }\n    \n    step_persist_and_send(analyzed, exits, warnings, stats, \"thirteenf\")\n\n\ndef run_weekly_review():\n    logger.info(\"█\" * 60)\n    logger.info(f\"WEEKLY REVIEW — {datetime.utcnow():%Y-%m-%d %H:%M UTC}\")\n    logger.info(\"█\" * 60)\n    \n    init_db()\n    try:\n        run_outcome_tracking()\n    except Exception as e:\n        logger.error(f\"Outcome tracking: {e}\")\n    \n    from src.utils.storage import get_conn\n    with get_conn() as conn:\n        wins = conn.execute(\"SELECT COUNT(*) as c FROM signals WHERE outcome='win'\").fetchone()[\"c\"]\n        losses = conn.execute(\"SELECT COUNT(*) as c FROM signals WHERE outcome='loss'\").fetchone()[\"c\"]\n        total = conn.execute(\"SELECT COUNT(*) as c FROM signals WHERE outcome IN ('win','loss')\").fetchone()[\"c\"]\n    \n    win_rate = wins / total if total > 0 else 0\n    \n    html = f\"\"\"\n    <html><body style=\"font-family: -apple-system, sans-serif; padding: 20px;\">\n      <h2>📊 Weekly Review</h2>\n      <p>Outcome-Tracking ausgeführt am {datetime.utcnow():%Y-%m-%d}</p>\n      <ul>\n        <li>Total geprüft: {total}</li>\n        <li>Wins: {wins}</li>\n        <li>Losses: {losses}</li>\n        <li>Win-Rate: {win_rate:.1%}</li>\n      </ul>\n    </body></html>\n    \"\"\"\n    from src.alerts.email_sender import send_email\n    send_email(f\"Weekly Review · WR {win_rate:.0%}\", html)\n\n\n# ── CLI ──────────────────────────────────────────────────────────────\n\ndef main():\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\n        \"--run-mode\",\n        choices=[\"daily_light\", \"weekly_full\", \"thirteenf\", \"weekly_review\"],\n        default=\"daily_light\",\n        help=\"Welcher Run-Mode soll ausgeführt werden\"\n    )\n    args = parser.parse_args()\n    \n    try:\n        if args.run_mode == \"daily_light\":\n            run_daily_light()\n        elif args.run_mode == \"weekly_full\":\n            run_weekly_full()\n        elif args.run_mode == \"thirteenf\":\n            run_thirteenf()\n        elif args.run_mode == \"weekly_review\":\n            run_weekly_review()\n    except Exception as e:\n        logger.exception(f\"Pipeline fail: {e}\")\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "src/__init__.py",
    "content": "\"\"\"Smart Money Scanner v2\"\"\"\n__version__ = \"2.0.0\"\n"
  },
  {
    "path": "src/ai/__init__.py",
    "content": "\"\"\"\nAI-Layer: Claude-Analyse + Outcome-Tracking.\n\"\"\"\n"
  },
  {
    "path": "src/ai/outcome_tracker.py",
    "content": "# src/ai/outcome_tracker.py\n\"\"\"\nOutcome-Tracker: misst Performance vergangener Signale.\n\nBerechnet 30/60/90d Returns nach Signal-Datum und kalibriert\nfund_weights automatisch basierend auf realer Performance.\n\"\"\"\nimport requests\nfrom datetime import datetime, timedelta\nfrom typing import List, Dict, Optional\nfrom src.utils.logger import logger\nfrom src.utils.storage import get_conn\nfrom src.utils.retry import retry\n\nHEADERS_YAHOO = {\n    \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\"\n}\n\n\n@retry(times=2, delay=3)\ndef _fetch_price_at(ticker: str, target_date: str) -> Optional[float]:\n    \"\"\"Holt Schlusskurs für ein bestimmtes Datum.\"\"\"\n    try:\n        target = datetime.strptime(target_date[:10], \"%Y-%m-%d\")\n        period_start = int((target - timedelta(days=5)).timestamp())\n        period_end = int((target + timedelta(days=5)).timestamp())\n        \n        url = f\"https://query1.finance.yahoo.com/v8/finance/chart/{ticker}\"\n        params = {\n            \"period1\": period_start,\n            \"period2\": period_end,\n            \"interval\": \"1d\"\n        }\n        resp = requests.get(url, headers=HEADERS_YAHOO, params=params, timeout=10)\n        if resp.status_code != 200:\n            return None\n        \n        data = resp.json()\n        result = data[\"chart\"][\"result\"][0]\n        timestamps = result.get(\"timestamp\", [])\n        closes = result[\"indicators\"][\"quote\"][0].get(\"close\", [])\n        \n        if not timestamps or not closes:\n            return None\n        \n        # Nächstgelegener Handelstag\n        target_ts = target.timestamp()\n        best_idx = min(\n            range(len(timestamps)),\n            key=lambda i: abs(timestamps[i] - target_ts)\n        )\n        return closes[best_idx] if closes[best_idx] else None\n    except Exception as e:\n        logger.debug(f\"Price {ticker}@{target_date}: {e}\")\n        return None\n\n\ndef calculate_returns(ticker: str, signal_date: str) -> Dict:\n    \"\"\"\n    Berechnet 30/60/90d Returns für ein Signal.\n    \"\"\"\n    entry_price = _fetch_price_at(ticker, signal_date)\n    if not entry_price:\n        return {\"error\": \"no_entry_price\"}\n    \n    sig_date = datetime.strptime(signal_date[:10], \"%Y-%m-%d\")\n    today = datetime.utcnow().date()\n    days_old = (today - sig_date.date()).days\n    \n    returns = {\"entry_price\": entry_price, \"days_since_signal\": days_old}\n    \n    for window in [30, 60, 90]:\n        if days_old >= window:\n            check_date = (sig_date + timedelta(days=window)).strftime(\"%Y-%m-%d\")\n            check_price = _fetch_price_at(ticker, check_date)\n            if check_price:\n                ret_pct = (check_price - entry_price) / entry_price * 100\n                returns[f\"return_{window}d\"] = round(ret_pct, 2)\n    \n    return returns\n\n\ndef update_signal_outcomes(min_age_days: int = 30):\n    \"\"\"\n    Updated Outcomes für alle Signale die älter als N Tage sind.\n    Klassifiziert win/loss basierend auf 60d-Return:\n      - win: >= +20%\n      - loss: <= -10%\n      - neutral: dazwischen\n    \"\"\"\n    with get_conn() as conn:\n        rows = conn.execute(\"\"\"\n            SELECT id, ticker, date, action, fund_name\n            FROM signals\n            WHERE outcome = '' \n              AND action IN ('trade', 'watchlist')\n              AND date <= date('now', ?)\n            ORDER BY date DESC\n            LIMIT 100\n        \"\"\", (f\"-{min_age_days} days\",)).fetchall()\n    \n    logger.info(f\"Outcome-Update: {len(rows)} Signale zu prüfen\")\n    updated = 0\n    \n    for row in rows:\n        ret = calculate_returns(row[\"ticker\"], row[\"date\"])\n        if \"error\" in ret:\n            continue\n        \n        # 60d-Return als Hauptkriterium\n        ret_60d = ret.get(\"return_60d\", ret.get(\"return_30d\", 0))\n        \n        if ret_60d >= 20:\n            outcome = \"win\"\n        elif ret_60d <= -10:\n            outcome = \"loss\"\n        else:\n            outcome = \"neutral\"\n        \n        with get_conn() as conn:\n            conn.execute(\"\"\"\n                UPDATE signals\n                SET outcome = ?, outcome_pct = ?\n                WHERE id = ?\n            \"\"\", (outcome, ret_60d, row[\"id\"]))\n        \n        updated += 1\n    \n    logger.info(f\"Outcome-Update: {updated} aktualisiert\")\n    return updated\n\n\ndef update_fund_performance():\n    \"\"\"\n    Aggregiert Outcomes pro Fund, schreibt fund_performance Tabelle.\n    \"\"\"\n    with get_conn() as conn:\n        rows = conn.execute(\"\"\"\n            SELECT fund_name,\n                   COUNT(*) as total,\n                   SUM(CASE WHEN outcome='win' THEN 1 ELSE 0 END) as wins,\n                   SUM(CASE WHEN outcome='loss' THEN 1 ELSE 0 END) as losses,\n                   AVG(CASE WHEN outcome='win' THEN outcome_pct END) as avg_win,\n                   AVG(CASE WHEN outcome='loss' THEN outcome_pct END) as avg_loss\n            FROM signals\n            WHERE outcome IN ('win', 'loss')\n            GROUP BY fund_name\n            HAVING total >= 5\n        \"\"\").fetchall()\n    \n    for row in rows:\n        total = row[\"total\"]\n        wins = row[\"wins\"] or 0\n        win_rate = wins / total if total > 0 else 0.5\n        \n        with get_conn() as conn:\n            conn.execute(\"\"\"\n                INSERT OR REPLACE INTO fund_performance\n                  (fund_name, total_signals, wins, losses, win_rate,\n                   avg_win_pct, avg_loss_pct, last_updated)\n                VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)\n            \"\"\", (\n                row[\"fund_name\"], total, wins, row[\"losses\"] or 0,\n                win_rate,\n                (row[\"avg_win\"] or 65) / 100 if row[\"avg_win\"] else 0.65,\n                (row[\"avg_loss\"] or -40) / 100 if row[\"avg_loss\"] else -0.40,\n            ))\n    \n    logger.info(f\"Fund-Performance: {len(rows)} Funds aktualisiert\")\n    return len(rows)\n\n\ndef run_outcome_tracking():\n    \"\"\"Hauptfunktion: ein kompletter Outcome-Update-Lauf.\"\"\"\n    update_signal_outcomes(min_age_days=30)\n    update_fund_performance()\n"
  },
  {
    "path": "src/ai/single_analyzer.py",
    "content": "# src/ai/single_analyzer.py\n\"\"\"\nSingle-Signal Analyzer mit Claude.\n\nErweiterter Prompt für 2-6 Monats-Calls mit:\n- Strategischer Kontext\n- Multi-Quartals-Trend\n- Preis-Kontext (Yahoo)\n- Catalyst (Earnings)\n- Options-Daten (Tradier real-time)\n- Position-Sizing (Quarter-Kelly)\n- Strikte Exit-Regeln\n\"\"\"\nimport os\nimport json\nimport re\nimport anthropic\nfrom typing import Dict, List\nfrom src.utils.logger import logger\nfrom src.utils.storage import get_fund_history, get_fund_accuracy\nfrom src.utils.config import get_threshold\nfrom src.utils.retry import retry\n\nSYSTEM = \"\"\"Du bist ein extrem disziplinierter, quantitativer Smart-Money-Analyst.\n\nDEINE AUFGABE:\nEntscheide ob ein gefiltertes Insider/Institutional-Signal trade-würdig ist\nfür einen 90-180 Tage Call-Option (mittelfristige Conviction).\n\nSTRIKTE REGELN:\n- Du wirst 60-70% aller Signale als \"kein_trade\" klassifizieren. Das ist Qualität.\n- Multi-Quartals-Trend (3+ Quartale in Folge) = stärkstes Signal überhaupt\n- Multi-Fund-Cluster (2+ Top-Funds): starke Tendenz zu trade/watchlist\n- IV-Rank > 50 = teurer Eintrag, brauchst stärkeres Signal\n- IV-Rank > 70 = Hard-Kill, niemals empfehlen\n- Earnings zu nah (<30d): IV bereits aufgebläht, warten\n- Earnings 30-90d: optimaler Katalysator-Bonus\n- 10b5-1 Plan-Trades sind IMMER schwächer als spontane Käufe\n- Preis-Kontext: nahe 52W-Tief + unter MA50 = besseres Setup\n\nPOSITION-SIZING:\n- Berechne Quarter-Kelly basierend auf Fund-Win-Rate\n- Maximum 5% pro Trade (auch wenn Kelly höher)\n- Minimum 0.5% (sonst zu klein)\n\nEXIT-REGELN (NIEMALS verhandelbar):\n- Take-Profit bei +80%\n- Stop-Loss bei -45%\n- IMMER raus bei ≤21 Tagen bis Expiry (Theta!)\n- Exit bei Fund-Verkauf im nächsten 13F\n- Exit bei Insider Sell-Off\n\nAntworte NUR mit validem JSON. Kein Text davor oder danach.\"\"\"\n\n\nPROMPT = \"\"\"SIGNAL ZUR ANALYSE (für 90-180d Call):\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nSIGNAL DETAILS:\nTicker:               {ticker}\nFund:                 {fund_name} (Score: {fund_score}/50, Kategorie: {fund_category})\nSignal-Typ:           {signal_type}\nQuellen ({source_count}):           {sources}\nSignal-Stärke:        {strength}/100\nConviction:           {conviction:.2f}\nMulti-Quartals-Trend: {consecutive_quarters} Quartale in Folge {trend_indicator}\nCluster-Info:         {cluster_info}\n10b5-1 Plan:          {is_10b5}\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPREIS-KONTEXT (Yahoo Finance):\n{price_context}\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nCATALYST:\n{catalyst_info}\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nOPTIONS-DATEN (Tradier Real-Time):\n{options_block}\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nFUND-HISTORIE (letzte 3):\n{fund_history}\nHistorische Trefferquote: {historical_accuracy:.0%}\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nNEWS & MAKRO:\nNews-Alignment:       {news_alignment:+.2f}  (-1=bearish, +1=bullish)\nMakro-Kontext:        {macro_context}\nAktuelle Headlines:\n{headlines}\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nAntworte mit exakt diesem JSON:\n{{\n  \"action\": \"trade\" | \"watchlist\" | \"kein_trade\",\n  \"confidence\": 0.0,\n  \"signal_score\": 0,\n  \"reasoning\": \"Max. 3 präzise Sätze: Warum stark/schwach? Welche Daten überzeugen?\",\n  \"key_arguments\": [\"Argument 1\", \"Argument 2\", \"Argument 3\"],\n  \"risk_factors\": [\"max. 2 konkrete Risiken\"],\n  \"suggested_instrument\": \"Exakter Strike, Expiry, Mid-Price - z.B. NVDA Call $200 Exp 2025-09-20 (120d) Mid $12.50\",\n  \"options_rationale\": \"Warum dieser Strike + Laufzeit\",\n  \"position_size_pct\": 2.5,\n  \"position_sizing_logic\": \"Quarter-Kelly basierend auf Win-Rate X%\",\n  \"exit_triggers\": {{\n    \"take_profit_pct\": 80,\n    \"stop_loss_pct\": -45,\n    \"time_exit_days\": 21,\n    \"fund_exit\": \"Wenn Fund im nächsten 13F reduziert\"\n  }},\n  \"no_trade_reason\": \"Nur wenn action=kein_trade, sonst null\"\n}}\"\"\"\n\n\nclass SingleAnalyzer:\n    \"\"\"Claude-basierte Single-Signal-Analyse.\"\"\"\n\n    def __init__(self):\n        api_key = os.environ.get(\"ANTHROPIC_API_KEY\")\n        if not api_key:\n            raise ValueError(\"ANTHROPIC_API_KEY nicht gesetzt\")\n        self.client = anthropic.Anthropic(api_key=api_key)\n        self.model = \"claude-sonnet-4-6\"\n\n    def _format_history(self, history: List[Dict]) -> str:\n        if not history:\n            return \"  Keine historischen Daten (neuer Fund im System)\"\n        lines = []\n        for h in history:\n            outcome = h.get(\"outcome\") or \"ausstehend\"\n            pct = h.get(\"outcome_pct\", 0)\n            lines.append(\n                f\"  {h.get('date', '?')} | {h.get('ticker', '?')} | \"\n                f\"{h.get('action', '?')} | {outcome} ({pct:+.0f}%) | \"\n                f\"{h.get('reasoning', '')[:60]}\"\n            )\n        return \"\\n\".join(lines)\n\n    def _format_options_block(self, signal: Dict) -> str:\n        opt_data = signal.get(\"options_data\") or {}\n        if not opt_data:\n            return \"  Keine Options-Daten verfügbar\"\n\n        return (\n            f\"  Call Strike ${opt_data.get('strike', 0):.2f} ({signal.get('otm_pct', 0):.1f}% OTM)\\n\"\n            f\"  Expiry: {opt_data.get('expiry', '?')} ({opt_data.get('days_to_exp', 0)} Tage)\\n\"\n            f\"  Mid: ${opt_data.get('mid', 0):.2f} | Bid: ${opt_data.get('bid', 0):.2f} | Ask: ${opt_data.get('ask', 0):.2f}\\n\"\n            f\"  Spread: {opt_data.get('spread_pct', 0):.2f}% | OI: {opt_data.get('open_interest', 0):,}\\n\"\n            f\"  IV: {opt_data.get('iv', 0):.1f}% | IV-Rank: {opt_data.get('iv_rank', 0):.0f}\\n\"\n            f\"  Greeks: Delta={opt_data.get('delta', 0):.2f} | Vega={opt_data.get('vega', 0):.3f} | \"\n            f\"Theta={opt_data.get('theta', 0):.3f}\"\n        )\n\n    def _format_catalyst(self, signal: Dict) -> str:\n        cat = signal.get(\"catalyst\") or {}\n        if not cat or not cat.get(\"has_catalyst\"):\n            return \"  Kein Earnings-Katalysator in Laufzeit (Conviction -0.10)\"\n\n        return (\n            f\"  Type: {cat.get('type', '?')}\\n\"\n            f\"  Datum: {cat.get('date', '?')} (in {cat.get('days_away', 0)} Tagen)\\n\"\n            f\"  Bewertung: {cat.get('summary', '')}\\n\"\n            f\"  Conviction-Modifier: {cat.get('conviction_modifier', 0):+.2f}\"\n        )\n\n    def _format_price_context(self, signal: Dict) -> str:\n        from src.enrich.price_context import format_for_prompt\n        ctx = signal.get(\"price_context\") or {}\n        return format_for_prompt(ctx)\n\n    @retry(times=3, delay=5)\n    def _call_api(self, prompt: str) -> str:\n        resp = self.client.messages.create(\n            model=self.model,\n            max_tokens=1500,\n            system=SYSTEM,\n            messages=[{\"role\": \"user\", \"content\": prompt}]\n        )\n        return resp.content[0].text.strip()\n\n    def analyze(self, signal: Dict, news: List[Dict]) -> Dict:\n        \"\"\"Hauptfunktion: einzelnes Signal analysieren.\"\"\"\n        fund_name = signal.get(\"fund_name\", \"Unknown\")\n        history = get_fund_history(fund_name)\n        accuracy = get_fund_accuracy(fund_name)\n\n        headlines = \"\\n\".join(f\"  - {n.get('title', '')}\" for n in news[:8]) \\\n                    or \"  Keine relevanten News\"\n\n        consecutive = signal.get(\"consecutive_quarters\", 0)\n        trend_indicator = \"\"\n        if consecutive >= 3:\n            trend_indicator = \"← STÄRKSTES SIGNAL ✓\"\n        elif consecutive >= 2:\n            trend_indicator = \"← Starkes Signal ✓\"\n\n        cluster_info = \"Standard\"\n        if signal.get(\"is_clustered\"):\n            cluster_info = \"Clustered (mehrere unabhängige Quellen)\"\n\n        prompt = PROMPT.format(\n            ticker=signal.get(\"ticker\", \"\"),\n            fund_name=fund_name,\n            fund_score=signal.get(\"fund_score\", 0),\n            fund_category=signal.get(\"fund_category\", \"unknown\"),\n            signal_type=signal.get(\"signal_type\", \"\"),\n            source_count=signal.get(\"source_count\", 1),\n            sources=\", \".join(signal.get(\"sources\", [signal.get(\"signal_type\", \"\")])),\n            strength=signal.get(\"strength\", 0),\n            conviction=signal.get(\"conviction\", 0.0),\n            consecutive_quarters=consecutive,\n            trend_indicator=trend_indicator,\n            cluster_info=cluster_info,\n            is_10b5=\"JA (schwächer)\" if signal.get(\"is_10b5\") else \"Nein\",\n            price_context=self._format_price_context(signal),\n            catalyst_info=self._format_catalyst(signal),\n            options_block=self._format_options_block(signal),\n            fund_history=self._format_history(history),\n            historical_accuracy=accuracy,\n            news_alignment=signal.get(\"news_alignment\", 0.0),\n            macro_context=signal.get(\"macro_context\", \"neutral\"),\n            headlines=headlines,\n        )\n\n        try:\n            response_text = self._call_api(prompt)\n\n            # Strip markdown code fences if present\n            response_text = re.sub(r\"^```(?:json)?\\s*\", \"\", response_text)\n            response_text = re.sub(r\"\\s*```$\", \"\", response_text)\n\n            result = json.loads(response_text.strip())\n            result[\"ticker\"] = signal.get(\"ticker\", \"\")\n            result[\"fund_name\"] = fund_name\n            result[\"raw_signal\"] = signal\n\n            action = result.get(\"action\", \"kein_trade\")\n            conf = result.get(\"confidence\", 0.0)\n            logger.info(f\"  Claude → {result['ticker']}: {action} ({conf:.2f})\")\n            return result\n        except json.JSONDecodeError as e:\n            logger.error(f\"JSON-Fehler: {e}\")\n            return {\n                \"action\": \"kein_trade\",\n                \"confidence\": 0.0,\n                \"ticker\": signal.get(\"ticker\", \"\"),\n                \"reasoning\": \"JSON-Parse-Fehler\",\n                \"raw_signal\": signal\n            }\n        except Exception as e:\n            logger.error(f\"Claude API: {e}\")\n            return {\n                \"action\": \"kein_trade\",\n                \"confidence\": 0.0,\n                \"ticker\": signal.get(\"ticker\", \"\"),\n                \"reasoning\": str(e),\n                \"raw_signal\": signal\n            }\n\n    def analyze_batch(self, signals: List[Dict], news_map: Dict) -> List[Dict]:\n        \"\"\"Mehrere Signale analysieren.\"\"\"\n        results = []\n        for sig in signals:\n            news = news_map.get(sig.get(\"ticker\", \"\"), [])\n            result = self.analyze(sig, news)\n            results.append(result)\n\n        trades = [r for r in results if r.get(\"action\") == \"trade\"]\n        watchlist = [r for r in results if r.get(\"action\") == \"watchlist\"]\n        no_trade = [r for r in results if r.get(\"action\") == \"kein_trade\"]\n        logger.info(\n            f\"Claude-Batch: {len(trades)} TRADE · \"\n            f\"{len(watchlist)} WATCHLIST · {len(no_trade)} KEIN\"\n        )\n        return results\n"
  },
  {
    "path": "src/alerts/__init__.py",
    "content": "\"\"\"\nAlerts-Layer: E-Mail-Versand mit Apple-Style HTML.\n\"\"\"\n"
  },
  {
    "path": "src/alerts/email_sender.py",
    "content": "# src/alerts/email_sender.py\n\"\"\"\nE-Mail-Versand mit Apple-Style HTML-Design.\n\nSektionen:\n- Trades (action=trade)\n- Watchlist (action=watchlist)\n- Exits (Exit-Trigger ausgelöst)\n- Source Health Warnings\n- Footer mit Stats\n\"\"\"\nimport os\nimport smtplib\nfrom datetime import datetime\nfrom typing import List, Dict\nfrom email.mime.text import MIMEText\nfrom email.mime.multipart import MIMEMultipart\nfrom src.utils.logger import logger\n\nGMAIL_USER = os.environ.get(\"GMAIL_USER\", \"\")\nGMAIL_PASSWORD = os.environ.get(\"GMAIL_PASSWORD\", \"\")\nRECIPIENT = os.environ.get(\"RECIPIENT_EMAIL\", \"\")\n\n\n# ── HTML Templates (Apple-inspiriert) ────────────────────────────────\n\nCSS = \"\"\"\n<style>\n  body { \n    font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif;\n    background: #f5f5f7;\n    margin: 0; padding: 24px;\n    color: #1d1d1f;\n    line-height: 1.5;\n  }\n  .container {\n    max-width: 720px; margin: 0 auto;\n    background: white; border-radius: 18px;\n    overflow: hidden;\n    box-shadow: 0 4px 20px rgba(0,0,0,0.06);\n  }\n  .header {\n    background: linear-gradient(135deg, #007aff 0%, #5856d6 100%);\n    color: white; padding: 32px 28px;\n  }\n  .header h1 { margin: 0 0 6px; font-size: 28px; font-weight: 600; }\n  .header .subtitle { opacity: 0.92; font-size: 14px; }\n  .section { padding: 24px 28px; border-bottom: 1px solid #f0f0f3; }\n  .section:last-child { border-bottom: none; }\n  .section-title {\n    font-size: 11px; font-weight: 700;\n    text-transform: uppercase; letter-spacing: 0.8px;\n    color: #6e6e73; margin-bottom: 14px;\n  }\n  .signal-card {\n    background: #fafafa; border-radius: 12px;\n    padding: 18px; margin-bottom: 12px;\n    border-left: 4px solid #007aff;\n  }\n  .signal-card.trade { border-left-color: #34c759; }\n  .signal-card.watchlist { border-left-color: #ff9500; }\n  .signal-card.exit { border-left-color: #ff3b30; }\n  .signal-card.exit-tp { border-left-color: #34c759; }\n  .signal-card.no-trade { border-left-color: #8e8e93; opacity: 0.85; }\n  .signal-header {\n    display: flex; justify-content: space-between;\n    align-items: baseline; margin-bottom: 8px;\n  }\n  .ticker { font-size: 22px; font-weight: 700; color: #1d1d1f; }\n  .badge {\n    display: inline-block; padding: 3px 10px;\n    border-radius: 12px; font-size: 11px;\n    font-weight: 600; text-transform: uppercase;\n  }\n  .badge-trade { background: #d1f2d8; color: #1e7e34; }\n  .badge-watch { background: #ffeacc; color: #b35d00; }\n  .badge-exit { background: #ffcdcd; color: #b30000; }\n  .badge-tp { background: #d1f2d8; color: #1e7e34; }\n  .badge-neutral { background: #e8e8ed; color: #424245; }\n  .meta {\n    font-size: 13px; color: #6e6e73;\n    margin: 8px 0;\n  }\n  .reasoning {\n    font-size: 14px; color: #1d1d1f;\n    margin: 10px 0; line-height: 1.6;\n  }\n  .args { margin: 10px 0; padding-left: 0; }\n  .args li {\n    list-style: none; padding: 5px 0 5px 22px;\n    font-size: 13px; position: relative;\n  }\n  .args li:before {\n    content: \"✓\"; position: absolute;\n    left: 0; color: #34c759; font-weight: 700;\n  }\n  .risks li:before { content: \"⚠\"; color: #ff9500; }\n  .instrument {\n    background: #1d1d1f; color: #f5f5f7;\n    padding: 12px 14px; border-radius: 8px;\n    font-family: 'SF Mono', Menlo, monospace;\n    font-size: 12px; margin-top: 12px;\n    overflow-x: auto;\n  }\n  .stats {\n    display: flex; gap: 18px;\n    flex-wrap: wrap; margin-top: 12px;\n  }\n  .stat {\n    background: white; padding: 6px 10px;\n    border-radius: 6px; font-size: 12px;\n    border: 1px solid #e8e8ed;\n  }\n  .stat strong { color: #007aff; }\n  .empty {\n    text-align: center; padding: 30px;\n    color: #8e8e93; font-style: italic;\n  }\n  .warning {\n    background: #fff8e1; border-left: 4px solid #ff9500;\n    padding: 14px 18px; border-radius: 8px;\n    margin-bottom: 12px; font-size: 13px;\n  }\n  .footer {\n    background: #f5f5f7;\n    padding: 18px 28px;\n    text-align: center;\n    font-size: 11px;\n    color: #8e8e93;\n  }\n  .pnl-pos { color: #34c759; font-weight: 600; }\n  .pnl-neg { color: #ff3b30; font-weight: 600; }\n</style>\n\"\"\"\n\n\ndef _format_signal(sig: Dict) -> str:\n    \"\"\"Formatiert ein Signal als HTML-Card.\"\"\"\n    action = sig.get(\"action\", \"kein_trade\")\n    ticker = sig.get(\"ticker\", \"?\")\n    confidence = sig.get(\"confidence\", 0)\n    reasoning = sig.get(\"reasoning\", \"Keine Begründung\")\n    instrument = sig.get(\"suggested_instrument\", \"\")\n    options_rationale = sig.get(\"options_rationale\", \"\")\n    position_size = sig.get(\"position_size_pct\", 0)\n    sizing_logic = sig.get(\"position_sizing_logic\", \"\")\n    \n    args = sig.get(\"key_arguments\", [])\n    risks = sig.get(\"risk_factors\", [])\n    \n    raw = sig.get(\"raw_signal\", {})\n    fund_name = raw.get(\"fund_name\", sig.get(\"fund_name\", \"Unknown\"))\n    consecutive = raw.get(\"consecutive_quarters\", 0)\n    \n    css_class = \"trade\" if action == \"trade\" else (\"watchlist\" if action == \"watchlist\" else \"no-trade\")\n    badge_class = \"trade\" if action == \"trade\" else (\"watch\" if action == \"watchlist\" else \"neutral\")\n    badge_text = action.upper().replace(\"_\", \" \")\n    \n    args_html = \"\".join(f\"<li>{a}</li>\" for a in args) if args else \"\"\n    risks_html = \"\".join(f'<li class=\"risk\">{r}</li>' for r in risks) if risks else \"\"\n    \n    consec_str = f\" · {consecutive}Q in Folge ✓\" if consecutive >= 2 else \"\"\n    \n    instrument_html = \"\"\n    if instrument:\n        instrument_html = f'<div class=\"instrument\">{instrument}'\n        if options_rationale:\n            instrument_html += f\"<br><br><em>{options_rationale}</em>\"\n        instrument_html += \"</div>\"\n    \n    sizing_html = \"\"\n    if action == \"trade\" and position_size:\n        sizing_html = (\n            f'<div class=\"stats\">'\n            f'<span class=\"stat\">Size: <strong>{position_size:.1f}%</strong></span>'\n            f'<span class=\"stat\">Conf: <strong>{confidence:.0%}</strong></span>'\n            f'</div>'\n            f'<div class=\"meta\" style=\"margin-top:6px\"><em>{sizing_logic}</em></div>'\n        )\n    \n    return f\"\"\"\n    <div class=\"signal-card {css_class}\">\n      <div class=\"signal-header\">\n        <div>\n          <span class=\"ticker\">{ticker}</span>\n          <span class=\"badge badge-{badge_class}\">{badge_text}</span>\n        </div>\n      </div>\n      <div class=\"meta\">{fund_name}{consec_str}</div>\n      <div class=\"reasoning\">{reasoning}</div>\n      {f'<ul class=\"args\">{args_html}</ul>' if args_html else ''}\n      {f'<ul class=\"args risks\">{risks_html}</ul>' if risks_html else ''}\n      {instrument_html}\n      {sizing_html}\n    </div>\n    \"\"\"\n\n\ndef _format_exit(exit_data: Dict) -> str:\n    \"\"\"Formatiert eine Exit-Empfehlung.\"\"\"\n    pos = exit_data[\"position\"]\n    trigger = exit_data[\"trigger\"]\n    \n    reason = trigger[\"reason\"]\n    pnl = trigger.get(\"pnl_pct\", 0)\n    days_left = trigger.get(\"days_left\", 0)\n    \n    pnl_class = \"pnl-pos\" if pnl > 0 else \"pnl-neg\"\n    \n    css_class = \"exit-tp\" if reason == \"take_profit\" else \"exit\"\n    badge_class = \"tp\" if reason == \"take_profit\" else \"exit\"\n    \n    reason_label = {\n        \"take_profit\": \"TAKE PROFIT\",\n        \"stop_loss\": \"STOP LOSS\",\n        \"time_exit\": \"TIME EXIT\",\n        \"partial_take\": \"PARTIAL TAKE\",\n    }.get(reason, reason.upper())\n    \n    return f\"\"\"\n    <div class=\"signal-card {css_class}\">\n      <div class=\"signal-header\">\n        <div>\n          <span class=\"ticker\">{pos['ticker']}</span>\n          <span class=\"badge badge-{badge_class}\">{reason_label}</span>\n        </div>\n      </div>\n      <div class=\"meta\">\n        Strike ${pos.get('strike', 0):.2f} · Exp {pos.get('expiry', '?')} · \n        {days_left}d remaining\n      </div>\n      <div class=\"reasoning\">\n        <strong>{trigger.get('message', '')}</strong><br>\n        Entry: ${pos.get('entry_price_option', 0):.2f} → \n        Current: ${trigger.get('current_mid', 0):.2f}\n        (<span class=\"{pnl_class}\">{pnl:+.1f}%</span>)\n      </div>\n    </div>\n    \"\"\"\n\n\ndef _format_warning(warning: Dict) -> str:\n    \"\"\"Source-Health-Warning.\"\"\"\n    return f\"\"\"\n    <div class=\"warning\">\n      ⚠️ <strong>{warning['source']}</strong> liefert seit {warning['days']} Tagen \n      keine Daten mehr. Bitte prüfen.\n    </div>\n    \"\"\"\n\n\ndef build_html(\n    trades: List[Dict],\n    watchlist: List[Dict],\n    exits: List[Dict],\n    no_trades: List[Dict],\n    warnings: List[Dict],\n    stats: Dict,\n    run_mode: str,\n) -> str:\n    \"\"\"Baut komplette HTML-Email.\"\"\"\n    \n    today = datetime.utcnow().strftime(\"%A, %d. %B %Y\")\n    \n    # Sections\n    trade_section = \"\"\n    if trades:\n        trade_html = \"\".join(_format_signal(t) for t in trades)\n        trade_section = f\"\"\"\n        <div class=\"section\">\n          <div class=\"section-title\">📈 Trade-Empfehlungen ({len(trades)})</div>\n          {trade_html}\n        </div>\n        \"\"\"\n    \n    watch_section = \"\"\n    if watchlist:\n        watch_html = \"\".join(_format_signal(w) for w in watchlist)\n        watch_section = f\"\"\"\n        <div class=\"section\">\n          <div class=\"section-title\">👁️ Watchlist ({len(watchlist)})</div>\n          {watch_html}\n        </div>\n        \"\"\"\n    \n    exit_section = \"\"\n    if exits:\n        exit_html = \"\".join(_format_exit(e) for e in exits)\n        exit_section = f\"\"\"\n        <div class=\"section\">\n          <div class=\"section-title\">🚪 Exit-Trigger ({len(exits)})</div>\n          {exit_html}\n        </div>\n        \"\"\"\n    \n    warning_section = \"\"\n    if warnings:\n        warning_html = \"\".join(_format_warning(w) for w in warnings)\n        warning_section = f\"\"\"\n        <div class=\"section\">\n          <div class=\"section-title\">⚠️ Datenquellen-Warnungen</div>\n          {warning_html}\n        </div>\n        \"\"\"\n    \n    no_trade_section = \"\"\n    if no_trades and run_mode == \"weekly_full\":\n        # Zeigt no-trades nur im Weekly-Full\n        nt_html = \"\".join(_format_signal(nt) for nt in no_trades[:5])\n        no_trade_section = f\"\"\"\n        <div class=\"section\">\n          <div class=\"section-title\">⚪ Verworfene Signale ({len(no_trades)} total, top 5)</div>\n          {nt_html}\n        </div>\n        \"\"\"\n    \n    # Empty state\n    if not trades and not watchlist and not exits:\n        empty_html = \"\"\"\n        <div class=\"section\">\n          <div class=\"empty\">\n            Keine handelbaren Signale heute.<br>\n            <small>Disziplin > Aktivität.</small>\n          </div>\n        </div>\n        \"\"\"\n    else:\n        empty_html = \"\"\n    \n    # Stats footer\n    stats_html = (\n        f\"Run: {run_mode} · \"\n        f\"Signals raw: {stats.get('total', 0)} · \"\n        f\"Filter passed: {stats.get('filtered', 0)} · \"\n        f\"Claude analyzed: {stats.get('analyzed', 0)} · \"\n        f\"Trades: {len(trades)} · Watchlist: {len(watchlist)}\"\n    )\n    \n    return f\"\"\"\n    <!DOCTYPE html>\n    <html>\n    <head>\n      <meta charset=\"utf-8\">\n      <title>Smart Money Scanner</title>\n      {CSS}\n    </head>\n    <body>\n      <div class=\"container\">\n        <div class=\"header\">\n          <h1>Smart Money Scanner</h1>\n          <div class=\"subtitle\">{today} · {run_mode}</div>\n        </div>\n        \n        {warning_section}\n        {exit_section}\n        {trade_section}\n        {watch_section}\n        {empty_html}\n        {no_trade_section}\n        \n        <div class=\"footer\">\n          {stats_html}<br>\n          v2.0.0 · Modular Architecture · Tradier + Claude\n        </div>\n      </div>\n    </body>\n    </html>\n    \"\"\"\n\n\ndef send_email(subject: str, html: str) -> bool:\n    \"\"\"Versendet E-Mail via Gmail SMTP.\"\"\"\n    if not GMAIL_USER or not GMAIL_PASSWORD or not RECIPIENT:\n        logger.error(\"E-Mail-Credentials fehlen\")\n        return False\n    \n    try:\n        msg = MIMEMultipart(\"alternative\")\n        msg[\"Subject\"] = subject\n        msg[\"From\"] = GMAIL_USER\n        msg[\"To\"] = RECIPIENT\n        msg.attach(MIMEText(html, \"html\", \"utf-8\"))\n        \n        with smtplib.SMTP_SSL(\"smtp.gmail.com\", 465, timeout=30) as server:\n            server.login(GMAIL_USER, GMAIL_PASSWORD)\n            server.sendmail(GMAIL_USER, RECIPIENT, msg.as_string())\n        \n        logger.info(f\"E-Mail gesendet an {RECIPIENT}\")\n        return True\n    except Exception as e:\n        logger.error(f\"E-Mail Fehler: {e}\")\n        return False\n\n\ndef send_report(\n    trades: List[Dict] = None,\n    watchlist: List[Dict] = None,\n    exits: List[Dict] = None,\n    no_trades: List[Dict] = None,\n    warnings: List[Dict] = None,\n    stats: Dict = None,\n    run_mode: str = \"scan\",\n) -> bool:\n    \"\"\"High-Level: Bericht zusammenstellen + senden.\"\"\"\n    trades = trades or []\n    watchlist = watchlist or []\n    exits = exits or []\n    no_trades = no_trades or []\n    warnings = warnings or []\n    stats = stats or {}\n    \n    # Subject\n    parts = []\n    if exits:\n        parts.append(f\"{len(exits)}🚪\")\n    if trades:\n        parts.append(f\"{len(trades)}📈\")\n    if watchlist:\n        parts.append(f\"{len(watchlist)}👁\")\n    \n    subject = (\n        f\"Smart Money: {' · '.join(parts)}\" if parts\n        else f\"Smart Money: keine Signale\"\n    )\n    \n    html = build_html(trades, watchlist, exits, no_trades, warnings, stats, run_mode)\n    return send_email(subject, html)\n"
  },
  {
    "path": "src/enrich/__init__.py",
    "content": "\"\"\"\nEnrich-Layer: Anreicherung der Rohdaten.\n- price_context: aktueller Kurs, MA50, 52W-Range\n- catalyst_finder: Earnings-Termine\n- options_prefilter: Tradier-basierte Pre-Filterung\n- sentiment: News-Sentiment\n\"\"\"\n"
  },
  {
    "path": "src/enrich/catalyst_finder.py",
    "content": "# src/enrich/catalyst_finder.py\n\"\"\"\nCatalyst-Finder: sucht Earnings-Termine im Options-Laufzeitfenster.\nQuelle: Yahoo Finance Calendar.\n\nBewertung:\n- Optimal (30-90 Tage): +0.20 conviction\n- Zu nah (<30 Tage): -0.15 (IV bereits aufgebläht)\n- Zu weit/keine: -0.10\n\"\"\"\nimport requests\nfrom typing import Optional, Dict\nfrom datetime import datetime, date, timedelta\nfrom src.utils.logger import logger\nfrom src.utils.retry import retry\nfrom src.utils.config import get_threshold\n\nHEADERS_YAHOO = {\n    \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\"\n}\n\n\n@retry(times=2, delay=3)\ndef get_earnings_date(ticker: str) -> Optional[date]:\n    \"\"\"Yahoo Finance Calendar für nächstes Earnings-Datum.\"\"\"\n    if not ticker or ticker in (\"UNKNOWN\", \"PORTFOLIO\", \"\"):\n        return None\n    \n    try:\n        url = f\"https://query1.finance.yahoo.com/v10/finance/quoteSummary/{ticker}\"\n        params = {\"modules\": \"calendarEvents\"}\n        resp = requests.get(url, params=params, headers=HEADERS_YAHOO, timeout=10)\n        if resp.status_code != 200:\n            return None\n        \n        data = resp.json()\n        earnings_dates = (\n            data.get(\"quoteSummary\", {})\n                .get(\"result\", [{}])[0]\n                .get(\"calendarEvents\", {})\n                .get(\"earnings\", {})\n                .get(\"earningsDate\", [])\n        )\n        if earnings_dates:\n            ts = earnings_dates[0][\"raw\"]\n            return datetime.utcfromtimestamp(ts).date()\n    except Exception as e:\n        logger.debug(f\"Earnings {ticker}: {e}\")\n    \n    return None\n\n\ndef catalyst_score(ticker: str, expiry_date: Optional[date] = None) -> Dict:\n    \"\"\"\n    Bewertet Earnings-Catalyst für eine Options-Position.\n    \n    Args:\n        ticker: Aktien-Ticker\n        expiry_date: Geplantes Options-Expiry (optional)\n    \n    Returns:\n        Dict mit has_catalyst, type, date, days_away, conviction_modifier\n    \"\"\"\n    earnings = get_earnings_date(ticker)\n    \n    if not earnings:\n        return {\n            \"has_catalyst\": False,\n            \"conviction_modifier\": get_threshold(\"catalyst\", \"penalty_no_catalyst\", -0.10),\n            \"summary\": \"Kein Earnings-Datum gefunden\"\n        }\n    \n    today = datetime.utcnow().date()\n    days_away = (earnings - today).days\n    \n    # Falls Expiry vorgegeben: muss Earnings davor liegen\n    if expiry_date:\n        days_to_expiry = (expiry_date - today).days\n        if days_away > days_to_expiry:\n            return {\n                \"has_catalyst\": False,\n                \"type\": \"earnings_after_expiry\",\n                \"conviction_modifier\": get_threshold(\"catalyst\", \"penalty_no_catalyst\", -0.10),\n                \"summary\": f\"Earnings ({earnings}) liegt nach Expiry\"\n            }\n    \n    too_close = get_threshold(\"catalyst\", \"earnings_too_close_days\", 30)\n    optimal_min = get_threshold(\"catalyst\", \"earnings_optimal_min_days\", 30)\n    optimal_max = get_threshold(\"catalyst\", \"earnings_optimal_max_days\", 90)\n    \n    if days_away < too_close:\n        return {\n            \"has_catalyst\": True,\n            \"type\": \"earnings_too_close\",\n            \"date\": str(earnings),\n            \"days_away\": days_away,\n            \"conviction_modifier\": get_threshold(\"catalyst\", \"penalty_too_close\", -0.15),\n            \"summary\": f\"Earnings in {days_away}d - zu nah, IV aufgebläht\"\n        }\n    \n    if optimal_min <= days_away <= optimal_max:\n        return {\n            \"has_catalyst\": True,\n            \"type\": \"earnings_optimal\",\n            \"date\": str(earnings),\n            \"days_away\": days_away,\n            \"conviction_modifier\": get_threshold(\"catalyst\", \"bonus_optimal\", 0.20),\n            \"summary\": f\"Earnings in {days_away}d ✓ optimal\"\n        }\n    \n    return {\n        \"has_catalyst\": True,\n        \"type\": \"earnings_far\",\n        \"date\": str(earnings),\n        \"days_away\": days_away,\n        \"conviction_modifier\": get_threshold(\"catalyst\", \"bonus_acceptable\", 0.05),\n        \"summary\": f\"Earnings in {days_away}d - akzeptabel\"\n    }\n"
  },
  {
    "path": "src/enrich/macro_context.py",
    "content": "# src/enrich/macro_context.py\n\"\"\"\nMakro-Kontext via Polymarket + Kalshi.\nLiefert \"bullish\" / \"neutral\" / \"bearish\" für aktuelles Marktumfeld.\n\"\"\"\nimport os\nimport requests\nfrom typing import Dict, List\nfrom src.utils.logger import logger\nfrom src.utils.retry import retry\n\nHEADERS = {\n    \"User-Agent\": f\"SmartMoneyScanner {os.environ.get('GMAIL_USER', 'scanner@example.com')}\"\n}\n\n\n@retry(times=2, delay=5)\ndef _polymarket(keywords: List[str]) -> List[Dict]:\n    try:\n        resp = requests.get(\n            \"https://gamma-api.polymarket.com/markets?closed=false&limit=100\",\n            headers=HEADERS, timeout=15\n        )\n        out = []\n        for m in resp.json():\n            q = m.get(\"question\", \"\").lower()\n            if any(k.lower() in q for k in keywords):\n                out.append({\n                    \"source\": \"polymarket\",\n                    \"question\": m.get(\"question\", \"\"),\n                    \"probability\": m.get(\"outcomePrices\", [None])[0],\n                })\n        return out[:4]\n    except Exception:\n        return []\n\n\n@retry(times=2, delay=5)\ndef _kalshi(keywords: List[str]) -> List[Dict]:\n    try:\n        resp = requests.get(\n            \"https://trading-api.kalshi.com/trade-api/v2/markets?limit=100&status=open\",\n            headers=HEADERS, timeout=15\n        )\n        out = []\n        for m in resp.json().get(\"markets\", []):\n            t = m.get(\"title\", \"\").lower()\n            if any(k.lower() in t for k in keywords):\n                out.append({\n                    \"source\": \"kalshi\",\n                    \"question\": m.get(\"title\", \"\"),\n                    \"probability\": m.get(\"last_price\"),\n                })\n        return out[:4]\n    except Exception:\n        return []\n\n\ndef get_macro_context(ticker: str, sector: str = \"\") -> Dict:\n    \"\"\"Holt Makro-Kontext aus Prediction Markets.\"\"\"\n    kw = [k for k in [ticker, sector, \"interest rate\", \"fed\", \"regulation\", \"inflation\"] if k]\n    \n    markets = []\n    try:\n        markets += _polymarket(kw)\n    except Exception as e:\n        logger.warning(f\"Polymarket: {e}\")\n    try:\n        markets += _kalshi(kw)\n    except Exception as e:\n        logger.warning(f\"Kalshi: {e}\")\n    \n    if not markets:\n        return {\"context\": \"neutral\", \"summary\": \"Keine Märkte gefunden\", \"markets\": []}\n    \n    bullish, bearish = 0, 0\n    for m in markets:\n        try:\n            p = float(m.get(\"probability\") or 0)\n            q = m.get(\"question\", \"\").lower()\n            if p > 0.65:\n                if any(w in q for w in [\"cut\", \"lower\", \"approve\", \"win\", \"bullish\"]):\n                    bullish += 1\n                elif any(w in q for w in [\"hike\", \"ban\", \"regulation\", \"bearish\", \"fail\"]):\n                    bearish += 1\n        except (ValueError, TypeError):\n            pass\n    \n    ctx = \"bullish\" if bullish > bearish else (\"bearish\" if bearish > bullish else \"neutral\")\n    summary = \" | \".join(\n        f\"{m['source']}: {m['question'][:55]} ({m['probability']})\"\n        for m in markets[:3]\n    )\n    \n    return {\"context\": ctx, \"summary\": summary, \"markets\": markets}\n"
  },
  {
    "path": "src/enrich/options_prefilter.py",
    "content": "\"\"\"\nOptions Pre-Filter mit Tradier-Daten.\nLÄUFT VOR CLAUDE - blockiert Signale die keine handelbaren Options haben.\n\nWICHTIG (nach Schnell-Fix):\n- \"no_quote\" und \"no_qualified_strike\" sind jetzt SOFT → Pipeline stirbt nicht mehr.\n- Nur echte Hard-Kills (z.B. IV-Rank > 70) blocken noch.\n\"\"\"\n\nfrom typing import Dict, Optional\nfrom datetime import datetime, timedelta\nfrom src.utils.logger import logger\nfrom src.utils.config import get_threshold\nfrom src.execution.tradier_client import get_client\n\n\ndef calculate_iv_rank(ticker: str, current_iv: float) -> float:\n    \"\"\"Echte IV-Rank-Berechnung via Tradier History. Fallback 50.0.\"\"\"\n    client = get_client()\n    if not client.is_configured:\n        return 50.0\n\n    try:\n        start = (datetime.utcnow() - timedelta(days=365)).strftime(\"%Y-%m-%d\")\n        end = datetime.utcnow().strftime(\"%Y-%m-%d\")\n        history = client.get_history(ticker, interval=\"weekly\", start=start, end=end)\n\n        if not history:\n            return 50.0\n\n        closes = [float(d.get(\"close\", 0)) for d in history if d.get(\"close\")]\n        if len(closes) < 10:\n            return 50.0\n\n        min_p, max_p = min(closes), max(closes)\n        if min_p <= 0:\n            return 50.0\n\n        price_range_pct = (max_p - min_p) / min_p * 100\n        if price_range_pct == 0:\n            return 50.0\n\n        iv_rank = min((current_iv / price_range_pct) * 50, 99.0)\n        return round(iv_rank, 1)\n    except Exception:\n        return 50.0\n\n\ndef find_target_expiry(ticker: str) -> Optional[str]:\n    \"\"\"Findet beste Expiry im 90-180 Tage Fenster.\"\"\"\n    client = get_client()\n    expirations = client.get_expirations(ticker)\n    if not expirations:\n        return None\n\n    today = datetime.utcnow().date()\n    min_days = get_threshold(\"options\", \"min_days_to_exp\", 90)\n    max_days = get_threshold(\"options\", \"max_days_to_exp\", 180)\n\n    candidates = []\n    for exp in expirations:\n        try:\n            exp_date = datetime.strptime(exp, \"%Y-%m-%d\").date()\n            days = (exp_date - today).days\n            if min_days <= days <= max_days:\n                candidates.append((days, exp))\n        except ValueError:\n            continue\n\n    if not candidates:\n        return None\n\n    candidates.sort(key=lambda x: abs(x[0] - 120))\n    return candidates[0][1]\n\n\ndef find_best_call(ticker: str, stock_price: float, expiration: str) -> Optional[Dict]:\n    \"\"\"Findet besten Call-Strike: 5-15% OTM, Delta 0.35-0.45.\"\"\"\n    client = get_client()\n    chain = client.get_options_chain(ticker, expiration, with_greeks=True)\n    if not chain:\n        return None\n\n    otm_min = get_threshold(\"options\", \"target_otm_min_pct\", 0.05)\n    otm_max = get_threshold(\"options\", \"target_otm_max_pct\", 0.15)\n\n    target_low = stock_price * (1 + otm_min)\n    target_high = stock_price * (1 + otm_max)\n\n    calls = [\n        c for c in chain\n        if c.get(\"option_type\") == \"call\"\n        and target_low <= float(c.get(\"strike\", 0)) <= target_high\n    ]\n\n    if not calls:\n        return None\n\n    min_oi = get_threshold(\"options\", \"min_open_interest\", 500)\n    max_spread = get_threshold(\"options\", \"max_spread_pct\", 4.0)\n\n    best = None\n    best_score = -1\n\n    for opt in calls:\n        bid = float(opt.get(\"bid\") or 0)\n        ask = float(opt.get(\"ask\") or 0)\n        oi = int(opt.get(\"open_interest\") or 0)\n\n        if bid <= 0 or ask <= 0 or oi < min_oi:\n            continue\n\n        mid = (bid + ask) / 2\n        spread_pct = (ask - bid) / mid * 100 if mid > 0 else 999\n        if spread_pct > max_spread:\n            continue\n\n        score = oi / 1000 + (max_spread - spread_pct)\n        if score > best_score:\n            best_score = score\n            greeks = opt.get(\"greeks\", {}) or {}\n            iv = float(greeks.get(\"smv_vol\") or 0) * 100\n\n            best = {\n                \"strike\": float(opt.get(\"strike\", 0)),\n                \"bid\": bid, \"ask\": ask, \"mid\": round(mid, 2),\n                \"open_interest\": oi, \"spread_pct\": round(spread_pct, 2),\n                \"iv\": round(iv, 1),\n                \"delta\": float(greeks.get(\"delta\") or 0),\n                \"vega\": float(greeks.get(\"vega\") or 0),\n                \"theta\": float(greeks.get(\"theta\") or 0),\n            }\n    return best\n\n\ndef options_prefilter(ticker: str) -> Dict:\n    \"\"\"HAUPTFUNKTION – jetzt mit Soft-Fails.\"\"\"\n    if not ticker or ticker in (\"UNKNOWN\", \"PORTFOLIO\", \"\"):\n        return {\"passed\": False, \"kill_reason\": \"no_ticker\"}\n\n    client = get_client()\n    if not client.is_configured:\n        logger.warning(f\"Tradier not configured - skipping options check for {ticker}\")\n        return {\"passed\": True, \"kill_reason\": None, \"options_data\": None,\n                \"iv_rank\": 50.0, \"summary\": \"Tradier nicht konfiguriert\"}\n\n    # 1. Quote – jetzt SOFT\n    quote = client.get_quote(ticker)\n    if not quote:\n        logger.warning(f\"⚠️ {ticker}: Tradier get_quote failed → SOFT FAIL (Claude entscheidet)\")\n        return {\n            \"passed\": True,\n            \"kill_reason\": \"no_quote_soft\",\n            \"options_data\": None,\n            \"iv_rank\": 50.0,\n            \"summary\": \"Quote nicht verfügbar – Claude entscheidet ohne Options-Daten\"\n        }\n\n    stock_price = float(quote.get(\"last\") or quote.get(\"close\") or 0)\n    if stock_price <= 0:\n        return {\"passed\": False, \"kill_reason\": \"invalid_price\"}\n\n    # 2. Expiry\n    target_exp = find_target_expiry(ticker)\n    if not target_exp:\n        return {\"passed\": False, \"kill_reason\": \"no_expiry_in_window\"}\n\n    # 3. Best Call – ebenfalls SOFT\n    call = find_best_call(ticker, stock_price, target_exp)\n    if not call:\n        logger.info(f\"  {ticker}: Kein qualifizierter Strike → SOFT FAIL\")\n        return {\n            \"passed\": True,\n            \"kill_reason\": \"no_qualified_strike_soft\",\n            \"options_data\": None,\n            \"iv_rank\": 50.0,\n            \"summary\": \"Kein passender Call gefunden – Claude entscheidet\"\n        }\n\n    # 4. IV-Rank (Hard-Kill bleibt)\n    iv_rank = calculate_iv_rank(ticker, call[\"iv\"])\n    if iv_rank > get_threshold(\"options\", \"iv_rank_kill\", 70):\n        return {\"passed\": False, \"kill_reason\": f\"iv_rank_too_high_{iv_rank}\", \"iv_rank\": iv_rank}\n\n    # Success\n    today = datetime.utcnow().date()\n    exp_date = datetime.strptime(target_exp, \"%Y-%m-%d\").date()\n    days_to_exp = (exp_date - today).days\n\n    return {\n        \"passed\": True,\n        \"kill_reason\": None,\n        \"options_data\": {\n            **call,\n            \"expiry\": target_exp,\n            \"days_to_exp\": days_to_exp,\n            \"stock_price\": stock_price,\n            \"iv_rank\": iv_rank,\n        },\n        \"iv_rank\": iv_rank,\n        \"summary\": (\n            f\"Call ${call['strike']:.0f} Exp {target_exp} ({days_to_exp}d) \"\n            f\"| Mid ${call['mid']} | IV-Rank {iv_rank} | OI {call['open_interest']}\"\n        )\n    }\n\n\ndef conviction_modifier_for_iv(iv_rank: float) -> float:\n    \"\"\"Dynamischer IV-Rank-Modifier.\"\"\"\n    ideal = get_threshold(\"options\", \"iv_rank_ideal\", 35)\n    acceptable = get_threshold(\"options\", \"iv_rank_acceptable\", 50)\n    risky = get_threshold(\"options\", \"iv_rank_risky\", 70)\n\n    if iv_rank <= ideal:\n        return +0.05\n    elif iv_rank <= acceptable:\n        return -0.10\n    elif iv_rank <= risky:\n        return -0.20\n    return -0.30\n\n\ndef post_claude_options_check(ticker: str) -> Dict:\n    \"\"\"\n    Wird NUR für finale trade-Kandidaten aufgerufen (nach Claude).\n    Macht den echten Options-Check und gibt entweder qualifizierte Daten oder Soft-Fail zurück.\n    \"\"\"\n    result = options_prefilter(ticker)  # benutzt die bestehende Funktion\n    if result.get(\"passed\"):\n        return result\n\n    # Soft-Fallback\n    kill_reason = result.get(\"kill_reason\", \"unknown\")\n    logger.warning(\n        f\"Post-Claude Options-Check {ticker} → {kill_reason} → \"\n        f\"Trade wird trotzdem akzeptiert (Claude hat schon entschieden)\"\n    )\n    return {\n        \"passed\": True,\n        \"kill_reason\": \"post_claude_soft\",\n        \"options_data\": None,\n        \"iv_rank\": 50.0,\n        \"summary\": f\"Options-Check fehlgeschlagen ({kill_reason}) – Trade trotzdem ausführen\"\n    }\n"
  },
  {
    "path": "src/enrich/price_context.py",
    "content": "# src/enrich/price_context.py\n\"\"\"\nPreis-Kontext für einen Ticker:\n- Aktueller Kurs\n- Abstand zu MA50\n- Position vs. 52W-Range\n- Relatives Volumen\n\nQuelle: Yahoo Finance (kostenlos, zuverlässig)\n\"\"\"\nimport requests\nfrom typing import Dict, Optional\nfrom datetime import datetime, timedelta\nfrom src.utils.logger import logger\nfrom src.utils.retry import retry\n\nHEADERS_YAHOO = {\n    \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) \"\n                  \"AppleWebKit/537.36 (KHTML, like Gecko) \"\n                  \"Chrome/134.0.0.0 Safari/537.36\"\n}\n\n\n@retry(times=2, delay=5)\ndef _fetch_history(ticker: str, period: str = \"1y\") -> Optional[Dict]:\n    \"\"\"Yahoo Chart API für Kurshistorie.\"\"\"\n    try:\n        url = f\"https://query1.finance.yahoo.com/v8/finance/chart/{ticker}\"\n        params = {\"interval\": \"1d\", \"range\": period}\n        resp = requests.get(url, headers=HEADERS_YAHOO, params=params, timeout=15)\n        if resp.status_code != 200:\n            return None\n        return resp.json()\n    except Exception:\n        return None\n\n\ndef get_price_context(ticker: str) -> Dict:\n    \"\"\"\n    Holt vollen Preis-Kontext.\n    \n    Returns:\n        Dict mit: price, ma50, vs_ma50_pct, low_52w, high_52w,\n                  vs_low_52w_pct, vs_high_52w_pct, rel_volume\n    \"\"\"\n    if not ticker or ticker in (\"UNKNOWN\", \"PORTFOLIO\", \"\"):\n        return {}\n    \n    data = _fetch_history(ticker, \"1y\")\n    if not data:\n        return {}\n    \n    try:\n        result = data[\"chart\"][\"result\"][0]\n        quote = result[\"indicators\"][\"quote\"][0]\n        closes = [c for c in quote.get(\"close\", []) if c is not None]\n        volumes = [v for v in quote.get(\"volume\", []) if v is not None]\n        \n        if not closes or len(closes) < 50:\n            return {}\n        \n        current_price = closes[-1]\n        ma50 = sum(closes[-50:]) / 50\n        low_52w = min(closes)\n        high_52w = max(closes)\n        \n        # Volume\n        avg_volume_30d = (\n            sum(volumes[-30:]) / len(volumes[-30:])\n            if len(volumes) >= 30 else 0\n        )\n        current_volume = volumes[-1] if volumes else 0\n        rel_volume = (\n            current_volume / avg_volume_30d\n            if avg_volume_30d > 0 else 1.0\n        )\n        \n        return {\n            \"price\": round(current_price, 2),\n            \"ma50\": round(ma50, 2),\n            \"vs_ma50_pct\": round((current_price - ma50) / ma50 * 100, 1),\n            \"low_52w\": round(low_52w, 2),\n            \"high_52w\": round(high_52w, 2),\n            \"vs_low_52w_pct\": round((current_price - low_52w) / low_52w * 100, 1),\n            \"vs_high_52w_pct\": round((current_price - high_52w) / high_52w * 100, 1),\n            \"rel_volume\": round(rel_volume, 2),\n        }\n    except Exception as e:\n        logger.warning(f\"Preis-Kontext {ticker}: {e}\")\n        return {}\n\n\ndef format_for_prompt(context: Dict) -> str:\n    \"\"\"Formatiert für Claude-Prompt.\"\"\"\n    if not context:\n        return \"  Preis-Kontext: nicht verfügbar\"\n    \n    lines = [\n        f\"  Aktueller Kurs:    ${context['price']}\",\n        f\"  vs. 52W-Tief:      {context['vs_low_52w_pct']:+.1f}%\",\n        f\"  vs. 52W-Hoch:      {context['vs_high_52w_pct']:+.1f}%\",\n        f\"  vs. MA50:          {context['vs_ma50_pct']:+.1f}%\",\n        f\"  Rel. Volumen:      {context['rel_volume']}x\",\n    ]\n    \n    # Interpretation\n    interp = []\n    if context['vs_low_52w_pct'] < 25:\n        interp.append(\"nahe 52W-Tief ✓\")\n    if context['vs_ma50_pct'] < 0:\n        interp.append(\"unter MA50 ✓\")\n    if context['rel_volume'] > 1.5:\n        interp.append(\"erhöhtes Volumen ✓\")\n    \n    if interp:\n        lines.append(f\"  → {' | '.join(interp)}\")\n    \n    return \"\\n\".join(lines)\n"
  },
  {
    "path": "src/enrich/sentiment.py",
    "content": "# src/enrich/sentiment.py\n\"\"\"\nNews-Sentiment-Analyse via Phrase-Matching.\nPro 2-6M Calls: nur als Kontext, nicht als Hard-Gate.\n\nSpätere Erweiterung: FinBERT Integration möglich.\n\"\"\"\nfrom typing import List, Dict\nfrom src.utils.logger import logger\n\nBULLISH_PHRASES = [\n    \"fda approval\", \"fda approved\", \"fda clears\", \"fda grants\",\n    \"guidance raised\", \"raises guidance\", \"guidance increased\",\n    \"beats estimates\", \"earnings beat\", \"beat expectations\",\n    \"record revenue\", \"record earnings\",\n    \"dividend increase\", \"buyback program\", \"share repurchase\",\n    \"contract awarded\", \"contract win\",\n    \"upgrade\", \"price target raised\", \"outperform\",\n    \"strong quarter\", \"expansion\",\n]\n\nBEARISH_PHRASES = [\n    \"fda rejection\", \"fda rejects\", \"fda warning\",\n    \"guidance cut\", \"guidance lowered\", \"lowers guidance\",\n    \"misses estimates\", \"earnings miss\",\n    \"revenue decline\", \"revenue miss\",\n    \"dividend cut\", \"dividend suspended\",\n    \"downgrade\", \"price target cut\", \"underperform\",\n    \"sec investigation\", \"doj probe\", \"class action\",\n    \"bankruptcy\", \"default\", \"recall\", \"product recall\",\n    \"layoffs\", \"restructuring charges\",\n]\n\nBULLISH_WORDS = [\"upgrade\", \"beat\", \"buyback\", \"dividend\", \"approval\"]\nBEARISH_WORDS = [\"downgrade\", \"miss\", \"investigation\", \"recall\", \"fraud\"]\n\n\ndef calculate(news: List[Dict]) -> float:\n    \"\"\"\n    Phrase-basiertes Sentiment.\n    \n    Returns:\n        Score von -1.0 (sehr negativ) bis +1.0 (sehr positiv)\n    \"\"\"\n    if not news:\n        return 0.0\n    \n    score = 0.0\n    for article in news:\n        title = article.get(\"title\", \"\").lower()\n        \n        phrase_bull = sum(1 for p in BULLISH_PHRASES if p in title)\n        phrase_bear = sum(1 for p in BEARISH_PHRASES if p in title)\n        \n        if phrase_bull == 0 and phrase_bear == 0:\n            kw_bull = sum(0.5 for w in BULLISH_WORDS if w in title)\n            kw_bear = sum(0.5 for w in BEARISH_WORDS if w in title)\n            score += kw_bull - kw_bear\n        else:\n            score += phrase_bull - phrase_bear\n    \n    return max(-1.0, min(1.0, score / len(news)))\n"
  },
  {
    "path": "src/execution/__init__.py",
    "content": "\"\"\"\nExecution-Layer: Tradier API Integration\n- tradier_client: Wrapper\n- exit_manager: Tägliche Position-Checks\n\"\"\"\n"
  },
  {
    "path": "src/execution/exit_manager.py",
    "content": "# src/execution/exit_manager.py\n\"\"\"\nExit-Manager: tägliche Checks aller offenen Positionen.\n\nTriggers:\n- Take-Profit bei +80%\n- Stop-Loss bei -45%\n- Time-Exit bei ≤21 Tagen bis Expiry\n- Partial Take bei +50% (50% Position raus)\n- Fund-Sell: Fund reduziert Position im letzten 13F um >20%\n- Insider-Sell: Form-4 Verkäufe seit Signal-Datum\n\"\"\"\nfrom datetime import datetime\nfrom typing import List, Dict\nfrom src.utils.logger import logger\nfrom src.utils.config import get_threshold\nfrom src.utils.storage import (\n    get_open_positions, update_position, close_position,\n    get_signal_fund_for_position, get_thirteenf_trend, get_form4_sells,\n)\nfrom src.execution.tradier_client import get_client\n\n\ndef _calculate_pnl(entry_mid: float, current_mid: float) -> float:\n    \"\"\"P&L in Prozent.\"\"\"\n    if entry_mid <= 0:\n        return 0.0\n    return (current_mid - entry_mid) / entry_mid * 100\n\n\ndef _days_to_expiry(expiry: str) -> int:\n    try:\n        exp_date = datetime.strptime(expiry, \"%Y-%m-%d\").date()\n        return (exp_date - datetime.utcnow().date()).days\n    except Exception:\n        return 999\n\n\ndef _get_current_option_mid(ticker: str, strike: float, expiry: str) -> float:\n    \"\"\"Aktuellen Mid-Preis der Option holen.\"\"\"\n    client = get_client()\n    if not client.is_configured:\n        return 0.0\n\n    chain = client.get_options_chain(ticker, expiry, with_greeks=False)\n    for opt in chain:\n        if opt.get(\"option_type\") != \"call\":\n            continue\n        if abs(float(opt.get(\"strike\", 0)) - strike) < 0.01:\n            bid = float(opt.get(\"bid\") or 0)\n            ask = float(opt.get(\"ask\") or 0)\n            if bid > 0 and ask > 0:\n                return (bid + ask) / 2\n    return 0.0\n\n\ndef _check_fund_sold(position: Dict) -> bool:\n    \"\"\"True wenn der ursprüngliche Fund seine Position im letzten 13F um >20% reduziert hat.\"\"\"\n    if not get_threshold(\"exit_rules\", \"exit_if_fund_sells\", True):\n        return False\n    fund_name = get_signal_fund_for_position(\n        position[\"ticker\"], position.get(\"signal_date\", \"\")\n    )\n    if not fund_name:\n        return False\n    holdings = get_thirteenf_trend(fund_name, position[\"ticker\"], quarters=2)\n    if len(holdings) < 2:\n        return False\n    latest = holdings[0][\"shares\"] or 0\n    previous = holdings[1][\"shares\"] or 0\n    if previous > 0 and latest < previous * 0.8:\n        logger.info(\n            f\"  Fund {fund_name} reduzierte {position['ticker']}: \"\n            f\"{previous:,}→{latest:,} Aktien ({(1 - latest/previous)*100:.0f}% weniger)\"\n        )\n        return True\n    return False\n\n\ndef _check_insider_sold(position: Dict) -> bool:\n    \"\"\"True wenn seit Signal-Datum Insider-Verkäufe für den Ticker registriert wurden.\"\"\"\n    if not get_threshold(\"exit_rules\", \"exit_if_insider_sells\", True):\n        return False\n    signal_date = position.get(\"signal_date\", \"\")\n    try:\n        days_open = (datetime.utcnow().date() -\n                     datetime.strptime(signal_date[:10], \"%Y-%m-%d\").date()).days + 1\n    except Exception:\n        days_open = 90\n    sells = get_form4_sells(position[\"ticker\"], days=days_open)\n    if sells:\n        logger.info(\n            f\"  Insider-Sell erkannt: {position['ticker']} \"\n            f\"({len(sells)} Transaktionen seit {signal_date[:10]})\"\n        )\n        return True\n    return False\n\n\ndef check_exit_triggers(position: Dict) -> Dict:\n    \"\"\"\n    Prüft alle Exit-Trigger für eine Position.\n\n    Returns:\n        Dict mit triggered (bool), reason, action, pnl_pct\n    \"\"\"\n    entry_mid = position.get(\"entry_price_option\", 0)\n    strike = position.get(\"strike\", 0)\n    expiry = position.get(\"expiry\", \"\")\n    ticker = position.get(\"ticker\", \"\")\n\n    current_mid = _get_current_option_mid(ticker, strike, expiry)\n    if current_mid <= 0:\n        return {\"triggered\": False, \"reason\": \"no_quote\", \"action\": \"hold\"}\n\n    pnl_pct = _calculate_pnl(entry_mid, current_mid)\n    days_left = _days_to_expiry(expiry)\n\n    tp = get_threshold(\"exit_rules\", \"take_profit_pct\", 80)\n    sl = get_threshold(\"exit_rules\", \"stop_loss_pct\", -45)\n    partial = get_threshold(\"exit_rules\", \"partial_take_pct\", 50)\n    min_days = get_threshold(\"exit_rules\", \"min_days_remaining\", 21)\n\n    # Take-Profit\n    if pnl_pct >= tp:\n        return {\n            \"triggered\": True,\n            \"reason\": \"take_profit\",\n            \"action\": \"close_full\",\n            \"pnl_pct\": pnl_pct,\n            \"current_mid\": current_mid,\n            \"days_left\": days_left,\n            \"message\": f\"TP +{pnl_pct:.0f}% — komplett raus\"\n        }\n\n    # Stop-Loss\n    if pnl_pct <= sl:\n        return {\n            \"triggered\": True,\n            \"reason\": \"stop_loss\",\n            \"action\": \"close_full\",\n            \"pnl_pct\": pnl_pct,\n            \"current_mid\": current_mid,\n            \"days_left\": days_left,\n            \"message\": f\"SL {pnl_pct:.0f}% — komplett raus\"\n        }\n\n    # Time-Exit\n    if days_left <= min_days:\n        return {\n            \"triggered\": True,\n            \"reason\": \"time_exit\",\n            \"action\": \"close_full\",\n            \"pnl_pct\": pnl_pct,\n            \"current_mid\": current_mid,\n            \"days_left\": days_left,\n            \"message\": f\"Nur noch {days_left}d — Theta frisst, raus\"\n        }\n\n    # Partial Take\n    if pnl_pct >= partial and not position.get(\"partial_taken\"):\n        return {\n            \"triggered\": True,\n            \"reason\": \"partial_take\",\n            \"action\": \"close_half\",\n            \"pnl_pct\": pnl_pct,\n            \"current_mid\": current_mid,\n            \"days_left\": days_left,\n            \"message\": f\"+{pnl_pct:.0f}% — 50% raus, Rest laufen lassen\"\n        }\n\n    # Fund/Insider sell exits (config-gesteuert)\n    if _check_fund_sold(position):\n        return {\n            \"triggered\": True,\n            \"reason\": \"fund_sold\",\n            \"action\": \"close_full\",\n            \"pnl_pct\": pnl_pct,\n            \"current_mid\": current_mid,\n            \"days_left\": days_left,\n            \"message\": \"Originating fund hat Position im letzten 13F reduziert — raus\"\n        }\n\n    if _check_insider_sold(position):\n        return {\n            \"triggered\": True,\n            \"reason\": \"insider_sold\",\n            \"action\": \"close_full\",\n            \"pnl_pct\": pnl_pct,\n            \"current_mid\": current_mid,\n            \"days_left\": days_left,\n            \"message\": \"Insider-Verkäufe seit Signal-Datum erkannt — raus\"\n        }\n\n    return {\n        \"triggered\": False,\n        \"reason\": \"monitor\",\n        \"action\": \"hold\",\n        \"pnl_pct\": pnl_pct,\n        \"current_mid\": current_mid,\n        \"days_left\": days_left\n    }\n\n\ndef run_exit_check() -> List[Dict]:\n    \"\"\"\n    Hauptfunktion: prüft alle offenen Positionen.\n\n    Returns:\n        Liste der Exit-Empfehlungen für E-Mail\n    \"\"\"\n    positions = get_open_positions()\n    if not positions:\n        logger.info(\"Keine offenen Positionen\")\n        return []\n\n    logger.info(f\"Exit-Check: {len(positions)} offene Positionen\")\n    exits = []\n\n    for pos in positions:\n        result = check_exit_triggers(pos)\n\n        # Auch bei Hold: P&L updaten\n        if \"current_mid\" in result:\n            update_position(pos[\"id\"], {\n                \"current_option_mid\": result[\"current_mid\"],\n                \"unrealized_pnl_pct\": result.get(\"pnl_pct\", 0),\n            })\n\n        if result[\"triggered\"]:\n            logger.info(\n                f\"  EXIT-TRIGGER {pos['ticker']}: {result['reason']} \"\n                f\"({result['pnl_pct']:.0f}%)\"\n            )\n            exits.append({\n                \"position\": pos,\n                \"trigger\": result\n            })\n\n            # Bei full close: in DB schließen\n            if result[\"action\"] == \"close_full\":\n                close_position(pos[\"id\"], {\n                    \"exit_reason\": result[\"reason\"],\n                    \"exit_price\": result[\"current_mid\"],\n                    \"realized_pnl_pct\": result[\"pnl_pct\"],\n                    \"realized_pnl_after_taxes\": (\n                        result[\"pnl_pct\"] *\n                        (1 - get_threshold(\"backtest\", \"tax_rate_short_term\", 0.35))\n                    ),\n                })\n\n    return exits\n"
  },
  {
    "path": "src/execution/tradier_client.py",
    "content": "\"\"\"\nTradier API Client.\nWrapper für alle Tradier-Endpoints.\nErlaubt einfaches Mocking für Tests.\n\"\"\"\nimport os\nimport requests\nfrom typing import Dict, List, Optional\nfrom datetime import datetime\nfrom src.utils.logger import logger\nfrom src.utils.retry import retry\n\n\nclass TradierClient:\n    \"\"\"Tradier Pro API Wrapper.\"\"\"\n    \n    BASE_URL = \"https://api.tradier.com/v1\"\n    \n    def __init__(self, api_key: Optional[str] = None):\n        self.api_key = api_key or os.environ.get(\"TRADIER_API_KEY\", \"\").strip()\n        if not self.api_key:\n            logger.warning(\"TRADIER_API_KEY nicht gesetzt!\")\n        \n        self.headers = {\n            \"Accept\": \"application/json\",\n            \"Authorization\": f\"Bearer {self.api_key}\"\n        }\n    \n    @property\n    def is_configured(self) -> bool:\n        return bool(self.api_key)\n    \n    # ── Markets / Quotes ─────────────────────────────────────────────\n    \n    @retry(times=3, delay=2, backoff=1.5)\n    def get_quote(self, ticker: str) -> Optional[Dict]:\n        \"\"\"Einzel-Quote für einen Ticker – mit besserer Diagnose.\"\"\"\n        if not self.is_configured:\n            logger.warning(f\"Tradier nicht konfiguriert für Quote {ticker}\")\n            return None\n        \n        try:\n            resp = requests.get(\n                f\"{self.BASE_URL}/markets/quotes\",\n                params={\"symbols\": ticker},\n                headers=self.headers,\n                timeout=10\n            )\n            \n            if resp.status_code != 200:\n                logger.warning(f\"Tradier quote {ticker} → HTTP {resp.status_code} | {resp.text[:200]}\")\n                return None\n            \n            data = resp.json().get(\"quotes\", {}).get(\"quote\")\n            \n            if isinstance(data, list):\n                data = data[0] if data else None\n                \n            if not data or (not data.get(\"last\") and not data.get(\"close\")):\n                logger.warning(f\"Tradier quote {ticker} → leere Response\")\n                return None\n                \n            return data\n        except Exception as e:\n            logger.error(f\"Tradier quote {ticker} EXCEPTION: {e}\")\n            return None\n    \n    # ── Restliche Methoden (unverändert) ──────────────────────────────\n    \n    @retry(times=2, delay=3)\n    def get_expirations(self, ticker: str) -> List[str]:\n        \"\"\"Verfügbare Options-Ablaufdaten.\"\"\"\n        if not self.is_configured:\n            return []\n        try:\n            resp = requests.get(\n                f\"{self.BASE_URL}/markets/options/expirations\",\n                params={\"symbol\": ticker},\n                headers=self.headers,\n                timeout=10\n            )\n            if resp.status_code != 200:\n                return []\n            return (resp.json().get(\"expirations\") or {}).get(\"date\", [])\n        except Exception as e:\n            logger.warning(f\"Tradier expirations {ticker}: {e}\")\n            return []\n\n    @retry(times=2, delay=3)\n    def get_options_chain(self, ticker: str, expiration: str, with_greeks: bool = True) -> List[Dict]:\n        \"\"\"Komplette Options-Chain für eine Expiration.\"\"\"\n        if not self.is_configured:\n            return []\n        try:\n            resp = requests.get(\n                f\"{self.BASE_URL}/markets/options/chains\",\n                params={\n                    \"symbol\": ticker,\n                    \"expiration\": expiration,\n                    \"greeks\": \"true\" if with_greeks else \"false\"\n                },\n                headers=self.headers,\n                timeout=15\n            )\n            if resp.status_code != 200:\n                return []\n            options = (resp.json().get(\"options\") or {}).get(\"option\", [])\n            if isinstance(options, dict):\n                options = [options]\n            return options\n        except Exception as e:\n            logger.warning(f\"Tradier chain {ticker} {expiration}: {e}\")\n            return []\n\n    @retry(times=2, delay=3)\n    def get_history(self, ticker: str, interval: str = \"weekly\", start: Optional[str] = None, end: Optional[str] = None) -> List[Dict]:\n        \"\"\"Historische Kurse für IV-Rank-Berechnung.\"\"\"\n        if not self.is_configured:\n            return []\n        try:\n            params = {\"symbol\": ticker, \"interval\": interval}\n            if start: params[\"start\"] = start\n            if end: params[\"end\"] = end\n            resp = requests.get(\n                f\"{self.BASE_URL}/markets/history\",\n                params=params,\n                headers=self.headers,\n                timeout=10\n            )\n            if resp.status_code != 200:\n                return []\n            data = (resp.json().get(\"history\") or {}).get(\"day\", [])\n            if isinstance(data, dict):\n                data = [data]\n            return data\n        except Exception as e:\n            logger.warning(f\"Tradier history {ticker}: {e}\")\n            return []\n\n\n_client: Optional[TradierClient] = None\n\ndef get_client() -> TradierClient:\n    global _client\n    if _client is None:\n        _client = TradierClient()\n    return _client\n"
  },
  {
    "path": "src/ingest/__init__.py",
    "content": "\"\"\"\nIngest-Layer: Datenquellen.\nJedes Modul implementiert ein einfaches Interface:\n  - fetch() -> List[Dict]\n  - Eigene Fehlerbehandlung\n  - Kein State außerhalb der Funktion\nDiese Modul können isoliert getestet und ersetzt werden.\n\"\"\"\n"
  },
  {
    "path": "src/ingest/eight_k_fetcher.py",
    "content": "# src/ingest/eight_k_fetcher.py\nimport os\nimport re\nimport requests\nimport xml.etree.ElementTree as ET\nfrom typing import List, Dict\nfrom src.utils.logger import logger\nfrom src.utils.retry import retry\nfrom src.utils.ticker_resolver import resolve_ticker\n\nHEADERS = {\n    \"User-Agent\": os.environ.get('EDGAR_USER_AGENT', 'SmartMoneyScanner contact@example.com'),\n    \"Accept-Encoding\": \"gzip, deflate\"\n}\n\n# Item-Nummern und ihre Signal-Qualität (0-100)\n# Hohe Scores = materiell für Kurs-Moves, niedrige = routinemäßig\n_ITEM_SCORES: Dict[str, int] = {\n    \"2.01\": 85,  # Completion of Acquisition or Disposition\n    \"5.01\": 80,  # Changes in Control\n    \"1.01\": 70,  # Material Definitive Agreement\n    \"2.02\": 70,  # Results of Operations (Earnings)\n    \"5.02\": 65,  # Director/Officer Changes (oft mit Insider-Info)\n    \"1.02\": 60,  # Termination of Material Agreement\n    \"4.01\": 55,  # Auditor Change\n    \"4.02\": 55,  # Auditor Disclosure\n    \"8.01\": 55,  # Other Events\n    \"2.03\": 55,  # Creation of Direct Financial Obligation\n    \"5.03\": 50,  # Amendments to Charter/Bylaws\n    \"7.01\": 40,  # Regulation FD Disclosure (oft nur PR)\n    \"9.01\": 20,  # Financial Statements (reines Anhang-Filing)\n}\n_DEFAULT_ITEM_SCORE = 55  # Fallback wenn Items nicht parsebar\n\n\ndef _extract_cik_from_url(url: str) -> int:\n    match = re.search(r'/data/([0-9]+)/', url)\n    if match:\n        try:\n            return int(match.group(1))\n        except ValueError:\n            return 0\n    return 0\n\n\ndef _parse_item_score(summary: str) -> int:\n    \"\"\"\n    Parst Item-Nummern aus dem SEC-RSS-Summary-Text.\n    Beispiel-Summary: \"...Items: 2.02, 9.01...\"\n    Gibt den höchsten Item-Score zurück.\n    \"\"\"\n    if not summary:\n        return _DEFAULT_ITEM_SCORE\n    items = re.findall(r'\\b(\\d\\.\\d{2})\\b', summary)\n    if not items:\n        return _DEFAULT_ITEM_SCORE\n    scores = [_ITEM_SCORES.get(item, _DEFAULT_ITEM_SCORE) for item in items]\n    return max(scores)\n\n\n@retry(times=3, delay=5)\ndef fetch() -> List[Dict]:\n    \"\"\"Holt aktuelle 8-K Filings der SEC inkl. item_score.\"\"\"\n    url = \"https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&type=8-K&count=40&output=atom\"\n\n    try:\n        resp = requests.get(url, headers=HEADERS, timeout=20)\n        resp.raise_for_status()\n\n        root = ET.fromstring(resp.text)\n        ns = {\"atom\": \"http://www.w3.org/2005/Atom\"}\n\n        entries = []\n        for entry in root.findall(\"atom:entry\", ns):\n            title = entry.findtext(\"atom:title\", default=\"\", namespaces=ns)\n            summary = entry.findtext(\"atom:summary\", default=\"\", namespaces=ns)\n            link_node = entry.find(\"atom:link\", ns)\n            link = link_node.attrib.get(\"href\", \"\") if link_node is not None else \"\"\n            updated = entry.findtext(\"atom:updated\", default=\"\", namespaces=ns)\n\n            cik = _extract_cik_from_url(link)\n            ticker = resolve_ticker(cik=cik, title=title)\n\n            if ticker != \"UNKNOWN\":\n                item_score = _parse_item_score(summary)\n                entries.append({\n                    \"ticker\": ticker,\n                    \"filed\": updated[:10],\n                    \"title\": title,\n                    \"url\": link,\n                    \"type\": \"8-K\",\n                    \"item_score\": item_score,\n                })\n\n        logger.info(f\"8-K Fetcher: {len(entries)} relevante Filings gefunden.\")\n        return entries\n\n    except Exception as e:\n        logger.error(f\"Fehler beim 8-K Fetch: {e}\")\n        return []\n"
  },
  {
    "path": "src/ingest/form4_fetcher.py",
    "content": "# src/ingest/form4_fetcher.py\n\"\"\"\nSEC EDGAR Form 4 Fetcher mit Clustered Insider Detection.\n\nOutput: List[Dict] mit Keys:\n  ticker, filed, title, summary, url, is_10b5,\n  is_clustered, cluster_size, cross_day_count\n\"\"\"\nimport os\nimport re\nimport requests\nimport xml.etree.ElementTree as ET\nfrom collections import defaultdict\nfrom datetime import datetime\nfrom typing import List, Dict\nfrom src.utils.logger import logger\nfrom src.utils.retry import retry\nfrom src.utils.ticker_resolver import resolve_ticker\nfrom src.utils.storage import (\n    save_form4_trades,\n    get_recent_form4_by_ticker,\n    cleanup_old_form4\n)\n\nHEADERS = {\n    \"User-Agent\": os.environ.get('EDGAR_USER_AGENT', 'SmartMoneyScanner contact@example.com'),\n    \"Accept-Encoding\": \"gzip, deflate\"\n}\n\ndef _extract_cik_from_url(url: str) -> int:\n    \"\"\"Extrahiert die CIK aus der SEC-URL (z.B. .../data/1234567/...)\"\"\"\n    # FIX: war r'/data/(\\[0-9\\]+)/' — escaped brackets matchten nie\n    match = re.search(r'/data/([0-9]+)/', url)\n    if match:\n        try:\n            return int(match.group(1))\n        except ValueError:\n            return 0\n    return 0\n\ndef _has_10b5_plan(text: str) -> bool:\n    indicators = [\"10b5-1\", \"10b5 1\", \"rule 10b5\", \"prearranged\", \"pre-arranged\"]\n    return any(i in text.lower() for i in indicators)\n\ndef _is_likely_sell(title: str, summary: str) -> bool:\n    \"\"\"Filtert Verkäufe und automatische Dispositionen.\"\"\"\n    text = (title + \" \" + summary).lower()\n    sell_indicators = [\n        \"disposed\", \"disposition\", \"sale\", \" sold \",\n        \"automatic sell\", \"tax withholding\", \"withheld\",\n        \"forfeiture\", \"forfeit\", \"surrender\",\n        \"code f\", \"code s\", \"code d\",\n    ]\n    buy_indicators = [\"purchased\", \"acquired\", \"bought\", \"grant\", \"award\"]\n    has_sell = any(s in text for s in sell_indicators)\n    has_buy = any(b in text for b in buy_indicators)\n    return has_sell and not has_buy\n\n@retry(times=3, delay=6)\ndef _fetch_raw() -> List[Dict]:\n    \"\"\"Holt Form 4 RSS Feed.\"\"\"\n    resp = requests.get(\n        \"https://www.sec.gov/cgi-bin/browse-edgar\"\n        \"?action=getcurrent&type=4&dateb=&owner=include&count=60&output=atom\",\n        headers=HEADERS, timeout=20\n    )\n    root = ET.fromstring(resp.text)\n    ns = {\"atom\": \"http://www.w3.org/2005/Atom\"}\n    out = []\n    for entry in root.findall(\"atom:entry\", ns):\n        title = entry.findtext(\"atom:title\", default=\"\", namespaces=ns)\n        updated = entry.findtext(\"atom:updated\", default=\"\", namespaces=ns)\n        summary = entry.findtext(\"atom:summary\", default=\"\", namespaces=ns)\n        link = entry.find(\"atom:link\", ns)\n        url = link.attrib.get(\"href\", \"\") if link is not None else \"\"\n        out.append({\n            \"title\": title,\n            \"filed\": updated[:10],\n            \"summary\": summary,\n            \"url\": url\n        })\n    logger.info(f\"Form 4 raw: {len(out)} Einträge\")\n    return out\n\ndef _detect_clustered(raw: List[Dict]) -> List[Dict]:\n    \"\"\"\n    Clustered Insider Detection:\n    - Intra-Fetch: mehrere Insider, gleicher Ticker, gleiches Fetch\n    - Cross-Day: SQLite-Historie der letzten 5 Tage\n    \"\"\"\n    by_ticker: Dict[str, List[Dict]] = defaultdict(list)\n\n    for entry in raw:\n        cik = _extract_cik_from_url(entry.get(\"url\", \"\"))\n        ticker = resolve_ticker(cik=cik, title=entry.get(\"title\", \"\"))\n\n        entry[\"ticker\"] = ticker\n        entry[\"is_10b5\"] = _has_10b5_plan(entry.get(\"summary\", \"\"))\n        entry[\"is_sell\"] = _is_likely_sell(\n            entry.get(\"title\", \"\"), entry.get(\"summary\", \"\")\n        )\n\n        if entry[\"is_sell\"]:\n            continue\n\n        by_ticker[ticker].append(entry)\n\n    # Persist für Cross-Day\n    all_valid = [\n        e for entries in by_ticker.values()\n        for e in entries\n        if e[\"ticker\"] != \"UNKNOWN\" and not e[\"is_10b5\"]\n    ]\n    save_form4_trades(all_valid)\n\n    enriched = []\n    for ticker, entries in by_ticker.items():\n        if ticker == \"UNKNOWN\":\n            continue\n\n        non_plan = [e for e in entries if not e[\"is_10b5\"]]\n        plan_count = len(entries) - len(non_plan)\n\n        # Intra-Fetch-Cluster\n        intra_dates = []\n        for e in non_plan:\n            try:\n                intra_dates.append(datetime.strptime(e[\"filed\"], \"%Y-%m-%d\"))\n            except ValueError:\n                pass\n\n        intra_cluster = (\n            len(intra_dates) >= 2 and\n            (max(intra_dates) - min(intra_dates)).days <= 3\n        ) if intra_dates else False\n\n        # Cross-Day-Cluster\n        history = get_recent_form4_by_ticker(ticker, days=5)\n        current_dates = {e.get(\"filed\", \"\") for e in non_plan}\n        history_extra = [h for h in history if h[\"filed_date\"] not in current_dates]\n        cross_day_cluster = len(history_extra) >= 1\n\n        is_clustered = intra_cluster or cross_day_cluster\n        cluster_size = len(non_plan) + len(history_extra)\n        cluster_type = (\n            \"intra+cross\" if (intra_cluster and cross_day_cluster) else\n            \"cross_day\" if cross_day_cluster else\n            \"intra\" if intra_cluster else\n            \"none\"\n        )\n\n        if is_clustered:\n            logger.info(\n                f\"  Cluster {ticker}: {cluster_size} Trades ({cluster_type})\"\n            )\n\n        for e in non_plan:\n            e[\"is_clustered\"] = is_clustered\n            e[\"cluster_size\"] = cluster_size\n            e[\"cluster_type\"] = cluster_type\n            e[\"cross_day_count\"] = len(history_extra)\n            e[\"plan_trades_filtered\"] = plan_count\n            e[\"type\"] = \"form4\"\n            enriched.append(e)\n\n    known = sum(1 for e in enriched if e[\"ticker\"] != \"UNKNOWN\")\n    clustered = [e for e in enriched if e[\"is_clustered\"]]\n    logger.info(f\"Form 4: {known} mit Ticker | {len(clustered)} Cluster\")\n    return enriched\n\ndef fetch() -> List[Dict]:\n    \"\"\"Hauptfunktion: holt Form 4 mit Cluster-Detection.\"\"\"\n    cleanup_old_form4(days=90)\n    raw = _fetch_raw()\n    return _detect_clustered(raw)\n"
  },
  {
    "path": "src/ingest/gov_trades_fetcher.py",
    "content": "# src/ingest/gov_trades_fetcher.py\n\"\"\"\nUS Politiker-Trades Fetcher.\nQuellen: Quiver Quantitative (primär, optional kostenpflichtig)\n         SEC EDGAR Direct (Fallback, kostenlos)\n\"\"\"\nimport os\nimport re\nimport hashlib\nimport requests\nfrom datetime import datetime, timedelta\nfrom typing import List, Dict\nfrom src.utils.logger import logger\nfrom src.utils.retry import retry\n\nHEADERS = {\n    \"User-Agent\": f\"SmartMoneyScanner {os.environ.get('GMAIL_USER', 'scanner@example.com')}\",\n    \"Accept\": \"application/json, application/xml, */*\",\n}\n\nMIN_TRADE_VALUE = 15_000\nLOOKBACK_DAYS = 60\n\nPOLITICIAN_SCORES = {\n    \"Nancy Pelosi\": 38,\n    \"Paul Pelosi\": 35,\n    \"Dan Crenshaw\": 30,\n    \"Michael McCaul\": 28,\n    \"Mark Warner\": 32,\n    \"Richard Burr\": 30,\n    \"Tommy Tuberville\": 28,\n    \"Josh Gottheimer\": 26,\n    \"Ro Khanna\": 24,\n    \"Raja Krishnamoorthi\": 24,\n    \"Patrick McHenry\": 26,\n    \"Jim Himes\": 24,\n    \"French Hill\": 26,\n}\n\n_TICKER_BLACKLIST = {\n    \"INC\", \"LLC\", \"LTD\", \"CORP\", \"CO\", \"LP\", \"NA\", \"PLC\", \"AG\", \"SE\", \"NV\", \"SA\",\n    \"NYSE\", \"NASDAQ\", \"ETF\", \"IPO\", \"SEC\", \"SPAC\", \"OTC\", \"ADR\", \"REIT\",\n    \"USA\", \"THE\", \"AND\", \"FOR\", \"NOT\", \"BUT\", \"ALL\", \"NEW\", \"CEO\", \"CFO\", \"COO\",\n    \"USD\", \"EUR\", \"GBP\", \"AI\", \"IT\", \"US\", \"UK\", \"EU\",\n    \"BUY\", \"SELL\", \"PUT\", \"CALL\", \"HOLD\", \"FUND\", \"BOND\",\n}\n\n\ndef _trade_id(politician: str, ticker: str, date: str, txtype: str) -> str:\n    key = f\"{politician}|{ticker}|{date}|{txtype}\".lower().strip()\n    return hashlib.md5(key.encode()).hexdigest()[:12]\n\n\ndef _is_buy(tx: str) -> bool:\n    t = str(tx).lower()\n    return any(w in t for w in [\n        \"purchase\", \"buy\", \"bought\", \"acquisition\", \"received\", \"exercise\"\n    ])\n\n\ndef _parse_amount(s: str) -> int:\n    if not s:\n        return 0\n    nums = re.findall(r\"[\\d,]+\", str(s).replace(\"$\", \"\").replace(\" \", \"\"))\n    vals = []\n    for n in nums:\n        clean = n.replace(\",\", \"\")\n        if clean.isdigit() and len(clean) >= 3:\n            vals.append(int(clean))\n    if not vals:\n        return 0\n    return sum(vals) // len(vals)\n\n\ndef _clean_ticker(raw: str):\n    if not raw:\n        return None\n    t = str(raw).strip().upper()\n    t = re.sub(r\"[^A-Z\\.]\", \"\", t)\n    if not re.match(r\"^[A-Z]{1,5}(?:\\.[A-Z]{1,2})?$\", t):\n        return None\n    base = t.split(\".\")[0]\n    if base in _TICKER_BLACKLIST:\n        return None\n    if len(base) < 2:\n        return None\n    return t\n\n\ndef _cutoff() -> str:\n    return (datetime.utcnow() - timedelta(days=LOOKBACK_DAYS)).strftime(\"%Y-%m-%d\")\n\n\n@retry(times=2, delay=10)\ndef _from_quiver() -> List[Dict]:\n    \"\"\"Quiver Quantitative API.\"\"\"\n    cutoff = _cutoff()\n    results = []\n    try:\n        resp = requests.get(\n            \"https://api.quiverquant.com/beta/live/congresstrading\",\n            headers={**HEADERS, \"accept\": \"application/json\"},\n            timeout=15\n        )\n        if resp.status_code != 200:\n            logger.warning(f\"Quiver HTTP {resp.status_code}\")\n            return []\n        \n        raw_data = resp.json()\n        for t in raw_data:\n            date = str(t.get(\"TransactionDate\", \"\"))[:10]\n            if date < cutoff:\n                continue\n            if not _is_buy(t.get(\"Transaction\", \"\")):\n                continue\n            ticker = _clean_ticker(t.get(\"Ticker\", \"\"))\n            if not ticker:\n                continue\n            amount_usd = _parse_amount(t.get(\"Amount\", \"\"))\n            if amount_usd < MIN_TRADE_VALUE:\n                continue\n            \n            results.append({\n                \"type\": \"gov_trade\",\n                \"source\": \"quiver\",\n                \"politician\": t.get(\"Representative\", \"\"),\n                \"ticker\": ticker,\n                \"transaction\": \"purchase\",\n                \"amount\": t.get(\"Amount\", \"\"),\n                \"amount_usd\": amount_usd,\n                \"date\": date,\n                \"chamber\": \"congress\",\n            })\n        \n        logger.info(f\"Quiver: {len(results)} Käufe\")\n        return results\n    except Exception as e:\n        logger.warning(f\"Quiver: {e}\")\n        return []\n\n\ndef _pol_score(name: str) -> int:\n    for known, score in POLITICIAN_SCORES.items():\n        if known.lower() in name.lower():\n            return score\n    return 18\n\n\ndef _dedup(trades: List[Dict]) -> List[Dict]:\n    seen = set()\n    out = []\n    for t in trades:\n        tid = _trade_id(\n            t.get(\"politician\", \"\"),\n            t.get(\"ticker\", \"\"),\n            t.get(\"date\", \"\"),\n            t.get(\"transaction\", \"\")\n        )\n        if tid not in seen:\n            seen.add(tid)\n            t[\"trade_id\"] = tid\n            out.append(t)\n    return out\n\n\ndef fetch(days_back: int = LOOKBACK_DAYS) -> List[Dict]:\n    \"\"\"Hauptfunktion: holt Politiker-Trades.\"\"\"\n    all_trades = []\n    \n    try:\n        quiver = _from_quiver()\n        all_trades.extend(quiver)\n    except Exception as e:\n        logger.warning(f\"Quiver Fehler: {e}\")\n    \n    deduped = _dedup(all_trades)\n    \n    for t in deduped:\n        t[\"politician_score\"] = _pol_score(t.get(\"politician\", \"\"))\n    \n    deduped.sort(\n        key=lambda x: (x[\"politician_score\"] * 100_000 + x.get(\"amount_usd\", 0)),\n        reverse=True\n    )\n    \n    logger.info(f\"Politiker-Trades: {len(deduped)}\")\n    return deduped[:50]\n"
  },
  {
    "path": "src/ingest/news_fetcher.py",
    "content": "# src/ingest/news_fetcher.py\n\"\"\"\nNews-Fetcher: Google + Yahoo RSS.\nOutput: List[Dict] mit title, url, date, source\n\"\"\"\nimport os\nimport requests\nimport xml.etree.ElementTree as ET\nfrom typing import List, Dict\nfrom src.utils.logger import logger\nfrom src.utils.retry import retry\n\nHEADERS = {\n    \"User-Agent\": f\"SmartMoneyScanner {os.environ.get('GMAIL_USER', 'scanner@example.com')}\"\n}\n\nKEYWORDS = [\n    \"insider\", \"sec\", \"merger\", \"acquisition\", \"guidance\", \"contract\",\n    \"investigation\", \"buyback\", \"dividend\", \"fda\", \"upgrade\", \"downgrade\",\n    \"earnings\", \"beat\", \"miss\", \"activist\", \"short\", \"alert\"\n]\n\n\ndef _match(text: str) -> bool:\n    return any(k in text.lower() for k in KEYWORDS)\n\n\n@retry(times=3, delay=5)\ndef _fetch_rss(url: str, source: str, ticker: str) -> List[Dict]:\n    resp = requests.get(url, headers=HEADERS, timeout=15)\n    root = ET.fromstring(resp.text)\n    out = []\n    for item in root.findall(\".//item\")[:12]:\n        title = item.findtext(\"title\", default=\"\")\n        if _match(title):\n            out.append({\n                \"source\": source,\n                \"ticker\": ticker,\n                \"title\": title,\n                \"url\": item.findtext(\"link\", default=\"\"),\n                \"date\": item.findtext(\"pubDate\", default=\"\")[:16]\n            })\n    return out\n\n\ndef fetch(ticker: str) -> List[Dict]:\n    \"\"\"Holt News aus Google + Yahoo für einen Ticker.\"\"\"\n    news = []\n    \n    try:\n        news += _fetch_rss(\n            f\"https://news.google.com/rss/search?q={ticker}+stock&hl=en-US&gl=US&ceid=US:en\",\n            \"google\", ticker\n        )\n    except Exception as e:\n        logger.warning(f\"Google News {ticker}: {e}\")\n    \n    try:\n        news += _fetch_rss(\n            f\"https://feeds.finance.yahoo.com/rss/2.0/headline?s={ticker}&region=US&lang=en-US\",\n            \"yahoo\", ticker\n        )\n    except Exception as e:\n        logger.warning(f\"Yahoo News {ticker}: {e}\")\n    \n    return news[:15]\n"
  },
  {
    "path": "src/ingest/thirteenf_fetcher.py",
    "content": "# src/ingest/thirteenf_fetcher.py\n\"\"\"\n13F-HR Fetcher mit direktem SEC-Parser.\n\nOutput: List[Dict] mit Delta-Signalen\nBonus: Multi-Quartals-Trend Detection (3+ Quartale = stärkstes Signal)\n\"\"\"\nimport os\nimport re\nimport time\nimport requests\nimport xml.etree.ElementTree as ET\nfrom datetime import datetime\nfrom typing import List, Dict, Optional, Tuple\nfrom src.utils.logger import logger\nfrom src.utils.retry import retry\nfrom src.utils.storage import get_conn\nfrom src.utils.config import load as load_config\n\nHEADERS = {\n    \"User-Agent\": f\"SmartMoneyScanner {os.environ.get('GMAIL_USER', 'scanner@example.com')}\",\n    \"Accept-Encoding\": \"gzip, deflate\",\n}\n\n\ndef _get_thresholds():\n    cfg = load_config(\"thresholds\")\n    return cfg.get(\"thirteenf\", {})\n\n\n# ── Quarter helpers ──────────────────────────────────────────────────\n\ndef _current_quarter() -> str:\n    now = datetime.utcnow()\n    return f\"{now.year}Q{(now.month - 1) // 3 + 1}\"\n\n\ndef _date_to_quarter(date_str: str) -> str:\n    try:\n        dt = datetime.strptime(str(date_str)[:10], \"%Y-%m-%d\")\n        return f\"{dt.year}Q{(dt.month - 1) // 3 + 1}\"\n    except Exception:\n        return _current_quarter()\n\n\n# ── DB Operations ────────────────────────────────────────────────────\n\ndef _save_holdings(cik: str, fund_name: str, quarter: str,\n                   holdings: List[Dict], total_value: int):\n    with get_conn() as conn:\n        conn.execute(\n            \"DELETE FROM thirteenf_holdings WHERE cik=? AND quarter=?\",\n            (cik, quarter)\n        )\n        conn.executemany(\"\"\"\n            INSERT INTO thirteenf_holdings\n              (fund_name, cik, quarter, ticker, cusip, company, shares, value_usd)\n            VALUES (?,?,?,?,?,?,?,?)\n        \"\"\", [\n            (fund_name, cik, quarter,\n             h[\"ticker\"], h.get(\"cusip\", \"\"), h.get(\"company\", \"\")[:80],\n             h[\"shares\"], h[\"value_usd\"])\n            for h in holdings\n        ])\n        conn.execute(\"\"\"\n            INSERT OR REPLACE INTO thirteenf_portfolio\n              (cik, quarter, total_value, position_count)\n            VALUES (?,?,?,?)\n        \"\"\", (cik, quarter, total_value, len(holdings)))\n\n\ndef _get_quarters(cik: str) -> List[str]:\n    with get_conn() as conn:\n        rows = conn.execute(\n            \"SELECT DISTINCT quarter FROM thirteenf_holdings WHERE cik=? ORDER BY quarter DESC\",\n            (cik,)\n        ).fetchall()\n    return [r[0] for r in rows]\n\n\ndef _get_holdings(cik: str, quarter: str) -> Dict[str, Dict]:\n    with get_conn() as conn:\n        rows = conn.execute(\n            \"SELECT ticker, company, shares, value_usd FROM thirteenf_holdings WHERE cik=? AND quarter=?\",\n            (cik, quarter)\n        ).fetchall()\n    return {\n        r[\"ticker\"]: {\n            \"company\": r[\"company\"],\n            \"shares\": r[\"shares\"],\n            \"value_usd\": r[\"value_usd\"],\n        }\n        for r in rows if r[\"ticker\"] and len(r[\"ticker\"]) <= 6\n    }\n\n\ndef _get_portfolio_total(cik: str, quarter: str) -> int:\n    with get_conn() as conn:\n        row = conn.execute(\n            \"SELECT total_value FROM thirteenf_portfolio WHERE cik=? AND quarter=?\",\n            (cik, quarter)\n        ).fetchone()\n    return row[\"total_value\"] if row else 0\n\n\n# ── Multi-Quartals-Trend (NEW) ───────────────────────────────────────\n\ndef get_consecutive_increases(cik: str, ticker: str) -> int:\n    \"\"\"\n    Wie viele Quartale in Folge hat Fund aufgestockt?\n    3+ = stärkstes Signal.\n    \"\"\"\n    quarters = sorted(_get_quarters(cik), reverse=True)\n    if len(quarters) < 2:\n        return 0\n    \n    consecutive = 0\n    for i in range(len(quarters) - 1):\n        curr_q = quarters[i]\n        prev_q = quarters[i + 1]\n        curr = _get_holdings(cik, curr_q)\n        prev = _get_holdings(cik, prev_q)\n        \n        if ticker in curr and ticker in prev:\n            if curr[ticker][\"value_usd\"] > prev[ticker][\"value_usd\"]:\n                consecutive += 1\n            else:\n                break\n        else:\n            break\n    \n    return consecutive\n\n\n# ── CUSIP/Company → Ticker Mapping ───────────────────────────────────\n\nKNOWN_COMPANIES = {\n    \"APPLE INC\": \"AAPL\", \"APPLE\": \"AAPL\",\n    \"MICROSOFT CORP\": \"MSFT\", \"MICROSOFT\": \"MSFT\",\n    \"AMAZON COM INC\": \"AMZN\", \"AMAZON\": \"AMZN\",\n    \"ALPHABET INC\": \"GOOGL\", \"ALPHABET\": \"GOOGL\",\n    \"NVIDIA CORP\": \"NVDA\", \"NVIDIA\": \"NVDA\",\n    \"META PLATFORMS\": \"META\", \"META\": \"META\",\n    \"TESLA INC\": \"TSLA\", \"TESLA\": \"TSLA\",\n    \"BERKSHIRE HATHAWAY\": \"BRK.B\",\n    \"JPMORGAN CHASE\": \"JPM\",\n    \"JOHNSON & JOHNSON\": \"JNJ\",\n    \"EXXON MOBIL\": \"XOM\",\n    \"UNITEDHEALTH\": \"UNH\",\n    \"VISA INC\": \"V\",\n    \"MASTERCARD\": \"MA\",\n    \"PROCTER & GAMBLE\": \"PG\",\n    \"HOME DEPOT\": \"HD\",\n    \"CHEVRON\": \"CVX\",\n    \"ABBVIE INC\": \"ABBV\",\n    \"COCA COLA\": \"KO\", \"COCA-COLA\": \"KO\",\n    \"PEPSICO\": \"PEP\",\n    \"BROADCOM\": \"AVGO\",\n    \"ELI LILLY\": \"LLY\",\n    \"COSTCO\": \"COST\",\n    \"MERCK\": \"MRK\",\n    \"WALMART\": \"WMT\",\n    \"PALANTIR\": \"PLTR\",\n    \"SALESFORCE\": \"CRM\",\n    \"ADOBE INC\": \"ADBE\",\n    \"NETFLIX\": \"NFLX\",\n    \"TAIWAN SEMICONDUCTOR\": \"TSM\",\n    \"UBER\": \"UBER\",\n    \"AIRBNB\": \"ABNB\",\n    \"SNOWFLAKE\": \"SNOW\",\n    \"CROWDSTRIKE\": \"CRWD\",\n    \"DATADOG\": \"DDOG\",\n    \"SERVICENOW\": \"NOW\",\n    \"INTUITIVE SURGICAL\": \"ISRG\",\n    \"AMD\": \"AMD\", \"ADVANCED MICRO DEVICES\": \"AMD\",\n    \"INTEL CORP\": \"INTC\", \"INTEL\": \"INTC\",\n    \"QUALCOMM\": \"QCOM\",\n    \"TEXAS INSTRUMENTS\": \"TXN\",\n    \"APPLIED MATERIALS\": \"AMAT\",\n    \"KKR\": \"KKR\",\n    \"BLACKSTONE\": \"BX\",\n    \"GOLDMAN SACHS\": \"GS\",\n    \"MORGAN STANLEY\": \"MS\",\n    \"BANK OF AMERICA\": \"BAC\",\n    \"WELLS FARGO\": \"WFC\",\n    \"CITIGROUP\": \"C\",\n    \"AMERICAN EXPRESS\": \"AXP\",\n    \"S&P GLOBAL\": \"SPGI\",\n    \"CHARLES SCHWAB\": \"SCHW\",\n    \"BLACKROCK\": \"BLK\",\n}\n\n\ndef _company_to_ticker(company: str) -> Optional[str]:\n    c = company.upper().strip()\n    if c in KNOWN_COMPANIES:\n        return KNOWN_COMPANIES[c]\n    for known, ticker in KNOWN_COMPANIES.items():\n        if c.startswith(known) or known.startswith(c[:min(len(c), 10)]):\n            return ticker\n    return None\n\n\ndef _cusip_cache_get(cusip: str) -> Optional[str]:\n    if not cusip:\n        return None\n    try:\n        with get_conn() as conn:\n            row = conn.execute(\n                \"SELECT ticker FROM cusip_ticker_cache WHERE cusip=?\", (cusip,)\n            ).fetchone()\n        return row[\"ticker\"] if row and row[\"ticker\"] else None\n    except Exception:\n        return None\n\n\ndef _cusip_cache_set(cusip: str, ticker: str, name: str = \"\"):\n    if not cusip or not ticker:\n        return\n    try:\n        with get_conn() as conn:\n            conn.execute(\"\"\"\n                INSERT OR REPLACE INTO cusip_ticker_cache (cusip, ticker, name)\n                VALUES (?,?,?)\n            \"\"\", (cusip, ticker, name[:60]))\n    except Exception:\n        pass\n\n\ndef _resolve_ticker(cusip: str, company: str) -> Optional[str]:\n    if cusip:\n        cached = _cusip_cache_get(cusip)\n        if cached:\n            return cached\n    \n    ticker = _company_to_ticker(company)\n    if ticker:\n        if cusip:\n            _cusip_cache_set(cusip, ticker, company)\n        return ticker\n    \n    # Fallback: aus Firmenname extrahieren\n    clean = re.sub(\n        r'\\b(INC|CORP|CO|LTD|LLC|PLC|AG|SE|NV|SA|GROUP|HOLDINGS|'\n        r'INTERNATIONAL|ENTERPRISES|CLASS A|CLASS B|CL A|CL B|COM)\\b\\.?',\n        '', company.upper()\n    ).strip()\n    words = clean.split()\n    if words and re.match(r'^[A-Z]{2,5}$', words[0]):\n        if words[0] not in {\"THE\", \"AND\", \"FOR\", \"NEW\", \"OLD\", \"INC\", \"COM\"}:\n            return words[0]\n    \n    return None\n\n\n# ── SEC API Direct ───────────────────────────────────────────────────\n\n@retry(times=3, delay=10)\ndef _get_submissions(cik: str) -> dict:\n    url = f\"https://data.sec.gov/submissions/CIK{cik.zfill(10)}.json\"\n    resp = requests.get(url, headers=HEADERS, timeout=15)\n    resp.raise_for_status()\n    return resp.json()\n\n\ndef _extract_infotable_xml(full_txt: str) -> Optional[str]:\n    pattern = (\n        r'<DOCUMENT>\\s*<TYPE>INFORMATION TABLE</TYPE>'\n        r'.*?<TEXT>(.*?)</TEXT>\\s*</DOCUMENT>'\n    )\n    match = re.search(pattern, full_txt, re.DOTALL | re.IGNORECASE)\n    if match:\n        xml_part = match.group(1).strip()\n        xml_part = re.sub(r'</?(?:XML|SEQUENCE|FILENAME)[^>]*>', '', xml_part)\n        xml_part = xml_part.strip()\n        if xml_part.startswith('<'):\n            return xml_part\n    \n    match2 = re.search(r'(<informationTable.*?</informationTable>)',\n                       full_txt, re.DOTALL | re.IGNORECASE)\n    if match2:\n        return match2.group(1)\n    \n    return None\n\n\ndef _findtext(elem, tag: str) -> str:\n    child = elem.find(tag)\n    if child is not None and child.text:\n        return child.text\n    for child in elem:\n        if child.tag.lower() == tag.lower():\n            return child.text or \"\"\n    return \"\"\n\n\ndef _parse_infotable_xml(xml_text: str, cik: str) -> Tuple[List[Dict], int]:\n    \"\"\"Parst XML zu Holdings-Liste.\"\"\"\n    th = _get_thresholds()\n    min_pos = th.get(\"min_position_value_usd\", 100000)\n    \n    xml_text = re.sub(r'<(/?)\\s*(?:\\w+:)', r'<\\1', xml_text)\n    xml_text = re.sub(r'\\s+xmlns(?::\\w+)?=\"[^\"]*\"', '', xml_text)\n    xml_text = re.sub(r'\\s+xsi:\\w+=\"[^\"]*\"', '', xml_text)\n    xml_text = xml_text.strip()\n    \n    if not xml_text.startswith('<'):\n        return [], 0\n    \n    try:\n        root = ET.fromstring(xml_text)\n    except ET.ParseError:\n        try:\n            root = ET.fromstring(f\"<root>{xml_text}</root>\")\n        except ET.ParseError as e:\n            logger.warning(f\"XML-Parse-Fehler: {e}\")\n            return [], 0\n    \n    info_tables = (\n        root.findall('.//infoTable') or\n        root.findall('.//InfoTable') or\n        root.findall('.//INFOTABLE') or\n        list(root)\n    )\n    \n    holdings = []\n    total_value = 0\n    \n    for info in info_tables:\n        try:\n            company = (_findtext(info, 'nameOfIssuer') or _findtext(info, 'NAMEOFISSUER') or \"\").strip()\n            cusip = (_findtext(info, 'cusip') or _findtext(info, 'CUSIP') or \"\").strip()\n            \n            value_raw = _findtext(info, 'value') or _findtext(info, 'VALUE') or \"0\"\n            value_usd = int(float(re.sub(r'[^\\d.]', '', value_raw) or \"0\"))\n            if 0 < value_usd < 10_000:\n                value_usd *= 1_000  # Tausend-Einheit normalisieren\n            \n            shr_container = info.find('shrsOrPrnAmt') or info.find('SHRSORPRNAMT')\n            shares_raw = (\n                (_findtext(shr_container, 'sshPrnamt') if shr_container is not None else \"\") or\n                _findtext(info, 'sshPrnamt') or _findtext(info, 'SSHPRNAMT') or\n                _findtext(info, 'shares') or \"0\"\n            )\n            shares = int(float(re.sub(r'[^\\d.]', '', shares_raw) or \"0\"))\n            \n            if value_usd < min_pos or not company:\n                continue\n            \n            ticker = _resolve_ticker(cusip, company)\n            if not ticker:\n                continue\n            \n            holdings.append({\n                \"ticker\": ticker,\n                \"cusip\": cusip,\n                \"company\": company,\n                \"shares\": shares,\n                \"value_usd\": value_usd,\n            })\n            total_value += value_usd\n        except (ValueError, TypeError, AttributeError):\n            continue\n    \n    return holdings, total_value\n\n\n@retry(times=2, delay=15)\ndef _fetch_filing(cik: str, index: int = 0) -> Optional[Tuple[str, List[Dict], int]]:\n    \"\"\"Holt ein 13F Filing direkt von SEC.\"\"\"\n    try:\n        data = _get_submissions(cik)\n        filings = data.get(\"filings\", {}).get(\"recent\", {})\n        forms = filings.get(\"form\", [])\n        accs = filings.get(\"accessionNumber\", [])\n        dates = filings.get(\"filingDate\", [])\n        \n        thirteenf = []\n        for i, form in enumerate(forms):\n            if form in (\"13F-HR\", \"13F-HR/A\") and i < len(accs):\n                thirteenf.append((dates[i], accs[i]))\n        \n        if not thirteenf or len(thirteenf) <= index:\n            return None\n        \n        thirteenf.sort(reverse=True)\n        filing_date, accession = thirteenf[index]\n        acc_clean = accession.replace(\"-\", \"\")\n        cik_padded = str(int(cik)).zfill(10)\n        \n        txt_url = (\n            f\"https://www.sec.gov/Archives/edgar/data/\"\n            f\"{cik_padded}/{acc_clean}/{acc_clean}.txt\"\n        )\n        \n        resp = requests.get(txt_url, headers=HEADERS, timeout=45)\n        if resp.status_code == 404:\n            idx_url = (\n                f\"https://www.sec.gov/Archives/edgar/data/\"\n                f\"{cik_padded}/{acc_clean}/{accession}-index.htm\"\n            )\n            idx_resp = requests.get(idx_url, headers=HEADERS, timeout=15)\n            txt_match = re.search(r'href=\"([^\"]+\\.txt)\"', idx_resp.text, re.IGNORECASE)\n            if txt_match:\n                alt_url = \"https://www.sec.gov\" + txt_match.group(1)\n                resp = requests.get(alt_url, headers=HEADERS, timeout=45)\n        \n        resp.raise_for_status()\n        time.sleep(0.15)  # SEC Rate-Limit\n        \n        xml_text = _extract_infotable_xml(resp.text)\n        if not xml_text:\n            return None\n        \n        holdings, total = _parse_infotable_xml(xml_text, cik)\n        if not holdings:\n            return None\n        \n        quarter = _date_to_quarter(filing_date)\n        logger.info(\n            f\"  ✓ {len(holdings)} Pos., ${total/1e9:.1f}B, {quarter}\"\n        )\n        return quarter, holdings, total\n    except Exception as e:\n        logger.error(f\"13F CIK {cik} Index {index}: {e}\")\n        return None\n\n\n# ── Conviction Berechnung ────────────────────────────────────────────\n\ndef _real_conviction(curr_val: int, prev_val: int, curr_shares: int,\n                     prev_shares: int, total_port: int,\n                     fund_score: int, delta_type: str,\n                     consecutive_qs: int = 0) -> float:\n    \"\"\"Conviction aus echten 13F-Daten + Multi-Quartals-Bonus.\"\"\"\n    c = 0.0\n    \n    # Portfolio-Anteil\n    port_pct = (curr_val / total_port * 100) if total_port > 0 else 0\n    if port_pct >= 8.0:\n        c += 0.40\n    elif port_pct >= 4.0:\n        c += 0.32\n    elif port_pct >= 2.0:\n        c += 0.22\n    elif port_pct >= 1.0:\n        c += 0.14\n    elif port_pct >= 0.5:\n        c += 0.08\n    elif port_pct >= 0.3:\n        c += 0.04\n    \n    # Value-Veränderung\n    if prev_val > 0:\n        val_chg = (curr_val - prev_val) / prev_val\n        if delta_type == \"new\":\n            c += 0.25\n        elif val_chg >= 1.0:\n            c += 0.25\n        elif val_chg >= 0.5:\n            c += 0.20\n        elif val_chg >= 0.25:\n            c += 0.14\n        elif val_chg >= 0.15:\n            c += 0.09\n    elif delta_type == \"new\":\n        c += 0.25\n    \n    # Shares-Bestätigung\n    if prev_shares > 0 and curr_shares > 0:\n        shr_chg = (curr_shares - prev_shares) / prev_shares\n        if delta_type == \"new\":\n            c += 0.15\n        elif shr_chg >= 0.20:\n            c += 0.15\n        elif shr_chg >= 0.10:\n            c += 0.10\n        elif shr_chg >= 0.05:\n            c += 0.05\n        elif shr_chg < 0 and (curr_val - prev_val) > 0:\n            c -= 0.05  # Nur Kurseffekt\n    elif delta_type == \"new\":\n        c += 0.15\n    \n    # Fund-Qualität\n    if fund_score >= 40:\n        c += 0.20\n    elif fund_score >= 32:\n        c += 0.15\n    elif fund_score >= 24:\n        c += 0.10\n    elif fund_score >= 16:\n        c += 0.06\n    \n    # Multi-Quartals-Bonus (NEW!)\n    th = _get_thresholds()\n    if consecutive_qs >= 3:\n        c += th.get(\"consecutive_3plus_bonus\", 0.30)\n    elif consecutive_qs >= 2:\n        c += th.get(\"consecutive_2_bonus\", 0.15)\n    \n    return round(min(max(c, 0.0), 1.0), 3)\n\n\ndef _calculate_delta(current: List[Dict], previous: Dict[str, Dict],\n                     fund_name: str, fund_score: int, cik: str,\n                     total_port: int, prev_total: int) -> List[Dict]:\n    \"\"\"Vergleicht aktuelles Quartal mit Vorquartal.\"\"\"\n    th = _get_thresholds()\n    min_inc_pct = th.get(\"min_increase_pct\", 15.0)\n    min_dec_pct = th.get(\"min_decrease_pct\", 30.0)\n    min_port_pct = th.get(\"min_portfolio_pct\", 0.3)\n    min_pos = th.get(\"min_position_value_usd\", 100000)\n    min_new = th.get(\"min_new_position_usd\", 500000)\n    \n    signals = []\n    current_map = {h[\"ticker\"]: h for h in current if h.get(\"ticker\")}\n    \n    for ticker, curr in current_map.items():\n        curr_val = curr[\"value_usd\"]\n        curr_shares = curr[\"shares\"]\n        company = curr.get(\"company\", \"\")\n        port_pct = (curr_val / total_port * 100) if total_port > 0 else 0\n        \n        if port_pct < min_port_pct:\n            continue\n        \n        # Multi-Quartals-Trend prüfen\n        consecutive = get_consecutive_increases(cik, ticker)\n        \n        if ticker in previous:\n            prev = previous[ticker]\n            prev_val = prev[\"value_usd\"]\n            prev_shares = prev[\"shares\"]\n            \n            if prev_val == 0:\n                continue\n            \n            val_chg_pct = (curr_val - prev_val) / prev_val * 100\n            shr_chg_pct = (\n                (curr_shares - prev_shares) / prev_shares * 100\n                if prev_shares > 0 else 0\n            )\n            \n            if val_chg_pct >= min_inc_pct and curr_val >= min_pos:\n                conviction = _real_conviction(\n                    curr_val, prev_val, curr_shares, prev_shares,\n                    total_port, fund_score, \"increase\", consecutive\n                )\n                \n                # Strength-Bonus für Multi-Quartals\n                base_strength = min(int(50 + val_chg_pct / 2), 92)\n                if consecutive >= 3:\n                    base_strength = min(base_strength + 8, 95)\n                elif consecutive >= 2:\n                    base_strength = min(base_strength + 4, 92)\n                \n                signals.append({\n                    \"ticker\": ticker,\n                    \"company\": company,\n                    \"signal_type\": \"13f_increase\",\n                    \"fund_name\": fund_name,\n                    \"fund_score\": fund_score,\n                    \"delta_type\": \"increase\",\n                    \"val_change_pct\": round(val_chg_pct, 1),\n                    \"shr_change_pct\": round(shr_chg_pct, 1),\n                    \"value_usd\": curr_val,\n                    \"prev_value_usd\": prev_val,\n                    \"shares\": curr_shares,\n                    \"portfolio_pct\": round(port_pct, 2),\n                    \"total_portfolio\": total_port,\n                    \"strength\": base_strength,\n                    \"conviction\": conviction,\n                    \"consecutive_quarters\": consecutive,\n                    \"source_count\": 1,\n                    \"summary\": (\n                        f\"{fund_name} +{val_chg_pct:.0f}% {ticker} \"\n                        f\"({consecutive}x in Folge)\" if consecutive >= 2\n                        else f\"{fund_name} +{val_chg_pct:.0f}% {ticker}\"\n                    ),\n                })\n                logger.info(\n                    f\"  INCREASE {ticker}: +{val_chg_pct:.0f}% | \"\n                    f\"{port_pct:.1f}% | conv={conviction:.2f} | {consecutive}q\"\n                )\n        else:\n            # Neue Position\n            if curr_val >= min_new:\n                conviction = _real_conviction(\n                    curr_val, 0, curr_shares, 0,\n                    total_port, fund_score, \"new\", 0\n                )\n                strength = 92 if fund_score >= 40 else (84 if fund_score >= 30 else 70)\n                \n                signals.append({\n                    \"ticker\": ticker,\n                    \"company\": company,\n                    \"signal_type\": \"13f_new_position\",\n                    \"fund_name\": fund_name,\n                    \"fund_score\": fund_score,\n                    \"delta_type\": \"new\",\n                    \"val_change_pct\": 100.0,\n                    \"value_usd\": curr_val,\n                    \"shares\": curr_shares,\n                    \"portfolio_pct\": round(port_pct, 2),\n                    \"total_portfolio\": total_port,\n                    \"strength\": strength,\n                    \"conviction\": conviction,\n                    \"consecutive_quarters\": 0,\n                    \"source_count\": 1,\n                    \"summary\": (\n                        f\"{fund_name} NEU {ticker}: ${curr_val/1e6:.1f}M \"\n                        f\"| {port_pct:.1f}%\"\n                    ),\n                })\n                logger.info(\n                    f\"  NEW {ticker}: ${curr_val/1e6:.1f}M | conv={conviction:.2f}\"\n                )\n    \n    return signals\n\n\ndef _fetch_baseline(cik: str, fund_name: str) -> bool:\n    \"\"\"Cold Start.\"\"\"\n    logger.info(f\"  Cold Start {fund_name}\")\n    result = _fetch_filing(cik, index=1)\n    if not result:\n        return False\n    quarter, holdings, total = result\n    _save_holdings(cik, fund_name, quarter, holdings, total)\n    return True\n\n\ndef fetch(funds_config: List[Dict], scorer) -> List[Dict]:\n    \"\"\"\n    Hauptfunktion: 13F Delta + Multi-Quartals-Trend.\n    \n    Args:\n        funds_config: [{\"name\": ..., \"cik\": ...}, ...]\n        scorer: FundScorer Instanz\n    \n    Returns:\n        Liste von Delta-Signalen\n    \"\"\"\n    all_signals = []\n    \n    for fund in funds_config:\n        fund_name = fund.get(\"name\", \"\")\n        cik = fund.get(\"cik\", \"\")\n        fund_score = scorer.get_score(fund_name)\n        \n        if fund_score < 15:\n            continue\n        \n        logger.info(f\"13F: {fund_name} (CIK {cik}, Score {fund_score})\")\n        \n        result = _fetch_filing(cik, index=0)\n        if not result:\n            continue\n        \n        curr_quarter, curr_holdings, curr_total = result\n        quarters = _get_quarters(cik)\n        has_prev = any(q < curr_quarter for q in quarters)\n        \n        if not has_prev:\n            ok = _fetch_baseline(cik, fund_name)\n            if ok:\n                quarters = _get_quarters(cik)\n            else:\n                _save_holdings(cik, fund_name, curr_quarter, curr_holdings, curr_total)\n                continue\n        \n        prev_quarters = sorted(\n            [q for q in quarters if q < curr_quarter], reverse=True\n        )\n        if not prev_quarters:\n            _save_holdings(cik, fund_name, curr_quarter, curr_holdings, curr_total)\n            continue\n        \n        prev_q = prev_quarters[0]\n        prev_hold = _get_holdings(cik, prev_q)\n        prev_total = _get_portfolio_total(cik, prev_q)\n        \n        signals = _calculate_delta(\n            curr_holdings, prev_hold,\n            fund_name, fund_score, cik,\n            curr_total, prev_total\n        )\n        \n        _save_holdings(cik, fund_name, curr_quarter, curr_holdings, curr_total)\n        all_signals.extend(signals)\n    \n    bullish = [s for s in all_signals if s[\"delta_type\"] in (\"new\", \"increase\")]\n    logger.info(f\"13F Delta: {len(bullish)} bullish\")\n    return bullish\n"
  },
  {
    "path": "src/score/__init__.py",
    "content": "\"\"\"\nScore-Layer: Bewertung & Filterung.\n- signal_builder: Erzeugt Signal-Objekte aus Rohdaten\n- merger: Merge by Ticker (Multi-Source-Detection)\n- signal_filter: Hard-Gates + Ranking\n- fund_scorer: Fund-Score-Lookup\n\"\"\"\n"
  },
  {
    "path": "src/score/fund_scorer.py",
    "content": "# src/score/fund_scorer.py\n\"\"\"Fund-Score Lookup aus fund_weights.yaml.\"\"\"\nimport re\nfrom typing import Dict\nfrom src.utils.config import load as load_config\nfrom src.utils.logger import logger\n\n\ndef _normalize(name: str) -> str:\n    \"\"\"Lowercase, Sonderzeichen entfernen, Whitespace normalisieren.\"\"\"\n    return re.sub(r'\\s+', ' ', re.sub(r'[^a-z0-9\\s]', ' ', name.lower())).strip()\n\n\nclass FundScorer:\n    def __init__(self):\n        self.config = load_config(\"fund_weights\")\n\n    def get_score(self, fund_name: str) -> int:\n        \"\"\"Gibt Score 0-50 zurück. 0 wenn ignoriert.\"\"\"\n        name_norm = _normalize(fund_name)\n\n        ignored = self.config.get(\"ignored_funds\", [])\n        if any(_normalize(i) in name_norm for i in ignored):\n            return 0\n\n        for known, data in self.config.get(\"funds\", {}).items():\n            known_norm = _normalize(known)\n            # Bidirektionaler Substring-Match: \"berkshire hathaway\" in \"berkshire hathaway inc.\"\n            # und \"berkshire\" in \"berkshire\" (abgekürzte Eingaben)\n            if known_norm in name_norm or name_norm in known_norm:\n                return data.get(\"score\", 0)\n\n        return 8  # Unknown fund default\n\n    def get_info(self, fund_name: str) -> Dict:\n        \"\"\"Voll Info über einen Fund.\"\"\"\n        name_norm = _normalize(fund_name)\n        for known, data in self.config.get(\"funds\", {}).items():\n            known_norm = _normalize(known)\n            if known_norm in name_norm or name_norm in known_norm:\n                return {\n                    \"fund_name\": known,\n                    \"score\": data.get(\"score\", 0),\n                    \"category\": data.get(\"category\", \"unknown\")\n                }\n        return {\"fund_name\": fund_name, \"score\": 8, \"category\": \"unknown\"}\n"
  },
  {
    "path": "src/score/signal_builder.py",
    "content": "# src/score/signal_builder.py\n\"\"\"\nErzeugt Signal-Objekte aus Rohdaten.\nZentrale Stelle für Signal-Konstruktion.\n\"\"\"\nfrom datetime import datetime\nfrom typing import List, Dict\nfrom collections import defaultdict\nfrom src.score.signal_filter import Signal, SignalFilter\nfrom src.score.fund_scorer import FundScorer\nfrom src.utils.logger import logger\nimport re\n\nINVALID_TICKERS = {\"UNKNOWN\", \"PORTFOLIO\", \"\", \"—\", \"N/A\", \"NA\"}\n\n\ndef is_valid_ticker(ticker: str) -> bool:\n    if not ticker or ticker in INVALID_TICKERS:\n        return False\n    return bool(re.match(r'^[A-Z]{1,5}(?:\\.[A-Z]{1,2})?$', ticker.strip()))\n\n\ndef build_signals_from_form4(form4: List[Dict],\n                              scorer: FundScorer,\n                              sf: SignalFilter) -> List[Signal]:\n    \"\"\"Form 4 → Signal Objects.\"\"\"\n    signals = []\n    for f in form4:\n        ticker = f.get(\"ticker\", \"\")\n        if not is_valid_ticker(ticker):\n            continue\n        \n        fund = f.get(\"title\", \"\")[:50]\n        score = scorer.get_score(fund)\n        if score < 10:\n            continue\n        \n        is_clustered = f.get(\"is_clustered\", False)\n        cross_day = f.get(\"cross_day_count\", 0) > 0\n        source_count = 2 if is_clustered else 1\n        \n        signals.append(Signal(\n            ticker=ticker,\n            signal_type=\"insider_buy\",\n            fund_name=fund,\n            fund_score=score,\n            strength=sf.calculate_strength([\"insider_buy\"]),\n            conviction=sf.calculate_conviction(\n                position_pct=3.5 if is_clustered else 1.5,\n                days_since_buy=1 if cross_day else 2,\n                price_vs_ma50_pct=-2.0,\n                is_clustered=is_clustered,\n            ),\n            is_clustered=is_clustered,\n            source_count=source_count,\n            raw=f,\n        ))\n    return signals\n\n\ndef build_signals_from_13f(thirteenf_signals: List[Dict],\n                             sf: SignalFilter) -> List[Signal]:\n    \"\"\"13F Delta → Signal Objects.\"\"\"\n    signals = []\n    for d in thirteenf_signals:\n        ticker = d.get(\"ticker\", \"\")\n        if not is_valid_ticker(ticker):\n            continue\n        \n        signals.append(Signal(\n            ticker=ticker,\n            signal_type=d.get(\"signal_type\", \"13f_increase\"),\n            fund_name=d.get(\"fund_name\", \"\"),\n            fund_score=d.get(\"fund_score\", 0),\n            strength=d.get(\"strength\", 60),\n            conviction=d.get(\"conviction\", 0.5),\n            consecutive_quarters=d.get(\"consecutive_quarters\", 0),\n            source_count=d.get(\"source_count\", 1),\n            raw=d,\n        ))\n    return signals\n\n\ndef build_signals_from_8k(eight_k: List[Dict],\n                           sf: SignalFilter) -> List[Signal]:\n    \"\"\"8-K → Signal Objects.\"\"\"\n    signals = []\n    for f in eight_k:\n        item_score = f.get(\"item_score\", 10)\n        if item_score < 50:\n            continue\n        ticker = f.get(\"ticker\", \"\")\n        if not is_valid_ticker(ticker):\n            continue\n        \n        signals.append(Signal(\n            ticker=ticker,\n            signal_type=\"8k_event\",\n            fund_name=\"Corporate\",\n            fund_score=22,\n            strength=sf.calculate_strength([\"8k_event\"]),\n            conviction=0.42,\n            item_score=item_score,\n            source_count=1,\n            raw=f,\n        ))\n    return signals\n\n\ndef build_signals_from_gov(gov: List[Dict],\n                            sf: SignalFilter) -> List[Signal]:\n    \"\"\"Politiker-Trades → Signal Objects.\"\"\"\n    signals = []\n    for t in gov:\n        ticker = t.get(\"ticker\", \"\")\n        if not is_valid_ticker(ticker):\n            continue\n        \n        pol_score = t.get(\"politician_score\", 18)\n        politician = t.get(\"politician\", \"Unknown\")\n        \n        trade_date = t.get(\"date\", \"\")\n        try:\n            days_old = (datetime.utcnow() - datetime.strptime(trade_date[:10], \"%Y-%m-%d\")).days\n        except Exception:\n            days_old = 30\n        \n        age_penalty = 0.0\n        if days_old > 40:\n            age_penalty = 0.20\n        elif days_old > 20:\n            age_penalty = 0.10\n        elif days_old > 10:\n            age_penalty = 0.05\n        \n        base_conviction = min(0.35 + (pol_score / 100), 0.65)\n        conviction = max(base_conviction - age_penalty, 0.20)\n        \n        signals.append(Signal(\n            ticker=ticker,\n            signal_type=\"gov_buy\",\n            fund_name=politician,\n            fund_score=pol_score,\n            strength=sf.calculate_strength([\"gov_buy\"]),\n            conviction=conviction,\n            source_count=1,\n            raw={\n                **t,\n                \"fund_category\": \"politician\",\n                \"days_since_trade\": days_old,\n                \"trade_date\": trade_date,\n            },\n        ))\n    return signals\n\n\ndef merge_by_ticker(signals: List[Signal], sf: SignalFilter) -> List[Signal]:\n    \"\"\"\n    Merged Signale gleichen Tickers.\n    Cluster-Detection: mehrere unabhängige Signale = stärker.\n    \"\"\"\n    by_ticker = defaultdict(list)\n    \n    for s in signals:\n        if s.ticker not in INVALID_TICKERS:\n            by_ticker[s.ticker].append(s)\n    \n    merged = []\n    for ticker, group in by_ticker.items():\n        if len(group) == 1:\n            merged.append(group[0])\n            continue\n        \n        best = max(group, key=lambda x: x.fund_score)\n        unique_types = list({g.signal_type for g in group})\n        type_count = len(unique_types)\n        \n        # Sonderfälle\n        gov_signals = [g for g in group if g.signal_type == \"gov_buy\"]\n        insider_signals = [g for g in group if g.signal_type == \"insider_buy\"]\n        fund_13f = [g for g in group if g.signal_type in (\"13f_increase\", \"13f_new_position\")]\n        \n        pol_cluster = len(gov_signals) >= 3\n        insider_cluster = len(insider_signals) >= 2\n        fund_cluster = len(fund_13f) >= 2\n        \n        effective_count = type_count\n        if pol_cluster and type_count == 1:\n            effective_count = 2\n        if insider_cluster and type_count == 1:\n            effective_count = 2\n        if fund_cluster and type_count == 1:\n            effective_count = 2\n        \n        best.source_count = effective_count\n        best.is_clustered = (\n            any(g.is_clustered for g in group) or\n            pol_cluster or insider_cluster or fund_cluster\n        )\n        best.conviction = max(g.conviction for g in group)\n        best.consecutive_quarters = max(g.consecutive_quarters for g in group)\n        \n        if fund_cluster:\n            fund_scores = [g.fund_score for g in fund_13f]\n            avg_score = sum(fund_scores) / len(fund_scores)\n            n_funds = len(fund_13f)\n            best.strength = min(int(70 + (n_funds - 1) * 5 + avg_score * 0.3), 95)\n            if avg_score >= 35:\n                best.conviction = min(best.conviction + 0.10, 1.0)\n        else:\n            best.strength = sf.calculate_strength(unique_types)\n        \n        cluster_flags = []\n        if pol_cluster:\n            cluster_flags.append(\"POL-CLUSTER\")\n        if insider_cluster:\n            cluster_flags.append(\"INS-CLUSTER\")\n        if fund_cluster:\n            cluster_flags.append(f\"FUND-CLUSTER({len(fund_13f)}x)\")\n        \n        logger.info(\n            f\"  Merge {ticker}: {len(group)} → \"\n            f\"sources={effective_count} ({', '.join(unique_types)}\"\n            f\"{' + ' + ' + '.join(cluster_flags) if cluster_flags else ''})\"\n        )\n        merged.append(best)\n    \n    return merged\n"
  },
  {
    "path": "src/score/signal_filter.py",
    "content": "# src/score/signal_filter.py\n\"\"\"\nSignal-Filter mit gewichtetem Scoring-Modell.\n\nGewichtetes Scoring:\n  Fund-Score        × 0.40\n  Multi-Signal      × 0.25\n  Conviction/Timing × 0.20\n  News + Options    × 0.15\n\nHard-Gates:\n  fund_score >= 15\n  source_count >= 2 (Multi-Signal-Gate)\n  conviction >= 0.38\n  news_alignment >= -0.70 (gelockert für 2-6M Calls)\n\"\"\"\nfrom dataclasses import dataclass, field\nfrom typing import List, Dict\nfrom src.utils.logger import logger\nfrom src.utils.config import get_threshold\n\n\n@dataclass\nclass Signal:\n    ticker: str\n    signal_type: str\n    fund_name: str\n    fund_score: int\n    strength: int\n    conviction: float\n    news_alignment: float = 0.0\n    macro_context: str = \"neutral\"\n    options_score: int = 0\n    options_summary: str = \"\"\n    options_qualified: bool = False\n    source_count: int = 1\n    is_clustered: bool = False\n    item_score: int = 50\n    consecutive_quarters: int = 0\n    catalyst_modifier: float = 0.0\n    raw: Dict = field(default_factory=dict)\n\n\nclass SignalFilter:\n    \"\"\"Filtert + rankt Signale via gewichtetes Modell.\"\"\"\n    \n    def is_valid(self, s: Signal) -> bool:\n        \"\"\"Hard-Gates.\"\"\"\n        min_fund = get_threshold(\"signal_filter\", \"min_fund_score\", 15)\n        min_sources = get_threshold(\"signal_filter\", \"min_sources\", 2)\n        min_conv = get_threshold(\"signal_filter\", \"min_conviction\", 0.38)\n        max_neg = get_threshold(\"signal_filter\", \"max_neg_news\", -0.70)\n\n        if s.signal_type in (\"insider_buy\", \"8k_event\"):\n            # fund_score and source_count gates are designed for hedge fund signals;\n            # insider/event quality is captured by conviction, item_score, and clustering.\n            if s.conviction < min_conv:\n                return False\n            if s.news_alignment < max_neg:\n                return False\n            return True\n\n        if s.fund_score < min_fund:\n            return False\n        if s.source_count < min_sources:\n            return False\n        if s.conviction < min_conv:\n            return False\n        if s.news_alignment < max_neg:\n            return False\n        return True\n    \n    def weighted_score(self, s: Signal) -> float:\n        \"\"\"Gewichtetes Scoring 0-100.\"\"\"\n        fund_c = (s.fund_score / 50) * 40\n        signal_c = (s.strength / 100) * 25\n        conv_c = s.conviction * 20\n        news_opt_c = (\n            ((s.news_alignment + 1) / 2) * 10 +\n            (s.options_score / 30) * 5\n        )\n        base = fund_c + signal_c + conv_c + news_opt_c\n        \n        # Boni\n        if s.is_clustered:\n            base += 12\n        if s.item_score >= 80:\n            base += 8\n        if s.macro_context == \"bullish\":\n            base += 5\n        if s.options_qualified:\n            base += 5\n        if s.signal_type == \"13f_new_position\" and s.fund_score >= 38:\n            base += 10\n        if s.consecutive_quarters >= 3:\n            base += 15  # Stärkstes Signal\n        elif s.consecutive_quarters >= 2:\n            base += 8\n        \n        # Catalyst-Modifier (von catalyst_finder)\n        base += s.catalyst_modifier * 50  # Skalierung\n        \n        return min(max(base, 0.0), 100.0)\n    \n    def calculate_conviction(self, position_pct: float, days_since_buy: int,\n                              price_vs_ma50_pct: float,\n                              is_clustered: bool = False) -> float:\n        \"\"\"Conviction-Berechnung für Form 4 Signale.\"\"\"\n        c = 0.0\n        if position_pct > 5.0:\n            c += 0.40\n        elif position_pct > 2.5:\n            c += 0.25\n        elif position_pct > 1.0:\n            c += 0.12\n        \n        if days_since_buy < 3:\n            c += 0.25\n        elif days_since_buy < 10:\n            c += 0.15\n        elif days_since_buy < 20:\n            c += 0.08\n        \n        if price_vs_ma50_pct < -5:\n            c += 0.25\n        elif price_vs_ma50_pct < 0:\n            c += 0.15\n        elif price_vs_ma50_pct < 10:\n            c += 0.05\n        \n        if is_clustered:\n            c += 0.20\n        \n        return min(c, 1.0)\n    \n    def calculate_strength(self, sources: List[str]) -> int:\n        \"\"\"Stärke aus Signal-Typ-Kombinationen.\"\"\"\n        combos = {\n            frozenset([\"insider_buy\", \"13f_increase\"]): 90,\n            frozenset([\"insider_buy\", \"13f_new_position\"]): 92,\n            frozenset([\"gov_buy\", \"8k_event\"]): 82,\n            frozenset([\"insider_buy\", \"8k_event\"]): 78,\n            frozenset([\"13f_increase\", \"8k_event\"]): 75,\n            frozenset([\"13f_new_position\", \"8k_event\"]): 80,\n            frozenset([\"insider_buy\", \"gov_buy\"]): 80,\n            frozenset([\"gov_buy\", \"13f_new_position\"]): 85,\n        }\n        ss = frozenset(sources)\n        for combo, val in combos.items():\n            if combo.issubset(ss):\n                return val\n        return 42 if len(sources) == 1 else 58\n    \n    def filter_and_rank(self, signals: List[Signal]) -> List[Signal]:\n        \"\"\"1. Filter via Hard-Gates  2. Sortiert by weighted_score.\"\"\"\n        valid = [s for s in signals if self.is_valid(s)]\n        logger.info(f\"Filter: {len(signals)} → {len(valid)} valide\")\n        return sorted(valid, key=self.weighted_score, reverse=True)\n"
  },
  {
    "path": "src/utils/__init__.py",
    "content": ""
  },
  {
    "path": "src/utils/config.py",
    "content": "# src/utils/config.py\n\"\"\"\nZentraler Config-Loader.\nAlle Module nutzen diese, statt direkt yaml zu öffnen.\n\nmtime-basiertes Caching: Wenn die YAML-Datei auf Disk geändert wird,\nwird sie beim nächsten load()-Aufruf automatisch neu eingelesen.\n\"\"\"\nimport yaml\nfrom pathlib import Path\nfrom typing import Dict, Any\nfrom src.utils.logger import logger\n\n# Speichert (data, mtime) pro Config-Name\n_config_cache: Dict[str, tuple] = {}\n\n\ndef load(name: str) -> Dict:\n    \"\"\"\n    Lädt eine YAML-Config aus config/.\n    Cached per mtime: Änderungen auf Disk werden automatisch erkannt.\n\n    Args:\n        name: Dateiname ohne .yaml (z.B. \"thresholds\")\n\n    Returns:\n        Dict mit Config-Inhalt\n    \"\"\"\n    path = Path(f\"config/{name}.yaml\")\n    if not path.exists():\n        logger.error(f\"Config '{name}' nicht gefunden: {path}\")\n        return {}\n\n    try:\n        mtime = path.stat().st_mtime\n    except OSError:\n        mtime = 0.0\n\n    if name in _config_cache:\n        cached_data, cached_mtime = _config_cache[name]\n        if cached_mtime == mtime:\n            return cached_data\n        logger.info(f\"Config '{name}' hat sich geändert — neu laden\")\n\n    with open(path, \"r\", encoding=\"utf-8\") as f:\n        cfg = yaml.safe_load(f) or {}\n\n    _config_cache[name] = (cfg, mtime)\n    return cfg\n\n\ndef get_threshold(category: str, key: str, default=None):\n    \"\"\"\n    Komfortable Funktion für thresholds.yaml.\n\n    Beispiel:\n        max_iv = get_threshold(\"options\", \"iv_rank_kill\")\n    \"\"\"\n    cfg = load(\"thresholds\")\n    return cfg.get(category, {}).get(key, default)\n\n\ndef reload():\n    \"\"\"Cache leeren — erzwingt Neu-Laden aller Configs beim nächsten Aufruf.\"\"\"\n    global _config_cache\n    _config_cache = {}\n    logger.info(\"Config-Cache geleert\")\n"
  },
  {
    "path": "src/utils/logger.py",
    "content": "# src/utils/logger.py\n\"\"\"Zentraler Logger für das ganze System.\"\"\"\nimport sys\nfrom loguru import logger\nfrom pathlib import Path\n\nPath(\"data/logs\").mkdir(parents=True, exist_ok=True)\n\nlogger.remove()\nlogger.add(\n    sys.stderr,\n    level=\"INFO\",\n    format=\"<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | {message}\"\n)\nlogger.add(\n    \"data/logs/scanner.log\",\n    rotation=\"7 days\",\n    retention=\"30 days\",\n    level=\"DEBUG\"\n)\n"
  },
  {
    "path": "src/utils/retry.py",
    "content": "# src/utils/retry.py\n\"\"\"Retry-Decorator mit exponential Backoff.\"\"\"\nimport time\nimport functools\nfrom src.utils.logger import logger\n\n\ndef retry(times=3, delay=5, backoff=2):\n    \"\"\"\n    Retry-Decorator mit exponential Backoff.\n    \n    Args:\n        times: Maximale Versuche\n        delay: Initial Wartezeit in Sekunden\n        backoff: Multiplikator pro Versuch\n    \"\"\"\n    def decorator(func):\n        @functools.wraps(func)\n        def wrapper(*args, **kwargs):\n            wait = delay\n            for attempt in range(1, times + 1):\n                try:\n                    return func(*args, **kwargs)\n                except Exception as e:\n                    if attempt == times:\n                        logger.error(\n                            f\"{func.__name__} fehlgeschlagen nach {times} Versuchen: {e}\"\n                        )\n                        raise\n                    logger.warning(\n                        f\"{func.__name__} Versuch {attempt}/{times}: {e} — warte {wait}s\"\n                    )\n                    time.sleep(wait)\n                    wait *= backoff\n        return wrapper\n    return decorator\n"
  },
  {
    "path": "src/utils/storage.py",
    "content": "# src/utils/storage.py\n\"\"\"\nSQLite Storage für alle Persistenz.\nZentrale Stelle für DB-Zugriffe — Module greifen NICHT direkt auf SQLite zu.\n\"\"\"\nimport sqlite3\nimport json\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import List, Dict, Optional\nfrom src.utils.logger import logger\n\nDB_PATH = Path(\"data/scanner.db\")\nDB_PATH.parent.mkdir(parents=True, exist_ok=True)\n\n\ndef get_conn():\n    \"\"\"Verbindung mit Row-Factory für Dict-ähnlichen Zugriff.\"\"\"\n    conn = sqlite3.connect(DB_PATH)\n    conn.row_factory = sqlite3.Row\n    return conn\n\n\ndef init_db():\n    \"\"\"Initialisiert alle Tabellen. Idempotent.\"\"\"\n    with get_conn() as conn:\n        conn.executescript(\"\"\"\n        -- Form4 History (für Cross-Day-Cluster)\n        CREATE TABLE IF NOT EXISTS form4_history (\n            id INTEGER PRIMARY KEY AUTOINCREMENT,\n            ticker TEXT,\n            filed_date TEXT,\n            title TEXT,\n            url TEXT,\n            amount_usd INTEGER DEFAULT 0,\n            is_10b5 INTEGER DEFAULT 0,\n            created_at TEXT DEFAULT CURRENT_TIMESTAMP\n        );\n        CREATE INDEX IF NOT EXISTS idx_form4_ticker_date\n            ON form4_history (ticker, filed_date);\n        \n        -- Signals (alle generierten Signale)\n        CREATE TABLE IF NOT EXISTS signals (\n            id INTEGER PRIMARY KEY AUTOINCREMENT,\n            date TEXT,\n            ticker TEXT,\n            signal_type TEXT,\n            fund_name TEXT,\n            fund_score INTEGER,\n            strength INTEGER,\n            conviction REAL,\n            action TEXT,\n            confidence REAL,\n            reasoning TEXT,\n            instrument TEXT,\n            raw_data TEXT,\n            outcome TEXT DEFAULT '',\n            outcome_pct REAL DEFAULT 0,\n            created_at TEXT DEFAULT CURRENT_TIMESTAMP\n        );\n        CREATE INDEX IF NOT EXISTS idx_signals_ticker_type\n            ON signals (ticker, signal_type, date);\n        \n        -- Open Positions (für Exit-Tracking)\n        CREATE TABLE IF NOT EXISTS open_positions (\n            id INTEGER PRIMARY KEY AUTOINCREMENT,\n            ticker TEXT,\n            signal_date TEXT,\n            entry_price_stock REAL,\n            entry_price_option REAL,\n            entry_bid REAL,\n            entry_ask REAL,\n            strike REAL,\n            expiry TEXT,\n            quantity INTEGER,\n            position_size_pct REAL,\n            portfolio_notional REAL,\n            delta_entry REAL,\n            vega_entry REAL,\n            theta_entry REAL,\n            current_stock_price REAL,\n            current_option_mid REAL,\n            unrealized_pnl_pct REAL,\n            exit_reason TEXT,\n            exit_date TEXT,\n            exit_price REAL,\n            realized_pnl_pct REAL,\n            realized_pnl_after_taxes REAL,\n            status TEXT DEFAULT 'open',\n            last_updated TEXT DEFAULT CURRENT_TIMESTAMP\n        );\n        \n        -- Scan Log\n        CREATE TABLE IF NOT EXISTS scan_log (\n            id INTEGER PRIMARY KEY AUTOINCREMENT,\n            date TEXT,\n            run_mode TEXT,\n            signals_found INTEGER,\n            signals_sent INTEGER,\n            status TEXT,\n            error TEXT,\n            created_at TEXT DEFAULT CURRENT_TIMESTAMP\n        );\n        \n        -- 13F Holdings History\n        CREATE TABLE IF NOT EXISTS thirteenf_holdings (\n            id INTEGER PRIMARY KEY AUTOINCREMENT,\n            fund_name TEXT,\n            cik TEXT,\n            quarter TEXT,\n            ticker TEXT,\n            cusip TEXT,\n            company TEXT,\n            shares INTEGER,\n            value_usd INTEGER,\n            created_at TEXT DEFAULT CURRENT_TIMESTAMP\n        );\n        CREATE INDEX IF NOT EXISTS idx_13f_cik_q\n            ON thirteenf_holdings (cik, quarter);\n        \n        CREATE TABLE IF NOT EXISTS thirteenf_portfolio (\n            cik TEXT,\n            quarter TEXT,\n            total_value INTEGER,\n            position_count INTEGER,\n            PRIMARY KEY (cik, quarter)\n        );\n        \n        -- Fund Performance (Auto-Kalibrierung)\n        CREATE TABLE IF NOT EXISTS fund_performance (\n            fund_name TEXT PRIMARY KEY,\n            total_signals INTEGER DEFAULT 0,\n            wins INTEGER DEFAULT 0,\n            losses INTEGER DEFAULT 0,\n            win_rate REAL DEFAULT 0.5,\n            avg_win_pct REAL DEFAULT 0.65,\n            avg_loss_pct REAL DEFAULT -0.40,\n            last_updated TEXT DEFAULT CURRENT_TIMESTAMP\n        );\n        \n        -- Source Health\n        CREATE TABLE IF NOT EXISTS source_health (\n            id INTEGER PRIMARY KEY AUTOINCREMENT,\n            date TEXT,\n            source TEXT,\n            count INTEGER,\n            created_at TEXT DEFAULT CURRENT_TIMESTAMP\n        );\n        \n        -- Ticker Cache (für CIK→Ticker Lookup)\n        CREATE TABLE IF NOT EXISTS ticker_cache (\n            cik TEXT PRIMARY KEY,\n            ticker TEXT,\n            name TEXT,\n            updated TEXT DEFAULT CURRENT_TIMESTAMP\n        );\n        \n        CREATE TABLE IF NOT EXISTS cusip_ticker_cache (\n            cusip TEXT PRIMARY KEY,\n            ticker TEXT,\n            name TEXT,\n            updated TEXT DEFAULT CURRENT_TIMESTAMP\n        );\n        \"\"\")\n    logger.info(\"DB initialisiert.\")\n\n\n# ── Signals ───────────────────────────────────────────────────────────\n\ndef save_signal(sig: dict):\n    \"\"\"Speichert ein Claude-analysiertes Signal.\"\"\"\n    with get_conn() as conn:\n        conn.execute(\"\"\"\n            INSERT INTO signals\n              (date, ticker, signal_type, fund_name, fund_score, strength,\n               conviction, action, confidence, reasoning, instrument, raw_data)\n            VALUES (?,?,?,?,?,?,?,?,?,?,?,?)\n        \"\"\", (\n            datetime.utcnow().strftime(\"%Y-%m-%d\"),\n            sig.get(\"ticker\", \"\"),\n            sig.get(\"signal_type\", \"\"),\n            sig.get(\"fund_name\", \"\"),\n            sig.get(\"fund_score\", 0),\n            sig.get(\"strength\", 0),\n            sig.get(\"conviction\", 0.0),\n            sig.get(\"action\", \"\"),\n            sig.get(\"confidence\", 0.0),\n            sig.get(\"reasoning\", \"\"),\n            sig.get(\"suggested_instrument\", \"\"),\n            json.dumps(sig)\n        ))\n\n\ndef get_fund_history(fund_name: str, limit: int = 3) -> list:\n    \"\"\"Letzte N Signale eines Funds für Claude-Kontext.\"\"\"\n    with get_conn() as conn:\n        rows = conn.execute(\"\"\"\n            SELECT ticker, action, confidence, reasoning, outcome, outcome_pct, date\n            FROM signals\n            WHERE fund_name LIKE ? AND action IN ('trade','watchlist')\n            ORDER BY created_at DESC LIMIT ?\n        \"\"\", (f\"%{fund_name}%\", limit)).fetchall()\n    return [dict(r) for r in rows]\n\n\ndef get_fund_accuracy(fund_name: str) -> float:\n    \"\"\"Win-Rate basierend auf Outcomes.\"\"\"\n    with get_conn() as conn:\n        rows = conn.execute(\"\"\"\n            SELECT outcome FROM signals\n            WHERE fund_name LIKE ? AND outcome != ''\n            ORDER BY created_at DESC LIMIT 50\n        \"\"\", (f\"%{fund_name}%\",)).fetchall()\n    if not rows:\n        return 0.5\n    wins = sum(1 for r in rows if r[\"outcome\"] == \"win\")\n    return wins / len(rows)\n\n\ndef is_duplicate(ticker: str, signal_type: str, days: int = 5) -> bool:\n    \"\"\"Prüft ob Signal in letzten N Tagen bereits gesendet.\"\"\"\n    with get_conn() as conn:\n        row = conn.execute(\"\"\"\n            SELECT id FROM signals\n            WHERE ticker=? AND signal_type=? AND date >= date('now',?)\n        \"\"\", (ticker, signal_type, f\"-{int(days)} days\")).fetchone()\n    return row is not None\n\n\n# ── Open Positions ────────────────────────────────────────────────────\n\ndef save_position(pos: dict):\n    \"\"\"Speichert eine neue offene Position.\"\"\"\n    with get_conn() as conn:\n        conn.execute(\"\"\"\n            INSERT INTO open_positions\n              (ticker, signal_date, entry_price_stock, entry_price_option,\n               entry_bid, entry_ask, strike, expiry, quantity,\n               position_size_pct, portfolio_notional,\n               delta_entry, vega_entry, theta_entry, status)\n            VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,'open')\n        \"\"\", (\n            pos[\"ticker\"], pos[\"signal_date\"],\n            pos.get(\"entry_price_stock\", 0), pos.get(\"entry_price_option\", 0),\n            pos.get(\"entry_bid\", 0), pos.get(\"entry_ask\", 0),\n            pos[\"strike\"], pos[\"expiry\"], pos.get(\"quantity\", 1),\n            pos.get(\"position_size_pct\", 0), pos.get(\"portfolio_notional\", 0),\n            pos.get(\"delta_entry\", 0), pos.get(\"vega_entry\", 0), pos.get(\"theta_entry\", 0),\n        ))\n\n\ndef get_open_positions() -> List[Dict]:\n    \"\"\"Alle offenen Positionen.\"\"\"\n    with get_conn() as conn:\n        rows = conn.execute(\n            \"SELECT * FROM open_positions WHERE status='open'\"\n        ).fetchall()\n    return [dict(r) for r in rows]\n\n\ndef update_position(position_id: int, updates: dict):\n    \"\"\"Update eine Position.\"\"\"\n    with get_conn() as conn:\n        cols = \", \".join(f\"{k}=?\" for k in updates.keys())\n        values = list(updates.values()) + [position_id]\n        conn.execute(\n            f\"UPDATE open_positions SET {cols}, last_updated=CURRENT_TIMESTAMP WHERE id=?\",\n            values\n        )\n\n\ndef close_position(position_id: int, exit_data: dict):\n    \"\"\"Schließt eine Position.\"\"\"\n    with get_conn() as conn:\n        conn.execute(\"\"\"\n            UPDATE open_positions\n            SET exit_reason=?, exit_date=?, exit_price=?,\n                realized_pnl_pct=?, realized_pnl_after_taxes=?,\n                status='closed', last_updated=CURRENT_TIMESTAMP\n            WHERE id=?\n        \"\"\", (\n            exit_data.get(\"exit_reason\", \"\"),\n            exit_data.get(\"exit_date\", datetime.utcnow().strftime(\"%Y-%m-%d\")),\n            exit_data.get(\"exit_price\", 0),\n            exit_data.get(\"realized_pnl_pct\", 0),\n            exit_data.get(\"realized_pnl_after_taxes\", 0),\n            position_id\n        ))\n\n\n# ── Scan Log ──────────────────────────────────────────────────────────\n\ndef log_scan(found: int, sent: int, status: str, error: str = \"\", run_mode: str = \"\"):\n    \"\"\"Loggt einen Scan-Run.\"\"\"\n    with get_conn() as conn:\n        conn.execute(\"\"\"\n            INSERT INTO scan_log (date, run_mode, signals_found, signals_sent, status, error)\n            VALUES (?,?,?,?,?,?)\n        \"\"\", (\n            datetime.utcnow().strftime(\"%Y-%m-%d\"),\n            run_mode, found, sent, status, error\n        ))\n\n\ndef log_source_health(source: str, count: int):\n    \"\"\"Speichert Datenquellen-Status.\"\"\"\n    with get_conn() as conn:\n        conn.execute(\"\"\"\n            INSERT INTO source_health (date, source, count)\n            VALUES (?,?,?)\n        \"\"\", (datetime.utcnow().strftime(\"%Y-%m-%d\"), source, count))\n\n\ndef get_source_warnings(consecutive_days: int = 3) -> list:\n    \"\"\"Quellen die N Tage in Folge 0 Daten geliefert haben.\"\"\"\n    warnings = []\n    try:\n        with get_conn() as conn:\n            rows = conn.execute(\"\"\"\n                SELECT source, date, count FROM source_health\n                WHERE date >= date('now', ?)\n                ORDER BY source, date DESC\n            \"\"\", (f\"-{consecutive_days + 2} days\",)).fetchall()\n        \n        from collections import defaultdict\n        by_source = defaultdict(list)\n        for r in rows:\n            by_source[r[0]].append((r[1], r[2]))\n        \n        for source, entries in by_source.items():\n            recent = entries[:consecutive_days]\n            if len(recent) >= consecutive_days:\n                if all(count == 0 for _, count in recent):\n                    warnings.append({\n                        \"source\": source,\n                        \"days\": consecutive_days,\n                        \"last_count\": entries[0][1] if entries else 0,\n                    })\n    except Exception:\n        pass\n    return warnings\n\n\n# ── Form 4 History ────────────────────────────────────────────────────\n\ndef save_form4_trades(trades: list):\n    \"\"\"Speichert Form-4 Trades für Cluster-Detection.\"\"\"\n    with get_conn() as conn:\n        conn.executemany(\"\"\"\n            INSERT OR IGNORE INTO form4_history\n              (ticker, filed_date, title, url, amount_usd, is_10b5)\n            VALUES (?,?,?,?,?,?)\n        \"\"\", [\n            (\n                t.get(\"ticker\", \"\"), t.get(\"filed\", \"\"),\n                t.get(\"title\", \"\")[:200], t.get(\"url\", \"\"),\n                t.get(\"amount_usd\", 0),\n                1 if t.get(\"is_10b5\") else 0\n            )\n            for t in trades if t.get(\"ticker\") and t.get(\"ticker\") != \"UNKNOWN\"\n        ])\n\n\ndef get_recent_form4_by_ticker(ticker: str, days: int = 5) -> list:\n    \"\"\"Form4-Trades eines Tickers aus letzten N Tagen.\"\"\"\n    with get_conn() as conn:\n        rows = conn.execute(\"\"\"\n            SELECT ticker, filed_date, title, amount_usd, is_10b5\n            FROM form4_history\n            WHERE ticker = ? AND filed_date >= date('now', ?) AND is_10b5 = 0\n            ORDER BY filed_date DESC\n        \"\"\", (ticker, f\"-{int(days)} days\")).fetchall()\n    return [dict(r) for r in rows]\n\n\ndef cleanup_old_form4(days: int = 90):\n    \"\"\"Bereinigt alte Form-4 Einträge.\"\"\"\n    with get_conn() as conn:\n        conn.execute(\n            \"DELETE FROM form4_history WHERE filed_date < date('now', ?)\",\n            (f\"-{int(days)} days\",)\n        )\n# ── Exit-Check Helpers ────────────────────────────────────────────────\n\ndef get_signal_fund_for_position(ticker: str, signal_date: str) -> Optional[str]:\n    \"\"\"Liefert den Fund-Namen des Signals das diese Position ausgelöst hat.\"\"\"\n    with get_conn() as conn:\n        row = conn.execute(\"\"\"\n            SELECT fund_name FROM signals\n            WHERE ticker = ? AND date = ? AND action IN ('trade', 'watchlist')\n            ORDER BY created_at DESC LIMIT 1\n        \"\"\", (ticker, signal_date[:10])).fetchone()\n    return row[\"fund_name\"] if row else None\n\n\ndef get_thirteenf_trend(fund_name: str, ticker: str, quarters: int = 2) -> List[Dict]:\n    \"\"\"Letzte N Quartale 13F-Holdings für fund+ticker, neueste zuerst.\"\"\"\n    with get_conn() as conn:\n        rows = conn.execute(\"\"\"\n            SELECT quarter, shares, value_usd FROM thirteenf_holdings\n            WHERE fund_name LIKE ? AND ticker = ?\n            ORDER BY quarter DESC LIMIT ?\n        \"\"\", (f\"%{fund_name}%\", ticker, quarters)).fetchall()\n    return [dict(r) for r in rows]\n\n\ndef get_form4_sells(ticker: str, days: int = 90) -> List[Dict]:\n    \"\"\"Form-4 Einträge die auf Insider-Verkäufe hindeuten.\"\"\"\n    with get_conn() as conn:\n        rows = conn.execute(\"\"\"\n            SELECT ticker, filed_date, title, amount_usd FROM form4_history\n            WHERE ticker = ? AND filed_date >= date('now', ?)\n              AND lower(title) LIKE '%sale%'\n            ORDER BY filed_date DESC\n        \"\"\", (ticker, f\"-{int(days)} days\")).fetchall()\n    return [dict(r) for r in rows]\n"
  },
  {
    "path": "src/utils/ticker_resolver.py",
    "content": "\"\"\"\nticker_resolver.py  –  v2.2\n----------------------------\nResolves SEC CIK numbers → exchange ticker symbols.\n\nArchitecture: Two-level cache\n  L1  In-memory TTLCache  (fast, lost on restart)\n  L2  SQLite table        (persistent across restarts, warm-starts L1)\n\nResolution chain per CIK:\n  1. L1 cache (in-memory, TTL-checked, LRU-bounded)\n  2. L2 cache (SQLite, TTL-checked — promotes hit to L1)\n  3. Regex extraction from filing title  (least trusted — EDGAR may override)\n  4. SEC EDGAR Submissions API           (most trusted — overwrites regex result)\n\nChangelog vs. v2.1\n  [FIX-F] _TICKER_PATTERN now allows class-share suffixes like BRK.B\n  [FIX-G] Added _extract_via_regex alias and _extract_cik() helper for tests\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nimport os\nimport re\nimport sqlite3\nimport threading\nimport time\nfrom collections import OrderedDict\nfrom contextlib import contextmanager\nfrom typing import Generator, Optional\n\nimport requests\n\n# ---------------------------------------------------------------------------\n# Logging\n# ---------------------------------------------------------------------------\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Configuration  (all overridable via environment variables)\n# ---------------------------------------------------------------------------\nCACHE_TTL_SECONDS: int = int(os.getenv(\"TICKER_CACHE_TTL\",    str(86_400)))  # 24 h\nCACHE_L1_MAXSIZE:  int = int(os.getenv(\"TICKER_CACHE_MAXSIZE\", \"5000\"))      # LRU limit\nMAX_EDGAR_CALLS:   int = int(os.getenv(\"EDGAR_MAX_CALLS\",      \"100\"))       # per window\nEDGAR_WINDOW_SEC:  int = int(os.getenv(\"EDGAR_WINDOW_SECONDS\", str(3_600)))  # 1 h\nEDGAR_TIMEOUT_SEC: int = int(os.getenv(\"EDGAR_TIMEOUT\",        \"8\"))\nDB_PATH:           str = os.getenv(\"TICKER_DB_PATH\", \"ticker_cache.db\")\n\n_RAW_USER_AGENT: str = os.getenv(\"EDGAR_USER_AGENT\", \"\")\n\n# [FIX-F] Allow optional class-share suffix (e.g. BRK.B, BF.A)\n_TICKER_PATTERN: re.Pattern = re.compile(\n    r\"\\(([A-Z]{1,5}(?:\\.[A-Z]{1,2})?)\\)\"\n)\n\n_TICKER_BLACKLIST: frozenset[str] = frozenset({\n    \"INC\", \"LLC\", \"LTD\", \"CORP\", \"CO\", \"PLC\", \"LP\", \"NA\", \"SA\",\n    \"AG\", \"SE\", \"NV\", \"AB\", \"AS\", \"THE\", \"AND\", \"FOR\", \"WITH\",\n    \"NEW\", \"OLD\", \"ACT\", \"SEC\", \"REG\", \"ETF\", \"ADR\", \"ADS\",\n})\n\nEDGAR_BASE_URL = \"https://data.sec.gov/submissions/CIK{cik:010d}.json\"\n\n\n# ---------------------------------------------------------------------------\n# [FIX-D]  User-Agent validation — fail-fast at import time\n# ---------------------------------------------------------------------------\ndef _get_user_agent() -> str:\n    ua = _RAW_USER_AGENT.strip()\n    if not ua:\n        raise EnvironmentError(\n            \"EDGAR_USER_AGENT environment variable is not set.\\n\"\n            \"Set it to 'YourCompany contact@yourdomain.com' before running.\\n\"\n            \"The SEC blocks IPs with missing or generic User-Agent strings.\"\n        )\n    if \"@\" not in ua:\n        raise ValueError(\n            f\"EDGAR_USER_AGENT={ua!r} does not look like 'CompanyName name@domain'.\\n\"\n            \"SEC policy requires a real email address in the User-Agent.\"\n        )\n    return ua\n\n\n_EDGAR_USER_AGENT: str = _get_user_agent()\n\n\n# ---------------------------------------------------------------------------\n# Exception taxonomy\n# ---------------------------------------------------------------------------\nclass _TransientError(Exception):\n    \"\"\"Network / timeout / HTTP-5xx — do NOT cache the miss.\"\"\"\n\n\nclass _PermanentMiss(Exception):\n    \"\"\"CIK not found or HTTP-4xx — safe to negative-cache with empty string.\"\"\"\n\n\n# ---------------------------------------------------------------------------\n# L1: Bounded in-memory cache with LRU eviction + TTL\n# ---------------------------------------------------------------------------\nclass _CIKCache:\n    def __init__(self, maxsize: int = CACHE_L1_MAXSIZE, ttl: int = CACHE_TTL_SECONDS) -> None:\n        self._maxsize = maxsize\n        self._ttl     = ttl\n        self._data: OrderedDict[int, tuple[str, float]] = OrderedDict()\n        self._lock = threading.Lock()\n\n    def get(self, cik: int) -> Optional[str]:\n        with self._lock:\n            if cik not in self._data:\n                return None\n            value, ts = self._data[cik]\n            if time.time() - ts > self._ttl:\n                del self._data[cik]\n                logger.debug(\"L1 EVICT(TTL) cik=%s\", cik)\n                return None\n            self._data.move_to_end(cik)\n            return value\n\n    def set(self, cik: int, ticker: str, *, source: str = \"?\") -> None:\n        with self._lock:\n            if cik in self._data:\n                self._data.move_to_end(cik)\n            self._data[cik] = (ticker, time.time())\n            if len(self._data) > self._maxsize:\n                evicted, _ = self._data.popitem(last=False)\n                logger.debug(\"L1 EVICT(LRU) cik=%s\", evicted)\n        logger.debug(\"L1 SET cik=%s ticker=%r source=%s\", cik, ticker or \"<empty>\", source)\n\n    def invalidate(self, cik: int) -> None:\n        with self._lock:\n            removed = self._data.pop(cik, None)\n        if removed:\n            logger.info(\"L1 INVALIDATE cik=%s (was %r)\", cik, removed[0])\n\n    def size(self) -> int:\n        with self._lock:\n            return len(self._data)\n\n\n_l1: _CIKCache = _CIKCache()\n\n\n# ---------------------------------------------------------------------------\n# L2: SQLite persistent cache\n# ---------------------------------------------------------------------------\n@contextmanager\ndef _db_conn() -> Generator[sqlite3.Connection, None, None]:\n    conn = sqlite3.connect(DB_PATH, timeout=5, check_same_thread=False)\n    conn.row_factory = sqlite3.Row\n    try:\n        yield conn\n        conn.commit()\n    except Exception:\n        conn.rollback()\n        raise\n    finally:\n        conn.close()\n\n\ndef _init_db() -> None:\n    with _db_conn() as conn:\n        conn.execute(\"\"\"\n            CREATE TABLE IF NOT EXISTS ticker_cache (\n                cik        INTEGER PRIMARY KEY,\n                ticker     TEXT    NOT NULL DEFAULT '',\n                source     TEXT    NOT NULL DEFAULT 'unknown',\n                updated_at REAL    NOT NULL,\n                CONSTRAINT ticker_len CHECK (length(ticker) <= 10)\n            )\n        \"\"\")\n        conn.execute(\n            \"CREATE INDEX IF NOT EXISTS idx_tc_updated ON ticker_cache(updated_at)\"\n        )\n    logger.debug(\"L2 SQLite ready: %s\", DB_PATH)\n\n\ndef _l2_get(cik: int) -> Optional[str]:\n    with _db_conn() as conn:\n        row = conn.execute(\n            \"SELECT ticker, updated_at FROM ticker_cache WHERE cik = ?\", (cik,)\n        ).fetchone()\n        if row is None:\n            return None\n        age = time.time() - row[\"updated_at\"]\n        if age > CACHE_TTL_SECONDS:\n            conn.execute(\"DELETE FROM ticker_cache WHERE cik = ?\", (cik,))\n            logger.debug(\"L2 EVICT(TTL) cik=%s age=%.0fs\", cik, age)\n            return None\n        return row[\"ticker\"]\n\n\ndef _l2_set(cik: int, ticker: str, *, source: str) -> None:\n    with _db_conn() as conn:\n        conn.execute(\"\"\"\n            INSERT INTO ticker_cache (cik, ticker, source, updated_at)\n            VALUES (?, ?, ?, ?)\n            ON CONFLICT(cik) DO UPDATE SET\n                ticker     = excluded.ticker,\n                source     = excluded.source,\n                updated_at = excluded.updated_at\n        \"\"\", (cik, ticker, source, time.time()))\n    logger.debug(\"L2 SET cik=%s ticker=%r source=%s\", cik, ticker or \"<empty>\", source)\n\n\ndef _warm_l1_from_db(limit: int = CACHE_L1_MAXSIZE) -> int:\n    cutoff = time.time() - CACHE_TTL_SECONDS\n    try:\n        with _db_conn() as conn:\n            rows = conn.execute(\n                \"SELECT cik, ticker FROM ticker_cache \"\n                \"WHERE updated_at >= ? ORDER BY updated_at DESC LIMIT ?\",\n                (cutoff, limit),\n            ).fetchall()\n    except Exception as exc:\n        logger.warning(\"L1 warm-start failed: %s\", exc)\n        return 0\n\n    for row in rows:\n        _l1.set(row[\"cik\"], row[\"ticker\"], source=\"warm-start\")\n    logger.info(\"L1 warm-started with %d entries from SQLite\", len(rows))\n    return len(rows)\n\n\n# ---------------------------------------------------------------------------\n# Unified cache helpers\n# ---------------------------------------------------------------------------\ndef _cache_get(cik: int) -> Optional[str]:\n    hit = _l1.get(cik)\n    if hit is not None:\n        return hit\n    l2_hit = _l2_get(cik)\n    if l2_hit is not None:\n        _l1.set(cik, l2_hit, source=\"l2-promote\")\n        return l2_hit\n    return None\n\n\ndef _cache_set(cik: int, ticker: str, *, source: str, overwrite: bool = False) -> None:\n    existing = _l1.get(cik)\n    if existing is not None and existing != \"\" and existing != ticker:\n        logger.info(\n            \"Cache UPDATE cik=%s %r → %r (source=%s, overwrite=%s)\",\n            cik, existing, ticker or \"<empty>\", source, overwrite,\n        )\n    _l1.set(cik, ticker, source=source)\n    try:\n        _l2_set(cik, ticker, source=source)\n    except Exception as exc:\n        logger.warning(\"L2 write failed for cik=%s: %s (L1 still updated)\", cik, exc)\n\n\ndef cache_invalidate(cik_raw: int | str) -> None:\n    cik = int(cik_raw)\n    _l1.invalidate(cik)\n    try:\n        with _db_conn() as conn:\n            conn.execute(\"DELETE FROM ticker_cache WHERE cik = ?\", (cik,))\n        logger.info(\"L2 INVALIDATE cik=%s\", cik)\n    except Exception as exc:\n        logger.warning(\"L2 invalidation failed for cik=%s: %s\", cik, exc)\n\n\n# ---------------------------------------------------------------------------\n# Thread-safe, time-windowed EDGAR rate-limiter\n# ---------------------------------------------------------------------------\n_edgar_lock = threading.Lock()\n_edgar_calls: int = 0\n_edgar_window_start: float = time.time()\n\n\ndef _edgar_call_allowed() -> bool:\n    global _edgar_calls, _edgar_window_start\n    with _edgar_lock:\n        now     = time.time()\n        elapsed = now - _edgar_window_start\n        if elapsed >= EDGAR_WINDOW_SEC:\n            logger.debug(\"EDGAR window reset (%.0fs, %d calls)\", elapsed, _edgar_calls)\n            _edgar_calls = 0\n            _edgar_window_start = now\n        if _edgar_calls >= MAX_EDGAR_CALLS:\n            logger.warning(\n                \"EDGAR rate-limit: %d/%d calls, %.0fs remaining in window\",\n                _edgar_calls, MAX_EDGAR_CALLS, EDGAR_WINDOW_SEC - elapsed,\n            )\n            return False\n        _edgar_calls += 1\n        logger.debug(\"EDGAR call %d/%d (%.0fs into window)\", _edgar_calls, MAX_EDGAR_CALLS, elapsed)\n        return True\n\n\n# ---------------------------------------------------------------------------\n# EDGAR Submissions API call\n# ---------------------------------------------------------------------------\ndef _lookup_via_edgar(cik: int) -> str:\n    url = EDGAR_BASE_URL.format(cik=cik)\n\n    try:\n        resp = requests.get(\n            url,\n            headers={\"User-Agent\": _EDGAR_USER_AGENT},\n            timeout=EDGAR_TIMEOUT_SEC,\n        )\n    except requests.exceptions.Timeout as exc:\n        raise _TransientError(f\"Timeout CIK {cik}\") from exc\n    except requests.exceptions.ConnectionError as exc:\n        raise _TransientError(f\"Connection error CIK {cik}\") from exc\n    except requests.exceptions.RequestException as exc:\n        raise _TransientError(f\"Network error CIK {cik}: {exc}\") from exc\n\n    if resp.status_code == 404:\n        raise _PermanentMiss(f\"HTTP 404 — CIK {cik} not in EDGAR\")\n    if resp.status_code == 429:\n        raise _TransientError(f\"HTTP 429 — server-side rate-limit CIK {cik}\")\n    if resp.status_code >= 500:\n        raise _TransientError(f\"HTTP {resp.status_code} — server error CIK {cik}\")\n    if resp.status_code != 200:\n        raise _PermanentMiss(f\"HTTP {resp.status_code} — permanent miss CIK {cik}\")\n\n    try:\n        data = resp.json()\n    except ValueError as exc:\n        raise _TransientError(f\"Malformed JSON for CIK {cik}: {exc}\") from exc\n\n    tickers: list[str] = data.get(\"tickers\", [])\n    if not tickers:\n        raise _PermanentMiss(f\"EDGAR returned empty tickers list for CIK {cik}\")\n\n    if len(tickers) > 1:\n        logger.info(\n            \"CIK %s has %d tickers %s — using primary %r\",\n            cik, len(tickers), tickers, tickers[0],\n        )\n    return tickers[0]\n\n\n# ---------------------------------------------------------------------------\n# Regex helpers\n# [FIX-F] Pattern now handles class-share suffixes (BRK.B, BF.A)\n# [FIX-G] Public alias + CIK extractor for backward compatibility\n# ---------------------------------------------------------------------------\ndef _extract_ticker_from_title(title: str) -> Optional[str]:\n    \"\"\"\n    Extract the first plausible ticker from a filing title string.\n    Returns None if nothing passes the blacklist filter.\n    Matches patterns like (AAPL), (NVDA), (BRK.B).\n    \"\"\"\n    for m in _TICKER_PATTERN.finditer(title):\n        candidate = m.group(1)\n        base = candidate.split(\".\")[0]  # check base symbol against blacklist\n        if base not in _TICKER_BLACKLIST:\n            return candidate\n        logger.debug(\"Regex: rejected %r in %r\", candidate, title)\n    return None\n\n\n# [FIX-G] Alias for test compatibility\n_extract_via_regex = _extract_ticker_from_title\n\n\ndef _extract_cik(title: str) -> Optional[str]:\n    \"\"\"\n    Extract a numeric SEC CIK (7-10 digits) from a filing title string.\n    CIKs appear in parentheses, e.g. 'Apple Inc. (0000320193)'.\n    Returns None if no CIK-like number is found.\n    \"\"\"\n    m = re.search(r'\\((\\d{7,10})\\)', title)\n    return m.group(1) if m else None\n\n\n# ---------------------------------------------------------------------------\n# Public API\n# ---------------------------------------------------------------------------\ndef resolve_ticker(\n    cik: int | str,\n    title: str = \"\",\n    *,\n    use_edgar_fallback: bool = True,\n) -> Optional[str]:\n    \"\"\"\n    Resolve a CIK to an exchange ticker symbol.\n\n    Resolution order:\n      1. L1 (memory) → L2 (SQLite) cache\n      2. Regex from title                 (low trust, provisional)\n      3. EDGAR Submissions API            (authoritative, overwrites Stage 2)\n    \"\"\"\n    try:\n        cik_int: int = int(cik)\n    except (ValueError, TypeError) as exc:\n        logger.error(\"resolve_ticker: invalid cik=%r — %s\", cik, exc)\n        return None\n\n    # Stage 1 — cache\n    cached = _cache_get(cik_int)\n    if cached is not None:\n        return cached if cached != \"\" else None\n\n    # Stage 2 — regex (provisional)\n    regex_ticker: Optional[str] = None\n    if title:\n        regex_ticker = _extract_ticker_from_title(title)\n        if regex_ticker and not use_edgar_fallback:\n            _cache_set(cik_int, regex_ticker, source=\"regex\")\n            return regex_ticker\n\n    # Stage 3 — EDGAR (authoritative)\n    if not use_edgar_fallback:\n        return regex_ticker\n\n    if not _edgar_call_allowed():\n        logger.warning(\n            \"EDGAR quota exhausted for CIK %s; returning unverified regex=%r\",\n            cik_int, regex_ticker,\n        )\n        return regex_ticker\n\n    try:\n        edgar_ticker = _lookup_via_edgar(cik_int)\n        _cache_set(cik_int, edgar_ticker, source=\"edgar\", overwrite=True)\n        if regex_ticker and regex_ticker != edgar_ticker:\n            logger.info(\n                \"CIK %s: EDGAR %r overrides regex guess %r\",\n                cik_int, edgar_ticker, regex_ticker,\n            )\n        return edgar_ticker\n\n    except _TransientError as exc:\n        logger.warning(\"Transient EDGAR error CIK %s: %s\", cik_int, exc)\n        return regex_ticker\n\n    except _PermanentMiss as exc:\n        logger.info(\"Permanent EDGAR miss CIK %s: %s\", cik_int, exc)\n        _cache_set(cik_int, \"\", source=\"edgar-permanent-miss\", overwrite=True)\n        return None\n\n    except Exception as exc:\n        logger.error(\"Unexpected error CIK %s: %s\", cik_int, exc, exc_info=True)\n        return regex_ticker\n\n\n# ---------------------------------------------------------------------------\n# Diagnostics / operational helpers\n# ---------------------------------------------------------------------------\ndef cache_stats() -> dict:\n    now = time.time()\n    with _edgar_lock:\n        calls      = _edgar_calls\n        window_age = now - _edgar_window_start\n\n    try:\n        with _db_conn() as conn:\n            l2_total = conn.execute(\"SELECT COUNT(*) FROM ticker_cache\").fetchone()[0]\n            l2_fresh = conn.execute(\n                \"SELECT COUNT(*) FROM ticker_cache WHERE updated_at >= ?\",\n                (now - CACHE_TTL_SECONDS,),\n            ).fetchone()[0]\n    except Exception:\n        l2_total = l2_fresh = -1\n\n    return {\n        \"l1_size\":                    _l1.size(),\n        \"l1_maxsize\":                 CACHE_L1_MAXSIZE,\n        \"l2_total_rows\":              l2_total,\n        \"l2_fresh_rows\":              l2_fresh,\n        \"edgar_calls_this_window\":    calls,\n        \"edgar_max_calls\":            MAX_EDGAR_CALLS,\n        \"edgar_window_age_sec\":       round(window_age, 1),\n        \"edgar_window_remaining_sec\": round(max(0.0, EDGAR_WINDOW_SEC - window_age), 1),\n    }\n\n\ndef clear_cache(*, l1: bool = True, l2: bool = False) -> None:\n    if l1:\n        with _l1._lock:\n            count = len(_l1._data)\n            _l1._data.clear()\n        logger.info(\"L1 cleared (%d entries)\", count)\n    if l2:\n        with _db_conn() as conn:\n            conn.execute(\"DELETE FROM ticker_cache\")\n        logger.info(\"L2 cleared\")\n\n\n# ---------------------------------------------------------------------------\n# Module initialisation\n# ---------------------------------------------------------------------------\n_init_db()\n_warm_l1_from_db()\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/test_ai.py",
    "content": "# tests/test_ai.py\n\"\"\"Tests für AI-Module (ohne API-Calls).\"\"\"\nimport sys\nfrom pathlib import Path\nsys.path.insert(0, str(Path(__file__).parent.parent))\n\nfrom src.enrich.sentiment import calculate as calc_sentiment\nfrom src.enrich.options_prefilter import conviction_modifier_for_iv\n\n\ndef test_sentiment_bullish():\n    news = [\n        {\"title\": \"Company X beats earnings, raises guidance\"},\n        {\"title\": \"FDA approval granted for new drug\"},\n    ]\n    score = calc_sentiment(news)\n    assert score > 0.4\n\n\ndef test_sentiment_bearish():\n    news = [\n        {\"title\": \"SEC investigation opened against company\"},\n        {\"title\": \"Earnings miss expectations, downgrade follows\"},\n    ]\n    score = calc_sentiment(news)\n    assert score < -0.3\n\n\ndef test_sentiment_neutral():\n    news = [\n        {\"title\": \"Company announces routine quarterly update\"},\n        {\"title\": \"Stock trades sideways in midday session\"},\n    ]\n    score = calc_sentiment(news)\n    assert -0.3 <= score <= 0.3\n\n\ndef test_iv_modifier_curves():\n    # Sehr niedrige IV-Rank: Bonus\n    assert conviction_modifier_for_iv(20) > 0\n    \n    # Mittlere IV-Rank: leichter Penalty\n    assert -0.20 < conviction_modifier_for_iv(45) < 0\n    \n    # Hohe IV-Rank: stärkerer Penalty\n    assert conviction_modifier_for_iv(60) < -0.10\n    \n    # Über Kill: noch stärker\n    assert conviction_modifier_for_iv(80) <= -0.20\n\n\nif __name__ == \"__main__\":\n    test_sentiment_bullish()\n    test_sentiment_bearish()\n    test_sentiment_neutral()\n    test_iv_modifier_curves()\n    print(\"✓ All AI tests passed\")\n"
  },
  {
    "path": "tests/test_ingest.py",
    "content": "# tests/test_ingest.py\n\"\"\"Tests für Ingest-Module.\"\"\"\nimport sys\nfrom pathlib import Path\nsys.path.insert(0, str(Path(__file__).parent.parent))\n\nfrom src.utils.ticker_resolver import _extract_via_regex, _extract_cik\nfrom src.ingest.eight_k_fetcher import _parse_item_score\nfrom src.ingest.gov_trades_fetcher import _clean_ticker, _parse_amount\n\n\ndef test_ticker_extraction_regex():\n    assert _extract_via_regex(\"Apple Inc. (AAPL)\") == \"AAPL\"\n    assert _extract_via_regex(\"Berkshire Hathaway (BRK.B)\") == \"BRK.B\"\n    assert _extract_via_regex(\"Random Filing (12345)\") is None      # Digits → kein Match\n    assert _extract_via_regex(\"Some Company (INC)\") is None         # Blacklist\n    assert _extract_via_regex(\"No ticker here\") is None\n\n\ndef test_cik_extraction():\n    assert _extract_cik(\"Apple Inc. (0000320193)\") == \"0000320193\"\n    assert _extract_cik(\"(AAPL) (0000320193)\") == \"0000320193\"\n    assert _extract_cik(\"No numbers\") is None\n\n\ndef test_8k_item_score():\n    score, item, _ = _parse_item_score(\"Item 1.01 Material Definitive Agreement\")\n    assert score == 90\n    assert item == \"1.01\"\n\n    score, item, _ = _parse_item_score(\"Item 5.02 Departure of Officers\")\n    assert score == 72\n    assert item == \"5.02\"\n\n    score, item, _ = _parse_item_score(\"Random text without item id\")\n    assert score == 10\n    assert item == \"unknown\"\n\n    # Groß-/Kleinschreibung\n    score, item, _ = _parse_item_score(\"item 2.01 completion of acquisition\")\n    assert score == 88\n    assert item == \"2.01\"\n\n\ndef test_gov_ticker_clean():\n    assert _clean_ticker(\"AAPL\") == \"AAPL\"\n    assert _clean_ticker(\"BRK.B\") == \"BRK.B\"\n    assert _clean_ticker(\"INC\") is None   # Blacklist\n    assert _clean_ticker(\"\") is None\n    assert _clean_ticker(\"123\") is None   # Rein numerisch\n    assert _clean_ticker(\"A\") is None     # Zu kurz\n\n\ndef test_gov_amount_parse():\n    assert _parse_amount(\"$1,001 - $15,000\") == (1001 + 15000) // 2\n    assert _parse_amount(\"$50,000 - $100,000\") == 75_000\n    assert _parse_amount(\"\") == 0\n\n\nif __name__ == \"__main__\":\n    test_ticker_extraction_regex()\n    test_cik_extraction()\n    test_8k_item_score()\n    test_gov_ticker_clean()\n    test_gov_amount_parse()\n    print(\"✓ All ingest tests passed\")\n"
  },
  {
    "path": "tests/test_scoring.py",
    "content": "# tests/test_scoring.py\n\"\"\"Tests für Scoring-Module.\"\"\"\nimport sys\nfrom pathlib import Path\nsys.path.insert(0, str(Path(__file__).parent.parent))\n\nfrom src.score.signal_filter import Signal, SignalFilter\nfrom src.score.fund_scorer import FundScorer\n\n\ndef test_fund_scorer_known_fund():\n    scorer = FundScorer()\n    assert scorer.get_score(\"Berkshire Hathaway\") >= 40\n    assert scorer.get_score(\"Pershing Square\") >= 40\n\n\ndef test_fund_scorer_ignored_fund():\n    scorer = FundScorer()\n    assert scorer.get_score(\"Vanguard\") == 0\n    assert scorer.get_score(\"BlackRock\") == 0\n\n\ndef test_fund_scorer_unknown_fund():\n    scorer = FundScorer()\n    score = scorer.get_score(\"Random Fund LLC\")\n    assert 0 <= score <= 10\n\n\ndef test_signal_filter_hard_gates():\n    sf = SignalFilter()\n    \n    # Valid signal\n    s = Signal(\n        ticker=\"AAPL\", signal_type=\"13f_increase\",\n        fund_name=\"Berkshire\", fund_score=48,\n        strength=80, conviction=0.6,\n        source_count=2\n    )\n    assert sf.is_valid(s)\n    \n    # Fund score too low\n    s.fund_score = 5\n    assert not sf.is_valid(s)\n    \n    # Single source\n    s.fund_score = 48\n    s.source_count = 1\n    assert not sf.is_valid(s)\n    \n    # Too low conviction\n    s.source_count = 2\n    s.conviction = 0.2\n    assert not sf.is_valid(s)\n\n\ndef test_weighted_score_consecutive_quarters_bonus():\n    sf = SignalFilter()\n    \n    base = Signal(\n        ticker=\"NVDA\", signal_type=\"13f_increase\",\n        fund_name=\"Coatue\", fund_score=32,\n        strength=70, conviction=0.6,\n        source_count=2,\n        consecutive_quarters=0\n    )\n    base_score = sf.weighted_score(base)\n    \n    base.consecutive_quarters = 3\n    boosted_score = sf.weighted_score(base)\n    \n    assert boosted_score > base_score\n    assert boosted_score - base_score >= 10  # Min 10 Punkte Bonus\n\n\ndef test_strength_combo():\n    sf = SignalFilter()\n    \n    # Insider + 13F = sehr hoch\n    s = sf.calculate_strength([\"insider_buy\", \"13f_increase\"])\n    assert s >= 85\n    \n    # Single signal = niedriger\n    s = sf.calculate_strength([\"insider_buy\"])\n    assert s < 60\n\n\nif __name__ == \"__main__\":\n    test_fund_scorer_known_fund()\n    test_fund_scorer_ignored_fund()\n    test_fund_scorer_unknown_fund()\n    test_signal_filter_hard_gates()\n    test_weighted_score_consecutive_quarters_bonus()\n    test_strength_combo()\n    print(\"✓ All scoring tests passed\")\n"
  }
]