TradingAgents/tradingagents/agents/portfolio/micro_summary_agent.py

186 lines
7.9 KiB
Python

"""Micro Summary Agent.
Pure-reasoning LLM node (no tools). Compresses holding reviews and ranked
candidates into a 1-page micro brief, injecting per-ticker reflexion memory.
Pattern: ``create_micro_summary_agent(llm, micro_memory)`` → closure
(mirrors macro_synthesis pattern).
"""
from __future__ import annotations
import json
import logging
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from tradingagents.memory.reflexion import ReflexionMemory
logger = logging.getLogger(__name__)
def create_micro_summary_agent(llm, micro_memory: ReflexionMemory | None = None):
"""Create a micro summary agent node.
Args:
llm: A LangChain chat model instance (mid_think or deep_think recommended).
micro_memory: Optional ReflexionMemory instance for per-ticker history
injection. When None, memory features are skipped.
Returns:
A node function ``micro_summary_node(state)`` compatible with LangGraph.
"""
def micro_summary_node(state: dict) -> dict:
analysis_date = state.get("analysis_date") or ""
# ------------------------------------------------------------------
# Parse inputs — handle missing / malformed gracefully
# ------------------------------------------------------------------
holding_reviews_raw = state.get("holding_reviews") or "{}"
candidates_raw = state.get("prioritized_candidates") or "[]"
holding_reviews: dict = _parse_json_safely(holding_reviews_raw, default={})
candidates: list = _parse_json_safely(candidates_raw, default=[])
# Optional: per-ticker trading graph analyses (fundamentals, technicals, etc.)
ticker_analyses: dict = state.get("ticker_analyses") or {}
# ------------------------------------------------------------------
# Collect all tickers and retrieve per-ticker memory
# ------------------------------------------------------------------
holding_tickers = list(holding_reviews.keys()) if isinstance(holding_reviews, dict) else []
candidate_tickers = [
c.get("ticker", "") for c in candidates if isinstance(c, dict) and c.get("ticker")
]
all_tickers = list(dict.fromkeys(holding_tickers + candidate_tickers)) # preserve order, dedupe
ticker_memory_dict: dict[str, str] = {}
if micro_memory is not None:
for ticker in all_tickers:
ticker_memory_dict[ticker] = micro_memory.build_context(ticker, limit=2)
ticker_memory_str = json.dumps(ticker_memory_dict)
# ------------------------------------------------------------------
# Build concise per-ticker input table
# ------------------------------------------------------------------
table_rows: list[str] = []
for ticker in holding_tickers:
review = holding_reviews.get(ticker, {}) if isinstance(holding_reviews, dict) else {}
if not isinstance(review, dict):
review = {}
rec = review.get("recommendation", "?")
confidence = review.get("confidence", "")
label = f"HOLDING | {rec} | conf:{confidence}" if confidence else f"HOLDING | {rec}"
# Enrich with trading graph analysis if available
analysis = ticker_analyses.get(ticker, {}) if isinstance(ticker_analyses, dict) else {}
key_number = analysis.get("final_trade_decision", "")[:80] if isinstance(analysis, dict) else ""
key_number = key_number or "-"
memory_snippet = (ticker_memory_dict.get(ticker, "")[:100] or "no memory")
table_rows.append(f"{ticker} | {label} | {key_number} | {memory_snippet}")
for c in candidates:
if not isinstance(c, dict):
continue
ticker = c.get("ticker", "?")
conviction = c.get("conviction", "?")
thesis = c.get("thesis_angle", "?")
score = c.get("score", "")
key_number = f"score:{score}" if score != "" else "-"
label = f"CANDIDATE | {conviction} | {thesis}"
memory_snippet = (ticker_memory_dict.get(ticker, "")[:100] or "no memory")
table_rows.append(f"{ticker} | {label} | {key_number} | {memory_snippet}")
ticker_table = "\n".join(table_rows) or "No tickers available."
# Serialise full detail for LLM context
holding_reviews_str = (
json.dumps(holding_reviews, indent=2)
if holding_reviews
else "No holding reviews available."
)
candidates_str = (
json.dumps(candidates, indent=2)
if candidates
else "No candidates available."
)
# ------------------------------------------------------------------
# Build system message
# ------------------------------------------------------------------
system_message = (
"You are a micro analyst compressing position-level data into a concise brief "
"for a portfolio manager.\n\n"
"## Per-Ticker Data\n"
f"{ticker_table}\n\n"
"## Holding Reviews (full detail)\n"
f"{holding_reviews_str}\n\n"
"## Prioritized Candidates (full detail)\n"
f"{candidates_str}\n\n"
"Produce a structured micro brief in this exact format:\n\n"
"HOLDINGS TABLE:\n"
"| TICKER | ACTION | KEY NUMBER | FLAG | MEMORY |\n"
"|--------|--------|------------|------|--------|\n"
"[one row per holding — if data is missing, write \"NO DATA\" in KEY NUMBER and FLAG columns]\n\n"
"CANDIDATES TABLE:\n"
"| TICKER | CONVICTION | THESIS ANGLE | KEY NUMBER | FLAG | MEMORY |\n"
"|--------|------------|--------------|------------|------|--------|\n"
"[one row per candidate — if data is missing, write \"NO DATA\"]\n\n"
"RED FLAGS: [list any tickers with accounting anomalies, high debt, or historical losses "
"— cite exact numbers]\n"
"GREEN FLAGS: [list tickers with strong momentum, insider buying, or positive memory "
"— cite exact numbers]\n\n"
"IMPORTANT: Retain exact debt ratios, P/E multiples, EPS values, and unrealized P&L "
"percentages. Never round or omit a numeric value. If a ticker has no data, write "
"\"NO DATA\" — do not guess."
)
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=analysis_date)
chain = prompt | llm
result = chain.invoke(state["messages"])
return {
"messages": [result],
"micro_brief": result.content,
"micro_memory_context": ticker_memory_str,
"sender": "micro_summary_agent",
}
return micro_summary_node
def _parse_json_safely(raw: str, *, default):
"""Parse a JSON string, returning *default* on any parse error.
Args:
raw: Raw string (may be JSON or empty/malformed).
default: Value to return when parsing fails.
"""
if not raw or not raw.strip():
return default
try:
return json.loads(raw)
except (json.JSONDecodeError, TypeError):
logger.warning(
"micro_summary_agent: could not parse JSON input (first 100): %s",
raw[:100],
)
return default