# app.py import gradio as gr import json import plotly.io as pio from pathlib import Path from agent.agent import run_agent REPORTS_DIR = Path("outputs/reports") FUND_UNIVERSE = sorted([ "ARKK", "ARKG", "DGRO", "DODGX", "FCNTX", "FXAIX", "AGTHX", "AIVSX", "IWM", "LMOPX", "MFEKX", "MTUM", "PRGFX", "PRFDX", "QUAL", "QQQ", "SEQUX", "CGMFX", "SPY", "SPYG", "SPYV", "SPLV", "USMV", "VOO", "VTV", "VUG", "VFINX", "VWUSX", "VYM", "XLF", "XLK", "XLE", "XLV" ]) CUSTOM_CSS = """ @import url('https://fonts.cdnfonts.com/css/playfair-display'); @import url('https://fonts.cdnfonts.com/css/ibm-plex-sans'); @import url('https://fonts.cdnfonts.com/css/ibm-plex-mono'); :root { --font-display: 'Playfair Display', Georgia, serif; --font-body: 'IBM Plex Sans', system-ui, sans-serif; --font-mono: 'IBM Plex Mono', monospace; --accent: #C9A84C; --accent-dim: rgba(201, 168, 76, 0.15); --accent-border: rgba(201, 168, 76, 0.4); --radius: 6px; --transition: 0.2s ease; } /* ── Light mode ── */ :root, .light { --bg-primary: #F7F5F0; --bg-secondary: #EDEAE2; --bg-card: #FFFFFF; --bg-sidebar: #F0EDE6; --text-primary: #1A1814; --text-secondary: #5C5648; --text-muted: #8C8478; --border: rgba(0,0,0,0.1); --shadow: 0 2px 12px rgba(0,0,0,0.06); } /* ── Dark mode ── */ .dark { --bg-primary: #0F0E0C; --bg-secondary: #181610; --bg-card: #1C1A16; --bg-sidebar: #161410; --text-primary: #F0EDE6; --text-secondary: #A89F8C; --text-muted: #6B6456; --border: rgba(255,255,255,0.07); --shadow: 0 2px 16px rgba(0,0,0,0.4); } body, .gradio-container { font-family: var(--font-body) !important; background: var(--bg-primary) !important; } /* ── Hero header ── */ .hero-block { background: linear-gradient(135deg, #0D1B2A 0%, #1A2F45 50%, #0D1B2A 100%); padding: 36px 48px 28px; border-bottom: 2px solid var(--accent); position: relative; overflow: hidden; } .hero-block::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: repeating-linear-gradient( 90deg, transparent, transparent 80px, rgba(201,168,76,0.03) 80px, rgba(201,168,76,0.03) 81px ); pointer-events: none; } .hero-title { font-family: var(--font-display) !important; font-size: 2.4rem !important; font-weight: 700 !important; color: #F0EDE6 !important; margin: 0 0 6px 0 !important; letter-spacing: -0.02em; line-height: 1.15; } .hero-sub { font-family: var(--font-mono) !important; font-size: 0.72rem !important; font-weight: 500 !important; color: var(--accent) !important; letter-spacing: 0.18em; text-transform: uppercase; margin: 0 !important; } /* ── Sidebar ── */ .sidebar-wrap { background: var(--bg-sidebar); border-right: 1px solid var(--border); padding: 0; height: 100%; } .sidebar-section { padding: 20px 20px 16px; border-bottom: 1px solid var(--border); } .sidebar-label { font-family: var(--font-mono) !important; font-size: 0.65rem !important; font-weight: 500 !important; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-muted) !important; margin: 0 0 12px 0 !important; } .fund-name-display { font-family: var(--font-display) !important; font-size: 1.15rem !important; font-weight: 600 !important; color: var(--text-primary) !important; margin: 0 0 10px 0 !important; line-height: 1.3; } .meta-row { font-size: 0.8rem !important; color: var(--text-secondary) !important; margin: 4px 0 !important; line-height: 1.5; } .meta-row strong { color: var(--text-primary) !important; font-weight: 500; } .conf-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 0.78rem !important; } .conf-row:last-child { border-bottom: none; } .conf-label { color: var(--text-muted) !important; font-family: var(--font-mono) !important; font-size: 0.7rem !important; } .conf-badge { font-weight: 600; font-size: 0.7rem !important; padding: 2px 8px; border-radius: 3px; font-family: var(--font-mono) !important; } .badge-high { background: rgba(74,222,128,0.15) !important; color: #4ADE80 !important; border: 1px solid rgba(74,222,128,0.3) !important; } .badge-moderate { background: rgba(251,191,36,0.15) !important; color: #FBBF24 !important; border: 1px solid rgba(251,191,36,0.3) !important; } .badge-low { background: rgba(248,113,113,0.15) !important; color: #F87171 !important; border: 1px solid rgba(248,113,113,0.3) !important; } .badge-adequate { background: rgba(74,222,128,0.15) !important; color: #4ADE80 !important; border: 1px solid rgba(74,222,128,0.3) !important; } .badge-marginal { background: rgba(251,191,36,0.15) !important; color: #FBBF24 !important; border: 1px solid rgba(251,191,36,0.3) !important; } .badge-insufficient { background: rgba(248,113,113,0.15) !important; color: #F87171 !important; border: 1px solid rgba(248,113,113,0.3) !important; } .badge-pass { background: rgba(74,222,128,0.15) !important; color: #4ADE80 !important; border: 1px solid rgba(74,222,128,0.3) !important; } .badge-flag { background: rgba(248,113,113,0.15) !important; color: #F87171 !important; border: 1px solid rgba(248,113,113,0.3) !important; } /* ── Main panel ── */ .main-panel { background: var(--bg-primary); padding: 24px 32px; } /* ── Dropdown ── */ .gr-dropdown, select, .wrap { font-family: var(--font-body) !important; border-radius: var(--radius) !important; } /* ── Tabs ── */ .tab-nav button { font-family: var(--font-mono) !important; font-size: 0.75rem !important; letter-spacing: 0.08em; text-transform: uppercase; } /* ── Charts ── */ .gr-plot { background: var(--bg-card) !important; border: 1px solid var(--border) !important; border-radius: var(--radius) !important; box-shadow: var(--shadow) !important; margin-bottom: 16px !important; } /* ── Narrative ── */ .narrative-block h2 { font-family: var(--font-display) !important; font-size: 1.3rem !important; color: var(--text-primary) !important; margin-bottom: 16px !important; padding-bottom: 8px; border-bottom: 1px solid var(--accent-border); } .narrative-block p { font-size: 0.88rem !important; line-height: 1.75 !important; color: var(--text-secondary) !important; margin-bottom: 12px !important; } .narrative-block h3 { font-family: var(--font-mono) !important; font-size: 0.72rem !important; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent) !important; margin: 20px 0 8px !important; } /* ── Holdings table ── */ .holdings-block table { width: 100%; border-collapse: collapse; font-size: 0.82rem !important; } .holdings-block th { font-family: var(--font-mono) !important; font-size: 0.65rem !important; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted) !important; padding: 8px 12px; border-bottom: 1px solid var(--border); text-align: left; } .holdings-block td { padding: 7px 12px; border-bottom: 1px solid var(--border); color: var(--text-secondary) !important; } .holdings-block tr:last-child td { border-bottom: none; } /* ── Methodology ── */ .method-block { font-family: var(--font-mono) !important; font-size: 0.68rem !important; color: var(--text-muted) !important; line-height: 1.6; padding: 16px 0; border-top: 1px solid var(--border); } /* ── Status message ── */ .status-msg { font-family: var(--font-mono) !important; font-size: 0.75rem !important; color: var(--accent) !important; padding: 8px 0; } /* ── Sidebar scrollable ── */ aside { overflow-y: auto !important; } /* ── Input & button ── */ input[type=text], .gr-textbox textarea { font-family: var(--font-mono) !important; font-size: 0.82rem !important; border-radius: var(--radius) !important; } button.primary { font-family: var(--font-mono) !important; font-size: 0.75rem !important; letter-spacing: 0.08em; text-transform: uppercase; background: var(--accent) !important; color: #0F0E0C !important; border-radius: var(--radius) !important; } /* ── Section divider ── */ .section-divider { height: 1px; background: var(--border); margin: 24px 0; } """ HERO_HTML = """

