diff options
| author | Mikka Petterson <m.petterson@student.maastrichtuniversity.nl> | 2026-10-14 07:45:20 +0200 |
|---|---|---|
| committer | Mikka Petterson <m.petterson@student.maastrichtuniversity.nl> | 2026-10-14 07:45:20 +0200 |
| commit | 0bcc5be35b6a99109bd04925c22890347c9d6e88 (patch) | |
| tree | fdd88728ad1747268b0a02aba0de08a3af6a52b2 /src/teradata/plots.py | |
Diffstat (limited to 'src/teradata/plots.py')
| -rw-r--r-- | src/teradata/plots.py | 698 |
1 files changed, 698 insertions, 0 deletions
diff --git a/src/teradata/plots.py b/src/teradata/plots.py new file mode 100644 index 0000000..6e1aa38 --- /dev/null +++ b/src/teradata/plots.py @@ -0,0 +1,698 @@ +lazy from pathlib import Path +lazy from threading import Lock + +lazy import pandas as pd +lazy import plotly.express as px +lazy import plotly.graph_objects as go +lazy from plotly.subplots import make_subplots + +lazy from .stock import ValuationSnapshot + +BG_COLOR = "#fafafa" +GRID_COLOR = "#e2e8f0" + +BASE_LAYOUT = dict( + paper_bgcolor=BG_COLOR, + plot_bgcolor=BG_COLOR, + font=dict(color="#1C1917", family="Inter, Arial, sans-serif"), + hovermode="x unified", + margin=dict(l=60, r=60, t=80, b=50), + legend=dict( + orientation="h", + yanchor="bottom", + y=1.02, + xanchor="right", + x=1, + bgcolor="rgba(250, 250, 250, 0.8)", + ), +) + +EXPORT_DIR = Path("charts") +EXPORT_DIR.mkdir(exist_ok=True) + +_KALEIDO_LOCK = Lock() + + +def export_for_powerpoint( + fig: go.Figure, filename: str, width: int = 1600, height: int = 900 +) -> None: + html_path = EXPORT_DIR / f"{filename}.html" + fig.write_html(str(html_path)) + + png_path = EXPORT_DIR / f"{filename}.png" + fig.write_image(str(png_path), width=width, height=height, scale=2) + + fig.update_layout( + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + ) + + svg_path = EXPORT_DIR / f"{filename}.svg" + fig.write_image(str(svg_path), width=width, height=height, scale=2) + + +def plot_revenue_and_profitability( + financials: pd.DataFrame, export: bool = True +) -> go.Figure: + df = financials.copy() + df.index = pd.to_datetime(df.index) + + rev_m = df["revenue"] / 1e6 + cloud_series = df["cloud_arr"].dropna() + cloud_unique = cloud_series[cloud_series != cloud_series.shift(1)] / 1e6 + net_inc_m = df["net_income"] / 1e6 + + fig = make_subplots(specs=[[{"secondary_y": True}]]) + + fig.add_trace( + go.Bar( + x=df.index, + y=rev_m, + name="Quarterly Revenue ($M)", + marker_color="#2b5cff", + opacity=0.85, + ), + secondary_y=False, + ) + fig.add_trace( + go.Scatter( + x=cloud_unique.index, + y=cloud_unique / 4, + name="Cloud Run-Rate (ARR / 4) ($M)", + line=dict(color="#00d2d3", width=3), + mode="lines+markers", + ), + secondary_y=False, + ) + fig.add_trace( + go.Scatter( + x=df.index, + y=net_inc_m, + name="Net Income ($M)", + line=dict(color="#ff9f43", width=3), + mode="lines+markers", + ), + secondary_y=True, + ) + + fig.add_hline(y=0, line_dash="dash", line_color="#cbd5e1", secondary_y=True) + + fig.update_layout( + **BASE_LAYOUT, + title="<b>Teradata (TDC)</b> — Quarterly Revenue, Cloud ARR & Net Income", + barmode="group", + xaxis=dict(title="Quarter End", showgrid=False), + yaxis=dict( + title="Quarterly Revenue / Cloud Run-Rate ($M)", + showgrid=True, + gridcolor=GRID_COLOR, + ), + yaxis2=dict(title="Net Income ($M)", showgrid=False), + ) + + fig.update_xaxes( + range=[ + df.index.min() - pd.Timedelta(days=45), + df.index.max() + pd.Timedelta(days=45), + ] + ) + + if export: + export_for_powerpoint(fig, "01_tdc_revenue_arr_net_income") + # fig.show() + return fig + + +def plot_assets_and_roa(financials: pd.DataFrame, export: bool = True) -> go.Figure: + df = financials.copy() + df.index = pd.to_datetime(df.index) + + assets_b = df["total_assets"] / 1e9 + roa_pct = df["roa_annualized"] * 100 + + fig = make_subplots(specs=[[{"secondary_y": True}]]) + + fig.add_trace( + go.Bar( + x=df.index, + y=assets_b, + name="Total Assets ($B)", + marker_color="#5f27cd", + opacity=0.75, + ), + secondary_y=False, + ) + fig.add_trace( + go.Scatter( + x=df.index, + y=roa_pct, + name="Annualized ROA (%)", + line=dict(color="#10ac84", width=3), + mode="lines+markers", + ), + secondary_y=True, + ) + + fig.add_hline(y=0, line_dash="dash", line_color="#cbd5e1", secondary_y=True) + + fig.update_layout( + **BASE_LAYOUT, + title="<b>Teradata (TDC)</b> — Total Assets & Annualized Return on Assets (ROA)", + xaxis=dict(title="Quarter End", showgrid=False), + yaxis=dict(title="Total Assets ($B)", showgrid=True, gridcolor=GRID_COLOR), + yaxis2=dict(title="Annualized ROA (%)", showgrid=False, ticksuffix="%"), + ) + + fig.update_xaxes( + range=[ + df.index.min() - pd.Timedelta(days=45), + df.index.max() + pd.Timedelta(days=45), + ] + ) + + if export: + export_for_powerpoint(fig, "02_tdc_assets_and_roa") + # fig.show() + return fig + + +def plot_tdc_vs_nasdaq( + cumulative_returns: pd.DataFrame, + benchmark_col: str = "^IXIC", + export: bool = True, +) -> go.Figure: + cum_ret = cumulative_returns.copy() + cum_ret.index = pd.to_datetime(cum_ret.index) + + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=cum_ret.index, + y=cum_ret["TDC"], + name="Teradata (TDC)", + line=dict(color="#ee5253", width=2.5), + ) + ) + fig.add_trace( + go.Scatter( + x=cum_ret.index, + y=cum_ret[benchmark_col], + name=f"Nasdaq Composite ({benchmark_col})", + line=dict(color="#2b5cff", width=2, dash="dash"), + ) + ) + + fig.add_hline(y=0, line_dash="solid", line_color="#718096", opacity=0.6) + + fig.update_layout( + **BASE_LAYOUT, + title="<b>Teradata (TDC) vs. Nasdaq Composite</b> — Cumulative Total Return", + legend_title_text="Benchmark", + yaxis=dict( + title="Cumulative Return (%)", + ticksuffix="%", + showgrid=True, + gridcolor=GRID_COLOR, + ), + xaxis=dict(title="Date", showgrid=False), + ) + + if export: + export_for_powerpoint(fig, "03_tdc_vs_nasdaq_performance") + # fig.show() + return fig + + +def plot_all_tickers(normalized_prices: pd.DataFrame, export: bool = True) -> go.Figure: + df = normalized_prices.copy() + df.index = pd.to_datetime(df.index) + + fig = px.line( + df, + x=df.index, + y=df.columns, + color_discrete_map={ + "TDC": "#dc2626", + "^IXIC": "#60a5fa", + "^NDX": "#2563eb", + "IYW": "#16a34a", + "THNQ": "#d97706", + "SNOW": "#0891b2", + }, + ) + + fig.add_hline( + y=100, + line_dash="dash", + line_color="#94a3b8", + opacity=0.8, + annotation_text="Base 100", + annotation_position="bottom right", + ) + + fig.update_traces(line=dict(width=1.5)) + fig.update_traces(selector=dict(name="TDC"), line=dict(width=3.2)) + + fig.update_layout( + **BASE_LAYOUT, + title="<b>Peer Comparison</b> — Normalized Price Performance (Base = 100)", + legend_title_text="Ticker", + xaxis=dict(title="Date", showgrid=False), + yaxis=dict( + title="Normalized Performance (Base = 100)", + showgrid=True, + gridcolor=GRID_COLOR, + ), + ) + + if export: + export_for_powerpoint(fig, "04_all_peers_normalized_base100") + # fig.show() + return fig + + +def plot_eps(financials: pd.DataFrame, export: bool = True) -> go.Figure: + df = financials.copy() + df.index = pd.to_datetime(df.index) + + fig = go.Figure() + + fig.add_trace( + go.Bar( + x=df.index, + y=df["eps_basic"], + name="Basic EPS ($)", + marker_color="#2563eb", + opacity=0.85, + ) + ) + + fig.add_trace( + go.Bar( + x=df.index, + y=df["eps_diluted"], + name="Diluted EPS ($)", + marker_color="#06b6d4", + opacity=0.85, + ) + ) + + fig.add_hline(y=0, line_dash="solid", line_color="#94a3b8", opacity=0.8) + + fig.update_layout( + **BASE_LAYOUT, + title="<b>Teradata (TDC)</b> — Earnings Per Share (Basic vs. Diluted)", + legend_title_text="Metric", + barmode="group", + xaxis=dict(title="Quarter End", showgrid=False), + yaxis=dict( + title="Earnings Per Share ($)", + tickprefix="$", + showgrid=True, + gridcolor=GRID_COLOR, + ), + ) + + fig.update_xaxes( + range=[ + df.index.min() - pd.Timedelta(days=45), + df.index.max() + pd.Timedelta(days=45), + ] + ) + + if export: + export_for_powerpoint(fig, "05_tdc_eps_basic_vs_diluted") + # fig.show() + return fig + + +def plot_stock_price( + price_data: pd.DataFrame, + ticker: str = "TDC", + sma_windows: tuple[int, ...] = (20, 50), + show_close: bool = True, + show_open: bool = True, + export: bool = True, +) -> go.Figure: + df = price_data.copy() + df.index = pd.to_datetime(df.index) + + df.columns = [str(col).strip().lower() for col in df.columns] + + if "close" not in df.columns or "open" not in df.columns: + raise ValueError("DataFrame must contain 'Open' and 'Close' columns.") + + fig = go.Figure() + + if show_close: + fig.add_trace( + go.Scatter( + x=df.index, + y=df["close"], + name="Close ($)", + line=dict(color="#1e293b", width=2.5), + mode="lines", + ) + ) + + if show_open: + fig.add_trace( + go.Scatter( + x=df.index, + y=df["open"], + name="Open ($)", + line=dict(color="#94a3b8", width=1.5, dash="dot"), + mode="lines", + ) + ) + + sma_colors = ["#2563eb", "#f59e0b", "#10b981", "#8b5cf6"] + for i, window in enumerate(sma_windows): + sma_col = f"sma_{window}" + if sma_col not in df.columns: + df[sma_col] = df["close"].rolling(window=window).mean() + + color = sma_colors[i % len(sma_colors)] + fig.add_trace( + go.Scatter( + x=df.index, + y=df[sma_col], + name=f"{window}-Day SMA ($)", + line=dict(color=color, width=1.8), + mode="lines", + ) + ) + + fig.update_layout( + **BASE_LAYOUT, + title=f"<b>{ticker.upper()}</b> — Daily Stock Price & Moving Averages", + legend_title_text="Series", + xaxis=dict( + title="Date", + showgrid=False, + rangebreaks=[dict(bounds=["sat", "mon"])], + ), + yaxis=dict( + title="Share Price ($)", + tickprefix="$", + showgrid=True, + gridcolor=GRID_COLOR, + ), + ) + + if export: + export_for_powerpoint(fig, f"06_{ticker.lower()}_price_sma") + + # fig.show() + return fig + + +def plot_earnings_reaction( + price_data: pd.DataFrame, + earnings_dates: pd.DataFrame | pd.Series | list, + ticker: str = "TDC", + reaction_window_days: int = 1, + export: bool = True, +) -> go.Figure: + df = price_data.copy() + df.index = pd.to_datetime(df.index) + df.columns = [str(col).strip().lower() for col in df.columns] + + if "close" not in df.columns: + raise ValueError("DataFrame must contain a 'Close' column.") + + df = df.sort_index() + + earnings_labels: dict[pd.Timestamp, str] = {} + if isinstance(earnings_dates, pd.DataFrame): + e_df = earnings_dates.copy() + date_col = next( + (c for c in e_df.columns if "date" in str(c).lower()), e_df.index.name + ) + if date_col and date_col in e_df.columns: + parsed_dates = pd.to_datetime(e_df[date_col]) + else: + parsed_dates = pd.to_datetime(e_df.index) + + label_col = next( + ( + c + for c in e_df.columns + if str(c).lower() in ["quarter", "period", "label", "form"] + ), + None, + ) + if label_col: + for dt, lbl in zip(parsed_dates, e_df[label_col]): + earnings_labels[pd.to_datetime(dt).normalize()] = str(lbl) + raw_dates = parsed_dates + elif isinstance(earnings_dates, pd.Series): + raw_dates = pd.to_datetime(earnings_dates.values) + else: + raw_dates = pd.to_datetime(list(earnings_dates)) + + raw_dates = sorted(pd.DatetimeIndex(raw_dates).dropna().normalize().unique()) + + reactions = [] + for event_date in raw_dates: + trading_days_on_or_after = df.index[df.index >= event_date] + if len(trading_days_on_or_after) == 0: + continue + event_idx = trading_days_on_or_after[0] + event_loc = df.index.get_loc(event_idx) + + base_loc = event_loc - 1 if event_loc > 0 else event_loc + target_loc = min(event_loc + reaction_window_days - 1, len(df) - 1) + + base_price = df["close"].iloc[base_loc] + react_price = df["close"].iloc[target_loc] + pct_change = ((react_price - base_price) / base_price) * 100 + + label = earnings_labels.get(event_date, "Earnings Call") + + reactions.append( + { + "date": df.index[target_loc], + "price": react_price, + "pct_change": pct_change, + "label": label, + } + ) + + react_df = pd.DataFrame(reactions) + + fig = go.Figure() + + fig.add_trace( + go.Scatter( + x=df.index, + y=df["close"], + name=f"{ticker.upper()} Close ($)", + line=dict(color="#334155", width=2), + mode="lines", + hoverinfo="skip", + ) + ) + + if not react_df.empty: + pos_df = react_df[react_df["pct_change"] >= 0] + neg_df = react_df[react_df["pct_change"] < 0] + + pos_sizes = (pos_df["pct_change"].abs() * 0.7 + 8).clip(8, 20) + neg_sizes = (neg_df["pct_change"].abs() * 0.7 + 8).clip(8, 20) + + pos_y = pos_df["price"] * 0.965 + neg_y = neg_df["price"] * 1.035 + + fig.add_trace( + go.Scatter( + x=pos_df["date"], + y=pos_y, + name="Positive Reaction (▲)", + mode="markers", + marker=dict( + symbol="triangle-up", + size=pos_sizes, + color="#10b981", + line=dict(width=1, color="#065f46"), + ), + customdata=list( + zip(pos_df["pct_change"], pos_df["label"], pos_df["price"]) + ), + hovertemplate=( + "<b>%{customdata[1]}</b><br>" + "Date: %{x|%Y-%m-%d}<br>" + "Reaction: +%{customdata[0]:.2f}%<br>" + "Close: $%{customdata[2]:.2f}<extra></extra>" + ), + ) + ) + + fig.add_trace( + go.Scatter( + x=neg_df["date"], + y=neg_y, + name="Negative Reaction (▼)", + mode="markers", + marker=dict( + symbol="triangle-down", + size=neg_sizes, + color="#ef4444", + line=dict(width=1, color="#7f1d1d"), + ), + customdata=list( + zip(neg_df["pct_change"], neg_df["label"], neg_df["price"]) + ), + hovertemplate=( + "<b>%{customdata[1]}</b><br>" + "Date: %{x|%Y-%m-%d}<br>" + "Reaction: %{customdata[0]:.2f}%<br>" + "Close: $%{customdata[2]:.2f}<extra></extra>" + ), + ) + ) + + fig.update_layout( + **BASE_LAYOUT, + title=f"<b>{ticker.upper()}</b> — Stock Price Reaction to Earnings Announcements", + legend_title_text="Reaction", + xaxis=dict( + title="Date", + showgrid=False, + rangebreaks=[dict(bounds=["sat", "mon"])], + ), + yaxis=dict( + title="Share Price ($)", + tickprefix="$", + showgrid=True, + gridcolor=GRID_COLOR, + ), + ) + + fig.update_xaxes( + range=[ + df.index.min() - pd.Timedelta(days=45), + df.index.max() + pd.Timedelta(days=45), + ] + ) + + if export: + export_for_powerpoint(fig, f"07_{ticker.lower()}_earnings_reactions") + + # fig.show() + return fig + + +def plot_valuation_comparison( + tdc: ValuationSnapshot, snow: ValuationSnapshot, export: bool = True +) -> go.Figure: + categories = [tdc.ticker, snow.ticker] + bar_colors = ["#2b5cff", "#00d2d3"] + + fig = make_subplots( + rows=1, + cols=3, + subplot_titles=( + "<b>EV / Sales (TTM)</b>", + "<b>YoY Revenue Growth (%)</b>", + "<b>EV / FCF (TTM)</b>", + ), + horizontal_spacing=0.08, + ) + + ev_sales_vals = [tdc.ev_to_sales or 0, snow.ev_to_sales or 0] + ev_sales_text = [ + f"{tdc.ev_to_sales:.1f}x" if tdc.ev_to_sales is not None else "N/A", + f"{snow.ev_to_sales:.1f}x" if snow.ev_to_sales is not None else "N/A", + ] + fig.add_trace( + go.Bar( + x=categories, + y=ev_sales_vals, + text=ev_sales_text, + textposition="outside", + cliponaxis=False, + marker_color=bar_colors, + opacity=0.85, + showlegend=False, + ), + row=1, + col=1, + ) + + rev_growth_vals = [tdc.revenue_growth_yoy or 0, snow.revenue_growth_yoy or 0] + rev_growth_text = [ + ( + f"{tdc.revenue_growth_yoy:+.1f}%" + if tdc.revenue_growth_yoy is not None + else "N/A" + ), + ( + f"{snow.revenue_growth_yoy:+.1f}%" + if snow.revenue_growth_yoy is not None + else "N/A" + ), + ] + fig.add_trace( + go.Bar( + x=categories, + y=rev_growth_vals, + text=rev_growth_text, + textposition="outside", + cliponaxis=False, + marker_color=bar_colors, + opacity=0.85, + showlegend=False, + ), + row=1, + col=2, + ) + + ev_fcf_vals = [tdc.ev_to_fcf or 0, snow.ev_to_fcf or 0] + ev_fcf_text = [ + f"{tdc.ev_to_fcf:.1f}x" if tdc.ev_to_fcf is not None else "N/A", + f"{snow.ev_to_fcf:.1f}x" if snow.ev_to_fcf is not None else "N/A", + ] + fig.add_trace( + go.Bar( + x=categories, + y=ev_fcf_vals, + text=ev_fcf_text, + textposition="outside", + cliponaxis=False, + marker_color=bar_colors, + opacity=0.85, + showlegend=False, + ), + row=1, + col=3, + ) + + fig.update_layout(**BASE_LAYOUT) + fig.update_layout( + title="<b>Valuation Disconnect</b> — Teradata (TDC) vs. Snowflake (SNOW)", + showlegend=False, + hovermode="closest", + ) + + for annotation in fig["layout"]["annotations"]: + annotation["font"] = dict( + family="Inter, Arial, sans-serif", + size=14, + color="#1C1917", + ) + + fig.update_xaxes(showgrid=False, tickfont=dict(size=12)) + fig.update_yaxes( + showgrid=True, + gridcolor=GRID_COLOR, + zeroline=True, + zerolinecolor="#cbd5e1", + zerolinewidth=1, + ) + + if export: + export_for_powerpoint(fig, "08_valuation_comparison_tdc_vs_snow") + + # fig.show() + return fig |
