TradingAgents/tradingagents/agents/managers/orchestrator.py

620 lines
27 KiB
Python

"""Advanced portfolio orchestrator that prioritizes hypotheses and schedules analysts."""
from __future__ import annotations
import json
import re
from datetime import date, datetime, timedelta
from typing import Any, Callable, Dict, List, Optional
from tradingagents.dataflows.interface import route_to_vendor
def create_portfolio_orchestrator(
llm: Any,
profile: Dict[str, object],
context_fetcher: Callable[[List[str]], List[Dict[str, str]]],
fast_news_fetcher: Callable[[str, str, int, int], Dict[str, str]],
plan_generator: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
):
"""Create an orchestrator node that generates and manages portfolio hypotheses."""
profile_name = profile.get("profile_name", "Portfolio")
mandate = profile.get("mandate", "")
risk_limits = profile.get("risk_limits", {})
notes = profile.get("notes", "")
universe_raw = profile.get("universe", "")
universe = [symbol.strip().upper() for symbol in universe_raw.split(",") if symbol.strip()]
sentiment_lookback = profile.get("sentiment_lookback_days", 2)
headline_limit = profile.get("news_headline_limit", 5)
market_lookback = profile.get("market_data_lookback_days", 30)
trade_policy = profile.get("trade_activation", {})
threshold = profile.get("hypothesis_threshold", 0.6)
max_hypotheses = profile.get("max_concurrent_hypotheses", 2)
risk_blurb = (
f"Max single position: {risk_limits.get('max_single_position_pct', 'n/a')} | "
f"Max sector: {risk_limits.get('max_sector_pct', 'n/a')}"
)
def _parse_account(account_text: str) -> Dict[str, str]:
summary = {}
pattern = re.compile(r"^([A-Za-z ]+):\s*\$?([\d\.,\-]+)")
for line in account_text.splitlines():
match = pattern.match(line.strip())
if match:
key = match.group(1).strip().lower().replace(" ", "_")
summary[key] = match.group(2).strip()
return summary
def _parse_positions(positions_text: str) -> List[Dict[str, str]]:
positions: List[Dict[str, str]] = []
current: Dict[str, str] = {}
for line in positions_text.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("Symbol:"):
if current:
positions.append(current)
current = {}
if ":" in line:
key, value = line.split(":", 1)
current[key.strip().lower().replace(" ", "_")] = value.strip()
if current:
positions.append(current)
return positions
def _to_float(value: Any) -> float:
if value is None:
return 0.0
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip()
if not text:
return 0.0
cleaned = text.replace("$", "").replace(",", "")
try:
return float(cleaned)
except ValueError:
return 0.0
def orchestrator_node(state):
override_symbols_raw = state.get("orchestrator_focus_override") or []
override_symbols = [str(sym).upper() for sym in override_symbols_raw if str(sym).strip()]
if override_symbols:
symbols = override_symbols.copy()
else:
symbols = [sym.upper() for sym in dict.fromkeys(universe)]
pending_override = [str(sym).upper() for sym in state.get("orchestrator_pending_tickers", []) if sym]
incumbent = state.get("company_of_interest")
if incumbent and incumbent.upper() not in symbols:
symbols.append(incumbent.upper())
# Step 1: Gather live portfolio context for the universe
snapshots = context_fetcher(symbols)
quick_signals: Dict[str, Dict[str, str]] = {
str(key).upper(): value for key, value in (state.get("orchestrator_quick_signals", {}) or {}).items()
}
market_data_cache: Dict[str, str] = {
str(key).upper(): value for key, value in (state.get("orchestrator_market_data", {}) or {}).items()
}
first_snapshot = snapshots[0] if snapshots else {}
# Step 3: Ask the LLM to synthesize hypotheses and routing plan
system_prompt = (
"You are the head of trading. Review portfolio context, mandate, and quick signals to propose trading "
"hypotheses. Each hypothesis must include: ticker, rationale, priority (0-1), required_analysts list (subset "
"of ['market','social','news','fundamentals']), and immediate actions (monitor, abandon, escalate). Limit to the "
f"strongest {max_hypotheses} hypotheses above priority {threshold}. Respond with valid JSON containing keys "
"'hypotheses' (list), 'summary' (string), and 'status' (string)."
)
account_summary = _parse_account(first_snapshot.get("account", "")) if first_snapshot else {}
positions_summary = _parse_positions(first_snapshot.get("positions", "")) if first_snapshot else []
buying_power_value = _to_float(account_summary.get("buying_power") or account_summary.get("buying_power_usd"))
cash_value = _to_float(account_summary.get("cash") or account_summary.get("cash_usd"))
portfolio_value = _to_float(
account_summary.get("portfolio_value")
or account_summary.get("equity")
or account_summary.get("equity_value")
)
current_holdings = set()
for item in positions_summary:
symbol = (item.get("symbol") or item.get("symbol:") or "").upper()
qty_raw = item.get("quantity", "")
qty_value = 0.0
if qty_raw:
match = re.search(r"[-+]?\d*\.?\d+", qty_raw.replace(",", ""))
if match:
try:
qty_value = float(match.group(0))
except ValueError:
qty_value = 0.0
if symbol and qty_value != 0.0:
current_holdings.add(symbol)
universe_gap = [sym for sym in symbols if sym not in current_holdings]
payload = {
"profile": {
"name": profile_name,
"mandate": mandate,
"risk_limits": risk_limits,
"notes": notes,
"risk_summary": risk_blurb,
},
"portfolio_snapshots": snapshots,
"quick_signals": {},
"current_hypotheses": state.get("orchestrator_hypotheses", []),
"account_summary": account_summary,
"positions": positions_summary,
"existing_holdings": list(current_holdings),
"universe_candidates": universe_gap,
"trade_policy": trade_policy,
}
response = llm.invoke(
[
{"role": "system", "content": system_prompt},
{"role": "user", "content": json.dumps(payload)},
]
)
try:
parsed = json.loads(response.content if hasattr(response, "content") else str(response))
except json.JSONDecodeError:
parsed = {"hypotheses": [], "notes": "Failed to parse orchestrator response."}
hypotheses_raw = parsed.get("hypotheses", [])[:max_hypotheses]
if override_symbols:
allowed = set(override_symbols)
filtered = [item for item in hypotheses_raw if isinstance(item, dict) and str(item.get("ticker", "")).upper() in allowed]
if filtered:
hypotheses = filtered
else:
hypotheses = [hypotheses_raw[0]] if hypotheses_raw else []
else:
hypotheses = hypotheses_raw
summary_text = parsed.get("summary") or first_snapshot.get("summary_prompt", "")
status_text = parsed.get("status") or first_snapshot.get("status", "ok")
trade_priority_threshold = float(trade_policy.get("priority_threshold", 0.8))
min_cash_abs = float(trade_policy.get("min_cash_absolute", 0))
min_cash_ratio = float(trade_policy.get("min_cash_ratio", 0))
min_cash_required = max(min_cash_abs, portfolio_value * min_cash_ratio)
if not hypotheses:
fallback_ticker = None
if override_symbols:
fallback_ticker = override_symbols[0]
elif symbols:
fallback_ticker = symbols[0]
elif incumbent:
fallback_ticker = incumbent.upper()
elif state.get("company_of_interest"):
fallback_ticker = str(state.get("company_of_interest")).upper()
if fallback_ticker:
fallback_required = ["market", "news", "fundamentals"]
can_trade = buying_power_value >= min_cash_required
fallback_action = "trade" if can_trade else "escalate"
fallback_priority = max(trade_priority_threshold, 0.8)
fallback = {
"ticker": fallback_ticker,
"rationale": (
f"Generated fallback hypothesis for {fallback_ticker} to evaluate trading opportunities with available buying power "
f"of ${buying_power_value:,.0f}. Analysts should validate signals before execution."
),
"priority": fallback_priority,
"required_analysts": fallback_required,
"immediate_actions": fallback_action,
}
hypotheses = [fallback]
if override_symbols and fallback_ticker not in override_symbols:
override_symbols.append(fallback_ticker)
if fallback_ticker not in symbols:
symbols.insert(0, fallback_ticker)
candidate_pool = override_symbols or symbols
try:
print(f"[Orchestrator] Override focus: {override_symbols if override_symbols else '<none>'}")
except Exception:
pass
focus_symbols: List[str] = []
hypothesis_map: Dict[str, Dict[str, Any]] = {}
for item in hypotheses:
ticker_value = str(item.get("ticker", "")).upper() if isinstance(item, dict) else ""
if ticker_value and ticker_value not in focus_symbols:
focus_symbols.append(ticker_value)
if isinstance(item, dict):
hypothesis_map[ticker_value] = item
if ticker_value and isinstance(item, dict):
hypothesis_map.setdefault(ticker_value, item)
for holding in current_holdings:
if holding not in focus_symbols:
focus_symbols.append(holding)
if incumbent:
incumbent_u = incumbent.upper()
if incumbent_u and incumbent_u not in focus_symbols:
focus_symbols.append(incumbent_u)
if not focus_symbols:
focus_symbols = symbols[: max_hypotheses or 1]
else:
for symbol in symbols:
if symbol not in focus_symbols:
focus_symbols.append(symbol)
if pending_override:
ordered_focus: List[str] = []
for sym in pending_override:
if sym in focus_symbols and sym not in ordered_focus:
ordered_focus.append(sym)
for sym in focus_symbols:
if sym not in ordered_focus:
ordered_focus.append(sym)
focus_symbols = ordered_focus
try:
print(f"[Orchestrator] Focus tickers: {', '.join(focus_symbols)}")
except Exception: # pragma: no cover - defensive logging
pass
if pending_override:
active_ticker = pending_override[0]
elif focus_symbols:
active_ticker = focus_symbols[0]
else:
active_ticker = state.get("target_ticker", state.get("company_of_interest", "")).upper()
active = hypothesis_map.get(active_ticker)
if active is None and hypotheses:
fallback = hypotheses[0]
if isinstance(fallback, dict) and fallback.get("ticker"):
active_ticker = str(fallback.get("ticker")).upper()
active = fallback
scheduled_sequence: List[str] = []
if active and isinstance(active, dict):
for analyst in active.get("required_analysts", []):
role = str(analyst).lower()
if role not in scheduled_sequence:
scheduled_sequence.append(role)
immediate_action = (active.get("immediate_actions") or active.get("action") or "").lower() if isinstance(active, dict) else ""
priority_val = float(active.get("priority") or 0) if isinstance(active, dict) else 0.0
holding_active = active_ticker in current_holdings
if isinstance(active, dict) and immediate_action in {"", "monitor"}:
if priority_val >= trade_priority_threshold:
if not holding_active and buying_power_value >= min_cash_required:
immediate_action = "trade"
active["immediate_actions"] = "trade"
else:
immediate_action = "escalate"
active["immediate_actions"] = "escalate"
default_analysis_order = ["market", "news", "social", "fundamentals"]
analysis_candidates = [
item for item in scheduled_sequence if item in {"market", "social", "news", "fundamentals"}
]
try:
if analysis_candidates:
print(f"[Orchestrator] Hypothesis requests analysts {analysis_candidates} for {active_ticker}.")
else:
print(f"[Orchestrator] No explicit analyst order from hypothesis; defaulting to {default_analysis_order}.")
except Exception:
pass
try:
print(
f"[Orchestrator] Active {active_ticker or '<none>'} priority {priority_val:.2f} -> {immediate_action.upper()} "
f"(cash ${cash_value:,.0f}, buying power ${buying_power_value:,.0f}, min cash ${min_cash_required:,.0f})"
)
except Exception:
pass
planner_raw: Dict[str, Any] = {}
planner_actions: List[str] = []
planner_immediate: str = ""
planner_notes: str = ""
planner_next_directive: Optional[str] = None
ticker_plan_summaries: Dict[str, Any] = dict(state.get("orchestrator_ticker_plans", {}) or {})
ACTION_ALIASES = {
"market": "market",
"market_analyst": "market",
"run_market": "market",
"run_market_analyst": "market",
"news": "news",
"news_analyst": "news",
"run_news": "news",
"social": "social",
"social_analyst": "social",
"fundamentals": "fundamentals",
"fundamental": "fundamentals",
"fundamentals_analyst": "fundamentals",
"debate": "debate",
"research_debate": "debate",
"manager": "manager",
"research_manager": "manager",
"trader": "trader",
"risk": "risk",
"risk_manager": "risk",
"orchestrator": "orchestrator",
"stop": "end",
"end": "end",
}
def normalise_action(value: str) -> Optional[str]:
key = (value or "").strip().lower()
return ACTION_ALIASES.get(key)
def append_unique(target: List[str], items: List[str]) -> None:
seen = set(target)
for elem in items:
if elem not in seen:
target.append(elem)
seen.add(elem)
trade_date = state.get("trade_date", "")
def _coerce_trade_date(value: str) -> date:
if not value:
return date.today()
try:
dt_value = datetime.fromisoformat(value)
except ValueError:
try:
dt_value = datetime.fromisoformat(f"{value}T00:00:00")
except ValueError:
dt_value = datetime.today()
return dt_value.date()
trade_date_obj = _coerce_trade_date(trade_date)
start_dt = trade_date_obj - timedelta(days=max(1, int(market_lookback)))
start_date_str = start_dt.isoformat()
end_date_str = trade_date_obj.isoformat()
market_data: Dict[str, str] = dict(market_data_cache)
for symbol in focus_symbols:
cache_key = symbol.upper()
if cache_key not in quick_signals or not quick_signals.get(cache_key):
try:
quick_signals[cache_key] = fast_news_fetcher(symbol, trade_date, sentiment_lookback, headline_limit)
except Exception as exc: # pragma: no cover - vendor failure is non-critical
quick_signals[cache_key] = {"error": str(exc)}
if cache_key not in market_data or not market_data.get(cache_key):
try:
market_data[cache_key] = route_to_vendor("get_stock_data", symbol, start_date_str, end_date_str)
except Exception as exc: # pragma: no cover - vendor failure is non-critical
market_data[cache_key] = f"Error fetching market data: {exc}"
try:
print(
"[Orchestrator] Retrieved market data for: "
+ ", ".join(sorted(market_data.keys()))
)
except Exception: # pragma: no cover - defensive logging
pass
existing_plan = ticker_plan_summaries.get(active_ticker or "", {}) if active_ticker else {}
if plan_generator is not None:
planner_payload = {
"profile": payload["profile"],
"hypotheses": hypotheses,
"active_hypothesis": active,
"summary": summary_text,
"status": status_text,
"account_summary": account_summary,
"positions_summary": positions_summary,
"portfolio_snapshots": snapshots,
"quick_signals": quick_signals,
"market_data": market_data,
"focus_symbols": focus_symbols,
"focus_symbol": active_ticker,
"trade_policy": trade_policy,
"buying_power": buying_power_value,
"cash_available": cash_value,
"portfolio_value": portfolio_value,
}
if existing_plan:
planner_raw = existing_plan
else:
planner_result = plan_generator(planner_payload) or {}
if isinstance(planner_result, dict):
planner_raw = planner_result
else:
planner_raw = {"text": str(planner_result)}
ticker_plan_summaries[active_ticker or "<unknown>"] = planner_raw if planner_raw else {}
if isinstance(planner_raw, dict):
plan_structured = planner_raw.get("structured")
planner_notes = planner_raw.get("text") or planner_raw.get("notes", "")
if plan_structured is None:
plan_text = planner_raw.get("text")
if plan_text:
try:
plan_structured = json.loads(plan_text)
except json.JSONDecodeError:
plan_structured = None
if plan_structured is None:
plan_structured = planner_raw.get("plan")
else:
plan_structured = None
if isinstance(plan_structured, str):
try:
plan_structured = json.loads(plan_structured)
except json.JSONDecodeError:
plan_structured = None
if isinstance(plan_structured, dict):
raw_actions = plan_structured.get("actions") or plan_structured.get("steps") or []
planner_immediate = str(plan_structured.get("next_decision") or "").lower()
planner_notes = planner_notes or plan_structured.get("notes", "")
planner_next_directive = plan_structured.get("next_directive")
elif isinstance(plan_structured, list):
raw_actions = plan_structured
else:
raw_actions = []
if planner_immediate and not immediate_action:
immediate_action = planner_immediate
for item in raw_actions:
if isinstance(item, str):
normalized = normalise_action(item)
elif isinstance(item, dict):
action_value = item.get("action") or item.get("name") or item.get("tool")
normalized = normalise_action(str(action_value)) if action_value else None
if not planner_notes and item.get("reason"):
planner_notes = str(item.get("reason"))
else:
normalized = None
if normalized:
planner_actions.append(normalized)
for symbol in focus_symbols:
if symbol == active_ticker:
continue
if override_symbols and symbol not in override_symbols:
continue
extra_payload = {
"profile": payload["profile"],
"hypotheses": hypotheses,
"active_hypothesis": hypothesis_map.get(symbol),
"summary": summary_text,
"status": status_text,
"account_summary": account_summary,
"positions_summary": positions_summary,
"portfolio_snapshots": snapshots,
"quick_signals": quick_signals,
"market_data": market_data,
"focus_symbols": focus_symbols,
"focus_symbol": symbol,
"trade_policy": trade_policy,
"buying_power": buying_power_value,
"cash_available": cash_value,
"portfolio_value": portfolio_value,
}
if symbol in ticker_plan_summaries and ticker_plan_summaries[symbol]:
continue
try:
extra_result = plan_generator(extra_payload) or {}
except Exception as exc: # pragma: no cover - planner failures shouldn't halt orchestration
extra_result = {"error": str(exc)}
if isinstance(extra_result, dict):
ticker_plan_summaries[symbol] = extra_result
else:
ticker_plan_summaries[symbol] = {"text": str(extra_result)}
action_queue: List[str] = []
if planner_actions:
append_unique(action_queue, planner_actions)
try:
print(f"[Orchestrator] Sequential planner actions for {active_ticker}: {planner_actions}")
except Exception:
pass
else:
append_unique(action_queue, analysis_candidates or default_analysis_order)
if immediate_action in {"escalate", "trade", "execute"}:
append_unique(action_queue, ["debate"])
append_unique(action_queue, ["manager"])
append_unique(action_queue, ["trader"])
if immediate_action in {"trade", "execute"}:
append_unique(action_queue, ["risk"])
next_directive_value = planner_next_directive or "end"
if override_symbols:
remaining_focus = []
next_directive_value = "end"
else:
remaining_focus = [sym for sym in focus_symbols if sym != active_ticker]
if remaining_focus and next_directive_value == "end":
next_directive_value = "orchestrator"
try:
print(
"[Orchestrator] Queue:"
f" focus={focus_symbols} | active={active_ticker} | immediate={immediate_action} | queue={action_queue}"
)
print(
"[Orchestrator] Ticker plans generated for: "
+ ", ".join(sorted(ticker_plan_summaries.keys()))
)
except Exception: # pragma: no cover - defensive logging
pass
# Log initial hypothesis for debugging
try:
formatted = json.dumps(
{
"hypotheses": hypotheses,
"summary": summary_text,
"status": status_text,
"action": immediate_action,
"queue": action_queue,
"planner_plan": planner_raw,
},
indent=2,
)
print(f"[Orchestrator] Initial hypotheses:\n{formatted}")
except Exception:
pass
analyst_schedule = [item for item in action_queue if item in {"market", "social", "news", "fundamentals"}]
if planner_actions:
plan_snapshot = planner_actions
else:
plan_snapshot = analysis_candidates or [item for item in default_analysis_order if item in action_queue]
serializable_plan = planner_raw
if isinstance(planner_raw, dict):
serializable_plan = {k: v for k, v in planner_raw.items() if k != "raw"}
if "raw" in planner_raw:
try:
serializable_plan.setdefault("raw_repr", repr(planner_raw["raw"]))
except Exception:
serializable_plan.setdefault("raw_repr", "<non-serializable>")
state_update = {
"messages": [response],
"portfolio_profile": profile,
"portfolio_summary": summary_text,
"orchestrator_status": status_text,
"alpaca_account_text": first_snapshot.get("account", ""),
"alpaca_positions_text": first_snapshot.get("positions", ""),
"alpaca_orders_text": first_snapshot.get("orders", ""),
"orchestrator_hypotheses": hypotheses,
"active_hypothesis": active,
"scheduled_analysts": analyst_schedule,
"scheduled_analysts_plan": plan_snapshot,
"company_of_interest": active_ticker or state.get("company_of_interest"),
"target_ticker": active_ticker,
"orchestrator_action": immediate_action,
"portfolio_account_summary": account_summary,
"portfolio_positions_summary": positions_summary,
"orchestrator_focus_symbols": focus_symbols,
"orchestrator_quick_signals": quick_signals,
"orchestrator_market_data": market_data,
"orchestrator_ticker_plans": ticker_plan_summaries,
"orchestrator_pending_tickers": remaining_focus,
"orchestrator_buying_power": buying_power_value,
"orchestrator_cash_available": cash_value,
"orchestrator_portfolio_value": portfolio_value,
"action_queue": action_queue,
"next_directive": next_directive_value,
"planner_plan": serializable_plan,
"planner_notes": planner_notes,
"orchestrator_focus_override": override_symbols,
}
return state_update
return orchestrator_node