Fund Style Drift Detector

Rolling Fama-French 6-factor analysis across 33 US equity funds. Detects statistically significant shifts in factor exposures over time.

Claude  +  Ken French  +  SEC EDGAR  +  yFinance

""" def load_report(ticker: str) -> dict | None: path = REPORTS_DIR / f"{ticker}.json" if not path.exists(): return None with open(path) as f: return json.load(f) def format_aum(aum: float | None) -> str: if aum is None: return "N/A" if aum >= 1_000_000_000: return f"${aum / 1_000_000_000:.1f}B" if aum >= 1_000_000: return f"${aum / 1_000_000:.0f}M" return f"${aum:,.0f}" def json_to_fig(json_str: str): if not json_str or json_str == "{}": return None try: return pio.from_json(json_str) except Exception: return None def badge(label: str) -> str: cls = f"badge-{label.lower()}" if label else "badge-low" return f'{label.upper()}' def render_sidebar(meta: dict, regression: dict) -> tuple: fund_name = meta.get("name", "") category = meta.get("category") or "N/A" aum = format_aum(meta.get("aum_usd")) expense = f"{meta.get('expense_ratio', 0) * 100:.2f}%" if meta.get("expense_ratio") else "N/A" inception = meta.get("inception_date") or "N/A" fund_name_html = f'

{fund_name}

' meta_html = ( f'

Category   {category}

' f'

AUM   {aum}

' f'

Expense Ratio   {expense}

' f'

Inception   {inception}

' ) conf_fit = regression.get("conf_fit", {}) conf_sig = regression.get("conf_significance", {}) conf_sample = regression.get("conf_sample", {}) conf_norm = regression.get("conf_normality", {}) conf_html = f"""
Fit {badge(conf_fit.get('label',''))}
Significance {badge(conf_sig.get('label',''))}
Sample {badge(conf_sample.get('label',''))}
Normality {badge(conf_norm.get('label',''))}
Adj R²={conf_fit.get('metric',0):.3f}  |  {int(conf_sig.get('metric',0))}/6 factors  |  {int(conf_sample.get('metric',0))}mo  |  JB p={conf_norm.get('metric',0):.3f}
""" return fund_name_html, meta_html, conf_html def render_report(report: dict): if not report: return ("", "", "", None, None, None, "", "", "") meta = report.get("metadata") or {} regression = report.get("regression") or {} holdings = report.get("holdings") or {} charts = report.get("charts") or {} narrative = report.get("narrative") or "" fund_name_html, meta_html, conf_html = render_sidebar(meta, regression) nav_fig = json_to_fig(charts.get("nav_chart_json")) loadings_fig = json_to_fig(charts.get("loadings_chart_json")) rolling_fig = json_to_fig(charts.get("rolling_chart_json")) top_holdings = holdings.get("top_holdings", []) if top_holdings: rows = "" for i, h in enumerate(top_holdings[:10], 1): rows += ( f"{i}" f"{h.get('name','N/A')}" f"" f"{h.get('pct_weight',0):.2f}%" ) holdings_html = f"""
{rows}
#SecurityWeight

