""" src/web_app/app.py Finnie β€” AI Finance Assistant (Streamlit UI) Three tabs: πŸ’¬ Chat conversational interface via LangGraph ReAct workflow πŸ“Š Portfolio live portfolio analysis with sector/allocation charts πŸ“ˆ Market real-time stock data and 30-day price history Run: uv run streamlit run src/web_app/app.py """ import sys import uuid from pathlib import Path import streamlit.components.v1 as components # Ensure the project root is on sys.path so `src.*` imports resolve # regardless of how Streamlit is launched (streamlit run, uv run, etc.) sys.path.insert(0, str(Path(__file__).resolve().parents[2])) import pandas as pd import plotly.express as px import plotly.graph_objects as go import streamlit as st import yfinance as yf from src.workflow.graph import chat as chat_invoke from src.agents.portfolio_agent import PortfolioAnalysisAgent from src.agents.market_agent import MarketAnalysisAgent from src.utils.market_tools import _fetch_alpha_vantage, _fetch_yfinance from src.utils.logger import get_logger log = get_logger(__name__) def _fetch_stock_info(ticker: str) -> dict | None: return _fetch_alpha_vantage(ticker) or _fetch_yfinance(ticker) # ── Page config ──────────────────────────────────────────────────────────────── st.set_page_config( page_title="Finnie β€” AI Finance Assistant", page_icon="πŸ’°", layout="wide", initial_sidebar_state="expanded", ) # Light custom styling st.markdown(""" """, unsafe_allow_html=True) # ── Session state init ───────────────────────────────────────────────────────── if "thread_id" not in st.session_state: st.session_state.thread_id = str(uuid.uuid4()) if "chat_history" not in st.session_state: st.session_state.chat_history = [] # [{"role": str, "content": str}] if "portfolio_result" not in st.session_state: st.session_state.portfolio_result = None if "market_data" not in st.session_state: st.session_state.market_data = None if "market_history" not in st.session_state: st.session_state.market_history = None if "market_analysis" not in st.session_state: st.session_state.market_analysis = None # ── Cached singletons ───────────────────────────────────────────────────────── @st.cache_resource def _portfolio_agent() -> PortfolioAnalysisAgent: return PortfolioAnalysisAgent() @st.cache_resource def _market_agent() -> MarketAnalysisAgent: return MarketAnalysisAgent() # ── Helpers ──────────────────────────────────────────────────────────────────── @st.cache_data(ttl=3600) def _resolve_ticker(name: str) -> str: """Resolve a ticker, company name, or misspelling to a canonical ticker. Uses LLM to infer intent before searching, so typos like 'aple' β†’ 'AAPL' work. """ from src.core.llm import load_llm from src.utils.market_tools import extract_ticker return extract_ticker(name, load_llm()) or name def _fmt_large(n) -> str: """Format large numbers: 1,234,567,890 β†’ $1.23B""" if n is None or n == "N/A": return "N/A" try: n = float(n) except (TypeError, ValueError): return str(n) if n >= 1e12: return f"${n / 1e12:.2f}T" if n >= 1e9: return f"${n / 1e9:.2f}B" if n >= 1e6: return f"${n / 1e6:.2f}M" return f"${n:,.0f}" def _change_html(change: float, change_pct: float) -> str: sign = "+" if change >= 0 else "" cls = "price-up" if change > 0 else ("price-down" if change < 0 else "price-neutral") arrow = "β–²" if change > 0 else ("β–Ό" if change < 0 else "") return ( f'' f'{arrow} {sign}{change:.2f} ({sign}{change_pct:.2f}%)' f'' ) # ── Sidebar ──────────────────────────────────────────────────────────────────── with st.sidebar: st.markdown('
πŸ’° Finnie
', unsafe_allow_html=True) st.caption("Your AI Finance Education Assistant") st.divider() st.subheader("Quick Tips") st.markdown(""" **πŸ’¬ Chat** β€” Ask anything: - "I want $2M in 20 years" - "I sold AAPL after 8 months β€” taxes?" - "Explain Roth IRA vs 401k" - "News on NVDA and MSFT" **πŸ“Š Portfolio** β€” Enter holdings: - `AAPL: 10, MSFT: 5, BND: 20` **πŸ“ˆ Market** β€” Look up any ticker: - Type `AAPL`, `TSLA`, `NVDA` … """) st.divider() if st.button("πŸ”„ New Conversation", width="stretch"): log.info("New conversation started β€” thread_id=%s", st.session_state.thread_id[:8]) st.session_state.thread_id = str(uuid.uuid4()) st.session_state.chat_history = [] st.rerun() st.caption(f"Session ID: `{st.session_state.thread_id[:8]}…`") # ── Tabs ─────────────────────────────────────────────────────────────────────── chat_tab, portfolio_tab, market_tab = st.tabs(["πŸ’¬ Chat", "πŸ“Š Portfolio", "πŸ“ˆ Market"]) # ═══════════════════════════════════════════════════════════════════════════════ # TAB 1 β€” CHAT # ═══════════════════════════════════════════════════════════════════════════════ with chat_tab: st.header("Chat with Finnie") st.caption( "Ask about retirement goals, taxes, portfolio analysis, market news, " "or any personal finance topic. Finnie remembers your conversation." ) # Welcome message if fresh session if not st.session_state.chat_history: with st.chat_message("assistant", avatar="πŸ’°"): st.markdown( "Hi! I'm **Finnie**, your AI finance education assistant. " "I can help you with:\n\n" "- πŸ“ˆ **Portfolio analysis** β€” diversification, sectors, allocation\n" "- 🎯 **Retirement & savings goals** β€” monthly contributions, projections\n" "- πŸ’΅ **Tax education** β€” capital gains, Roth IRA, 401k, HSA\n" "- πŸ“° **Financial news** β€” latest headlines for any stock\n" "- πŸ“š **Investing basics** β€” ETFs, bonds, compound interest and more\n\n" "What would you like to explore today?" ) # Replay history for msg in st.session_state.chat_history: avatar = "πŸ’°" if msg["role"] == "assistant" else None with st.chat_message(msg["role"], avatar=avatar): st.markdown(msg["content"]) # Scroll to the latest message after every rerun if st.session_state.chat_history: components.html(""" """, height=0) # User input if prompt := st.chat_input("Ask Finnie anything about your finances…"): log.info("Chat | thread=%s | query=%r", st.session_state.thread_id[:8], prompt[:80]) with st.spinner("Finnie is thinking…"): result = chat_invoke(prompt, thread_id=st.session_state.thread_id) answer = result["answer"] agents_used = result.get("agents_used", []) # Build agent-badge suffix so the evaluator can see multi-agent in action agent_labels = { "answer_finance_question": "πŸ“š Finance Q&A", "plan_financial_goal": "🎯 Goal Planner", "get_tax_education": "πŸ’° Tax Advisor", "analyze_portfolio": "πŸ“Š Portfolio Analyst", "get_market_data": "πŸ“ˆ Market Data", "get_financial_news": "πŸ“° News Synthesizer", } if agents_used: badges = " ".join( f"`{agent_labels.get(a, a)}`" for a in agents_used ) answer = f"**Agents consulted:** {badges}\n\n---\n\n{answer}" log.info("Chat | thread=%s | agents=%s | answer_len=%d", st.session_state.thread_id[:8], agents_used, len(answer)) st.session_state.chat_history.append({"role": "user", "content": prompt}) st.session_state.chat_history.append({"role": "assistant", "content": answer}) st.rerun() # ═══════════════════════════════════════════════════════════════════════════════ # TAB 2 β€” PORTFOLIO DASHBOARD # ═══════════════════════════════════════════════════════════════════════════════ with portfolio_tab: st.header("Portfolio Analysis Dashboard") st.caption("Enter your holdings to get a full analysis with sector allocation and diversification score.") col_input, col_risk = st.columns([3, 1]) with col_input: holdings_text = st.text_area( "Your Holdings", placeholder="e.g. RKLB - 200, Google - 60, 10 Apple shares, MSFT: 5", height=80, help="Any format works β€” tickers, company names, or plain English. e.g. 'Google - 60, 10 Apple shares, MSFT: 5'", ) with col_risk: risk_profile = st.selectbox( "Risk Profile", ["conservative", "moderate", "aggressive"], index=1, ) analyze_btn = st.button("πŸ” Analyze Portfolio", type="primary", width="content") if analyze_btn: if not holdings_text.strip(): st.warning("Please enter your holdings β€” tickers, company names, or plain English.") else: log.info("Portfolio | input=%r | risk=%s", holdings_text[:80], risk_profile) with st.spinner("Analysing your portfolio…"): result = _portfolio_agent().run( query=holdings_text, risk_profile=risk_profile, ) failed = result.get("failed", []) if failed: log.warning("Portfolio | failed tickers=%s", failed) if not result.get("metrics"): st.error("Could not identify any holdings. Try describing them like: '10 Apple shares, 5 Microsoft, 20 BND'.") else: log.info("Portfolio | done | value=$%.2f | score=%s", result["metrics"].get("total_value", 0), result["metrics"].get("diversification_score", "n/a")) st.session_state.portfolio_result = result # ── Display results ─────────────────────────────────────────────────────── res = st.session_state.portfolio_result if res: metrics = res.get("metrics", {}) failed = res.get("failed", []) if not metrics: st.error(res.get("answer", "Could not analyze portfolio. Please check your tickers.")) else: # Failed tickers warning if failed: st.warning(f"⚠️ Could not fetch data for: {', '.join(failed)}. Results exclude them.") # ── Top metric cards ────────────────────────────────────────────── c1, c2, c3, c4 = st.columns(4) c1.metric("Total Value", f"${metrics.get('total_value', 0):,.2f}") c2.metric("Positions", metrics.get("num_positions", 0)) c3.metric("Diversification Score", f"{metrics.get('diversification_score', 0)} / 10") c4.metric("Asset Types", len(metrics.get("asset_pct", {}))) st.divider() # ── Charts row ──────────────────────────────────────────────────── chart_left, chart_mid, chart_right = st.columns(3) with chart_left: sector_pct = metrics.get("sector_pct", {}) if sector_pct: fig = px.pie( values=list(sector_pct.values()), names=list(sector_pct.keys()), title="Sector Allocation", hole=0.35, color_discrete_sequence=px.colors.qualitative.Plotly, ) fig.update_traces(textposition="inside", textinfo="percent+label") fig.update_layout(margin=dict(t=40, b=0, l=0, r=0), showlegend=False) st.plotly_chart(fig, width="stretch") with chart_mid: asset_pct = metrics.get("asset_pct", {}) if asset_pct: fig = px.pie( values=list(asset_pct.values()), names=list(asset_pct.keys()), title="Asset Type Mix", hole=0.35, color_discrete_sequence=px.colors.qualitative.Set2, ) fig.update_traces(textposition="inside", textinfo="percent+label") fig.update_layout(margin=dict(t=40, b=0, l=0, r=0), showlegend=False) st.plotly_chart(fig, width="stretch") with chart_right: holdings_list = metrics.get("holdings", []) if holdings_list: df = pd.DataFrame(holdings_list).sort_values("position_value", ascending=True) fig = px.bar( df, x="position_value", y="ticker", orientation="h", title="Position Values ($)", labels={"position_value": "Value ($)", "ticker": ""}, color="allocation_pct", color_continuous_scale="Blues", text=df["position_value"].apply(lambda v: f"${v:,.0f}"), ) fig.update_traces(textposition="outside") fig.update_layout( margin=dict(t=40, b=0, l=0, r=60), coloraxis_showscale=False, yaxis=dict(tickfont=dict(size=12)), ) st.plotly_chart(fig, width="stretch") # ── Holdings table ──────────────────────────────────────────────── st.subheader("Holdings Detail") if holdings_list: df_table = pd.DataFrame(holdings_list)[ ["ticker", "name", "shares", "price", "position_value", "allocation_pct", "sector", "asset_type"] ].copy() df_table.columns = ["Ticker", "Name", "Shares", "Price ($)", "Value ($)", "Allocation (%)", "Sector", "Type"] df_table["Price ($)"] = df_table["Price ($)"].apply(lambda x: f"${x:,.2f}") df_table["Value ($)"] = df_table["Value ($)"].apply(lambda x: f"${x:,.2f}") df_table["Allocation (%)"] = df_table["Allocation (%)"].apply(lambda x: f"{x:.1f}%") st.dataframe(df_table, width="stretch", hide_index=True) # ── AI analysis ─────────────────────────────────────────────────── with st.expander("πŸ“ Finnie's Analysis", expanded=True): st.markdown(res.get("answer", "")) # ═══════════════════════════════════════════════════════════════════════════════ # TAB 3 β€” MARKET OVERVIEW # ═══════════════════════════════════════════════════════════════════════════════ with market_tab: st.header("Market Overview") st.caption("Real-time stock data, 52-week range, and 30-day price history β€” one stock at a time. For multiple stocks use the Portfolio tab.") col_search, col_period = st.columns([3, 1]) with col_search: ticker_input = st.text_input( "Ticker Symbol", placeholder="One ticker or company name β€” e.g. Tesla, AAPL, SPY", label_visibility="collapsed", ).strip() # keep original case so extract_ticker's LLM path handles company names with col_period: history_period = st.selectbox("Period", ["1mo", "3mo", "6mo", "1y"], index=0, label_visibility="collapsed") lookup_btn = st.button("πŸ” Look Up", type="primary") if lookup_btn and ticker_input: # Detect multi-stock input: any comma, " and ", or " & " between names t_lower = ticker_input.lower() if "," in ticker_input or " and " in t_lower or " & " in t_lower: st.warning( "This tab looks up one stock at a time. " "For multiple stocks, use the **Portfolio** tab." ) st.stop() log.info("Market | input=%r", ticker_input) with st.spinner(f"Resolving {ticker_input}…"): resolved = _resolve_ticker(ticker_input) log.info("Market | resolved=%s", resolved) with st.spinner(f"Fetching {resolved}…"): data = _fetch_stock_info(resolved) if data: hist = yf.Ticker(resolved).history(period=history_period) analysis = _market_agent().run(resolved) log.info("Market | %s | price=$%.2f | source=%s", resolved, data.get("price", 0), data.get("source", "?")) st.session_state.market_data = data st.session_state.market_history = hist st.session_state.market_analysis = analysis else: log.warning("Market | no data for %s", resolved) st.session_state.market_data = None st.session_state.market_history = None st.session_state.market_analysis = None st.error(f"Could not find data for **{resolved}**. Check the ticker symbol and try again.") # ── Display market data ──────────────────────────────────────────────────── data = st.session_state.market_data hist = st.session_state.market_history if data: # ── Price header ────────────────────────────────────────────────────── h_left, h_right = st.columns([2, 3]) with h_left: st.subheader(f"{data['name']} ({data['ticker']})") st.markdown(f"### ${data['price']:,.2f}") st.markdown( _change_html(data["change"], data["change_pct"]), unsafe_allow_html=True, ) if data["sector"] and data["sector"] != "N/A": st.caption(f"Sector: {data['sector']}") # ── 52-week range bar ───────────────────────────────────────────────── with h_right: lo52 = data.get("week_52_low") hi52 = data.get("week_52_high") price = data["price"] if lo52 and hi52 and hi52 > lo52: position_pct = (price - lo52) / (hi52 - lo52) fig_gauge = go.Figure(go.Indicator( mode="gauge+number", value=price, number={"prefix": "$", "valueformat": ".2f"}, gauge={ "axis": {"range": [lo52, hi52], "tickformat": "$,.0f"}, "bar": {"color": "#1f77b4"}, "steps": [ {"range": [lo52, lo52 + (hi52 - lo52) * 0.33], "color": "#ffcccc"}, {"range": [lo52 + (hi52 - lo52) * 0.33, lo52 + (hi52 - lo52) * 0.67], "color": "#fff3cc"}, {"range": [lo52 + (hi52 - lo52) * 0.67, hi52], "color": "#ccffcc"}, ], "threshold": { "line": {"color": "black", "width": 2}, "thickness": 0.75, "value": price, }, }, title={"text": "52-Week Range"}, )) fig_gauge.update_layout(height=200, margin=dict(t=30, b=0, l=20, r=20)) st.plotly_chart(fig_gauge, width="stretch") st.divider() # ── Key metrics row ─────────────────────────────────────────────────── m1, m2, m3, m4, m5, m6 = st.columns(6) m1.metric("Day High", f"${data['high']:,.2f}") m2.metric("Day Low", f"${data['low']:,.2f}") m3.metric("Volume", f"{data['volume']:,}" if data["volume"] else "N/A") m4.metric("Market Cap", _fmt_large(data["market_cap"])) m5.metric("P/E Ratio", f"{data['pe_ratio']:.1f}" if isinstance(data.get("pe_ratio"), float) else "N/A") m6.metric("Div. Yield", f"{data['dividend_yield'] * 100:.2f}%" if isinstance(data.get("dividend_yield"), float) else "N/A") # ── Price history chart ─────────────────────────────────────────────── if hist is not None and not hist.empty: st.subheader(f"{data['ticker']} Price History") fig_hist = go.Figure() # Candlestick fig_hist.add_trace(go.Candlestick( x=hist.index, open=hist["Open"], high=hist["High"], low=hist["Low"], close=hist["Close"], name=data["ticker"], increasing_line_color="#2ca02c", decreasing_line_color="#d62728", )) # 20-day moving average if len(hist) >= 20: ma20 = hist["Close"].rolling(20).mean() fig_hist.add_trace(go.Scatter( x=hist.index, y=ma20, mode="lines", name="20-day MA", line=dict(color="#ff7f0e", width=1.5, dash="dot"), )) fig_hist.update_layout( xaxis_rangeslider_visible=False, height=400, margin=dict(t=10, b=20, l=0, r=0), legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), hovermode="x unified", ) fig_hist.update_xaxes( rangebreaks=[dict(bounds=["sat", "mon"])] # hide weekends ) st.plotly_chart(fig_hist, width="stretch") # Volume bar chart below fig_vol = px.bar( x=hist.index, y=hist["Volume"], labels={"x": "", "y": "Volume"}, color=hist["Close"] >= hist["Open"], color_discrete_map={True: "#2ca02c", False: "#d62728"}, ) fig_vol.update_layout( height=120, margin=dict(t=0, b=20, l=0, r=0), showlegend=False, yaxis_title="Volume", ) st.plotly_chart(fig_vol, width="stretch") # ── Company description ─────────────────────────────────────────────── if data.get("description"): with st.expander("About the Company"): st.write(data["description"]) # ── AI analysis ─────────────────────────────────────────────────────── analysis = st.session_state.market_analysis if analysis: with st.expander("πŸ“ Finnie's Analysis", expanded=True): st.markdown(analysis.get("answer", "")) if analysis.get("source"): st.caption(f"Data source: {analysis['source']}") elif not (lookup_btn and ticker_input): # Empty state st.info( "Enter a ticker symbol above (e.g. **AAPL**, **TSLA**, **SPY**) and click **Look Up** " "to see real-time price data and chart." ) # Show a few popular tickers as quick-pick st.subheader("Popular Tickers") quick_cols = st.columns(6) quick_tickers = ["AAPL", "MSFT", "NVDA", "TSLA", "SPY", "QQQ"] for i, qt in enumerate(quick_tickers): with quick_cols[i]: if st.button(qt, width="stretch"): with st.spinner(f"Fetching {qt}…"): d = _fetch_stock_info(qt) h = yf.Ticker(qt).history(period="1mo") if d else None a = _market_agent().run(qt) if d else None st.session_state.market_data = d st.session_state.market_history = h st.session_state.market_analysis = a st.rerun()