# -*- coding: utf-8 -*- import streamlit as st import yfinance as yf import pandas as pd import numpy as np import plotly.graph_objects as go from datetime import datetime, timedelta import time # Für Fehlerbehandlung Ticker # --- Konfiguration --- st.set_page_config(layout="wide", page_title="Option Yield Pro (Premium Focus)") # --- Hilfsfunktionen --- # -- Caching -- @st.cache_resource(ttl=600) # Cache das Ticker-OBJEKT für 10 Min. def get_ticker_object(ticker_symbol): """Holt und cacht das yfinance Ticker-Objekt.""" if not ticker_symbol: return None # Frühzeitiger Ausstieg, wenn kein Ticker vorhanden try: ticker = yf.Ticker(ticker_symbol) # Prüfe Gültigkeit (löst einen kleinen Download aus) # .info kann manchmal leer sein, auch wenn Ticker gültig. Besser: History-Check? if ticker.history(period="1d").empty: raise ValueError(f"Keine Daten für {ticker_symbol} abrufbar. Ungültiger Ticker?") return ticker except Exception as e: # Fehler nicht im UI anzeigen, wenn die Funktion im Hintergrund läuft print(f"Fehler beim Erstellen des Ticker-Objekts für {ticker_symbol}: {e}") return None # Return None on failure @st.cache_data(ttl=300) # Cache die AKTIENDATEN (dict) für 5 Minuten def get_stock_data(_ticker_obj): # Nimm Objekt statt Symbol """Holt Aktieninformationen und Preis mithilfe des Ticker-Objekts.""" if _ticker_obj is None: return None, "Ungültiges Ticker-Objekt" try: info = _ticker_obj.info # Verschiedene Felder für den aktuellen Preis prüfen price = info.get('currentPrice') or info.get('regularMarketPrice') or info.get('bid') or info.get('ask') prev_close = info.get('previousClose') or info.get('regularMarketPreviousClose') # History-Fallback, wenn Preis/Schlusskurs fehlen if price is None or prev_close is None: hist = _ticker_obj.history(period="2d") if not hist.empty: price = hist['Close'].iloc[-1] prev_close = hist['Close'].iloc[-2] if len(hist) > 1 else price # Fallback für nur einen Tag History else: # Fallback: Versuche 'financialData' fdata = info.get('financialData', {}) price = fdata.get('currentPrice') if price is None: return None, "Aktueller Preis konnte nicht ermittelt werden." # Wenn prev_close immer noch fehlt, nehmen wir einfach den aktuellen Preis (suboptimal) if prev_close is None: prev_close = price change = price - prev_close change_percent = (change / prev_close) * 100 if prev_close != 0 else 0 # Vermeide Division durch Null name = info.get('shortName', _ticker_obj.ticker) currency = info.get('currency', '$') # Währung holen fifty_two_week_low = info.get('fiftyTwoWeekLow') fifty_two_week_high = info.get('fiftyTwoWeekHigh') stock_data = { "name": name, "price": price, "change": change, "change_percent": change_percent, "currency": currency, "fifty_two_week_low": fifty_two_week_low, "fifty_two_week_high": fifty_two_week_high, } return stock_data, None # Return data dict and None for error except Exception as e: return None, f"Fehler beim Abrufen der Aktiendaten für {_ticker_obj.ticker}: {e}" @st.cache_data(ttl=600) # Cache die Liste der ABLAUFDATEN def get_option_dates(_ticker_obj): """Holt verfügbare Optionsablaufdaten.""" if _ticker_obj is None: return [] try: # Manchmal gibt .options einen Fehler, wenn keine Optionen existieren opts = _ticker_obj.options return opts if opts else [] except Exception as e: print(f"Fehler beim Abrufen der Optionsdaten ({_ticker_obj.ticker}): {e}") return [] @st.cache_data(ttl=300) # Cache die OPTIONSKETTE DataFrame für 5 Min. def get_option_chain(_ticker_obj, expiration_date): """Holt die Optionskette für ein spezifisches Datum.""" if _ticker_obj is None or not expiration_date: return pd.DataFrame() try: chain = _ticker_obj.option_chain(expiration_date) # Nur Calls, Index zurücksetzen und sicherstellen, dass Spalten existieren # Check if 'calls' key exists before accessing if 'calls' not in chain or chain.calls is None: return pd.DataFrame() # Return empty if no calls data calls = chain.calls.reset_index(drop=True).copy() # Standardwerte für fehlende Griechen setzen (falls yfinance sie nicht liefert) for greek in ['delta', 'gamma', 'theta', 'vega']: if greek not in calls.columns: calls[greek] = np.nan return calls except Exception as e: # Oft passiert "KeyError: 'calls'" wenn keine Kette für das Datum existiert print(f"Fehler/Warnung beim Abrufen der Optionskette für {_ticker_obj.ticker} am {expiration_date}: {e}") return pd.DataFrame() # Return empty dataframe on error @st.cache_data(ttl=1800) # Cache History länger (30 Min) def get_stock_history(_ticker_obj, period='1y', interval='1d'): """Holt historische Kurse für den Chart.""" if _ticker_obj is None: return pd.DataFrame() try: # yfinance erwartet manchmal Großbuchstaben für Intervalle wie '1WK' interval_map = {'1d': '1d', '1wk': '1wk', '1mo': '1mo'} # Mapping zur Sicherheit hist = _ticker_obj.history(period=period, interval=interval_map.get(interval.lower(), '1d')) return hist except Exception as e: st.error(f"Fehler beim Laden der Historie für {_ticker_obj.ticker} (Periode: {period}, Intervall: {interval}): {e}") return pd.DataFrame() # -- Berechnungen & Logik -- def calculate_covered_calls(calls_df, current_price, shares_owned, cost_basis, expiration_date, assumed_premium_price_type='bid', custom_premium=None, commission_per_contract=0.0): """Berechnet Covered Call Metriken und fügt sie dem DataFrame hinzu.""" if calls_df.empty: return calls_df df = calls_df.copy() # --- Prämienberechnung --- if custom_premium is not None and custom_premium > 0: df['assumedPremiumPerShare'] = float(custom_premium) # Stelle sicher, dass es float ist elif assumed_premium_price_type == 'mid': bid_filled = df['bid'].fillna(0) ask_filled = df['ask'].fillna(df['lastPrice']).fillna(bid_filled) # Fallback: Last -> Bid -> 0 df['assumedPremiumPerShare'] = (bid_filled + ask_filled) / 2 elif assumed_premium_price_type == 'last': df['assumedPremiumPerShare'] = df['lastPrice'].fillna(0) else: # Default to Bid df['assumedPremiumPerShare'] = df['bid'].fillna(0) # Stelle sicher, dass Prämie nicht negativ ist und float ist df['assumedPremiumPerShare'] = df['assumedPremiumPerShare'].apply(lambda x: max(0, float(x))) num_contracts = float(shares_owned) / 100.0 df['premiumGrossTotal'] = df['assumedPremiumPerShare'] * float(shares_owned) total_commission = num_contracts * float(commission_per_contract) df['premiumNetTotal'] = df['premiumGrossTotal'] - total_commission # Nettoprämie nach Provision # --- Kostenbasis & Kernberechnungen --- cost_basis = float(cost_basis) shares_owned = float(shares_owned) current_price = float(current_price) df['costBasisTotal'] = cost_basis * shares_owned df['strike'] = df['strike'].astype(float) # Max Profit if Assigned (Nettoprämie verwenden) df['maxProfitAssignedNet'] = ((df['strike'] - cost_basis) * shares_owned) + df['premiumNetTotal'] # Behalten für optionale Anzeige # --- Prozentuale Metriken (auf Basis der Nettoprämie) --- cost_basis_total_val = df['costBasisTotal'].iloc[0] if not df.empty else 0 # Berechne premium_net_per_share als Series premium_net_per_share = df['premiumNetTotal'] / shares_owned if shares_owned > 0 else pd.Series([0.0] * len(df), index=df.index) # Sicherstellen, dass es eine Series ist, falls shares_owned=0 galt if not isinstance(premium_net_per_share, pd.Series): premium_net_per_share = pd.Series([premium_net_per_share] * len(df), index=df.index) # Fall: Kostenbasis > 0 if cost_basis_total_val > 0: df['maxProfitPercentNet'] = (df['maxProfitAssignedNet'] / cost_basis_total_val) * 100 # Behalten für optionale Anzeige df['returnIfFlatPercentNet'] = (df['premiumNetTotal'] / cost_basis_total_val) * 100 # Downside Protection braucht premium_net_per_share (Series) und cost_basis (Skalar) df['downsideProtectionPercentNet'] = (premium_net_per_share / cost_basis) * 100 if cost_basis > 0 else np.inf # Return on Risk braucht premium_net_per_share (Series) und capital_at_risk (Series) capital_at_risk = cost_basis - premium_net_per_share df['returnOnRiskPercentNet'] = np.where( capital_at_risk > 0, (premium_net_per_share / capital_at_risk) * 100, np.inf ) # Fall: Kostenbasis <= 0 (oder nicht berechenbar) else: for col in ['maxProfitPercentNet', 'returnIfFlatPercentNet', 'downsideProtectionPercentNet', 'returnOnRiskPercentNet']: df[col] = np.inf # --- DTE (Days To Expiration) --- try: # Verwende datetime.date für reinen Datumsvergleich exp_date_obj = datetime.strptime(expiration_date, '%Y-%m-%d').date() today = datetime.now().date() dte = (exp_date_obj - today).days dte = max(0, dte) # Sicherstellen, dass DTE nicht negativ ist except ValueError: dte = 0 # st.warning nicht in cached function, print ist besser für Debugging print(f"Konnte Datum {expiration_date} nicht für DTE-Berechnung parsen.") df['DTE'] = dte # --- Annualisierte Rendite (Basis: Nettoprämie, wenn Flat) --- df['annualizedReturnPercentNet'] = 0.0 # Initialisiere Spalte # Stelle sicher, dass returnIfFlatPercentNet existiert und numerisch ist if 'returnIfFlatPercentNet' in df.columns: df['returnIfFlatPercentNet'] = pd.to_numeric(df['returnIfFlatPercentNet'], errors='coerce') # Umwandeln, Fehler -> NaN valid_annual_mask = (df['DTE'] > 0) & df['returnIfFlatPercentNet'].notna() & (df['returnIfFlatPercentNet'] > 0) safe_dte = df.loc[valid_annual_mask, 'DTE'].replace(0, 1) # Ersetze 0 durch 1 für Berechnung # Überprüfe ob safe_dte leer ist, bevor darauf zugegriffen wird if not safe_dte.empty: df.loc[valid_annual_mask, 'annualizedReturnPercentNet'] = \ df.loc[valid_annual_mask, 'returnIfFlatPercentNet'] * (365.0 / safe_dte) # Setze NaN für ungültige Fälle und fülle dann mit 0.0 df['annualizedReturnPercentNet'] = df['annualizedReturnPercentNet'].fillna(0.0) # --- Breakeven (Basis: Nettoprämie pro Aktie) --- df['breakeven'] = cost_basis - premium_net_per_share # --- % OTM (Out of the Money) --- df['percentOTM'] = 0.0 otm_mask = df['strike'] > current_price # Wende Berechnung nur auf OTM-Strikes an df.loc[otm_mask, 'percentOTM'] = ((df.loc[otm_mask, 'strike'] - current_price) / current_price) * 100 # --- Griechen & P.OTM --- for greek in ['delta', 'gamma', 'theta', 'vega']: if greek in df.columns: df[greek] = pd.to_numeric(df[greek], errors='coerce').fillna(np.nan) # Sicherstellen, dass numerisch oder NaN else: df[greek] = np.nan # Sicherstellen, dass Spalte existiert # Wahrscheinlichkeit OTM (vereinfacht: 1 - Delta) # Fülle fehlende Deltas mit 0.5 (50/50 Chance) oder NaN? NaN ist neutraler. df['prob_OTM'] = (1.0 - df['delta']).clip(0, 1) # Auf 0-1 begrenzen, NaN bleibt NaN return df # -- Datums-Prüfungen -- def is_monthly_expiration(date_str): """Prüft, ob ein Datum wahrscheinlich ein Monatsverfall ist (3. Freitag).""" try: date_obj = datetime.strptime(date_str, '%Y-%m-%d') # Standard US equity monthly expiration return 15 <= date_obj.day <= 21 and date_obj.weekday() == 4 # 4 = Freitag except ValueError: return False def is_weekly_expiration(date_str): """Prüft, ob ein Datum wahrscheinlich ein Wochenverfall ist (Freitag, aber nicht der 3.).""" try: date_obj = datetime.strptime(date_str, '%Y-%m-%d') # Freitag, aber nicht der 3. Freitag des Monats is_friday = date_obj.weekday() == 4 is_third_friday = 15 <= date_obj.day <= 21 return is_friday and not is_third_friday except ValueError: return False # --- Plotting Funktion --- def plot_stock_chart(hist_df, ticker_symbol, indicators, sma_periods): """Erstellt einen interaktiven Plotly Chart.""" if hist_df.empty: return go.Figure() # Leere Figur zurückgeben, wenn keine Daten fig = go.Figure() # Candlestick Chart hinzufügen (nur wenn notwendige Spalten vorhanden sind) required_cols = ['Open', 'High', 'Low', 'Close'] if all(col in hist_df.columns for col in required_cols): fig.add_trace(go.Candlestick(x=hist_df.index, open=hist_df['Open'], high=hist_df['High'], low=hist_df['Low'], close=hist_df['Close'], name='Kurs')) else: # Fallback: Linienchart für Schlusskurs, wenn verfügbar if 'Close' in hist_df.columns: st.warning("Candlestick Daten unvollständig, zeige Linienchart (Close).") fig.add_trace(go.Scatter(x=hist_df.index, y=hist_df['Close'], mode='lines', name='Schlusskurs')) else: st.warning("Keine ausreichenden Daten für Chart vorhanden.") return fig # Leere Figur zurückgeben # Indikatoren hinzufügen if 'SMA' in indicators and 'Close' in hist_df.columns: for period in sma_periods: if not isinstance(period, int) or period <= 0: continue # Ungültige Periode überspringen sma_col = f'SMA_{period}' hist_df[sma_col] = hist_df['Close'].rolling(window=period, min_periods=1).mean() # min_periods=1 für Anfangswerte fig.add_trace(go.Scatter(x=hist_df.index, y=hist_df[sma_col], mode='lines', name=sma_col)) if 'Bollinger Bands' in indicators and 'Close' in hist_df.columns: # Nutze erste gültige SMA-Periode oder Standard 20 bb_period = next((p for p in sma_periods if isinstance(p, int) and p > 0), 20) sma_col = f'SMA_{bb_period}' # Berechne SMA falls noch nicht vorhanden oder Periode ungültig war if sma_col not in hist_df.columns: hist_df[sma_col] = hist_df['Close'].rolling(window=bb_period, min_periods=1).mean() std_dev = hist_df['Close'].rolling(window=bb_period, min_periods=1).std() upper_band = hist_df[sma_col] + (std_dev * 2) lower_band = hist_df[sma_col] - (std_dev * 2) # Nur hinzufügen, wenn Bänder berechnet werden konnten if upper_band.notna().any(): # Prüfen ob nicht nur NaNs enthalten sind fig.add_trace(go.Scatter(x=hist_df.index, y=upper_band, mode='lines', name=f'Upper Band ({bb_period})', line=dict(width=1, dash='dash', color='rgba(150,150,150,0.7)'))) if lower_band.notna().any(): fig.add_trace(go.Scatter(x=hist_df.index, y=lower_band, mode='lines', name=f'Lower Band ({bb_period})', line=dict(width=1, dash='dash', color='rgba(150,150,150,0.7)'), fill='tonexty', fillcolor='rgba(173,216,230,0.2)')) fig.update_layout( title=f'{ticker_symbol} Kursverlauf & Indikatoren', xaxis_title='Datum', yaxis_title='Preis', xaxis_rangeslider_visible=False, # Range Slider unten ausblenden legend_title_text='Legende', height=500, # Höhe des Charts anpassen margin=dict(l=20, r=20, t=40, b=20) # Ränder reduzieren ) return fig # --- Session State Initialisierung --- # Neue Standardspalten mit Fokus auf Prämie/Risiko base_default_columns_premium = [ 'Strike', 'Bid', 'Ask', '% OTM', 'Delta', 'prob_OTM', 'Impl. Vol (%)', 'Prämie Netto ({currency})', 'Rendite (Statisch %)', 'Rendite p.a. (%)', 'Risikopuffer (%)', 'Return on Risk (%)', 'Theta', 'DTE' ] # Optionale Spalten (inkl. der alten Max-Profit-Spalten) other_possible_cols_premium = [ 'Last', 'Volumen', 'Open Int.', 'Gamma', 'Vega', 'Max. Gewinn ({currency})', 'Max. Gewinn (%)', 'Breakeven ({currency})' ] # Initialisiere State if 'ticker_symbol' not in st.session_state: st.session_state.ticker_symbol = "TSLA" if 'shares_owned' not in st.session_state: st.session_state.shares_owned = 100 if 'cost_basis' not in st.session_state: st.session_state.cost_basis = 100.00 if 'stock_data' not in st.session_state: st.session_state.stock_data = None if 'commission' not in st.session_state: st.session_state.commission = 0.65 if 'premium_price_type' not in st.session_state: st.session_state.premium_price_type = 'bid' if 'custom_premium' not in st.session_state: st.session_state.custom_premium = None # Initialisiere mit den *neuen* Premium-fokussierten Defaults if 'selected_columns' not in st.session_state: st.session_state.selected_columns = [col.format(currency='$') for col in base_default_columns_premium] if 'min_annual_return_filter' not in st.session_state: st.session_state.min_annual_return_filter = 5.0 # Höherer Standard-Min.-Filter if 'max_annual_return_filter' not in st.session_state: st.session_state.max_annual_return_filter = 1000.0 if 'min_delta_filter' not in st.session_state: st.session_state.min_delta_filter = 0.0 if 'max_delta_filter' not in st.session_state: st.session_state.max_delta_filter = 0.35 # Standardmäßig OTM fokussieren if 'highlight_min_return' not in st.session_state: st.session_state.highlight_min_return = 25.0 # Höherer Schwellenwert if 'highlight_max_delta' not in st.session_state: st.session_state.highlight_max_delta = 0.30 # Delta-Schwelle für Hervorhebung if 'show_only_otm' not in st.session_state: st.session_state.show_only_otm = True # Standardmäßig nur OTM zeigen # Chart & Datum Filter State (bleiben gleich) if 'chart_period' not in st.session_state: st.session_state.chart_period = '1y' if 'chart_interval' not in st.session_state: st.session_state.chart_interval = '1d' if 'chart_indicators' not in st.session_state: st.session_state.chart_indicators = ['SMA'] if 'chart_sma_periods' not in st.session_state: st.session_state.chart_sma_periods = [50, 200] if 'date_filter_type' not in st.session_state: st.session_state.date_filter_type = 'Alle' # --- Währung und Spaltennamen VOR der Sidebar bestimmen --- current_currency_symbol = '$' # Default if st.session_state.stock_data and isinstance(st.session_state.stock_data, dict): current_currency_symbol = st.session_state.stock_data.get('currency', '$') # Verwende die *neuen* Basis-Spaltenlisten current_default_columns = [col.format(currency=current_currency_symbol) for col in base_default_columns_premium] current_all_possible_columns = current_default_columns + [col.format(currency=current_currency_symbol) for col in other_possible_cols_premium] # Stelle sicher, dass die Defaults im State die aktuelle Währung verwenden temp_selected_columns = [] # Use a temporary list to avoid modifying while iterating if necessary current_selection_in_state = list(st.session_state.selected_columns) for col in current_selection_in_state: updated_col = col # Start with the column as is # Try replacing known currency symbols/placeholders updated_col = updated_col.replace("($)", f"({current_currency_symbol})") updated_col = updated_col.replace("(USD)", f"({current_currency_symbol})") updated_col = updated_col.replace("(EUR)", f"({current_currency_symbol})") # Add if it's now a valid possible column if updated_col in current_all_possible_columns: if updated_col not in temp_selected_columns: # Avoid duplicates temp_selected_columns.append(updated_col) # Fallback: If original base column name (without currency) exists, format it else: for base_col in base_default_columns_premium + other_possible_cols_premium: # Check if the base name (placeholder removed) is part of the current column name if base_col.replace("{currency}", "") in col: formatted_base_col = base_col.format(currency=current_currency_symbol) if formatted_base_col in current_all_possible_columns and formatted_base_col not in temp_selected_columns: temp_selected_columns.append(formatted_base_col) break # Found match for this base column # Update the state with the cleaned list st.session_state.selected_columns = temp_selected_columns # Filter defaults to only those that are valid *now* valid_defaults_for_multiselect = [ col for col in st.session_state.selected_columns if col in current_all_possible_columns ] # If filtering removed all defaults, reset to the current default list if not valid_defaults_for_multiselect: valid_defaults_for_multiselect = current_default_columns # --- SIDEBAR --- with st.sidebar: st.header("⚙️ Eingaben & Einstellungen") # -- Positionsdetails -- st.subheader("Positionsdetails") ticker_input = st.text_input( "Aktien Ticker", value=st.session_state.ticker_symbol, key="ticker_input_key" ).upper() shares_input = st.number_input( "Anzahl Aktien", min_value=100, step=100, value=st.session_state.shares_owned, key="shares_input_key" ) cost_basis_input = st.number_input( "Ø Einstandskurs / Aktie", min_value=0.01, step=0.01, value=st.session_state.cost_basis, format="%.2f", key="cost_basis_input_key", help="Wird für Breakeven & optionale Max-Profit-Spalten verwendet." ) # -- Annahmen -- st.subheader("Annahmen") premium_price_type_input = st.selectbox( "Preis für Prämie?", options=['bid', 'mid', 'last'], index=['bid', 'mid', 'last'].index(st.session_state.premium_price_type), # Use state for index help="Welcher Preis für Prämienberechnung? 'Bid' konservativ.", key="premium_select_key" ) custom_premium_input = st.number_input( "Manuelle Prämie / Aktie", min_value=0.0, step=0.01, value=st.session_state.custom_premium if st.session_state.custom_premium is not None else 0.0, format="%.2f", help="> 0 überschreibt Auswahl oben.", key="custom_prem_key" ) commission_input = st.number_input( "Provision / Kontrakt", min_value=0.0, step=0.01, value=st.session_state.commission, format="%.2f", key="commission_key" ) # -- Analyse Knopf -- if st.button("Analyse Starten / Werte Übernehmen", key="analyze_sidebar", use_container_width=True): ticker_changed = (ticker_input.strip() != st.session_state.ticker_symbol.strip()) # Ignore whitespace # Update state from inputs st.session_state.ticker_symbol = ticker_input.strip() # Store cleaned ticker st.session_state.shares_owned = shares_input st.session_state.cost_basis = cost_basis_input st.session_state.premium_price_type = premium_price_type_input st.session_state.custom_premium = custom_premium_input if custom_premium_input > 0 else None st.session_state.commission = commission_input # Reset stock data if ticker changed to force reload if ticker_changed: st.session_state.stock_data = None # Reset selected columns to the new premium defaults with '$' placeholder st.session_state.selected_columns = [col.format(currency='$') for col in base_default_columns_premium] st.rerun() # Force rerun to apply changes and reload data if needed st.divider() # Visuelle Trennung # -- Tabellen-Anpassung -- st.subheader("Tabellen-Anzeige") # Neuer Filter: Nur OTM anzeigen st.session_state.show_only_otm = st.toggle( "Nur OTM/ATM anzeigen", value=st.session_state.show_only_otm, key="otm_toggle", help="Zeigt nur Optionen, deren Strike größer oder gleich dem aktuellen Aktienkurs ist." ) selected_cols_input = st.multiselect( "Angezeigte Spalten:", options=sorted(list(set(current_all_possible_columns))), # Use current options default=valid_defaults_for_multiselect, # Use filtered current defaults key="col_select_key" ) # Update state only if selection actually changed to prevent unnecessary reruns if selected_cols_input != st.session_state.selected_columns: st.session_state.selected_columns = selected_cols_input st.rerun() # Rerun to apply column selection # -- Hervorhebung -- st.subheader("Hervorhebung (Tabelle)") hc1, hc2 = st.columns(2) with hc1: st.session_state.highlight_min_return = st.number_input( "Min. Rendite p.a. >", min_value=0.0, step=1.0, value=st.session_state.highlight_min_return, format="%.1f", key="highlight_ret_key" ) with hc2: st.session_state.highlight_max_delta = st.number_input( "Max. Delta <", min_value=0.0, max_value=1.0, step=0.01, value=st.session_state.highlight_max_delta, format="%.2f", key="highlight_delta_key" ) st.divider() # -- Chart-Einstellungen -- st.subheader("Chart-Einstellungen") st.session_state.chart_period = st.selectbox("Zeitraum:", ['1mo', '3mo', '6mo', 'ytd', '1y', '2y', '5y', 'max'], index=4, key="chart_period_key") st.session_state.chart_interval = st.selectbox("Intervall:", ['1d', '1wk', '1mo'], index=0, key="chart_interval_key") st.session_state.chart_indicators = st.multiselect("Indikatoren:", ['SMA', 'Bollinger Bands'], default=st.session_state.chart_indicators, key="chart_ind_key") if 'SMA' in st.session_state.chart_indicators or 'Bollinger Bands' in st.session_state.chart_indicators: sma_periods_str = st.text_input( "SMA Perioden (kommagetrennt):", value=", ".join(map(str, st.session_state.chart_sma_periods)), key="chart_sma_key" ) try: parsed_periods = [int(p.strip()) for p in sma_periods_str.split(',') if p.strip().isdigit() and int(p.strip()) > 0] # Nur positive Zahlen if parsed_periods: # Nur updaten, wenn gültige Perioden gefunden wurden st.session_state.chart_sma_periods = parsed_periods else: # Wenn Eingabe leer oder ungültig war if sma_periods_str.strip(): # Nur warnen wenn etwas eingegeben wurde st.warning("Ungültige Eingabe für SMA Perioden. Bsp: 50, 200") st.session_state.chart_sma_periods = [50, 200] # Fallback except Exception as e: # Allgemeinere Fehlerbehandlung st.warning(f"Fehler beim Verarbeiten der SMA Perioden: {e}") st.session_state.chart_sma_periods = [50, 200] # Fallback # --- HAUPTSEITE --- st.title("📈 Option Yield Pro (Premium Focus)") # Nur weitermachen, wenn Ticker im State ist if not st.session_state.ticker_symbol: st.info("👈 Bitte geben Sie links einen Aktien-Ticker ein und klicken Sie auf 'Analyse Starten'.") st.stop() # --- Aktiendaten Laden & Anzeigen --- # Versuche Ticker Objekt zu holen (aus Cache oder neu) ticker_obj = get_ticker_object(st.session_state.ticker_symbol) if ticker_obj is None: st.error(f"Konnte Ticker-Objekt für '{st.session_state.ticker_symbol}' nicht laden. Ist der Ticker gültig?") st.session_state.ticker_symbol = "" # Reset ticker st.session_state.stock_data = None # Reset data time.sleep(2) # Allow user to see the message st.rerun() # Lade Aktiendaten nur, wenn sie noch nicht im State sind für diesen Ticker rerun_needed_after_data_load = False if st.session_state.stock_data is None: with st.spinner(f"Lade Daten für {st.session_state.ticker_symbol}..."): stock_data_result, error_msg = get_stock_data(ticker_obj) if error_msg: st.error(f"Fehler Aktiendaten: {error_msg}") # Setze Ticker zurück, wenn Daten nicht geladen werden konnten st.session_state.ticker_symbol = "" time.sleep(2) st.rerun() st.stop() st.session_state.stock_data = stock_data_result rerun_needed_after_data_load = True # Mark that a rerun is needed # Wichtig: Wenn Daten gerade geladen wurden, neu ausführen, damit die Währung # korrekt in der Sidebar *beim nächsten Durchlauf* verwendet wird! if rerun_needed_after_data_load: st.rerun() # --- Ab hier gehen wir davon aus, dass stock_data im State ist --- stock_data = st.session_state.stock_data currency_symbol = stock_data.get('currency', '$') # --- Dynamische Spaltennamen für die Anzeige DEFINIEREN --- display_columns = { 'strike': 'Strike', 'bid': 'Bid', 'ask': 'Ask', 'lastPrice': 'Last', 'volume': 'Volumen', 'openInterest': 'Open Int.', 'impliedVolatility': 'Impl. Vol (%)', 'premiumNetTotal': f'Prämie Netto ({currency_symbol})', 'returnIfFlatPercentNet': 'Rendite (Statisch %)', # Prämienrendite 'annualizedReturnPercentNet': 'Rendite p.a. (%)', # Prämie p.a. 'maxProfitAssignedNet': f'Max. Gewinn ({currency_symbol})', # Optional 'maxProfitPercentNet': 'Max. Gewinn (%)', # Optional 'breakeven': f'Breakeven ({currency_symbol})', # Optional 'downsideProtectionPercentNet': 'Risikopuffer (%)', 'returnOnRiskPercentNet': 'Return on Risk (%)', 'percentOTM': '% OTM', 'delta': 'Delta', 'theta': 'Theta', 'gamma': 'Gamma', 'vega': 'Vega', 'prob_OTM': 'W\'keit OTM (%)', # Neu 'DTE': 'DTE' } # --- Anzeige der Aktiendaten (mit korrigierter 52W-Spanne) --- st.header(f"📊 {stock_data['name']} ({st.session_state.ticker_symbol})") price_color = "green" if stock_data.get('change', 0) >= 0 else "red" # Default 0 if change missing mcol1, mcol2, mcol3 = st.columns(3) # Behalte die Spaltendefinition with mcol1: st.metric( f"Akt. Kurs ({currency_symbol})", f"{stock_data.get('price', 0):.2f}", # Default 0 f"{stock_data.get('change', 0):.2f} ({stock_data.get('change_percent', 0):.2f}%)", # Default 0 delta_color="normal" if price_color == "green" else "inverse" ) low_52w = stock_data.get('fifty_two_week_low') high_52w = stock_data.get('fifty_two_week_high') with mcol2: # Definiere den Kontext für Spalte 2 if low_52w is not None and high_52w is not None: # Prüfe *innerhalb* des Kontexts st.metric("52W Spanne", f"{currency_symbol}{low_52w:.2f} - {high_52w:.2f}") else: st.metric("52W Spanne", "-") # Platzhalter with mcol3: # Spalte 3 leer lassen pass # --- Chart Sektion --- with st.expander("📈 Detailierter Aktienchart Anzeigen/Verbergen"): hist_df = get_stock_history(ticker_obj, st.session_state.chart_period, st.session_state.chart_interval) if not hist_df.empty: fig = plot_stock_chart(hist_df, st.session_state.ticker_symbol, st.session_state.chart_indicators, st.session_state.chart_sma_periods) st.plotly_chart(fig, use_container_width=True) else: st.warning("Keine historischen Daten für die gewählten Chart-Parameter verfügbar.") st.divider(); st.header("🔍 Options-Analyse") # --- Ablaufdatum Auswahl --- col_d1, col_d2 = st.columns([1, 3]) with col_d1: st.markdown("###### Verfallstyp Filter:") date_filter_options = ['Alle', 'Monatlich', 'Wöchentlich']; date_filter_idx = 0 try: date_filter_idx = date_filter_options.index(st.session_state.date_filter_type) except ValueError: pass st.session_state.date_filter_type = st.radio("Typ:", date_filter_options, index=date_filter_idx, horizontal=True, label_visibility="collapsed") with col_d2: st.markdown("###### Ablaufdatum Wählen:") option_dates_all = get_option_dates(ticker_obj) if not option_dates_all: st.warning("Keine Optionsdaten gefunden."); st.stop() # Filter dates based on selection if st.session_state.date_filter_type == 'Monatlich': option_dates_filtered = [d for d in option_dates_all if is_monthly_expiration(d)] elif st.session_state.date_filter_type == 'Wöchentlich': option_dates_filtered = [d for d in option_dates_all if is_weekly_expiration(d)] else: option_dates_filtered = option_dates_all # Alle if not option_dates_filtered: st.warning(f"Keine '{st.session_state.date_filter_type}' Ablaufdaten."); selected_exp_date = None else: selected_exp_date = st.selectbox("Verfügbare Daten:", option_dates_filtered, index=0, label_visibility="collapsed") # --- Optionskette Laden & Berechnen --- if selected_exp_date: # Lade Kette mit Spinner with st.spinner(f"Lade Optionskette für {selected_exp_date}..."): calls_chain = get_option_chain(ticker_obj, selected_exp_date) if not calls_chain.empty: # Berechne Metriken mit Spinner with st.spinner("Berechne Metriken..."): calculated_df_unfiltered = calculate_covered_calls( calls_chain, stock_data['price'], st.session_state.shares_owned, st.session_state.cost_basis, selected_exp_date, st.session_state.premium_price_type, st.session_state.custom_premium, st.session_state.commission ) # --- Tabellenfilter Widgets --- st.markdown("###### Tabellenfilter:") fcol1, fcol2 = st.columns(2) with fcol1: min_r, max_r = st.slider("Filter: Rendite p.a. (%)", 0.0, 500.0, (st.session_state.min_annual_return_filter, st.session_state.max_annual_return_filter), 5.0); st.session_state.min_annual_return_filter, st.session_state.max_annual_return_filter = min(min_r, max_r), max(min_r, max_r) with fcol2: min_d, max_d = st.slider("Filter: Delta", 0.0, 1.0, (st.session_state.min_delta_filter, st.session_state.max_delta_filter), 0.01); st.session_state.min_delta_filter, st.session_state.max_delta_filter = min(min_d, max_d), max(min_d, max_d) # --- Filter anwenden --- filtered_df = calculated_df_unfiltered.copy() # OTM/ATM Filter (wenn aktiviert) if st.session_state.show_only_otm: # Behalte auch NaNs bei strike oder wenn strike >= current_price current_stock_price_filter = stock_data.get('price', 0) # Sicherer Zugriff if current_stock_price_filter > 0: # Nur filtern wenn Preis bekannt filtered_df = filtered_df[(filtered_df['strike'] >= current_stock_price_filter) | filtered_df['strike'].isna()] # Rendite Filter rendite_col = 'annualizedReturnPercentNet'; delta_col = 'delta' if rendite_col in filtered_df.columns: numeric_rendite = pd.to_numeric(filtered_df[rendite_col], errors='coerce') filtered_df = filtered_df[numeric_rendite.between(st.session_state.min_annual_return_filter, st.session_state.max_annual_return_filter, inclusive='both') | numeric_rendite.isna()] # Delta Filter if delta_col in filtered_df.columns: numeric_delta = pd.to_numeric(filtered_df[delta_col], errors='coerce') filtered_df = filtered_df[numeric_delta.between(st.session_state.min_delta_filter, st.session_state.max_delta_filter, inclusive='both') | numeric_delta.isna()] # --- Tabelle Anzeigen --- st.markdown(f"###### Call-Optionen für {datetime.strptime(selected_exp_date, '%Y-%m-%d').strftime('%d. %B %Y')} ({len(filtered_df)} angezeigt)") # Spaltenauswahl Logik selected_display_names = st.session_state.selected_columns original_cols_keys_to_display = [key for key, value in display_columns.items() if value in selected_display_names] cols_to_display_final = [col for col in original_cols_keys_to_display if col in filtered_df.columns] if not cols_to_display_final: st.warning("Keine gültigen Spalten zur Anzeige ausgewählt oder in den Daten vorhanden.") display_df_render = pd.DataFrame() else: # Sortiere standardmäßig nach Strike (optional) if 'strike' in cols_to_display_final: display_df_render = filtered_df[cols_to_display_final].sort_values(by='strike').rename(columns=display_columns) else: display_df_render = filtered_df[cols_to_display_final].rename(columns=display_columns) if not display_df_render.empty: # Formatierung format_dict = { col: '{:.2f}' for col in display_df_render.columns if col.startswith(('Strike', 'Bid', 'Ask', 'Last')) } format_dict.update({ col: '{:,.2f}' for col in display_df_render.columns if f'({currency_symbol})' in col }) format_dict.update({ col: '{:.1f}%' for col in display_df_render.columns if '%' in col and col not in ['Impl. Vol (%)', 'W\'keit OTM (%)']}) # Explizit ausschließen format_dict.update({ 'Impl. Vol (%)': '{:.1%}', 'W\'keit OTM (%)': '{:.1%}', 'Delta': '{:.3f}', 'Theta': '{:.3f}', 'Gamma': '{:.4f}', 'Vega': '{:.3f}', 'Volumen': '{:,}', 'Open Int.': '{:,}', 'DTE': '{:,d}' }) final_format_dict = {k: v for k, v in format_dict.items() if k in display_df_render.columns} # Styler styler = display_df_render.style.format(final_format_dict, na_rep="-") # Highlight basierend auf Rendite UND Delta highlight_ret_col = 'Rendite p.a. (%)'; highlight_delta_col = 'Delta' min_ret = st.session_state.highlight_min_return; max_delta = st.session_state.highlight_max_delta if highlight_ret_col in display_df_render.columns and highlight_delta_col in display_df_render.columns: try: def highlight_premium_sweetspot(row): # Konvertiere zu Numerisch, Fehler werden zu NaN ret_val = pd.to_numeric(row[highlight_ret_col], errors='coerce') delta_val = pd.to_numeric(row[highlight_delta_col], errors='coerce') # Prüfe ob beide Werte gültige Zahlen sind und die Bedingungen erfüllen if pd.notna(ret_val) and pd.notna(delta_val) and ret_val >= min_ret and delta_val <= max_delta: return ['background-color: #e8f5e9'] * len(row) # Helles Grün return [''] * len(row) # Kein Stil styler.apply(highlight_premium_sweetspot, axis=1) except Exception as e: print(f"Styling Error (Highlight Sweetspot): {e}") # Farbverläufe etc. gradient_cols = {'Rendite p.a. (%)': 'Greens', 'Return on Risk (%)': 'Greens', 'Impl. Vol (%)': 'RdYlGn', 'Delta': 'Blues', 'Theta': 'Reds_r', 'W\'keit OTM (%)': 'Greens'} for col, cmap in gradient_cols.items(): if col in display_df_render.columns: try: styler.background_gradient(cmap=cmap, subset=[col], axis=0) except Exception as e: print(f"Styling Error (Gradient {col}): {e}") bar_cols = ["Volumen", "Open Int.", "% OTM"] for col in bar_cols: if col in display_df_render.columns: try: styler.bar(subset=[col], color='lightblue', align='zero', vmin=0) except Exception as e: print(f"Styling Error (Bar {col}): {e}") st.dataframe(styler, use_container_width=True, height=500) st.caption("🖱️ Spalten sortieren. Grüner Hintergrund: Erfüllt Hervorhebungs-Kriterien (Rendite & Delta).") # --- Payoff Visualisierung --- st.divider(); st.subheader("💡 Payoff & Kennzahlen (Auswahl)") available_strikes = sorted(calculated_df_unfiltered['strike'].round(2).unique().tolist()) if available_strikes: current_p = stock_data.get('price', 0) # Sicherer Zugriff default_strike_idx = 0 # Default zum ersten Strike if current_p > 0: # Nur berechnen wenn Preis > 0 default_strike_idx = np.abs(np.array(available_strikes) - current_p).argmin() selected_strike_for_chart = st.selectbox( "Strike zur Visualisierung wählen:", options=available_strikes, index=default_strike_idx, # Standardmäßig nächster Strike format_func=lambda x: f"{currency_symbol}{x:.2f}", key="payoff_strike_select" ) # Finde die Zeile im *ungefilterten* DF mit Toleranz selected_rows = calculated_df_unfiltered[np.isclose(calculated_df_unfiltered['strike'], selected_strike_for_chart)] if not selected_rows.empty: selected_row = selected_rows.iloc[0] # Payoff Chart premium_net_chart = selected_row['premiumNetTotal']; strike_chart = selected_row['strike']; cost_basis_chart = st.session_state.cost_basis; shares_chart = st.session_state.shares_owned min_price_chart = cost_basis_chart * 0.7; max_price_chart = max(current_p, strike_chart) * 1.3; stock_prices_at_exp = np.linspace(min_price_chart, max_price_chart, 150) profit_loss = [((strike_chart - cost_basis_chart) * shares_chart + premium_net_chart) if px > strike_chart else ((px - cost_basis_chart) * shares_chart + premium_net_chart) for px in stock_prices_at_exp] payoff_df = pd.DataFrame({'Aktienkurs bei Ablauf': stock_prices_at_exp, f'Gewinn/Verlust ({currency_symbol})': profit_loss}) st.line_chart(payoff_df, x='Aktienkurs bei Ablauf', y=f'Gewinn/Verlust ({currency_symbol})'); st.caption(f"Payoff für {currency_symbol}{strike_chart:.2f} Call (Netto).") # Kennzahlen (Fokus auf Prämie & Risiko OTM) st.markdown("###### Kennzahlen für ausgewählten Strike:") kcol1, kcol2, kcol3, kcol4 = st.columns(4) with kcol1: st.metric("Prämie (Netto)", f"{currency_symbol}{selected_row.get('premiumNetTotal', 0):,.2f}", f"{selected_row.get('returnIfFlatPercentNet', 0):.1f}% Stat." if pd.notna(selected_row.get('returnIfFlatPercentNet')) and np.isfinite(selected_row.get('returnIfFlatPercentNet')) else "N/A") with kcol2: st.metric("Rendite p.a.", f"{selected_row.get('annualizedReturnPercentNet', 0):.1f}%" if pd.notna(selected_row.get('annualizedReturnPercentNet')) and selected_row.get('annualizedReturnPercentNet', 0) > 0 else "N/A") with kcol3: st.metric("Risikopuffer", f"{selected_row.get('downsideProtectionPercentNet', 0):.1f}%" if pd.notna(selected_row.get('downsideProtectionPercentNet')) and np.isfinite(selected_row.get('downsideProtectionPercentNet')) else "N/A") with kcol4: st.metric("W'keit OTM", f"{selected_row.get('prob_OTM', 0):.1%}" if pd.notna(selected_row.get('prob_OTM')) else "-", help="Ca. W'keit, dass die Option wertlos verfällt (1 - Delta).") kcol5, kcol6, kcol7, kcol8 = st.columns(4) with kcol5: st.metric("Return on Risk", f"{selected_row.get('returnOnRiskPercentNet', 0):.1f}%" if pd.notna(selected_row.get('returnOnRiskPercentNet')) and np.isfinite(selected_row.get('returnOnRiskPercentNet')) else "N/A") with kcol6: st.metric("Delta", f"{selected_row.get('delta', 0):.3f}" if pd.notna(selected_row.get('delta')) else "-") with kcol7: st.metric("Theta", f"{selected_row.get('theta', 0):.3f}" if pd.notna(selected_row.get('theta')) else "-") with kcol8: st.metric("DTE", f"{selected_row.get('DTE', 0):.0f}" if pd.notna(selected_row.get('DTE')) else "-") else: st.warning(f"Keine Daten für den ausgewählten Strike {selected_strike_for_chart} gefunden.") else: st.info("Keine Strikes zur Visualisierung verfügbar.") else: st.info("Keine Optionen entsprechen den aktuellen Filterkriterien.") else: st.warning(f"Keine Call-Optionen für {st.session_state.ticker_symbol} am {selected_exp_date} gefunden.") # --- Footer --- st.divider() st.caption(f"Haftungsausschluss: Nur zu Informationszwecken. Keine Finanzberatung. Daten von yfinance können verzögert sein. Berechnungen ohne Gewähr. Letzte Aktualisierung: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")