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