As of {holdings.get('period_ending','N/A')}  |  {holdings.get('total_holdings','N/A')} total holdings  |  Top 10 concentration: {holdings.get('top10_concentration',0):.1f}%

""" else: holdings_html = '

Holdings data not available for this fund.

' return ( fund_name_html, meta_html, conf_html, nav_fig, loadings_fig, rolling_fig, narrative, holdings_html, "" ) def on_gallery_select(ticker: str): if not ticker: return ("", "", "", None, None, None, "", "", "") report = load_report(ticker) if not report: return ("", "", "", None, None, None, "", "", f"No report found for {ticker}.") return render_report(report) def on_live_regen(ticker: str): ticker = ticker.strip().upper() if not ticker: return ("", "", "", None, None, None, "", "", "Please enter a ticker symbol.") report = run_agent(ticker) if report.get("error"): return ("", "", "", None, None, None, "", "", f"Error: {report['error']}") return render_report(report) with gr.Blocks( css=CUSTOM_CSS, theme=gr.themes.Base(), title="Fund Style Drift Detector" ) as demo: gr.HTML(HERO_HTML) with gr.Sidebar(elem_classes=["sidebar-wrap"]): with gr.Column(elem_classes=["sidebar-section"]): gr.HTML('') gallery_dropdown = gr.Dropdown( choices=FUND_UNIVERSE, value=None, label="", interactive=True, show_label=False ) with gr.Column(elem_classes=["sidebar-section"]): gr.HTML('') fund_name_html = gr.HTML("") meta_html = gr.HTML("") with gr.Column(elem_classes=["sidebar-section"]): gr.HTML('') conf_html = gr.HTML("") with gr.Column(elem_classes=["main-panel"]): status_md = gr.HTML("") with gr.Tabs(): with gr.Tab("Gallery"): gr.HTML( '

' 'Select a fund from the sidebar to load its pre-generated report.

' ) with gr.Tab("Live Regen"): with gr.Row(): live_input = gr.Textbox( placeholder="Enter ticker e.g. QQQ VFINX ARKK", label="", show_label=False, scale=4 ) live_btn = gr.Button( "Run Analysis", variant="primary", scale=1 ) gr.HTML( '

' 'Runs the full pipeline live. Expect 30-60 seconds. ' 'Supported: any of the 33 funds in the universe.

' ) nav_chart = gr.Plot(label="Price History") loadings_chart = gr.Plot(label="Factor Loadings") rolling_chart = gr.Plot(label="Rolling Factor Exposures") gr.HTML('
') gr.HTML('') narrative_md = gr.Markdown("", elem_classes=["narrative-block"]) gr.HTML('
') gr.HTML('') holdings_html = gr.HTML("") gr.HTML("""
Methodology: Fama-French 6-factor OLS regression (Mkt-RF, SMB, HML, RMW, CMA, Mom). Rolling 24-month windows, 1-month step. Drift flagged at 1.5σ from historical mean. Factor data: Ken French Data Library (Dartmouth). Holdings: SEC EDGAR N-PORT filings. Returns: Yahoo Finance.
""") outputs = [ fund_name_html, meta_html, conf_html, nav_chart, loadings_chart, rolling_chart, narrative_md, holdings_html, status_md ] gallery_dropdown.change( fn=on_gallery_select, inputs=[gallery_dropdown], outputs=outputs ) live_btn.click( fn=on_live_regen, inputs=[live_input], outputs=outputs ) live_input.submit( fn=on_live_regen, inputs=[live_input], outputs=outputs ) if __name__ == "__main__": demo.launch()