finnie / src /web_app /app.py
Vishnu Rama
Fixing bugs
634376a
"""
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 ─────────────────────────────────────────────────────────
@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'<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()