cc / app.py
Swobby's picture
Update app.py
cd63333 verified
Raw History Blame Contribute Delete
44.7 kB
# -*- 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')}")