FinanceEducationAssistant / src /agents /PortfolioAgent.py
Fade0510's picture
Cleaned up code before reimplementing semantic cache
6710fbe
Raw
History Blame Contribute Delete
6.22 kB
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