summaryrefslogtreecommitdiff
path: root/src/teradata/plots.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/teradata/plots.py')
-rw-r--r--src/teradata/plots.py698
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