Spaces:
Sleeping
Sleeping
| """ | |
| 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(""" | |
| <style> | |
| .finnie-header { font-size: 2rem; font-weight: 700; color: #1f77b4; } | |
| .metric-card { background: #f8f9fa; border-radius: 8px; padding: 1rem; | |
| text-align: center; border: 1px solid #e0e0e0; } | |
| .price-up { color: #2ca02c; font-weight: 700; font-size: 1.1rem; } | |
| .price-down { color: #d62728; font-weight: 700; font-size: 1.1rem; } | |
| .price-neutral { color: #666; font-weight: 700; font-size: 1.1rem; } | |
| div[data-testid="stChatMessageContent"] p { margin-bottom: 0.5rem; } | |
| </style> | |
| """, 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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _portfolio_agent() -> PortfolioAnalysisAgent: | |
| return PortfolioAnalysisAgent() | |
| def _market_agent() -> MarketAnalysisAgent: | |
| return MarketAnalysisAgent() | |
| # ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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'<span class="{cls}">' | |
| f'{arrow} {sign}{change:.2f} ({sign}{change_pct:.2f}%)' | |
| f'</span>' | |
| ) | |
| # ββ Sidebar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with st.sidebar: | |
| st.markdown('<div class="finnie-header">π° Finnie</div>', 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(""" | |
| <script> | |
| setTimeout(() => { | |
| const app = window.parent.document.querySelector('[data-testid="stAppViewContainer"]'); | |
| if (app) app.scrollTop = app.scrollHeight; | |
| }, 100); | |
| </script> | |
| """, 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() | |