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="Teradata (TDC) — 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="Teradata (TDC) — 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="Teradata (TDC) vs. Nasdaq Composite — 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="Peer Comparison — 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="Teradata (TDC) — 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"{ticker.upper()} — 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=( "%{customdata[1]}
" "Date: %{x|%Y-%m-%d}
" "Reaction: +%{customdata[0]:.2f}%
" "Close: $%{customdata[2]:.2f}" ), ) ) 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=( "%{customdata[1]}
" "Date: %{x|%Y-%m-%d}
" "Reaction: %{customdata[0]:.2f}%
" "Close: $%{customdata[2]:.2f}" ), ) ) fig.update_layout( **BASE_LAYOUT, title=f"{ticker.upper()} — 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=( "EV / Sales (TTM)", "YoY Revenue Growth (%)", "EV / FCF (TTM)", ), 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="Valuation Disconnect — 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