# agent/tools/chart_generator.py
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import pandas as pd
import json
from pydantic import BaseModel
from typing import Optional
from dotenv import load_dotenv
load_dotenv()
# Visual language constants - consistent across all charts
COLORS = {
"market": "#4F8EF7", # blue - Mkt-RF
"size": "#F7A84F", # orange - SMB
"value": "#4FD18B", # green - HML
"profit": "#F76F6F", # red - RMW
"invest": "#A084E8", # purple - CMA
"momentum": "#F7E24F", # yellow - Mom
"drift": "#FF4444", # red markers for drift events
"background": "#0E1117", # dark background matching HF Spaces dark mode
"surface": "#1A1D27", # card surface
"text": "#FAFAFA", # primary text
"subtext": "#A0A0B0", # secondary text
"grid": "#2A2D3A", # grid lines
}
FACTOR_COLORS = {
"Mkt-RF": COLORS["market"],
"SMB": COLORS["size"],
"HML": COLORS["value"],
"RMW": COLORS["profit"],
"CMA": COLORS["invest"],
"Mom": COLORS["momentum"],
}
FACTOR_LABELS = {
"Mkt-RF": "Market (Mkt-RF)",
"SMB": "Size (SMB)",
"HML": "Value (HML)",
"RMW": "Profitability (RMW)",
"CMA": "Investment (CMA)",
"Mom": "Momentum (Mom)",
}
class ChartInput(BaseModel):
ticker: str
fund_name: str
# For NAV chart - monthly prices {"YYYY-MM": price}
monthly_prices: dict
# For factor loading bar chart - single regression result
factor_loadings: dict # {factor: loading}
factor_tstats: dict # {factor: t_stat}
# For rolling exposure chart - from drift detection engine
# List of {date, factor_loadings, adj_r_squared}
rolling_windows: list
# Drift events for markers - list of {date, factor, z_score, direction}
drift_events: list
class ChartOutput(BaseModel):
nav_chart_json: str # Plotly figure as JSON string
loadings_chart_json: str # Plotly figure as JSON string
rolling_chart_json: str # Plotly figure as JSON string
error: Optional[str] = None
def _apply_dark_theme(fig: go.Figure, title: str) -> go.Figure:
"""Apply consistent dark theme to any figure."""
fig.update_layout(
title=dict(
text=title,
font=dict(color=COLORS["text"], size=14),
x=0.0,
xanchor="left"
),
paper_bgcolor=COLORS["background"],
plot_bgcolor=COLORS["surface"],
font=dict(color=COLORS["text"], size=11),
margin=dict(l=60, r=30, t=60, b=50),
legend=dict(
bgcolor=COLORS["surface"],
bordercolor=COLORS["grid"],
borderwidth=1,
font=dict(color=COLORS["text"], size=10)
),
xaxis=dict(
gridcolor=COLORS["grid"],
linecolor=COLORS["grid"],
tickfont=dict(color=COLORS["subtext"])
),
yaxis=dict(
gridcolor=COLORS["grid"],
linecolor=COLORS["grid"],
tickfont=dict(color=COLORS["subtext"])
)
)
return fig
def build_nav_chart(ticker: str, fund_name: str, monthly_prices: dict) -> go.Figure:
"""
Line chart of monthly NAV/price over the full history.
Simple and clean - shows the fund's price trajectory.
"""
dates = list(monthly_prices.keys())
prices = list(monthly_prices.values())
fig = go.Figure()
fig.add_trace(go.Scatter(
x=dates,
y=prices,
mode="lines",
name="NAV",
line=dict(color=COLORS["market"], width=2),
fill="tozeroy",
fillcolor="rgba(79, 142, 247, 0.08)"
))
fig = _apply_dark_theme(fig, f"{ticker} - Price History")
fig.update_layout(
yaxis_title="Price (USD)",
xaxis_title=None,
showlegend=False,
height=300
)
return fig
def build_loadings_chart(
ticker: str,
factor_loadings: dict,
factor_tstats: dict
) -> go.Figure:
"""
Horizontal bar chart of current factor loadings.
Significant factors (abs t-stat >= 2.0) are fully opaque.
Non-significant factors are dimmed.
"""
factors = list(FACTOR_LABELS.keys())
loadings = [factor_loadings.get(f, 0.0) for f in factors]
tstats = [factor_tstats.get(f, 0.0) for f in factors]
labels = [FACTOR_LABELS[f] for f in factors]
colors = [FACTOR_COLORS[f] for f in factors]
# Dim non-significant bars
opacities = [1.0 if abs(t) >= 2.0 else 0.35 for t in tstats]
bar_colors = []
for c, op in zip(colors, opacities):
if op < 1.0:
# Convert hex to rgba with reduced opacity
r = int(c[1:3], 16)
g = int(c[3:5], 16)
b = int(c[5:7], 16)
bar_colors.append(f"rgba({r},{g},{b},{op})")
else:
bar_colors.append(c)
# Custom hover text
hover_texts = []
for f, l, t in zip(factors, loadings, tstats):
sig = "significant" if abs(t) >= 2.0 else "not significant"
hover_texts.append(f"{FACTOR_LABELS[f]}
Loading: {l:.4f}
t-stat: {t:.2f} ({sig})")
fig = go.Figure()
fig.add_trace(go.Bar(
x=loadings,
y=labels,
orientation="h",
marker_color=bar_colors,
hovertext=hover_texts,
hoverinfo="text",
text=[f"{l:.3f}" for l in loadings],
textposition="outside",
textfont=dict(color=COLORS["text"], size=10)
))
# Zero line
fig.add_vline(x=0, line_color=COLORS["subtext"], line_width=1)
fig = _apply_dark_theme(fig, f"{ticker} - Factor Loadings (Full Period)")
fig.update_layout(
xaxis_title="Factor Loading",
yaxis_title=None,
height=350,
xaxis=dict(
gridcolor=COLORS["grid"],
linecolor=COLORS["grid"],
tickfont=dict(color=COLORS["subtext"]),
zeroline=False
)
)
return fig
def build_rolling_chart(
ticker: str,
rolling_windows: list,
drift_events: list,
factors_to_show: list = None
) -> go.Figure:
"""
Line chart of rolling factor exposures over time.
Each factor is one line. Drift events are marked with vertical lines.
Default: show all 6 factors. Can be filtered to a subset.
"""
if factors_to_show is None:
factors_to_show = ["Mkt-RF", "SMB", "HML", "RMW", "CMA", "Mom"]
# Build time series per factor from rolling windows
dates = [w["date"] for w in rolling_windows]
factor_series = {f: [] for f in factors_to_show}
for w in rolling_windows:
for f in factors_to_show:
factor_series[f].append(w["factor_loadings"].get(f, None))
fig = go.Figure()
# One line per factor
for f in factors_to_show:
fig.add_trace(go.Scatter(
x=dates,
y=factor_series[f],
mode="lines",
name=FACTOR_LABELS[f],
line=dict(color=FACTOR_COLORS[f], width=1.5),
hovertemplate=f"{FACTOR_LABELS[f]}: %{{y:.4f}}
%{{x}}"
))
# Drift event markers - vertical lines at drift dates
# Deduplicate dates (multiple factors can drift on same date)
drift_dates = list(set(e["date"] for e in drift_events))
for d in drift_dates:
fig.add_vline(
x=d,
line_color=COLORS["drift"],
line_width=1,
line_dash="dot",
opacity=0.6
)
# Add a single invisible trace for the drift legend entry
if drift_dates:
fig.add_trace(go.Scatter(
x=[None], y=[None],
mode="lines",
name="Drift flagged",
line=dict(color=COLORS["drift"], width=1, dash="dot"),
showlegend=True
))
# Zero reference line
fig.add_hline(y=0, line_color=COLORS["subtext"], line_width=0.5)
fig = _apply_dark_theme(fig, f"{ticker} - Rolling Factor Exposures (24-Month Window)")
fig.update_layout(
yaxis_title="Factor Loading",
xaxis_title=None,
height=450,
legend=dict(
orientation="h",
yanchor="bottom",
y=1.02,
xanchor="left",
x=0
)
)
return fig
def generate_charts(inp: ChartInput) -> ChartOutput:
try:
# Build all three charts
nav_fig = build_nav_chart(
ticker=inp.ticker,
fund_name=inp.fund_name,
monthly_prices=inp.monthly_prices
)
loadings_fig = build_loadings_chart(
ticker=inp.ticker,
factor_loadings=inp.factor_loadings,
factor_tstats=inp.factor_tstats
)
rolling_fig = build_rolling_chart(
ticker=inp.ticker,
rolling_windows=inp.rolling_windows,
drift_events=inp.drift_events
)
return ChartOutput(
nav_chart_json=nav_fig.to_json(),
loadings_chart_json=loadings_fig.to_json(),
rolling_chart_json=rolling_fig.to_json(),
error=None
)
except Exception as e:
return ChartOutput(
nav_chart_json="{}",
loadings_chart_json="{}",
rolling_chart_json="{}",
error=str(e)
)
if __name__ == "__main__":
# Pull real data and render charts for ARKK
from agent.tools.french_factor_fetcher import get_french_factors, FrenchFactorInput
from agent.tools.fund_price_fetcher import get_fund_returns, FundPriceInput
from agent.tools.factor_regression_engine import run_factor_regression, FactorRegressionInput
from agent.tools.drift_detection_engine import detect_drift, DriftDetectionInput
ticker = "ARKK"
start = "2019-01"
end = "2025-12"
print(f"Fetching data for {ticker}...")
factors = get_french_factors(FrenchFactorInput(start_date=start, end_date=end))
prices = get_fund_returns(FundPriceInput(ticker=ticker, start_date=start, end_date=end))
print("Running regression...")
regression = run_factor_regression(FactorRegressionInput(
ticker=ticker,
returns=prices.returns,
factors=factors.factors,
start_date=start,
end_date=end
))
print("Running drift detection...")
drift = detect_drift(DriftDetectionInput(
ticker=ticker,
returns=prices.returns,
factors=factors.factors,
start_date=start,
end_date=end
))
print("Generating charts...")
charts = generate_charts(ChartInput(
ticker=ticker,
fund_name="ARK Innovation ETF",
monthly_prices=prices.returns, # using returns as proxy for now
factor_loadings=regression.factor_loadings,
factor_tstats=regression.factor_tstats,
rolling_windows=[w.model_dump() for w in drift.rolling_windows],
drift_events=[e.model_dump() for e in drift.drift_events]
))
print(f"Error: {charts.error}")
print(f"NAV chart JSON length: {len(charts.nav_chart_json)}")
print(f"Loadings chart JSON length: {len(charts.loadings_chart_json)}")
print(f"Rolling chart JSON length: {len(charts.rolling_chart_json)}")
# Save charts to outputs/ for visual inspection
import plotly.io as pio
import os
os.makedirs("outputs", exist_ok=True)
for name, json_str in [
("nav", charts.nav_chart_json),
("loadings", charts.loadings_chart_json),
("rolling", charts.rolling_chart_json)
]:
fig = pio.from_json(json_str)
path = f"outputs/test_{ticker}_{name}.html"
fig.write_html(path)
print(f"Saved: {path}")
print("\nOpen the HTML files in your browser to inspect the charts.")