# 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 = """
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
{fund_name}
' meta_html = ( f'' f'' f'' f'' ) 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"""| # | Security | Weight |
|---|
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}%
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("""