""" Portfolio context builder — injects open positions into AI prompts. Provides: - get_open_trades_with_moves(): open trades + 1d/5d price moves via yfinance - get_portfolio_concentration(): count per asset_class - build_portfolio_context_block(): formatted prompt block for AI injection """ import logging import math from typing import List, Dict, Optional from datetime import date, datetime _log = logging.getLogger("portfolio_context") def _safe_float(v, ndigits: int = 2) -> Optional[float]: """Round float, returning None for NaN/Inf/None — JSON-safe.""" if v is None: return None try: f = float(v) if math.isnan(f) or math.isinf(f): return None return round(f, ndigits) except (TypeError, ValueError): return None def get_open_trades_with_moves() -> List[Dict]: """Fetch all open trades and compute recent underlying price moves.""" from services.database import get_trade_entry_prices, _normalize_asset_class, _asset_class_from_ticker import yfinance as yf trades = get_trade_entry_prices(days=365) # all open trades regardless of age result = [] for t in trades: sym = t.get("underlying", "") entry_price = t.get("entry_price") move_1d = move_5d = current_price = None try: hist = yf.Ticker(sym).history(period="5d", auto_adjust=True) if not hist.empty: closes = hist["Close"].squeeze().dropna() if len(closes) >= 1: current_price = _safe_float(closes.iloc[-1], 4) if len(closes) >= 2 and closes.iloc[-2] != 0: move_1d = _safe_float((closes.iloc[-1] / closes.iloc[-2] - 1) * 100) if len(closes) >= 5 and closes.iloc[0] != 0: move_5d = _safe_float((closes.iloc[-1] / closes.iloc[0] - 1) * 100) except Exception as e: _log.debug(f"[PortfolioCtx] yfinance failed for {sym}: {e}") # Days held / remaining entry_str = t.get("entry_date", "") horizon = t.get("horizon_days") or 30 days_held = 0 days_remaining = horizon try: entry_dt = datetime.strptime(entry_str, "%Y-%m-%d").date() days_held = (date.today() - entry_dt).days days_remaining = max(0, horizon - days_held) except Exception: pass # Canonical asset class cls = _normalize_asset_class(t.get("asset_class") or "", sym) result.append({ "id": t.get("id"), "underlying": sym, "strategy": t.get("strategy") or "?", "asset_class": cls, "pattern_name": (t.get("pattern_name") or "")[:50], "entry_price": _safe_float(entry_price, 4), "current_price": current_price, "move_1d_pct": move_1d, "move_5d_pct": move_5d, "score_at_entry": t.get("score_at_entry"), "days_held": days_held, "days_remaining": days_remaining, "horizon_days": horizon, }) return result def get_portfolio_concentration(open_trades: List[Dict]) -> Dict[str, int]: """Count open trades by canonical asset_class.""" conc: Dict[str, int] = {} for t in open_trades: cls = t.get("asset_class") or "unknown" conc[cls] = conc.get(cls, 0) + 1 return conc def build_portfolio_context_block(open_trades: List[Dict], concentration: Dict[str, int]) -> str: """Build a prompt section describing current portfolio for injection into AI prompts.""" if not open_trades: return "\n## CURRENT PORTFOLIO\nNo open trades — empty portfolio.\n" total = len(open_trades) conc_sorted = sorted(concentration.items(), key=lambda x: -x[1]) conc_str = " | ".join(f"{cls.upper()}: {n}" for cls, n in conc_sorted) lines: List[str] = [f"Total: {total} open trade(s) | Concentration: {conc_str}", ""] for t in open_trades: sym = t["underlying"] strat = t["strategy"] cls = t.get("asset_class") or "?" held = t.get("days_held", "?") rem = t.get("days_remaining", "?") m1d_str = f"{t['move_1d_pct']:+.1f}%" if t.get("move_1d_pct") is not None else "N/A" m5d_str = f"{t['move_5d_pct']:+.1f}%" if t.get("move_5d_pct") is not None else "N/A" pat = t.get("pattern_name", "") entry = t.get("entry_price") cur = t.get("current_price") ep_str = f"entry {entry:.2f} → current {cur:.2f}" if entry and cur else "" line = f" • {sym} | {strat} [{cls}] | {held}d held / {rem}d remaining | D-1: {m1d_str} | D-5: {m5d_str}" if ep_str: line += f" | {ep_str}" if pat: line += f" | thesis: «{pat}»" lines.append(line) # Identify overweight classes overweight = [cls for cls, n in conc_sorted if n >= 3] ow_str = ", ".join(overweight) if overweight else "none" block = ( "\n## CURRENT PORTFOLIO — OPEN POSITIONS\n" + "\n".join(lines) + f"\n\nOverweight classes (≥3 trades): {ow_str}\n" + "\n⚠️ MANDATORY RULES (non-negotiable):\n" + "1. DO NOT suggest a new trade on an underlying already in the portfolio — strictly forbidden.\n" + "2. Explicitly flag in 'rationale' if a suggestion CONTRADICTS an open position (potential exit signal).\n" + "3. Avoid adding to an overweight class (≥3 trades) unless an exceptional justified catalyst exists.\n" + "4. A signal opposing an open position = EXIT opportunity to document, not a reverse entry.\n" ) return block