TradingAgents/tradingagents/agents/portfolio/macro_summary_agent.py

201 lines
8.0 KiB
Python

"""Macro Summary Agent.
Pure-reasoning LLM node (no tools). Reads the macro scan output and compresses
it into a concise 1-page regime brief, injecting past macro regime memory.
Pattern: ``create_macro_summary_agent(llm, macro_memory)`` → closure
(mirrors macro_synthesis pattern).
"""
from __future__ import annotations
import json
import logging
import re
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from tradingagents.memory.macro_memory import MacroMemory
logger = logging.getLogger(__name__)
def create_macro_summary_agent(llm, macro_memory: MacroMemory | None = None):
"""Create a macro summary agent node.
Args:
llm: A LangChain chat model instance (deep_think recommended).
macro_memory: Optional MacroMemory instance for regime history injection
and post-call persistence. When None, memory features are skipped.
Returns:
A node function ``macro_summary_node(state)`` compatible with LangGraph.
"""
def macro_summary_node(state: dict) -> dict:
scan_summary = state.get("scan_summary") or {}
# Guard: abort early if scan data is absent or *only* contains an error
# (partial failures with real data + an "error" key are still usable)
if not scan_summary or (isinstance(scan_summary, dict) and scan_summary.keys() == {"error"}):
return {
"messages": [],
"macro_brief": "NO DATA AVAILABLE - ABORT MACRO",
"macro_memory_context": "",
"sender": "macro_summary_agent",
}
# ------------------------------------------------------------------
# Compress scan data to save tokens
# ------------------------------------------------------------------
executive_summary: str = scan_summary.get("executive_summary", "Not available")
macro_context: dict = scan_summary.get("macro_context", {})
macro_context_str = (
f"Economic cycle: {macro_context.get('economic_cycle', 'N/A')}\n"
f"Central bank stance: {macro_context.get('central_bank_stance', 'N/A')}\n"
f"Geopolitical risks: {macro_context.get('geopolitical_risks', 'N/A')}"
)
key_themes: list = scan_summary.get("key_themes", [])
key_themes_str = "\n".join(
f"- {t.get('theme', '?')} [{t.get('conviction', '?')}] "
f"({t.get('timeframe', '?')}): {t.get('description', '')}"
for t in key_themes
) or "None"
# Strip verbose rationale — retain only what the brief needs
ticker_conviction = [
{
"ticker": t.get("ticker", "?"),
"conviction": t.get("conviction", "?"),
"thesis_angle": t.get("thesis_angle", "?"),
}
for t in scan_summary.get("stocks_to_investigate", [])
]
ticker_conviction_str = json.dumps(ticker_conviction, indent=2) or "[]"
risk_factors: list = scan_summary.get("risk_factors", [])
risk_factors_str = "\n".join(f"- {r}" for r in risk_factors) or "None"
# ------------------------------------------------------------------
# Past macro regime history
# ------------------------------------------------------------------
if macro_memory is not None:
past_context = macro_memory.build_macro_context(limit=3)
else:
past_context = "No prior macro regime history available."
# ------------------------------------------------------------------
# Build system message
# ------------------------------------------------------------------
system_message = (
"You are a macro strategist compressing a scanner report into a concise regime brief.\n\n"
"## Past Macro Regime History\n"
f"{past_context}\n\n"
"## Current Scan Data\n"
"### Executive Summary\n"
f"{executive_summary}\n\n"
"### Macro Context\n"
f"{macro_context_str}\n\n"
"### Key Themes\n"
f"{key_themes_str}\n\n"
"### Candidate Tickers (conviction only)\n"
f"{ticker_conviction_str}\n\n"
"### Risk Factors\n"
f"{risk_factors_str}\n\n"
"Produce a structured macro brief in this exact format:\n\n"
"MACRO REGIME: [risk-on|risk-off|neutral|transition]\n\n"
"KEY NUMBERS: [retain ALL exact numeric values — VIX levels, %, yield values, "
"sector weightings — do not round or omit]\n\n"
"TOP 3 THEMES:\n"
"1. [theme]: [description — retain all numbers]\n"
"2. [theme]: [description — retain all numbers]\n"
"3. [theme]: [description — retain all numbers]\n\n"
"MACRO-ALIGNED TICKERS: [list tickers with high conviction and why they fit the regime]\n\n"
"REGIME MEMORY NOTE: [any relevant lesson from past macro history that applies now]\n\n"
"IMPORTANT: Do NOT restrict yourself to a word count. Retain every numeric value from the "
"scan data. If the scan data is incomplete, note it explicitly — do not guess or extrapolate."
)
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"You are a helpful AI assistant, collaborating with other assistants."
" You have access to the following tools: {tool_names}.\n{system_message}"
" For your reference, the current date is {current_date}.",
),
MessagesPlaceholder(variable_name="messages"),
]
)
prompt = prompt.partial(system_message=system_message)
prompt = prompt.partial(tool_names="none")
prompt = prompt.partial(current_date=state.get("analysis_date", ""))
chain = prompt | llm
result = chain.invoke(state["messages"])
# ------------------------------------------------------------------
# Persist macro regime call to memory
# ------------------------------------------------------------------
if macro_memory is not None:
_persist_regime(result.content, scan_summary, macro_memory, state)
return {
"messages": [result],
"macro_brief": result.content,
"macro_memory_context": past_context,
"sender": "macro_summary_agent",
}
return macro_summary_node
def _persist_regime(
brief: str,
scan_summary: dict,
macro_memory: MacroMemory,
state: dict,
) -> None:
"""Extract MACRO REGIME line and persist to MacroMemory.
Fails silently — memory persistence must never break the pipeline.
"""
try:
macro_call = "neutral"
match = re.search(r"MACRO REGIME:\s*([^\n]+)", brief, re.IGNORECASE)
if match:
raw_call = match.group(1).strip().lower()
# Normalise to one of the four valid values
for valid in ("risk-on", "risk-off", "transition", "neutral"):
if valid in raw_call:
macro_call = valid
break
# Best-effort VIX extraction — scan data rarely includes a bare float
vix_level = 0.0
vix_match = re.search(r"VIX[:\s]+([0-9]+(?:\.[0-9]+)?)", brief, re.IGNORECASE)
if vix_match:
try:
vix_level = float(vix_match.group(1))
except ValueError:
pass
key_themes = [
t.get("theme", "") for t in scan_summary.get("key_themes", []) if t.get("theme")
]
sector_thesis = scan_summary.get("executive_summary", "")[:500]
analysis_date = state.get("analysis_date", "")
macro_memory.record_macro_state(
date=analysis_date,
vix_level=vix_level,
macro_call=macro_call,
sector_thesis=sector_thesis,
key_themes=key_themes,
)
except Exception:
logger.warning("macro_summary_agent: failed to persist regime to memory", exc_info=True)