Files
All-in-one-Financial-Analysis/views/tab9_portfolio.py
T
shawnkim1997andClaude Opus 4.6 b2acda81ee feat: add Atlas Terminal — Next.js 14 + FastAPI full-stack migration
Complete migration from Streamlit to Next.js 14 App Router + FastAPI backend.

Frontend (Next.js 14):
- 10 pages: Overview, Research, Valuation, Technical, Markets, Earnings, News, Portfolio, Filings, Settings
- Terminal Noir dark theme with custom Tailwind config
- TradingView Lightweight Charts for candlestick/volume
- Valuation: DCF, Sensitivity Matrix, Monte Carlo, Tornado, Reverse DCF
- Financial Statements table with YoY growth badges and margin rows
- SEC EDGAR inline filing viewer with section tabs
- News split-view with iframe article embedding
- Technical Analysis with RSI, MACD, Bollinger, Fibonacci, Moving Averages
- Earnings beat/miss visualization
- AI Copilot chat panel with Gemini integration

Backend (FastAPI):
- 13 routers: market_data, financials, valuation, technical, earnings, insider, edgar, news, portfolio, analysis, chat, estimates, fx
- Services: DCF engine, Monte Carlo simulation, sensitivity analysis, risk metrics, SEC parser, technical indicators
- yfinance + yahooquery data sources with fallback pattern
- SQLite caching layer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 02:10:10 +00:00

199 lines
7.5 KiB
Python

