import logging from typing import Any, Dict, List, Tuple from src.core.AgentCommand import AgentCommand from src.core.FinanceState import FinanceState from src.core.errors import add_error from src.rag.StockMarketRag import StockMarketRag logger = logging.getLogger(__name__) class PortfolioAgent(AgentCommand): def __init__(self, state: FinanceState): self.state = state self.yf = StockMarketRag() def process(self): trace_id = str(self.state.get("trace_id") or "") logger.info("[trace=%s] PortfolioAgent.start", trace_id) holdings = self.state.get("portfolio") or [] if not holdings: self.state["response"] = ( "Educational only, not financial advice.\n\n" "I can analyze a portfolio if you provide holdings with quantities.\n" "Example: 'My portfolio is 10 AAPL, 5 MSFT, 2 VTI'." ) return self.state normalized: List[Tuple[str, float]] = [] for h in holdings: try: symbol = str(h.get("symbol") or "").strip().upper() qty_raw = h.get("quantity") qty = float(qty_raw) if qty_raw is not None else 0.0 except Exception: continue if symbol and qty > 0: normalized.append((symbol, qty)) if not normalized: self.state["response"] = ( "Educational only, not financial advice.\n\n" "I couldn't parse any valid holdings. Please provide symbols and quantities." ) return self.state logger.info("[trace=%s] PortfolioAgent.holdings=%d", trace_id, len(normalized)) rows: List[Dict[str, Any]] = [] errors: List[str] = [] total_value = 0.0 for symbol, qty in normalized: details = self.yf.get_stock_details(symbol) if details.get("error"): add_error( self.state, code="portfolio_quote_error", message=str(details.get("error")), agent="portfolio_agent", detail={"symbol": symbol, "provider": details.get("provider")}, ) errors.append(f"{symbol}: {details.get('error')}") continue price = details.get("currentPrice") try: price_f = float(price) if price is not None else None except Exception: price_f = None if price_f is None: add_error( self.state, code="portfolio_quote_error", message="Missing current price", agent="portfolio_agent", detail={"symbol": symbol, "provider": details.get("provider")}, ) errors.append(f"{symbol}: missing current price") continue value = price_f * qty total_value += value rows.append( { "symbol": symbol, "quantity": qty, "price": price_f, "value": value, "sector": details.get("sector"), "industry": details.get("industry"), } ) if not rows: self.state["response"] = ( "Educational only, not financial advice.\n\n" "I couldn't retrieve prices for your holdings. " + (f"Errors: {', '.join(errors[:5])}" if errors else "") ) return self.state # Compute weights and concentration. rows.sort(key=lambda r: r["value"], reverse=True) for r in rows: r["weight"] = (r["value"] / total_value) if total_value > 0 else 0.0 top1 = float(rows[0]["weight"]) top3 = float(sum(r["weight"] for r in rows[:3])) n = len(rows) concentration_note = "Moderate concentration." if top1 >= 0.5: concentration_note = "High concentration in a single holding." elif top3 >= 0.8: concentration_note = "High concentration across the top 3 holdings." # Sector breakdown (best-effort). sector_totals: Dict[str, float] = {} for r in rows: sector = r.get("sector") if isinstance(r.get("sector"), str) else None key = (sector or "Unknown").strip() or "Unknown" sector_totals[key] = sector_totals.get(key, 0.0) + float(r["weight"]) top_sectors = sorted(sector_totals.items(), key=lambda kv: kv[1], reverse=True)[ :5 ] lines: List[str] = [] lines.append("Educational only, not financial advice.\n") lines.append( f"Portfolio snapshot (approx): ${total_value:,.2f} across {n} holdings." ) lines.append( f"Concentration: top holding {top1:.0%}, top 3 holdings {top3:.0%}. {concentration_note}" ) lines.append("\nHoldings (by value):") for r in rows: lines.append( f"- {r['symbol']}: {r['quantity']:.4g} shares x ${r['price']:.2f} " f"= ${r['value']:,.2f} ({float(r['weight']):.0%})" ) if top_sectors: lines.append("\nSector exposure (best-effort):") for sector, w in top_sectors: lines.append(f"- {sector}: {w:.0%}") lines.append("\nWhat to consider next (general education):") lines.append( "- Diversification: consider whether any single holding dominates outcomes." ) lines.append( "- Time horizon and risk: align stock-heavy exposure with your ability to tolerate volatility." ) lines.append( "- Rebalancing: consider simple rules (e.g., annual review) rather than reacting to short-term moves." ) if errors: lines.append("\nData issues:") for e in errors[:8]: lines.append(f"- {e}") self.state["response"] = "\n".join(lines).strip() return self.state