"""get_portfolio — persona-scoped. Each agent sees ONLY its own paper account. LIVE-first: pulls the account + positions straight from Alpaca at call time, so overnight research reflects intraday bracket exits (a stop/take that filled at 2pm is gone from the book by night). Falls back to the last reconciled DB snapshot (execution/reconcile.py) if Alpaca is unreachable — clearly labeled as cached so the agent knows the freshness. """ from __future__ import annotations import json from datetime import datetime, timezone from langchain_core.tools import tool from config.settings import get_settings from state import db as statedb def _live_portfolio(account: str) -> str | None: """Live account + positions from Alpaca. None if unreachable/unconfigured.""" try: from execution.broker import for_persona b = for_persona(account) acct = b.get_account() positions = b.get_positions() except Exception: return None now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") lines = [ f"LIVE from Alpaca as of {now}: equity=${float(acct.equity):,.2f}, " f"cash=${float(acct.cash):,.2f}, buying_power=${float(acct.buying_power):,.2f}" ] if not positions: lines.append("Holdings: none (all cash).") else: lines.append("Holdings:") for p in positions: lines.append( f" {p.symbol}: {p.qty} @ {p.avg_entry_price} " f"-> mv={p.market_value}, unrealized_pl={p.unrealized_pl}" ) return "\n".join(lines) def _snapshot_portfolio(account: str) -> str: """Last reconciled DB snapshot — the offline fallback.""" path = get_settings().db_path if not path.exists(): return "No portfolio data yet (fresh account, no trades recorded)." try: con = statedb.connect(path) snap = statedb.latest_snapshot(con, account or None) con.close() except Exception as e: return f"No portfolio data yet ({e})." if snap is None: return "No holdings yet (fresh account, all cash)." lines = [ f"CACHED snapshot from {snap['ts']} (live fetch unavailable — intraday fills after " f"that time are NOT reflected): equity=${snap['equity']:,.2f}, " f"cash=${snap['cash']:,.2f}, buying_power=${snap['buying_power']:,.2f}" ] try: positions = json.loads(snap["positions_json"] or "[]") except Exception: positions = [] if not positions: lines.append("Holdings: none (all cash).") else: lines.append("Holdings:") for p in positions: lines.append( f" {p.get('symbol')}: {p.get('qty')} @ {p.get('avg_entry')} " f"-> mv={p.get('market_value')}, unrealized_pl={p.get('unrealized_pl')}" ) return "\n".join(lines) def make_get_portfolio(account: str): """Return a get_portfolio tool bound to one account (persona id).""" @tool def get_portfolio() -> str: """Return YOUR current paper-account holdings, unrealized PnL, and cash (live from Alpaca; falls back to the last recorded snapshot if offline). Use this BEFORE proposing trades so you account for what you already own. """ return _live_portfolio(account) or _snapshot_portfolio(account) return get_portfolio