Merge remote-tracking branch 'upstream/main' into crypto-analysis-mvp

# Conflicts:
#	tradingagents/agents/researchers/bear_researcher.py
#	tradingagents/agents/researchers/bull_researcher.py
#	tradingagents/graph/propagation.py
This commit is contained in:
CadeYu committed 2026-05-08 18:57:09 +08:00
commit 5bae826749
39 files changed
+3259 -458

No files matched your search

-2
View File
@@ -1,6 +1,5 @@
from .utils.agent_utils import create_msg_delete
from .utils.agent_states import AgentState, InvestDebateState, RiskDebateState
from .utils.memory import FinancialSituationMemory
from .analysts.fundamentals_analyst import create_fundamentals_analyst
from .analysts.market_analyst import create_market_analyst
@@ -20,7 +19,6 @@ from .managers.portfolio_manager import create_portfolio_manager
from .trader.trader import create_trader
__all__ = [
"FinancialSituationMemory",
"AgentState",
"create_msg_delete",
"InvestDebateState",
@@ -1,26 +1,43 @@
from tradingagents.agents.utils.agent_utils import build_instrument_context, get_language_instruction
"""Portfolio Manager: synthesises the risk-analyst debate into the final decision.
Uses LangChain's ``with_structured_output`` so the LLM produces a typed
``PortfolioDecision`` directly, in a single call. The result is rendered
back to markdown for storage in ``final_trade_decision`` so memory log,
CLI display, and saved reports continue to consume the same shape they do
today. When a provider does not expose structured output, the agent falls
back gracefully to free-text generation.
"""
from __future__ import annotations
from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision
from tradingagents.agents.utils.agent_utils import (
build_instrument_context,
get_language_instruction,
)
from tradingagents.agents.utils.structured import (
bind_structured,
invoke_structured_or_freetext,
)
def create_portfolio_manager(llm, memory):
def create_portfolio_manager(llm):
structured_llm = bind_structured(llm, PortfolioDecision, "Portfolio Manager")
def portfolio_manager_node(state) -> dict:
instrument_context = build_instrument_context(state["company_of_interest"])
history = state["risk_debate_state"]["history"]
risk_debate_state = state["risk_debate_state"]
market_research_report = state["market_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
sentiment_report = state["sentiment_report"]
research_plan = state["investment_plan"]
trader_plan = state["trader_investment_plan"]
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}"
past_memories = memory.get_memories(curr_situation, n_matches=2)
past_memory_str = ""
for i, rec in enumerate(past_memories, 1):
past_memory_str += rec["recommendation"] + "\n\n"
past_context = state.get("past_context", "")
lessons_line = (
f"- Lessons from prior decisions and outcomes:\n{past_context}\n"
if past_context
else ""
)
prompt = f"""As the Portfolio Manager, synthesize the risk analysts' debate and deliver the final trading decision.
@@ -38,15 +55,7 @@ def create_portfolio_manager(llm, memory):
**Context:**
- Research Manager's investment plan: **{research_plan}**
- Trader's transaction proposal: **{trader_plan}**
- Lessons from past decisions: **{past_memory_str}**
**Required Output Structure:**
1. **Rating**: State one of Buy / Overweight / Hold / Underweight / Sell.
2. **Executive Summary**: A concise action plan covering entry strategy, position sizing, key risk levels, and time horizon.
3. **Investment Thesis**: Detailed reasoning anchored in the analysts' debate and past reflections.
---
{lessons_line}
**Risk Analysts Debate History:**
{history}
@@ -54,10 +63,16 @@ def create_portfolio_manager(llm, memory):
Be decisive and ground every conclusion in specific evidence from the analysts.{get_language_instruction()}"""
response = llm.invoke(prompt)
final_trade_decision = invoke_structured_or_freetext(
structured_llm,
llm,
prompt,
render_pm_decision,
"Portfolio Manager",
)
new_risk_debate_state = {
"judge_decision": response.content,
"judge_decision": final_trade_decision,
"history": risk_debate_state["history"],
"aggressive_history": risk_debate_state["aggressive_history"],
"conservative_history": risk_debate_state["conservative_history"],
@@ -71,7 +86,7 @@ Be decisive and ground every conclusion in specific evidence from the analysts.{
return {
"risk_debate_state": new_risk_debate_state,
"final_trade_decision": response.content,
"final_trade_decision": final_trade_decision,
}
return portfolio_manager_node
@@ -1,58 +1,64 @@
"""Research Manager: turns the bull/bear debate into a structured investment plan for the trader."""
from __future__ import annotations
from tradingagents.agents.schemas import ResearchPlan, render_research_plan
from tradingagents.agents.utils.agent_utils import build_instrument_context
from tradingagents.agents.utils.structured import (
bind_structured,
invoke_structured_or_freetext,
)
def create_research_manager(llm, memory):
def create_research_manager(llm):
structured_llm = bind_structured(llm, ResearchPlan, "Research Manager")
def research_manager_node(state) -> dict:
instrument_context = build_instrument_context(state["company_of_interest"])
history = state["investment_debate_state"].get("history", "")
market_research_report = state["market_report"]
sentiment_report = state["sentiment_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
investment_debate_state = state["investment_debate_state"]
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}"
past_memories = memory.get_memories(curr_situation, n_matches=2)
past_memory_str = ""
for i, rec in enumerate(past_memories, 1):
past_memory_str += rec["recommendation"] + "\n\n"
prompt = f"""As the portfolio manager and debate facilitator, your role is to critically evaluate this round of debate and make a definitive decision: align with the bear analyst, the bull analyst, or choose Hold only if it is strongly justified based on the arguments presented.
Summarize the key points from both sides concisely, focusing on the most compelling evidence or reasoning. Your recommendation—Buy, Sell, or Hold—must be clear and actionable. Avoid defaulting to Hold simply because both sides have valid points; commit to a stance grounded in the debate's strongest arguments.
Additionally, develop a detailed investment plan for the trader. This should include:
Your Recommendation: A decisive stance supported by the most convincing arguments.
Rationale: An explanation of why these arguments lead to your conclusion.
Strategic Actions: Concrete steps for implementing the recommendation.
Take into account your past mistakes on similar situations. Use these insights to refine your decision-making and ensure you are learning and improving. Present your analysis conversationally, as if speaking naturally, without special formatting.
Here are your past reflections on mistakes:
\"{past_memory_str}\"
prompt = f"""As the Research Manager and debate facilitator, your role is to critically evaluate this round of debate and deliver a clear, actionable investment plan for the trader.
{instrument_context}
Here is the debate:
Debate History:
---
**Rating Scale** (use exactly one):
- **Buy**: Strong conviction in the bull thesis; recommend taking or growing the position
- **Overweight**: Constructive view; recommend gradually increasing exposure
- **Hold**: Balanced view; recommend maintaining the current position
- **Underweight**: Cautious view; recommend trimming exposure
- **Sell**: Strong conviction in the bear thesis; recommend exiting or avoiding the position
Commit to a clear stance whenever the debate's strongest arguments warrant one; reserve Hold for situations where the evidence on both sides is genuinely balanced.
---
**Debate History:**
{history}"""
response = llm.invoke(prompt)
investment_plan = invoke_structured_or_freetext(
structured_llm,
llm,
prompt,
render_research_plan,
"Research Manager",
)
new_investment_debate_state = {
"judge_decision": response.content,
"judge_decision": investment_plan,
"history": investment_debate_state.get("history", ""),
"bear_history": investment_debate_state.get("bear_history", ""),
"bull_history": investment_debate_state.get("bull_history", ""),
"current_response": response.content,
"current_response": investment_plan,
"count": investment_debate_state["count"],
}
return {
"investment_debate_state": new_investment_debate_state,
"investment_plan": response.content,
"investment_plan": investment_plan,
}
return research_manager_node
@@ -1,6 +1,6 @@
def create_bear_researcher(llm, memory):
def create_bear_researcher(llm):
def bear_node(state) -> dict:
investment_debate_state = state["investment_debate_state"]
history = investment_debate_state.get("history", "")
@@ -19,13 +19,6 @@ def create_bear_researcher(llm, memory):
else "Asset fundamentals report (may be unavailable for crypto)"
)
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}"
past_memories = memory.get_memories(curr_situation, n_matches=2)
past_memory_str = ""
for i, rec in enumerate(past_memories, 1):
past_memory_str += rec["recommendation"] + "\n\n"
prompt = f"""You are a Bear Analyst making the case against investing in the {target_label}. Your goal is to present a well-reasoned argument emphasizing risks, challenges, and negative indicators. Leverage the provided research and data to highlight potential downsides and counter bullish arguments effectively.
Key points to focus on:
@@ -44,8 +37,7 @@ Latest world affairs news: {news_report}
{fundamentals_label}: {fundamentals_report}
Conversation history of the debate: {history}
Last bull argument: {current_response}
Reflections from similar situations and lessons learned: {past_memory_str}
Use this information to deliver a compelling bear argument, refute the bull's claims, and engage in a dynamic debate that demonstrates the risks and weaknesses of investing in the {target_label}. You must also address reflections and learn from lessons and mistakes you made in the past.
Use this information to deliver a compelling bear argument, refute the bull's claims, and engage in a dynamic debate that demonstrates the risks and weaknesses of investing in the {target_label}.
"""
response = llm.invoke(prompt)
@@ -1,6 +1,6 @@
def create_bull_researcher(llm, memory):
def create_bull_researcher(llm):
def bull_node(state) -> dict:
investment_debate_state = state["investment_debate_state"]
history = investment_debate_state.get("history", "")
@@ -19,13 +19,6 @@ def create_bull_researcher(llm, memory):
else "Asset fundamentals report (may be unavailable for crypto)"
)
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}"
past_memories = memory.get_memories(curr_situation, n_matches=2)
past_memory_str = ""
for i, rec in enumerate(past_memories, 1):
past_memory_str += rec["recommendation"] + "\n\n"
prompt = f"""You are a Bull Analyst advocating for investing in the {target_label}. Your task is to build a strong, evidence-based case emphasizing growth potential, competitive advantages, and positive market indicators. Leverage the provided research and data to address concerns and counter bearish arguments effectively.
Key points to focus on:
@@ -42,8 +35,7 @@ Latest world affairs news: {news_report}
{fundamentals_label}: {fundamentals_report}
Conversation history of the debate: {history}
Last bear argument: {current_response}
Reflections from similar situations and lessons learned: {past_memory_str}
Use this information to deliver a compelling bull argument, refute the bear's concerns, and engage in a dynamic debate that demonstrates the strengths of the bull position. You must also address reflections and learn from lessons and mistakes you made in the past.
Use this information to deliver a compelling bull argument, refute the bear's concerns, and engage in a dynamic debate that demonstrates the strengths of the bull position.
"""
response = llm.invoke(prompt)
+228
View File
@@ -0,0 +1,228 @@
"""Pydantic schemas used by agents that produce structured output.
The framework's primary artifact is still prose: each agent's natural-language
reasoning is what users read in the saved markdown reports and what the
downstream agents read as context. Structured output is layered onto the
three decision-making agents (Research Manager, Trader, Portfolio Manager)
so that:
- Their outputs follow consistent section headers across runs and providers
- Each provider's native structured-output mode is used (json_schema for
OpenAI/xAI, response_schema for Gemini, tool-use for Anthropic)
- Schema field descriptions become the model's output instructions, freeing
the prompt body to focus on context and the rating-scale guidance
- A render helper turns the parsed Pydantic instance back into the same
markdown shape the rest of the system already consumes, so display,
memory log, and saved reports keep working unchanged
"""
from __future__ import annotations
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Shared rating types
# ---------------------------------------------------------------------------
class PortfolioRating(str, Enum):
"""5-tier rating used by the Research Manager and Portfolio Manager."""
BUY = "Buy"
OVERWEIGHT = "Overweight"
HOLD = "Hold"
UNDERWEIGHT = "Underweight"
SELL = "Sell"
class TraderAction(str, Enum):
"""3-tier transaction direction used by the Trader.
The Trader's job is to translate the Research Manager's investment plan
into a concrete transaction proposal: should the desk execute a Buy, a
Sell, or sit on Hold this round. Position sizing and the nuanced
Overweight / Underweight calls happen later at the Portfolio Manager.
"""
BUY = "Buy"
HOLD = "Hold"
SELL = "Sell"
# ---------------------------------------------------------------------------
# Research Manager
# ---------------------------------------------------------------------------
class ResearchPlan(BaseModel):
"""Structured investment plan produced by the Research Manager.
Hand-off to the Trader: the recommendation pins the directional view,
the rationale captures which side of the bull/bear debate carried the
argument, and the strategic actions translate that into concrete
instructions the trader can execute against.
"""
recommendation: PortfolioRating = Field(
description=(
"The investment recommendation. Exactly one of Buy / Overweight / "
"Hold / Underweight / Sell. Reserve Hold for situations where the "
"evidence on both sides is genuinely balanced; otherwise commit to "
"the side with the stronger arguments."
),
)
rationale: str = Field(
description=(
"Conversational summary of the key points from both sides of the "
"debate, ending with which arguments led to the recommendation. "
"Speak naturally, as if to a teammate."
),
)
strategic_actions: str = Field(
description=(
"Concrete steps for the trader to implement the recommendation, "
"including position sizing guidance consistent with the rating."
),
)
def render_research_plan(plan: ResearchPlan) -> str:
"""Render a ResearchPlan to markdown for storage and the trader's prompt context."""
return "\n".join([
f"**Recommendation**: {plan.recommendation.value}",
"",
f"**Rationale**: {plan.rationale}",
"",
f"**Strategic Actions**: {plan.strategic_actions}",
])
# ---------------------------------------------------------------------------
# Trader
# ---------------------------------------------------------------------------
class TraderProposal(BaseModel):
"""Structured transaction proposal produced by the Trader.
The trader reads the Research Manager's investment plan and the analyst
reports, then turns them into a concrete transaction: what action to
take, the reasoning that justifies it, and the practical levels for
entry, stop-loss, and sizing.
"""
action: TraderAction = Field(
description="The transaction direction. Exactly one of Buy / Hold / Sell.",
)
reasoning: str = Field(
description=(
"The case for this action, anchored in the analysts' reports and "
"the research plan. Two to four sentences."
),
)
entry_price: Optional[float] = Field(
default=None,
description="Optional entry price target in the instrument's quote currency.",
)
stop_loss: Optional[float] = Field(
default=None,
description="Optional stop-loss price in the instrument's quote currency.",
)
position_sizing: Optional[str] = Field(
default=None,
description="Optional sizing guidance, e.g. '5% of portfolio'.",
)
def render_trader_proposal(proposal: TraderProposal) -> str:
"""Render a TraderProposal to markdown.
The trailing ``FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL**`` line is
preserved for backward compatibility with the analyst stop-signal text
and any external code that greps for it.
"""
parts = [
f"**Action**: {proposal.action.value}",
"",
f"**Reasoning**: {proposal.reasoning}",
]
if proposal.entry_price is not None:
parts.extend(["", f"**Entry Price**: {proposal.entry_price}"])
if proposal.stop_loss is not None:
parts.extend(["", f"**Stop Loss**: {proposal.stop_loss}"])
if proposal.position_sizing:
parts.extend(["", f"**Position Sizing**: {proposal.position_sizing}"])
parts.extend([
"",
f"FINAL TRANSACTION PROPOSAL: **{proposal.action.value.upper()}**",
])
return "\n".join(parts)
# ---------------------------------------------------------------------------
# Portfolio Manager
# ---------------------------------------------------------------------------
class PortfolioDecision(BaseModel):
"""Structured output produced by the Portfolio Manager.
The model fills every field as part of its primary LLM call; no separate
extraction pass is required. Field descriptions double as the model's
output instructions, so the prompt body only needs to convey context and
the rating-scale guidance.
"""
rating: PortfolioRating = Field(
description=(
"The final position rating. Exactly one of Buy / Overweight / Hold / "
"Underweight / Sell, picked based on the analysts' debate."
),
)
executive_summary: str = Field(
description=(
"A concise action plan covering entry strategy, position sizing, "
"key risk levels, and time horizon. Two to four sentences."
),
)
investment_thesis: str = Field(
description=(
"Detailed reasoning anchored in specific evidence from the analysts' "
"debate. If prior lessons are referenced in the prompt context, "
"incorporate them; otherwise rely solely on the current analysis."
),
)
price_target: Optional[float] = Field(
default=None,
description="Optional target price in the instrument's quote currency.",
)
time_horizon: Optional[str] = Field(
default=None,
description="Optional recommended holding period, e.g. '3-6 months'.",
)
def render_pm_decision(decision: PortfolioDecision) -> str:
"""Render a PortfolioDecision back to the markdown shape the rest of the system expects.
Memory log, CLI display, and saved report files all read this markdown,
so the rendered output preserves the exact section headers (``**Rating**``,
``**Executive Summary**``, ``**Investment Thesis**``) that downstream
parsers and the report writers already handle.
"""
parts = [
f"**Rating**: {decision.rating.value}",
"",
f"**Executive Summary**: {decision.executive_summary}",
"",
f"**Investment Thesis**: {decision.investment_thesis}",
]
if decision.price_target is not None:
parts.extend(["", f"**Price Target**: {decision.price_target}"])
if decision.time_horizon:
parts.extend(["", f"**Time Horizon**: {decision.time_horizon}"])
return "\n".join(parts)
+39 -25
View File
@@ -1,47 +1,61 @@
"""Trader: turns the Research Manager's investment plan into a concrete transaction proposal."""
from __future__ import annotations
import functools
from langchain_core.messages import AIMessage
from tradingagents.agents.schemas import TraderProposal, render_trader_proposal
from tradingagents.agents.utils.agent_utils import build_instrument_context
from tradingagents.agents.utils.structured import (
bind_structured,
invoke_structured_or_freetext,
)
def create_trader(llm, memory):
def create_trader(llm):
structured_llm = bind_structured(llm, TraderProposal, "Trader")
def trader_node(state, name):
company_name = state["company_of_interest"]
asset_type = state.get("asset_type", "stock")
instrument_context = build_instrument_context(company_name, asset_type)
investment_plan = state["investment_plan"]
market_research_report = state["market_report"]
sentiment_report = state["sentiment_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}"
past_memories = memory.get_memories(curr_situation, n_matches=2)
past_memory_str = ""
if past_memories:
for i, rec in enumerate(past_memories, 1):
past_memory_str += rec["recommendation"] + "\n\n"
else:
past_memory_str = "No past memories found."
context = {
"role": "user",
"content": f"Based on a comprehensive analysis by a team of analysts, here is an investment plan tailored for {company_name}. {instrument_context} This plan incorporates insights from current technical market trends, macroeconomic indicators, and social media sentiment. Use this plan as a foundation for evaluating your next trading decision.\n\nProposed Investment Plan: {investment_plan}\n\nLeverage these insights to make an informed and strategic decision.",
}
messages = [
{
"role": "system",
"content": f"""You are a trading agent analyzing market data to make investment decisions. Based on your analysis, provide a specific recommendation to buy, sell, or hold. End with a firm decision and always conclude your response with 'FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL**' to confirm your recommendation. Apply lessons from past decisions to strengthen your analysis. Here are reflections from similar situations you traded in and the lessons learned: {past_memory_str}""",
"content": (
"You are a trading agent analyzing market data to make investment decisions. "
"Based on your analysis, provide a specific recommendation to buy, sell, or hold. "
"Anchor your reasoning in the analysts' reports and the research plan."
),
},
{
"role": "user",
"content": (
f"Based on a comprehensive analysis by a team of analysts, here is an investment "
f"plan tailored for {company_name}. {instrument_context} This plan incorporates "
f"insights from current technical market trends, macroeconomic indicators, and "
f"social media sentiment. Use this plan as a foundation for evaluating your next "
f"trading decision.\n\nProposed Investment Plan: {investment_plan}\n\n"
f"Leverage these insights to make an informed and strategic decision."
),
},
context,
]
result = llm.invoke(messages)
trader_plan = invoke_structured_or_freetext(
structured_llm,
llm,
messages,
render_trader_proposal,
"Trader",
)
return {
"messages": [result],
"trader_investment_plan": result.content,
"messages": [AIMessage(content=trader_plan)],
"trader_investment_plan": trader_plan,
"sender": name,
}
@@ -71,3 +71,4 @@ class AgentState(MessagesState):
RiskDebateState, "Current state of the debate on evaluating risk"
]
final_trade_decision: Annotated[str, "Final decision made by the Risk Analysts"]
past_context: Annotated[str, "Memory log context injected at run start (same-ticker decisions + cross-ticker lessons)"]
+273 -117
View File
@@ -1,144 +1,300 @@
"""Financial situation memory using BM25 for lexical similarity matching.
"""Append-only markdown decision log for TradingAgents."""
Uses BM25 (Best Matching 25) algorithm for retrieval - no API calls,
no token limits, works offline with any LLM provider.
"""
from rank_bm25 import BM25Okapi
from typing import List, Tuple
from typing import List, Optional
from pathlib import Path
import re
from tradingagents.agents.utils.rating import parse_rating
class FinancialSituationMemory:
"""Memory system for storing and retrieving financial situations using BM25."""
def __init__(self, name: str, config: dict = None):
"""Initialize the memory system.
class TradingMemoryLog:
"""Append-only markdown log of trading decisions and reflections."""
Args:
name: Name identifier for this memory instance
config: Configuration dict (kept for API compatibility, not used for BM25)
"""
self.name = name
self.documents: List[str] = []
self.recommendations: List[str] = []
self.bm25 = None
# HTML comment: cannot appear in LLM prose output, safe as a hard delimiter
_SEPARATOR = "\n\n<!-- ENTRY_END -->\n\n"
# Precompiled patterns — avoids re-compilation on every load_entries() call
_DECISION_RE = re.compile(r"DECISION:\n(.*?)(?=\nREFLECTION:|\Z)", re.DOTALL)
_REFLECTION_RE = re.compile(r"REFLECTION:\n(.*?)$", re.DOTALL)
def _tokenize(self, text: str) -> List[str]:
"""Tokenize text for BM25 indexing.
def __init__(self, config: dict = None):
cfg = config or {}
self._log_path = None
path = cfg.get("memory_log_path")
if path:
self._log_path = Path(path).expanduser()
self._log_path.parent.mkdir(parents=True, exist_ok=True)
# Optional cap on resolved entries. None disables rotation.
self._max_entries = cfg.get("memory_log_max_entries")
Simple whitespace + punctuation tokenization with lowercasing.
"""
# Lowercase and split on non-alphanumeric characters
tokens = re.findall(r'\b\w+\b', text.lower())
return tokens
# --- Write path (Phase A) ---
def _rebuild_index(self):
"""Rebuild the BM25 index after adding documents."""
if self.documents:
tokenized_docs = [self._tokenize(doc) for doc in self.documents]
self.bm25 = BM25Okapi(tokenized_docs)
else:
self.bm25 = None
def store_decision(
self,
ticker: str,
trade_date: str,
final_trade_decision: str,
) -> None:
"""Append pending entry at end of propagate(). No LLM call."""
if not self._log_path:
return
# Idempotency guard: fast raw-text scan instead of full parse
if self._log_path.exists():
raw = self._log_path.read_text(encoding="utf-8")
for line in raw.splitlines():
if line.startswith(f"[{trade_date} | {ticker} |") and line.endswith("| pending]"):
return
rating = parse_rating(final_trade_decision)
tag = f"[{trade_date} | {ticker} | {rating} | pending]"
entry = f"{tag}\n\nDECISION:\n{final_trade_decision}{self._SEPARATOR}"
with open(self._log_path, "a", encoding="utf-8") as f:
f.write(entry)
def add_situations(self, situations_and_advice: List[Tuple[str, str]]):
"""Add financial situations and their corresponding advice.
# --- Read path (Phase A) ---
Args:
situations_and_advice: List of tuples (situation, recommendation)
"""
for situation, recommendation in situations_and_advice:
self.documents.append(situation)
self.recommendations.append(recommendation)
# Rebuild BM25 index with new documents
self._rebuild_index()
def get_memories(self, current_situation: str, n_matches: int = 1) -> List[dict]:
"""Find matching recommendations using BM25 similarity.
Args:
current_situation: The current financial situation to match against
n_matches: Number of top matches to return
Returns:
List of dicts with matched_situation, recommendation, and similarity_score
"""
if not self.documents or self.bm25 is None:
def load_entries(self) -> List[dict]:
"""Parse all entries from log. Returns list of dicts."""
if not self._log_path or not self._log_path.exists():
return []
text = self._log_path.read_text(encoding="utf-8")
raw_entries = [e.strip() for e in text.split(self._SEPARATOR) if e.strip()]
entries = []
for raw in raw_entries:
parsed = self._parse_entry(raw)
if parsed:
entries.append(parsed)
return entries
# Tokenize query
query_tokens = self._tokenize(current_situation)
def get_pending_entries(self) -> List[dict]:
"""Return entries with outcome:pending (for Phase B)."""
return [e for e in self.load_entries() if e.get("pending")]
# Get BM25 scores for all documents
scores = self.bm25.get_scores(query_tokens)
def get_past_context(self, ticker: str, n_same: int = 5, n_cross: int = 3) -> str:
"""Return formatted past context string for agent prompt injection."""
entries = [e for e in self.load_entries() if not e.get("pending")]
if not entries:
return ""
# Get top-n indices sorted by score (descending)
top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:n_matches]
same, cross = [], []
for e in reversed(entries):
if len(same) >= n_same and len(cross) >= n_cross:
break
if e["ticker"] == ticker and len(same) < n_same:
same.append(e)
elif e["ticker"] != ticker and len(cross) < n_cross:
cross.append(e)
# Build results
results = []
max_score = float(scores.max()) if len(scores) > 0 and scores.max() > 0 else 1.0
if not same and not cross:
return ""
for idx in top_indices:
# Normalize score to 0-1 range for consistency
normalized_score = scores[idx] / max_score if max_score > 0 else 0
results.append({
"matched_situation": self.documents[idx],
"recommendation": self.recommendations[idx],
"similarity_score": normalized_score,
})
parts = []
if same:
parts.append(f"Past analyses of {ticker} (most recent first):")
parts.extend(self._format_full(e) for e in same)
if cross:
parts.append("Recent cross-ticker lessons:")
parts.extend(self._format_reflection_only(e) for e in cross)
return "\n\n".join(parts)
return results
# --- Update path (Phase B) ---
def clear(self):
"""Clear all stored memories."""
self.documents = []
self.recommendations = []
self.bm25 = None
def update_with_outcome(
self,
ticker: str,
trade_date: str,
raw_return: float,
alpha_return: float,
holding_days: int,
reflection: str,
) -> None:
"""Replace pending tag and append REFLECTION section using atomic write.
Finds the first pending entry matching (trade_date, ticker), updates
its tag with return figures, and appends a REFLECTION section. Uses
a temp-file + os.replace() so a crash mid-write never corrupts the log.
"""
if not self._log_path or not self._log_path.exists():
return
if __name__ == "__main__":
# Example usage
matcher = FinancialSituationMemory("test_memory")
text = self._log_path.read_text(encoding="utf-8")
blocks = text.split(self._SEPARATOR)
# Example data
example_data = [
(
"High inflation rate with rising interest rates and declining consumer spending",
"Consider defensive sectors like consumer staples and utilities. Review fixed-income portfolio duration.",
),
(
"Tech sector showing high volatility with increasing institutional selling pressure",
"Reduce exposure to high-growth tech stocks. Look for value opportunities in established tech companies with strong cash flows.",
),
(
"Strong dollar affecting emerging markets with increasing forex volatility",
"Hedge currency exposure in international positions. Consider reducing allocation to emerging market debt.",
),
(
"Market showing signs of sector rotation with rising yields",
"Rebalance portfolio to maintain target allocations. Consider increasing exposure to sectors benefiting from higher rates.",
),
]
pending_prefix = f"[{trade_date} | {ticker} |"
raw_pct = f"{raw_return:+.1%}"
alpha_pct = f"{alpha_return:+.1%}"
# Add the example situations and recommendations
matcher.add_situations(example_data)
updated = False
new_blocks = []
for block in blocks:
stripped = block.strip()
if not stripped:
new_blocks.append(block)
continue
# Example query
current_situation = """
Market showing increased volatility in tech sector, with institutional investors
reducing positions and rising interest rates affecting growth stock valuations
"""
lines = stripped.splitlines()
tag_line = lines[0].strip()
try:
recommendations = matcher.get_memories(current_situation, n_matches=2)
if (
not updated
and tag_line.startswith(pending_prefix)
and tag_line.endswith("| pending]")
):
# Parse rating from the existing pending tag
fields = [f.strip() for f in tag_line[1:-1].split("|")]
rating = fields[2]
new_tag = (
f"[{trade_date} | {ticker} | {rating}"
f" | {raw_pct} | {alpha_pct} | {holding_days}d]"
)
rest = "\n".join(lines[1:])
new_blocks.append(
f"{new_tag}\n\n{rest.lstrip()}\n\nREFLECTION:\n{reflection}"
)
updated = True
else:
new_blocks.append(block)
for i, rec in enumerate(recommendations, 1):
print(f"\nMatch {i}:")
print(f"Similarity Score: {rec['similarity_score']:.2f}")
print(f"Matched Situation: {rec['matched_situation']}")
print(f"Recommendation: {rec['recommendation']}")
if not updated:
return
except Exception as e:
print(f"Error during recommendation: {str(e)}")
new_blocks = self._apply_rotation(new_blocks)
new_text = self._SEPARATOR.join(new_blocks)
tmp_path = self._log_path.with_suffix(".tmp")
tmp_path.write_text(new_text, encoding="utf-8")
tmp_path.replace(self._log_path)
def batch_update_with_outcomes(self, updates: List[dict]) -> None:
"""Apply multiple outcome updates in a single read + atomic write.
Each element of updates must have keys: ticker, trade_date,
raw_return, alpha_return, holding_days, reflection.
"""
if not self._log_path or not self._log_path.exists() or not updates:
return
text = self._log_path.read_text(encoding="utf-8")
blocks = text.split(self._SEPARATOR)
# Build lookup keyed by (trade_date, ticker) for O(1) dispatch
update_map = {(u["trade_date"], u["ticker"]): u for u in updates}
new_blocks = []
for block in blocks:
stripped = block.strip()
if not stripped:
new_blocks.append(block)
continue
lines = stripped.splitlines()
tag_line = lines[0].strip()
matched = False
for (trade_date, ticker), upd in list(update_map.items()):
pending_prefix = f"[{trade_date} | {ticker} |"
if tag_line.startswith(pending_prefix) and tag_line.endswith("| pending]"):
fields = [f.strip() for f in tag_line[1:-1].split("|")]
rating = fields[2]
raw_pct = f"{upd['raw_return']:+.1%}"
alpha_pct = f"{upd['alpha_return']:+.1%}"
new_tag = (
f"[{trade_date} | {ticker} | {rating}"
f" | {raw_pct} | {alpha_pct} | {upd['holding_days']}d]"
)
rest = "\n".join(lines[1:])
new_blocks.append(
f"{new_tag}\n\n{rest.lstrip()}\n\nREFLECTION:\n{upd['reflection']}"
)
del update_map[(trade_date, ticker)]
matched = True
break
if not matched:
new_blocks.append(block)
new_blocks = self._apply_rotation(new_blocks)
new_text = self._SEPARATOR.join(new_blocks)
tmp_path = self._log_path.with_suffix(".tmp")
tmp_path.write_text(new_text, encoding="utf-8")
tmp_path.replace(self._log_path)
# --- Helpers ---
def _apply_rotation(self, blocks: List[str]) -> List[str]:
"""Drop oldest resolved blocks when their count exceeds max_entries.
Pending blocks are always kept (they represent unprocessed work).
Returns ``blocks`` unchanged when rotation is disabled or under cap.
"""
if not self._max_entries or self._max_entries <= 0:
return blocks
# Tag each block with (kept, is_resolved) by parsing tag-line markers.
decisions = []
for block in blocks:
stripped = block.strip()
if not stripped:
decisions.append((block, False))
continue
tag_line = stripped.splitlines()[0].strip()
is_resolved = (
tag_line.startswith("[")
and tag_line.endswith("]")
and not tag_line.endswith("| pending]")
)
decisions.append((block, is_resolved))
resolved_count = sum(1 for _, r in decisions if r)
if resolved_count <= self._max_entries:
return blocks
to_drop = resolved_count - self._max_entries
kept: List[str] = []
for block, is_resolved in decisions:
if is_resolved and to_drop > 0:
to_drop -= 1
continue
kept.append(block)
return kept
def _parse_entry(self, raw: str) -> Optional[dict]:
lines = raw.strip().splitlines()
if not lines:
return None
tag_line = lines[0].strip()
if not (tag_line.startswith("[") and tag_line.endswith("]")):
return None
fields = [f.strip() for f in tag_line[1:-1].split("|")]
if len(fields) < 4:
return None
entry = {
"date": fields[0],
"ticker": fields[1],
"rating": fields[2],
"pending": fields[3] == "pending",
"raw": fields[3] if fields[3] != "pending" else None,
"alpha": fields[4] if len(fields) > 4 else None,
"holding": fields[5] if len(fields) > 5 else None,
}
body = "\n".join(lines[1:]).strip()
decision_match = self._DECISION_RE.search(body)
reflection_match = self._REFLECTION_RE.search(body)
entry["decision"] = decision_match.group(1).strip() if decision_match else ""
entry["reflection"] = reflection_match.group(1).strip() if reflection_match else ""
return entry
def _format_full(self, e: dict) -> str:
raw = e["raw"] or "n/a"
alpha = e["alpha"] or "n/a"
holding = e["holding"] or "n/a"
tag = f"[{e['date']} | {e['ticker']} | {e['rating']} | {raw} | {alpha} | {holding}]"
parts = [tag, f"DECISION:\n{e['decision']}"]
if e["reflection"]:
parts.append(f"REFLECTION:\n{e['reflection']}")
return "\n\n".join(parts)
def _format_reflection_only(self, e: dict) -> str:
tag = f"[{e['date']} | {e['ticker']} | {e['rating']} | {e['raw'] or 'n/a'}]"
if e["reflection"]:
return f"{tag}\n{e['reflection']}"
text = e["decision"][:300]
suffix = "..." if len(e["decision"]) > 300 else ""
return f"{tag}\n{text}{suffix}"
+50
View File
@@ -0,0 +1,50 @@
"""Shared 5-tier rating vocabulary and a deterministic heuristic parser.
The same five-tier scale (Buy, Overweight, Hold, Underweight, Sell) is used by:
- The Research Manager (investment plan recommendation)
- The Portfolio Manager (final position decision)
- The signal processor (rating extracted for downstream consumers)
- The memory log (rating tag stored alongside each decision entry)
Centralising it here avoids drift between those call sites.
"""
from __future__ import annotations
import re
from typing import Tuple
# Canonical, ordered 5-tier scale (most bullish to most bearish).
RATINGS_5_TIER: Tuple[str, ...] = (
"Buy", "Overweight", "Hold", "Underweight", "Sell",
)
_RATING_SET = {r.lower() for r in RATINGS_5_TIER}
# Matches "Rating: X" / "rating - X" / "Rating: **X**" — tolerates markdown
# bold wrappers and either a colon or hyphen separator.
_RATING_LABEL_RE = re.compile(r"rating.*?[:\-][\s*]*(\w+)", re.IGNORECASE)
def parse_rating(text: str, default: str = "Hold") -> str:
"""Heuristically extract a 5-tier rating from prose text.
Two-pass strategy:
1. Look for an explicit "Rating: X" label (tolerant of markdown bold).
2. Fall back to the first 5-tier rating word found anywhere in the text.
Returns a Title-cased rating string, or ``default`` if no rating word appears.
"""
for line in text.splitlines():
m = _RATING_LABEL_RE.search(line)
if m and m.group(1).lower() in _RATING_SET:
return m.group(1).capitalize()
for line in text.splitlines():
for word in line.lower().split():
clean = word.strip("*:.,")
if clean in _RATING_SET:
return clean.capitalize()
return default
+73
View File
@@ -0,0 +1,73 @@
"""Shared helpers for invoking an agent with structured output and a graceful fallback.
The Portfolio Manager, Trader, and Research Manager all follow the same
canonical pattern:
1. At agent creation, wrap the LLM with ``with_structured_output(Schema)``
so the model returns a typed Pydantic instance. If the provider does
not support structured output (rare; mostly older Ollama models), the
wrap is skipped and the agent uses free-text generation instead.
2. At invocation, run the structured call and render the result back to
markdown. If the structured call itself fails for any reason
(malformed JSON from a weak model, transient provider issue), fall
back to a plain ``llm.invoke`` so the pipeline never blocks.
Centralising the pattern here keeps the agent factories small and ensures
all three agents log the same warnings when fallback fires.
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Optional, TypeVar
from pydantic import BaseModel
logger = logging.getLogger(__name__)
T = TypeVar("T", bound=BaseModel)
def bind_structured(llm: Any, schema: type[T], agent_name: str) -> Optional[Any]:
"""Return ``llm.with_structured_output(schema)`` or ``None`` if unsupported.
Logs a warning when the binding fails so the user understands the agent
will use free-text generation for every call instead of one-shot fallback.
"""
try:
return llm.with_structured_output(schema)
except (NotImplementedError, AttributeError) as exc:
logger.warning(
"%s: provider does not support with_structured_output (%s); "
"falling back to free-text generation",
agent_name, exc,
)
return None
def invoke_structured_or_freetext(
structured_llm: Optional[Any],
plain_llm: Any,
prompt: Any,
render: Callable[[T], str],
agent_name: str,
) -> str:
"""Run the structured call and render to markdown; fall back to free-text on any failure.
``prompt`` is whatever the underlying LLM accepts (a string for chat
invocations, a list of message dicts for chat models that take that
shape). The same value is forwarded to the free-text path so the
fallback sees the same input the structured call did.
"""
if structured_llm is not None:
try:
result = structured_llm.invoke(prompt)
return render(result)
except Exception as exc:
logger.warning(
"%s: structured-output invocation failed (%s); retrying once as free text",
agent_name, exc,
)
response = plain_llm.invoke(prompt)
return response.content