201 lines
8.0 KiB
Python
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)
|