"""
Tab 9 — Portfolio Management
Full-featured portfolio dashboard: holdings table, sector pie chart,
earnings calendar, dividends, key events, news, AI OCR import.
"""
import streamlit as st
import pandas as pd
from data.portfolio import get_portfolio_prices, get_sector_allocation
from views.portfolio_widgets import render_earnings, render_dividends, render_news
try:
import plotly.express as px
except ImportError:
px = None
# ───────── Session State Init ─────────
def _init_portfolio():
if "portfolio_holdings" not in st.session_state:
st.session_state["portfolio_holdings"] = []
# ───────── Manual Entry Form ─────────
def _render_add_form():
"""Manual stock entry form."""
st.markdown("#### Add Holding")
cols = st.columns([2, 1, 1, 1])
with cols[0]:
ticker = st.text_input("Ticker", placeholder="AAPL", key="pf_add_ticker")
with cols[1]:
name = st.text_input("Name", placeholder="Apple Inc", key="pf_add_name")
with cols[2]:
shares = st.number_input("Shares", min_value=0.0, step=0.01, key="pf_add_shares")
with cols[3]:
avg_cost = st.number_input("Avg Cost ($)", min_value=0.0, step=0.01, key="pf_add_avg")
if st.button("Add to Portfolio", key="pf_add_btn"):
if ticker.strip():
st.session_state["portfolio_holdings"].append({
"ticker": ticker.strip().upper(),
"name": name.strip() or ticker.strip().upper(),
"shares": shares, "avg_cost": avg_cost,
})
st.rerun()
# ───────── AI OCR Import ─────────
def _render_ai_import():
"""AI-powered screenshot import for Trading 212 / IBKR."""
st.markdown("#### Import from Brokerage Screenshot")
google_api_key = (st.session_state.get("google_api_key") or "").strip()
if not google_api_key:
st.info("Enter your Google API Key in the sidebar to enable AI portfolio import.")
return
broker = st.selectbox("Broker", ["Trading 212", "IBKR", "Other"], key="pf_broker")
uploaded = st.file_uploader(
"Upload screenshot", type=["png", "jpg", "jpeg", "webp"],
key="pf_screenshot", help="Upload a screenshot of your portfolio holdings"
)
if uploaded and st.button("Extract Holdings with AI", key="pf_extract_btn", type="primary"):
with st.spinner("Gemini is analyzing your screenshot..."):
from ai.gemini_portfolio import extract_portfolio_from_image
holdings = extract_portfolio_from_image(google_api_key, uploaded.read(), broker)
if holdings:
for h in holdings:
st.session_state["portfolio_holdings"].append({
"ticker": h["ticker"], "name": h.get("name", h["ticker"]),
"shares": h.get("shares") or 0,
"avg_cost": h.get("avg_cost") or h.get("current_price") or 0,
})
st.success(f"Extracted {len(holdings)} holdings!")
st.rerun()
else:
st.warning("No holdings found. Try a clearer screenshot.")
# ───────── Portfolio Overview Cards ─────────
def _render_overview(holdings: list, prices: dict):
"""Total asset value, daily P&L, total P&L cards."""
total_value, total_cost, daily_pnl = 0.0, 0.0, 0.0
for h in holdings:
p = prices.get(h["ticker"], {})
cur = p.get("price") or h.get("avg_cost") or 0
prev = p.get("prev_close") or cur
shares = h.get("shares", 0)
total_value += cur * shares
total_cost += h.get("avg_cost", 0) * shares
daily_pnl += (cur - prev) * shares
total_pnl = total_value - total_cost
pnl_pct = (total_pnl / total_cost * 100) if total_cost > 0 else 0
c1, c2, c3 = st.columns(3)
with c1:
st.metric("Total Portfolio Value", f"${total_value:,.2f}")
with c2:
st.metric("Daily P&L", f"${daily_pnl:,.2f}", delta=f"{daily_pnl:+,.2f}")
with c3:
st.metric("Total P&L", f"${total_pnl:,.2f}", delta=f"{pnl_pct:+.2f}%")
# ───────── Holdings Table ─────────
def _render_holdings_table(holdings: list, prices: dict):
"""Display holdings with current price, change, P&L."""
if not holdings:
return
st.markdown("#### Holdings")
rows = []
for i, h in enumerate(holdings):
sym = h["ticker"]
p = prices.get(sym, {})
cur = p.get("price")
chg = p.get("change_pct", 0)
shares, avg = h.get("shares", 0), h.get("avg_cost", 0)
mkt = (cur or 0) * shares
pnl = (cur - avg) * shares if cur and avg else 0
pnl_p = ((cur - avg) / avg * 100) if cur and avg and avg > 0 else 0
rows.append({
"": i, "Ticker": sym, "Name": h.get("name", sym),
"Shares": f"{shares:,.2f}" if shares != int(shares) else f"{int(shares):,}",
"Avg Cost": f"${avg:,.2f}" if avg else "N/A",
"Current": f"${cur:,.2f}" if cur else "N/A",
"Day Chg": f"{chg:+.2f}%", "P&L": f"${pnl:+,.2f}",
"P&L %": f"{pnl_p:+.2f}%", "Mkt Value": f"${mkt:,.2f}",
})
df = pd.DataFrame(rows).set_index("")
st.dataframe(df, use_container_width=True, height=min(40 * len(rows) + 38, 500))
with st.expander("Remove Holdings"):
for i, h in enumerate(holdings):
if st.button(f"Remove {h['ticker']}", key=f"pf_del_{i}"):
st.session_state["portfolio_holdings"].pop(i)
st.rerun()
# ───────── Sector Pie Chart ─────────
def _render_sector_pie(holdings: list):
"""Sector allocation donut chart."""
if not holdings or not px:
return
tickers = tuple(h["ticker"] for h in holdings)
sectors = get_sector_allocation(tickers)
agg = {}
for h in holdings:
sec = sectors.get(h["ticker"], "Other")
agg[sec] = agg.get(sec, 0) + h.get("shares", 0) * h.get("avg_cost", 0)
if not agg:
return
fig = px.pie(
names=list(agg.keys()), values=list(agg.values()),
title="Sector Allocation", hole=0.4,
color_discrete_sequence=px.colors.qualitative.Set3,
)
fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
font_color="#E5E7EB", height=350, margin=dict(t=40, b=20, l=20, r=20),
)
st.plotly_chart(fig, use_container_width=True)
# ───────── Main Render ─────────
def render_tab9():
"""Render the Portfolio Management tab."""
_init_portfolio()
st.subheader("Portfolio Management")
holdings = st.session_state["portfolio_holdings"]
with st.expander("Add / Import Holdings", expanded=not bool(holdings)):
t1, t2 = st.tabs(["Manual Entry", "AI Screenshot Import"])
with t1:
_render_add_form()
with t2:
_render_ai_import()
if not holdings:
st.info("Add holdings above to see your portfolio dashboard.")
return
tickers = tuple(h["ticker"] for h in holdings)
with st.spinner("Fetching live prices..."):
prices = get_portfolio_prices(tickers)
_render_overview(holdings, prices)
st.markdown("---")
left, right = st.columns([3, 2])
with left:
_render_holdings_table(holdings, prices)
_render_sector_pie(holdings)
with right:
render_earnings(tickers)
st.markdown("---")
render_dividends(tickers)
st.markdown("---")
render_news(tickers)