From 82ec67a024a4b6e4c8d49dffe8135f2ef98053bd Mon Sep 17 00:00:00 2001 From: Garrick Date: Tue, 24 Mar 2026 17:56:29 -0700 Subject: [PATCH] feat: add position sizing and structured stock synthesis --- tests/test_position_sizing_analyst.py | 322 ++++++++++++++++++ tests/test_stock_role_wiring.py | 292 ++++++++-------- tradingagents/agents/__init__.py | 2 + .../analysts/position_sizing_analyst.py | 146 ++++++++ .../agents/managers/portfolio_manager.py | 8 +- .../agents/managers/research_manager.py | 12 +- .../agents/researchers/bear_researcher.py | 12 +- .../agents/researchers/bull_researcher.py | 12 +- .../agents/risk_mgmt/aggressive_debator.py | 8 +- .../agents/risk_mgmt/conservative_debator.py | 8 +- .../agents/risk_mgmt/neutral_debator.py | 8 +- tradingagents/agents/utils/agent_states.py | 10 + tradingagents/agents/utils/agent_utils.py | 52 +++ tradingagents/agents/utils/sizing_tools.py | 55 +++ tradingagents/graph/propagation.py | 1 + tradingagents/graph/setup.py | 11 + tradingagents/graph/trading_graph.py | 14 + 17 files changed, 807 insertions(+), 166 deletions(-) create mode 100644 tests/test_position_sizing_analyst.py create mode 100644 tradingagents/agents/analysts/position_sizing_analyst.py create mode 100644 tradingagents/agents/utils/sizing_tools.py diff --git a/tests/test_position_sizing_analyst.py b/tests/test_position_sizing_analyst.py new file mode 100644 index 00000000..598b62f6 --- /dev/null +++ b/tests/test_position_sizing_analyst.py @@ -0,0 +1,322 @@ +import json + +from tradingagents.graph.setup import GraphSetup +from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.graph.propagation import Propagator + + +class DummyStateGraph: + def __init__(self, _state_type): + self.nodes = {} + self.conditional_edges = {} + + def add_node(self, name, node): + self.nodes[name] = node + + def add_edge(self, *_args, **_kwargs): + return None + + def add_conditional_edges(self, source, condition, destinations): + self.conditional_edges[source] = { + "condition": condition, + "destinations": destinations, + } + + def compile(self): + return { + "nodes": self.nodes, + "conditional_edges": self.conditional_edges, + } + + +class DummyToolNode: + def __init__(self, tools): + self.tools = tools + + +def test_position_sizing_tools_route_to_vendor(monkeypatch): + import tradingagents.dataflows.interface as interface + from tradingagents.agents.utils.sizing_tools import ( + get_sizing_fundamentals, + get_sizing_indicator, + get_sizing_price_history, + ) + + calls = [] + + def fake_route_to_vendor(method, *args, **kwargs): + calls.append((method, args, kwargs)) + return f"{method}-result" + + monkeypatch.setattr(interface, "route_to_vendor", fake_route_to_vendor) + + assert ( + get_sizing_fundamentals.invoke({"ticker": "AAPL", "curr_date": "2026-03-24"}) + == "get_fundamentals-result" + ) + assert ( + get_sizing_indicator.invoke( + { + "symbol": "AAPL", + "indicator": "atr", + "curr_date": "2026-03-24", + "look_back_days": 30, + } + ) + == "get_indicators-result" + ) + assert ( + get_sizing_price_history.invoke( + { + "symbol": "AAPL", + "start_date": "2026-02-01", + "end_date": "2026-03-24", + } + ) + == "get_stock_data-result" + ) + assert calls == [ + ("get_fundamentals", (), {"ticker": "AAPL", "curr_date": "2026-03-24"}), + ( + "get_indicators", + (), + { + "symbol": "AAPL", + "indicator": "atr", + "curr_date": "2026-03-24", + "look_back_days": 30, + }, + ), + ( + "get_stock_data", + (), + { + "symbol": "AAPL", + "start_date": "2026-02-01", + "end_date": "2026-03-24", + }, + ), + ] + + +def test_graph_setup_wires_position_sizing_analyst_and_tools(monkeypatch): + recorded_llms = {} + + monkeypatch.setattr("tradingagents.graph.setup.StateGraph", DummyStateGraph) + monkeypatch.setattr("tradingagents.graph.setup.create_msg_delete", lambda: "delete") + + def make_factory(node_name): + def factory(llm, *_args): + recorded_llms[node_name] = llm + return node_name + + return factory + + monkeypatch.setattr( + "tradingagents.graph.setup.create_market_analyst", + make_factory("Market Analyst"), + ) + monkeypatch.setattr( + "tradingagents.graph.setup.create_position_sizing_analyst", + make_factory("Position_sizing Analyst"), + ) + monkeypatch.setattr( + "tradingagents.graph.setup.create_social_media_analyst", + make_factory("Social Analyst"), + ) + monkeypatch.setattr( + "tradingagents.graph.setup.create_news_analyst", + make_factory("News Analyst"), + ) + monkeypatch.setattr( + "tradingagents.graph.setup.create_fundamentals_analyst", + make_factory("Fundamentals Analyst"), + ) + monkeypatch.setattr( + "tradingagents.graph.setup.create_bull_researcher", + make_factory("Bull Researcher"), + ) + monkeypatch.setattr( + "tradingagents.graph.setup.create_bear_researcher", + make_factory("Bear Researcher"), + ) + monkeypatch.setattr( + "tradingagents.graph.setup.create_research_manager", + make_factory("Research Manager"), + ) + monkeypatch.setattr( + "tradingagents.graph.setup.create_trader", + make_factory("Trader"), + ) + monkeypatch.setattr( + "tradingagents.graph.setup.create_aggressive_debator", + make_factory("Aggressive Analyst"), + ) + monkeypatch.setattr( + "tradingagents.graph.setup.create_neutral_debator", + make_factory("Neutral Analyst"), + ) + monkeypatch.setattr( + "tradingagents.graph.setup.create_conservative_debator", + make_factory("Conservative Analyst"), + ) + monkeypatch.setattr( + "tradingagents.graph.setup.create_portfolio_manager", + make_factory("Portfolio Manager"), + ) + + class PartialConditionalLogic: + def should_continue_market(self, _state): + return "Msg Clear Market" + + def should_continue_position_sizing(self, _state): + return "Msg Clear Position_sizing" + + def should_continue_debate(self, _state): + return "Research Manager" + + def should_continue_risk_analysis(self, _state): + return "Portfolio Manager" + + setup = GraphSetup( + quick_thinking_llm="quick-llm", + deep_thinking_llm="deep-llm", + tool_nodes={"market": "market-tools", "position_sizing": "position-tools"}, + bull_memory=object(), + bear_memory=object(), + trader_memory=object(), + invest_judge_memory=object(), + portfolio_manager_memory=object(), + conditional_logic=PartialConditionalLogic(), + role_llms={"position_sizing": "position-llm"}, + ) + + graph = setup.setup_graph(selected_analysts=["market", "position_sizing"]) + + assert recorded_llms["Position_sizing Analyst"] == "position-llm" + assert graph["nodes"]["Position_sizing Analyst"] == "Position_sizing Analyst" + assert graph["nodes"]["tools_position_sizing"] == "position-tools" + assert "Position_sizing Analyst" in graph["conditional_edges"] + + +def test_trading_graph_creates_position_sizing_tool_node(monkeypatch): + monkeypatch.setattr("tradingagents.graph.trading_graph.ToolNode", DummyToolNode) + + graph = TradingAgentsGraph.__new__(TradingAgentsGraph) + tool_nodes = TradingAgentsGraph._create_tool_nodes(graph) + + assert [tool.name for tool in tool_nodes["position_sizing"].tools] == [ + "get_sizing_fundamentals", + "get_sizing_indicator", + "get_sizing_price_history", + ] + + +class DummyPrompt: + def __init__(self, result): + self.result = result + + def partial(self, **_kwargs): + return self + + def __or__(self, _other): + return DummyChain(self.result) + + +class DummyChain: + def __init__(self, result): + self.result = result + + def invoke(self, _messages): + return self.result + + +class DummyResult: + def __init__(self, content, tool_calls): + self.content = content + self.tool_calls = tool_calls + + +class DummyLLM: + def __init__(self, result): + self.result = result + self.bound_tool_names = [] + + def bind_tools(self, tools): + self.bound_tool_names = [tool.name for tool in tools] + return object() + + +def test_position_sizing_analyst_returns_structured_data(monkeypatch): + from tradingagents.agents.analysts.position_sizing_analyst import ( + create_position_sizing_analyst, + ) + + result = DummyResult( + content="""## Position Sizing Summary + +High conviction setup with a staged entry and explicit loss budget. + + ```json + { + "conviction": "high", + "target_weight_pct": 8, + "initial_weight_pct": 4, + "max_loss_pct": 1.5, + "sizing_rationale": "Strong setup with manageable downside and room to scale." + } + ```""", + tool_calls=[], + ) + + monkeypatch.setattr( + "tradingagents.agents.analysts.position_sizing_analyst.ChatPromptTemplate.from_messages", + lambda *_args, **_kwargs: DummyPrompt(result), + ) + + llm = DummyLLM(result) + node = create_position_sizing_analyst(llm) + + payload = node( + { + "messages": [("human", "AAPL")], + "trade_date": "2026-03-24", + "company_of_interest": "AAPL", + } + ) + + assert llm.bound_tool_names == [ + "get_sizing_fundamentals", + "get_sizing_indicator", + "get_sizing_price_history", + ] + assert payload["position_sizing_report"] == result.content + assert payload["position_sizing_data"] == { + "ticker": "AAPL", + "analysis_date": "2026-03-24", + "conviction": "high", + "target_weight_pct": 8, + "initial_weight_pct": 4, + "max_loss_pct": 1.5, + "sizing_rationale": "Strong setup with manageable downside and room to scale.", + } + + +def test_position_sizing_state_fields_are_declared_and_seeded(): + from tradingagents.agents.utils.agent_states import AgentState + + assert "position_sizing_report" in AgentState.__annotations__ + assert "position_sizing_data" in AgentState.__annotations__ + + state = Propagator().create_initial_state("AAPL", "2026-03-24") + + assert state["position_sizing_report"] == "" + assert state["position_sizing_data"] == { + "ticker": "", + "analysis_date": "", + "conviction": "", + "target_weight_pct": None, + "initial_weight_pct": None, + "max_loss_pct": None, + "sizing_rationale": "", + } diff --git a/tests/test_stock_role_wiring.py b/tests/test_stock_role_wiring.py index 4ce213d6..88a01e11 100644 --- a/tests/test_stock_role_wiring.py +++ b/tests/test_stock_role_wiring.py @@ -1,59 +1,17 @@ from copy import deepcopy -from langgraph.graph import END, START, StateGraph - -from tradingagents.agents.utils.agent_states import AgentState from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager from tradingagents.agents.managers.research_manager import create_research_manager +from tradingagents.agents.researchers.bear_researcher import create_bear_researcher +from tradingagents.agents.researchers.bull_researcher import create_bull_researcher +from tradingagents.agents.risk_mgmt.aggressive_debator import create_aggressive_debator +from tradingagents.agents.risk_mgmt.conservative_debator import ( + create_conservative_debator, +) +from tradingagents.agents.risk_mgmt.neutral_debator import create_neutral_debator from tradingagents.graph.propagation import Propagator -EXPECTED_VALUATION_DATA = { - "fair_value_range": {"low": None, "high": None}, - "expected_return_pct": None, - "primary_method": "", - "thesis": "", -} - -EXPECTED_SEGMENT_DATA = { - "ticker": "", - "analysis_date": "", - "business_unit_decomposition": [], - "segment_economics": {}, - "value_driver_map": [], -} - -EXPECTED_SCENARIO_CATALYST_DATA = { - "ticker": "", - "analysis_date": "", - "scenario_map": [], - "dated_catalyst_map": [], - "invalidation_triggers": [], -} - -EXPECTED_POSITION_SIZING_DATA = { - "conviction": "", - "target_weight_pct": None, - "initial_weight_pct": None, - "max_loss_pct": None, -} - -EXPECTED_CHIEF_ANALYST_DATA = { - "action": "", - "summary": "", - "thesis": "", - "confidence": "", -} - -STRUCTURED_PASSTHROUGH_KEYS = { - "valuation_data", - "segment_data", - "scenario_catalyst_data", - "position_sizing_data", - "chief_analyst_data", -} - - class DummyMemory: def get_memories(self, _situation, n_matches=2): return [] @@ -64,139 +22,159 @@ class DummyResponse: self.content = content -class DummyLLM: +class RecordingLLM: def __init__(self, content): self.content = content + self.prompts = [] - def invoke(self, _prompt): + def invoke(self, prompt): + self.prompts.append(prompt) return DummyResponse(self.content) -def assert_structured_stock_fields(payload): - assert payload["valuation_data"] == EXPECTED_VALUATION_DATA - assert payload["segment_data"] == EXPECTED_SEGMENT_DATA - assert payload["scenario_catalyst_data"] == EXPECTED_SCENARIO_CATALYST_DATA - assert payload["position_sizing_data"] == EXPECTED_POSITION_SIZING_DATA - assert payload["chief_analyst_data"] == EXPECTED_CHIEF_ANALYST_DATA +def build_state(): + state = Propagator().create_initial_state("NVDA", "2026-03-24") + state.update( + { + "market_report": "Market report", + "sentiment_report": "Sentiment report", + "news_report": "News report", + "fundamentals_report": "Fundamentals report", + "segment_report": "Segment report", + "segment_data": { + "ticker": "NVDA", + "analysis_date": "2026-03-24", + "business_unit_decomposition": [ + { + "segment": "Alpha Widget", + "revenue_share_pct": 61, + "growth_trend": "expanding", + "strategic_role": "primary compute engine", + } + ], + "segment_economics": { + "margin_profile": "elite", + "capital_intensity": "moderate", + "cyclicality": "medium", + }, + "value_driver_map": [ + { + "driver": "AI rack demand", + "impacted_segments": ["Alpha Widget"], + "direction": "positive", + "horizon": "6-12 months", + "evidence": "backlog remains elevated", + } + ], + }, + "scenario_catalyst_report": "Scenario report", + "scenario_catalyst_data": { + "ticker": "NVDA", + "analysis_date": "2026-03-24", + "scenario_map": [ + { + "name": "bull", + "probability_pct": 35, + "thesis": "AI demand acceleration", + "valuation_implication": "re-rating higher", + "signposts": ["order lead-times extend"], + } + ], + "dated_catalyst_map": [ + { + "catalyst": "Lunar-launch catalyst", + "date_or_window": "2026-05", + "related_scenarios": ["bull"], + "expected_impact": "positive", + "confidence": "medium", + } + ], + "invalidation_triggers": [ + { + "trigger": "gross margin drops below 70%", + "affected_scenarios": ["bull"], + "severity": "high", + "evidence_to_watch": "earnings release", + } + ], + }, + "position_sizing_report": "Sizing report", + "position_sizing_data": { + "ticker": "NVDA", + "analysis_date": "2026-03-24", + "conviction": "high", + "target_weight_pct": 11.5, + "initial_weight_pct": 6.0, + "max_loss_pct": 1.25, + "sizing_rationale": "Stage in but preserve dry powder for confirmation.", + }, + "investment_plan": "Existing investment plan", + "trader_investment_plan": "Trader plan", + } + ) + return state -def assert_manager_update_omits_structured_passthrough( - payload, expected_present_keys -): - for key in expected_present_keys: - assert key in payload - assert STRUCTURED_PASSTHROUGH_KEYS.isdisjoint(payload) +def assert_prompt_mentions_structured_fields(prompt): + text = str(prompt) + assert "Prioritize the structured stock underwriting outputs below as primary evidence." in text + assert "Alpha Widget" in text + assert "AI rack demand" in text + assert "Lunar-launch catalyst" in text + assert "revenue_share_pct" in text + assert "probability_pct" in text + assert "target_weight_pct" in text + assert "11.5" in text + assert "1.25" in text -def compile_single_node_graph(node_name, node): - workflow = StateGraph(AgentState) - workflow.add_node(node_name, node) - workflow.add_edge(START, node_name) - workflow.add_edge(node_name, END) - return workflow.compile() - - -def test_propagator_initializes_structured_stock_underwriting_fields(): - initial_state = Propagator().create_initial_state("NVDA", "2026-03-24") - - assert_structured_stock_fields(initial_state) - - -def test_research_manager_update_omits_structured_stock_passthrough_fields(monkeypatch): +def test_research_side_prompts_consume_structured_fields(monkeypatch): monkeypatch.setattr( "tradingagents.agents.managers.research_manager.build_instrument_context", lambda _ticker: "instrument context", ) - state = Propagator().create_initial_state("NVDA", "2026-03-24") - research_manager = create_research_manager( - DummyLLM("Research manager output"), - DummyMemory(), - ) - research_result = research_manager(deepcopy(state)) + state = build_state() + bull_llm = RecordingLLM("Bull case") + create_bull_researcher(bull_llm, DummyMemory())(deepcopy(state)) + assert_prompt_mentions_structured_fields(bull_llm.prompts[0]) + + bear_llm = RecordingLLM("Bear case") + create_bear_researcher(bear_llm, DummyMemory())(deepcopy(state)) + assert_prompt_mentions_structured_fields(bear_llm.prompts[0]) + + research_llm = RecordingLLM("Research manager output") + research_result = create_research_manager(research_llm, DummyMemory())( + deepcopy(state) + ) + assert_prompt_mentions_structured_fields(research_llm.prompts[0]) assert research_result["investment_plan"] == "Research manager output" - assert research_result["investment_debate_state"]["judge_decision"] == ( - "Research manager output" - ) - assert_manager_update_omits_structured_passthrough( - research_result, - {"investment_debate_state", "investment_plan"}, - ) -def test_research_manager_graph_preserves_structured_stock_underwriting_fields( - monkeypatch, -): - monkeypatch.setattr( - "tradingagents.agents.managers.research_manager.build_instrument_context", - lambda _ticker: "instrument context", - ) - - research_manager = create_research_manager( - DummyLLM("Research manager output"), - DummyMemory(), - ) - state = Propagator().create_initial_state("NVDA", "2026-03-24") - - final_state = compile_single_node_graph("Research Manager", research_manager).invoke( - state - ) - - assert final_state["investment_plan"] == "Research manager output" - assert final_state["investment_debate_state"]["judge_decision"] == ( - "Research manager output" - ) - assert_structured_stock_fields(final_state) - - -def test_portfolio_manager_update_omits_structured_stock_passthrough_fields( - monkeypatch, -): +def test_risk_and_portfolio_prompts_consume_structured_fields(monkeypatch): monkeypatch.setattr( "tradingagents.agents.managers.portfolio_manager.build_instrument_context", lambda _ticker: "instrument context", ) - state = Propagator().create_initial_state("NVDA", "2026-03-24") - state["investment_plan"] = "Existing investment plan" - portfolio_manager = create_portfolio_manager( - DummyLLM("Portfolio manager output"), - DummyMemory(), - ) - portfolio_result = portfolio_manager(deepcopy(state)) + state = build_state() - assert portfolio_result["final_trade_decision"] == "Portfolio manager output" - assert portfolio_result["risk_debate_state"]["judge_decision"] == ( - "Portfolio manager output" - ) - assert_manager_update_omits_structured_passthrough( - portfolio_result, - {"risk_debate_state", "final_trade_decision"}, - ) + aggressive_llm = RecordingLLM("Aggressive case") + create_aggressive_debator(aggressive_llm)(deepcopy(state)) + assert_prompt_mentions_structured_fields(aggressive_llm.prompts[0]) + conservative_llm = RecordingLLM("Conservative case") + create_conservative_debator(conservative_llm)(deepcopy(state)) + assert_prompt_mentions_structured_fields(conservative_llm.prompts[0]) -def test_portfolio_manager_graph_preserves_structured_stock_underwriting_fields( - monkeypatch, -): - monkeypatch.setattr( - "tradingagents.agents.managers.portfolio_manager.build_instrument_context", - lambda _ticker: "instrument context", + neutral_llm = RecordingLLM("Neutral case") + create_neutral_debator(neutral_llm)(deepcopy(state)) + assert_prompt_mentions_structured_fields(neutral_llm.prompts[0]) + + portfolio_llm = RecordingLLM("Portfolio output") + portfolio_result = create_portfolio_manager(portfolio_llm, DummyMemory())( + deepcopy(state) ) - - portfolio_manager = create_portfolio_manager( - DummyLLM("Portfolio manager output"), - DummyMemory(), - ) - state = Propagator().create_initial_state("NVDA", "2026-03-24") - state["investment_plan"] = "Existing investment plan" - - final_state = compile_single_node_graph( - "Portfolio Manager", portfolio_manager - ).invoke(state) - - assert final_state["final_trade_decision"] == "Portfolio manager output" - assert final_state["risk_debate_state"]["judge_decision"] == ( - "Portfolio manager output" - ) - assert_structured_stock_fields(final_state) + assert_prompt_mentions_structured_fields(portfolio_llm.prompts[0]) + assert portfolio_result["final_trade_decision"] == "Portfolio output" diff --git a/tradingagents/agents/__init__.py b/tradingagents/agents/__init__.py index 8a4b68f6..43fc3dcc 100644 --- a/tradingagents/agents/__init__.py +++ b/tradingagents/agents/__init__.py @@ -7,6 +7,7 @@ from .analysts.factor_rule_analyst import create_factor_rule_analyst from .analysts.macro_analyst import create_macro_analyst from .analysts.market_analyst import create_market_analyst from .analysts.news_analyst import create_news_analyst +from .analysts.position_sizing_analyst import create_position_sizing_analyst from .analysts.scenario_catalyst_analyst import create_scenario_catalyst_analyst from .analysts.segment_analyst import create_segment_analyst from .analysts.social_media_analyst import create_social_media_analyst @@ -39,6 +40,7 @@ __all__ = [ "create_market_analyst", "create_neutral_debator", "create_news_analyst", + "create_position_sizing_analyst", "create_scenario_catalyst_analyst", "create_segment_analyst", "create_valuation_analyst", diff --git a/tradingagents/agents/analysts/position_sizing_analyst.py b/tradingagents/agents/analysts/position_sizing_analyst.py new file mode 100644 index 00000000..7fa68d1f --- /dev/null +++ b/tradingagents/agents/analysts/position_sizing_analyst.py @@ -0,0 +1,146 @@ +import json +import re +from datetime import datetime, timedelta + +from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder + +from tradingagents.agents.utils.agent_utils import ( + build_instrument_context, + get_sizing_fundamentals, + get_sizing_indicator, + get_sizing_price_history, +) + + +def _extract_position_sizing_payload(report: str) -> dict: + if not report: + return {} + + for match in re.finditer( + r"```(?:\s*([A-Za-z]+))?\s*(\{.*?\})\s*```", + report, + re.DOTALL, + ): + label = (match.group(1) or "").strip().lower() + if label and label != "json": + continue + try: + payload = json.loads(match.group(2)) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + return payload + + decoder = json.JSONDecoder() + for brace_match in re.finditer(r"\{", report): + candidate = report[brace_match.start() :].lstrip() + try: + payload, _ = decoder.raw_decode(candidate) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + return payload + + return {} + + +def _build_position_sizing_data(ticker: str, analysis_date: str, report: str) -> dict: + payload = _extract_position_sizing_payload(report) + + conviction = payload.get("conviction", "") + target_weight_pct = payload.get("target_weight_pct") + initial_weight_pct = payload.get("initial_weight_pct") + max_loss_pct = payload.get("max_loss_pct") + sizing_rationale = payload.get("sizing_rationale", "") + + if not isinstance(conviction, str): + conviction = "" + if not isinstance(target_weight_pct, (int, float)): + target_weight_pct = None + if not isinstance(initial_weight_pct, (int, float)): + initial_weight_pct = None + if not isinstance(max_loss_pct, (int, float)): + max_loss_pct = None + if not isinstance(sizing_rationale, str): + sizing_rationale = "" + + return { + "ticker": ticker, + "analysis_date": analysis_date, + "conviction": conviction, + "target_weight_pct": target_weight_pct, + "initial_weight_pct": initial_weight_pct, + "max_loss_pct": max_loss_pct, + "sizing_rationale": sizing_rationale, + } + + +def create_position_sizing_analyst(llm): + def position_sizing_analyst_node(state): + current_date = state["trade_date"] + ticker = state["company_of_interest"] + instrument_context = build_instrument_context(ticker) + current_dt = datetime.strptime(current_date, "%Y-%m-%d") + start_date = (current_dt - timedelta(days=60)).strftime("%Y-%m-%d") + + tools = [ + get_sizing_fundamentals, + get_sizing_indicator, + get_sizing_price_history, + ] + + system_message = ( + "You are a position sizing analyst focused on translating conviction into a disciplined " + "trade size. Use `get_sizing_fundamentals` to anchor thesis quality, " + "`get_sizing_indicator` to retrieve ATR or other volatility context for stop placement, " + "and `get_sizing_price_history` to inspect recent price behavior over the last 60 days. " + "Deliver a concise Markdown narrative with target size, starter size, max loss budget, " + "and the core rationale behind the sizing plan. Your response must contain two parts: " + "(1) a Markdown summary, followed by " + "(2) a fenced JSON block (```json ... ```) with exactly these top-level keys: " + "`conviction` (string), `target_weight_pct` (number), `initial_weight_pct` (number), " + "`max_loss_pct` (number), and `sizing_rationale` (string). If data is unavailable, " + "still include all keys using empty strings or nulls. " + f"Use `{start_date}` as the default start date when requesting recent stock data unless the " + "conversation requires a different window." + ) + + prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + "You are a helpful AI assistant, collaborating with other assistants." + " Use the provided tools to progress towards answering the question." + " If you are unable to fully answer, that's OK; another assistant with different tools" + " will help where you left off. Execute what you can to make progress." + " If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable," + " prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop." + " You have access to the following tools: {tool_names}.\n{system_message}" + "For your reference, the current date is {current_date}. {instrument_context}", + ), + MessagesPlaceholder(variable_name="messages"), + ] + ) + + prompt = prompt.partial(system_message=system_message) + prompt = prompt.partial(tool_names=", ".join(tool.name for tool in tools)) + prompt = prompt.partial(current_date=current_date) + prompt = prompt.partial(instrument_context=instrument_context) + + chain = prompt | llm.bind_tools(tools) + result = chain.invoke(state["messages"]) + + tool_calls = getattr(result, "tool_calls", None) or [] + report = result.content if len(tool_calls) == 0 else "" + + return { + "messages": [result], + "position_sizing_report": report, + "position_sizing_data": _build_position_sizing_data( + ticker, + current_date, + report, + ), + } + + return position_sizing_analyst_node diff --git a/tradingagents/agents/managers/portfolio_manager.py b/tradingagents/agents/managers/portfolio_manager.py index 2d20194d..23fb1c73 100644 --- a/tradingagents/agents/managers/portfolio_manager.py +++ b/tradingagents/agents/managers/portfolio_manager.py @@ -1,11 +1,13 @@ from tradingagents.agents.utils.agent_utils import ( build_analyst_report_context, build_instrument_context, + build_structured_stock_priority_context, ) def create_portfolio_manager(llm, memory): def portfolio_manager_node(state) -> dict: + instrument_context = build_instrument_context(state["company_of_interest"]) analyst_report_context = build_analyst_report_context(state) factor_rules_report = state.get("factor_rules_report", "") @@ -13,9 +15,12 @@ def create_portfolio_manager(llm, memory): history = state["risk_debate_state"]["history"] risk_debate_state = state["risk_debate_state"] + structured_stock_context = build_structured_stock_priority_context(state) trader_plan = state["investment_plan"] - curr_situation = f"{analyst_report_context}\n{factor_rules_context}" + curr_situation = ( + f"{analyst_report_context}\n{factor_rules_context}\n\n{structured_stock_context}" + ) past_memories = memory.get_memories(curr_situation, n_matches=2) past_memory_str = "" @@ -39,6 +44,7 @@ def create_portfolio_manager(llm, memory): - Trader's proposed plan: **{trader_plan}** - Source analyst reports: **{analyst_report_context}** - {factor_rules_context} +- Structured stock underwriting outputs to prioritize: **{structured_stock_context}** - Lessons from past decisions: **{past_memory_str}** **Required Output Structure:** diff --git a/tradingagents/agents/managers/research_manager.py b/tradingagents/agents/managers/research_manager.py index 5e150aed..13d5f416 100644 --- a/tradingagents/agents/managers/research_manager.py +++ b/tradingagents/agents/managers/research_manager.py @@ -1,6 +1,10 @@ +import time +import json + from tradingagents.agents.utils.agent_utils import ( build_analyst_report_context, build_instrument_context, + build_structured_stock_priority_context, ) @@ -11,10 +15,13 @@ def create_research_manager(llm, memory): factor_rules_report = state.get("factor_rules_report", "") factor_rules_context = f"Factor rules summary: {factor_rules_report}" history = state["investment_debate_state"].get("history", "") + structured_stock_context = build_structured_stock_priority_context(state) investment_debate_state = state["investment_debate_state"] - curr_situation = f"{analyst_report_context}\n{factor_rules_context}" + curr_situation = ( + f"{analyst_report_context}\n{factor_rules_context}\n\n{structured_stock_context}" + ) past_memories = memory.get_memories(curr_situation, n_matches=2) past_memory_str = "" @@ -41,6 +48,9 @@ Source analyst reports: {analyst_report_context} {factor_rules_context} +Structured stock underwriting outputs to prioritize: +{structured_stock_context} + Here is the debate: Debate History: {history}""" diff --git a/tradingagents/agents/researchers/bear_researcher.py b/tradingagents/agents/researchers/bear_researcher.py index 424e86a4..aa6de87f 100644 --- a/tradingagents/agents/researchers/bear_researcher.py +++ b/tradingagents/agents/researchers/bear_researcher.py @@ -2,7 +2,10 @@ from langchain_core.messages import AIMessage import time import json -from tradingagents.agents.utils.agent_utils import build_analyst_report_context +from tradingagents.agents.utils.agent_utils import ( + build_analyst_report_context, + build_structured_stock_priority_context, +) def create_bear_researcher(llm, memory): @@ -15,7 +18,11 @@ def create_bear_researcher(llm, memory): analyst_report_context = build_analyst_report_context(state) factor_rules_report = state.get("factor_rules_report", "") factor_rules_context = f"Factor rules summary: {factor_rules_report}" - curr_situation = f"{analyst_report_context}\n{factor_rules_context}" + structured_stock_context = build_structured_stock_priority_context(state) + + curr_situation = ( + f"{analyst_report_context}\n{factor_rules_context}\n\n{structured_stock_context}" + ) past_memories = memory.get_memories(curr_situation, n_matches=2) past_memory_str = "" @@ -36,6 +43,7 @@ Resources available: {analyst_report_context} {factor_rules_context} +Structured stock underwriting outputs to prioritize: {structured_stock_context} Conversation history of the debate: {history} Last bull argument: {current_response} Reflections from similar situations and lessons learned: {past_memory_str} diff --git a/tradingagents/agents/researchers/bull_researcher.py b/tradingagents/agents/researchers/bull_researcher.py index f11b4c55..6cf92a9c 100644 --- a/tradingagents/agents/researchers/bull_researcher.py +++ b/tradingagents/agents/researchers/bull_researcher.py @@ -2,7 +2,10 @@ from langchain_core.messages import AIMessage import time import json -from tradingagents.agents.utils.agent_utils import build_analyst_report_context +from tradingagents.agents.utils.agent_utils import ( + build_analyst_report_context, + build_structured_stock_priority_context, +) def create_bull_researcher(llm, memory): @@ -15,7 +18,11 @@ def create_bull_researcher(llm, memory): analyst_report_context = build_analyst_report_context(state) factor_rules_report = state.get("factor_rules_report", "") factor_rules_context = f"Factor rules summary: {factor_rules_report}" - curr_situation = f"{analyst_report_context}\n{factor_rules_context}" + structured_stock_context = build_structured_stock_priority_context(state) + + curr_situation = ( + f"{analyst_report_context}\n{factor_rules_context}\n\n{structured_stock_context}" + ) past_memories = memory.get_memories(curr_situation, n_matches=2) past_memory_str = "" @@ -34,6 +41,7 @@ Key points to focus on: Resources available: {analyst_report_context} {factor_rules_context} +Structured stock underwriting outputs to prioritize: {structured_stock_context} Conversation history of the debate: {history} Last bear argument: {current_response} Reflections from similar situations and lessons learned: {past_memory_str} diff --git a/tradingagents/agents/risk_mgmt/aggressive_debator.py b/tradingagents/agents/risk_mgmt/aggressive_debator.py index 9bda6a4d..fbd6dfce 100644 --- a/tradingagents/agents/risk_mgmt/aggressive_debator.py +++ b/tradingagents/agents/risk_mgmt/aggressive_debator.py @@ -1,7 +1,10 @@ import time import json -from tradingagents.agents.utils.agent_utils import build_analyst_report_context +from tradingagents.agents.utils.agent_utils import ( + build_analyst_report_context, + build_structured_stock_priority_context, +) def create_aggressive_debator(llm): @@ -14,6 +17,8 @@ def create_aggressive_debator(llm): current_neutral_response = risk_debate_state.get("current_neutral_response", "") analyst_report_context = build_analyst_report_context(state) + structured_stock_context = build_structured_stock_priority_context(state) + trader_decision = state["trader_investment_plan"] prompt = f"""As the Aggressive Risk Analyst, your role is to actively champion high-reward, high-risk opportunities, emphasizing bold strategies and competitive advantages. When evaluating the trader's decision or plan, focus intently on the potential upside, growth potential, and innovative benefits—even when these come with elevated risk. Use the provided market data and sentiment analysis to strengthen your arguments and challenge the opposing views. Specifically, respond directly to each point made by the conservative and neutral analysts, countering with data-driven rebuttals and persuasive reasoning. Highlight where their caution might miss critical opportunities or where their assumptions may be overly conservative. Here is the trader's decision: @@ -23,6 +28,7 @@ def create_aggressive_debator(llm): Your task is to create a compelling case for the trader's decision by questioning and critiquing the conservative and neutral stances to demonstrate why your high-reward perspective offers the best path forward. Incorporate insights from the following sources into your arguments: {analyst_report_context} +Structured Stock Underwriting Outputs To Prioritize: {structured_stock_context} Here is the current conversation history: {history} Here are the last arguments from the conservative analyst: {current_conservative_response} Here are the last arguments from the neutral analyst: {current_neutral_response}. If there are no responses from the other viewpoints yet, present your own argument based on the available data. Engage actively by addressing any specific concerns raised, refuting the weaknesses in their logic, and asserting the benefits of risk-taking to outpace market norms. Maintain a focus on debating and persuading, not just presenting data. Challenge each counterpoint to underscore why a high-risk approach is optimal. Output conversationally as if you are speaking without any special formatting.""" diff --git a/tradingagents/agents/risk_mgmt/conservative_debator.py b/tradingagents/agents/risk_mgmt/conservative_debator.py index 2ce2b841..a87b3403 100644 --- a/tradingagents/agents/risk_mgmt/conservative_debator.py +++ b/tradingagents/agents/risk_mgmt/conservative_debator.py @@ -2,7 +2,10 @@ from langchain_core.messages import AIMessage import time import json -from tradingagents.agents.utils.agent_utils import build_analyst_report_context +from tradingagents.agents.utils.agent_utils import ( + build_analyst_report_context, + build_structured_stock_priority_context, +) def create_conservative_debator(llm): @@ -15,6 +18,8 @@ def create_conservative_debator(llm): current_neutral_response = risk_debate_state.get("current_neutral_response", "") analyst_report_context = build_analyst_report_context(state) + structured_stock_context = build_structured_stock_priority_context(state) + trader_decision = state["trader_investment_plan"] prompt = f"""As the Conservative Risk Analyst, your primary objective is to protect assets, minimize volatility, and ensure steady, reliable growth. You prioritize stability, security, and risk mitigation, carefully assessing potential losses, economic downturns, and market volatility. When evaluating the trader's decision or plan, critically examine high-risk elements, pointing out where the decision may expose the firm to undue risk and where more cautious alternatives could secure long-term gains. Here is the trader's decision: @@ -24,6 +29,7 @@ def create_conservative_debator(llm): Your task is to actively counter the arguments of the Aggressive and Neutral Analysts, highlighting where their views may overlook potential threats or fail to prioritize sustainability. Respond directly to their points, drawing from the following data sources to build a convincing case for a low-risk approach adjustment to the trader's decision: {analyst_report_context} +Structured Stock Underwriting Outputs To Prioritize: {structured_stock_context} Here is the current conversation history: {history} Here is the last response from the aggressive analyst: {current_aggressive_response} Here is the last response from the neutral analyst: {current_neutral_response}. If there are no responses from the other viewpoints yet, present your own argument based on the available data. Engage by questioning their optimism and emphasizing the potential downsides they may have overlooked. Address each of their counterpoints to showcase why a conservative stance is ultimately the safest path for the firm's assets. Focus on debating and critiquing their arguments to demonstrate the strength of a low-risk strategy over their approaches. Output conversationally as if you are speaking without any special formatting.""" diff --git a/tradingagents/agents/risk_mgmt/neutral_debator.py b/tradingagents/agents/risk_mgmt/neutral_debator.py index c8618d95..96574740 100644 --- a/tradingagents/agents/risk_mgmt/neutral_debator.py +++ b/tradingagents/agents/risk_mgmt/neutral_debator.py @@ -1,7 +1,10 @@ import time import json -from tradingagents.agents.utils.agent_utils import build_analyst_report_context +from tradingagents.agents.utils.agent_utils import ( + build_analyst_report_context, + build_structured_stock_priority_context, +) def create_neutral_debator(llm): @@ -14,6 +17,8 @@ def create_neutral_debator(llm): current_conservative_response = risk_debate_state.get("current_conservative_response", "") analyst_report_context = build_analyst_report_context(state) + structured_stock_context = build_structured_stock_priority_context(state) + trader_decision = state["trader_investment_plan"] prompt = f"""As the Neutral Risk Analyst, your role is to provide a balanced perspective, weighing both the potential benefits and risks of the trader's decision or plan. You prioritize a well-rounded approach, evaluating the upsides and downsides while factoring in broader market trends, potential economic shifts, and diversification strategies.Here is the trader's decision: @@ -23,6 +28,7 @@ def create_neutral_debator(llm): Your task is to challenge both the Aggressive and Conservative Analysts, pointing out where each perspective may be overly optimistic or overly cautious. Use insights from the following data sources to support a moderate, sustainable strategy to adjust the trader's decision: {analyst_report_context} +Structured Stock Underwriting Outputs To Prioritize: {structured_stock_context} Here is the current conversation history: {history} Here is the last response from the aggressive analyst: {current_aggressive_response} Here is the last response from the conservative analyst: {current_conservative_response}. If there are no responses from the other viewpoints yet, present your own argument based on the available data. Engage actively by analyzing both sides critically, addressing weaknesses in the aggressive and conservative arguments to advocate for a more balanced approach. Challenge each of their points to illustrate why a moderate risk strategy might offer the best of both worlds, providing growth potential while safeguarding against extreme volatility. Focus on debating rather than simply presenting data, aiming to show that a balanced view can lead to the most reliable outcomes. Output conversationally as if you are speaking without any special formatting.""" diff --git a/tradingagents/agents/utils/agent_states.py b/tradingagents/agents/utils/agent_states.py index a1a09292..18e3795f 100644 --- a/tradingagents/agents/utils/agent_states.py +++ b/tradingagents/agents/utils/agent_states.py @@ -82,10 +82,13 @@ class ScenarioCatalystData(TypedDict): class PositionSizingData(TypedDict): + ticker: str + analysis_date: str conviction: str target_weight_pct: Optional[float] initial_weight_pct: Optional[float] max_loss_pct: Optional[float] + sizing_rationale: str class ChiefAnalystData(TypedDict): @@ -134,10 +137,13 @@ def make_default_scenario_catalyst_data() -> ScenarioCatalystData: def make_default_position_sizing_data() -> PositionSizingData: return { + "ticker": "", + "analysis_date": "", "conviction": "", "target_weight_pct": None, "initial_weight_pct": None, "max_loss_pct": None, + "sizing_rationale": "", } @@ -182,6 +188,10 @@ class AgentState(MessagesState): str, "Report from the Scenario and Catalyst Analyst", ] + position_sizing_report: Annotated[ + str, + "Report from the Position Sizing Analyst", + ] valuation_data: Annotated[ ValuationData, "Structured valuation underwriting output" ] diff --git a/tradingagents/agents/utils/agent_utils.py b/tradingagents/agents/utils/agent_utils.py index 392d6bc9..eb998f1f 100644 --- a/tradingagents/agents/utils/agent_utils.py +++ b/tradingagents/agents/utils/agent_utils.py @@ -1,4 +1,5 @@ from collections.abc import Mapping +import json from typing import Any from langchain_core.messages import HumanMessage, RemoveMessage @@ -36,6 +37,11 @@ from tradingagents.agents.utils.segment_tools import ( get_segment_income_statement, get_segment_news, ) +from tradingagents.agents.utils.sizing_tools import ( + get_sizing_fundamentals, + get_sizing_indicator, + get_sizing_price_history, +) from tradingagents.agents.utils.valuation_tools import ( get_valuation_inputs, ) @@ -44,6 +50,8 @@ from tradingagents.agents.utils.valuation_tools import ( __all__ = [ "build_instrument_context", "build_analyst_report_context", + "build_structured_stock_context", + "build_structured_stock_priority_context", "create_msg_delete", "get_balance_sheet", "get_cashflow", @@ -61,6 +69,9 @@ __all__ = [ "get_segment_fundamentals", "get_segment_income_statement", "get_segment_news", + "get_sizing_fundamentals", + "get_sizing_indicator", + "get_sizing_price_history", "get_stock_data", "get_valuation_inputs", "get_yield_curve", @@ -91,6 +102,47 @@ def build_analyst_report_context(state: Mapping[str, Any]) -> str: ) +def build_structured_stock_context(state: Mapping[str, Any]) -> str: + """Render structured underwriting outputs into prompt-friendly text.""" + sections = [] + + segment_data = state.get("segment_data", {}) + if segment_data: + sections.append( + "Structured segment analysis:\n" + + json.dumps(segment_data, indent=2, sort_keys=True) + ) + + scenario_catalyst_data = state.get("scenario_catalyst_data", {}) + if scenario_catalyst_data: + sections.append( + "Structured scenario and catalyst analysis:\n" + + json.dumps(scenario_catalyst_data, indent=2, sort_keys=True) + ) + + position_sizing_data = state.get("position_sizing_data", {}) + if position_sizing_data: + sections.append( + "Structured position sizing analysis:\n" + + json.dumps(position_sizing_data, indent=2, sort_keys=True) + ) + + return "\n\n".join(section for section in sections if section) + + +def build_structured_stock_priority_context(state: Mapping[str, Any]) -> str: + structured_context = build_structured_stock_context(state) + if not structured_context: + return "" + return ( + "Prioritize the structured stock underwriting outputs below as primary evidence. " + "Anchor your reasoning first on numeric fields such as revenue_share_pct, " + "probability_pct, target_weight_pct, initial_weight_pct, and max_loss_pct " + "before using freeform analyst reports for narrative color.\n\n" + + structured_context + ) + + def create_msg_delete(): def delete_messages(state): """Clear messages and add placeholder for Anthropic compatibility""" diff --git a/tradingagents/agents/utils/sizing_tools.py b/tradingagents/agents/utils/sizing_tools.py new file mode 100644 index 00000000..014e1de6 --- /dev/null +++ b/tradingagents/agents/utils/sizing_tools.py @@ -0,0 +1,55 @@ +from typing import Annotated + +from langchain_core.tools import tool + + +@tool +def get_sizing_fundamentals( + ticker: Annotated[str, "company ticker symbol"], + curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"], +) -> str: + """Retrieve fundamentals that anchor conviction and portfolio sizing discipline.""" + from tradingagents.dataflows.interface import route_to_vendor + + return route_to_vendor("get_fundamentals", ticker=ticker, curr_date=curr_date) + + +@tool +def get_sizing_price_history( + symbol: Annotated[str, "ticker symbol of the company"], + start_date: Annotated[str, "start date in yyyy-mm-dd format"], + end_date: Annotated[str, "end date in yyyy-mm-dd format"], +) -> str: + """Retrieve recent price action used to estimate sizing bands and entry staging.""" + from tradingagents.dataflows.interface import route_to_vendor + + return route_to_vendor( + "get_stock_data", + symbol=symbol, + start_date=start_date, + end_date=end_date, + ) + + +@tool +def get_sizing_indicator( + symbol: Annotated[str, "ticker symbol of the company"], + indicator: Annotated[str, "technical indicator to retrieve"], + curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"], + look_back_days: Annotated[int, "how many days to look back"] = 30, +) -> str: + """Retrieve a volatility indicator, such as ATR, for stop-distance-aware sizing.""" + from tradingagents.dataflows.interface import route_to_vendor + + return route_to_vendor( + "get_indicators", + symbol=symbol, + indicator=indicator, + curr_date=curr_date, + look_back_days=look_back_days, + ) + + +get_position_sizing_fundamentals = get_sizing_fundamentals +get_position_sizing_stock_data = get_sizing_price_history +get_position_sizing_indicators = get_sizing_indicator diff --git a/tradingagents/graph/propagation.py b/tradingagents/graph/propagation.py index 7741e973..69d8f027 100644 --- a/tradingagents/graph/propagation.py +++ b/tradingagents/graph/propagation.py @@ -56,6 +56,7 @@ class Propagator: "macro_report": "", "segment_report": "", "scenario_catalyst_report": "", + "position_sizing_report": "", **make_default_structured_stock_underwriting_state(), } diff --git a/tradingagents/graph/setup.py b/tradingagents/graph/setup.py index e117b4e9..e99b6497 100644 --- a/tradingagents/graph/setup.py +++ b/tradingagents/graph/setup.py @@ -59,6 +59,9 @@ class GraphSetup: ) self.segment_analyst_llm = self._get_role_llm("segment", self.quick_thinking_llm) self.scenario_analyst_llm = self._get_role_llm("scenario", self.quick_thinking_llm) + self.position_sizing_analyst_llm = self._get_role_llm( + "position_sizing", self.quick_thinking_llm + ) self.macro_analyst_llm = self._get_role_llm("macro", self.quick_thinking_llm) self.bull_researcher_llm = self._get_role_llm( "bull_researcher", self.quick_thinking_llm @@ -119,6 +122,7 @@ class GraphSetup: - "valuation": Valuation analyst - "segment": Segment analyst - "scenario": Scenario and catalyst analyst + - "position_sizing": Position sizing analyst - "macro": Macro analyst """ if len(selected_analysts) == 0: @@ -183,6 +187,13 @@ class GraphSetup: delete_nodes["scenario"] = create_msg_delete() tool_nodes["scenario"] = self.tool_nodes["scenario"] + if "position_sizing" in selected_analysts: + analyst_nodes["position_sizing"] = create_position_sizing_analyst( + self.position_sizing_analyst_llm + ) + delete_nodes["position_sizing"] = create_msg_delete() + tool_nodes["position_sizing"] = self.tool_nodes["position_sizing"] + if "macro" in selected_analysts: analyst_nodes["macro"] = create_macro_analyst(self.macro_analyst_llm) delete_nodes["macro"] = create_msg_delete() diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index c5f61e83..b21eb0d4 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -39,6 +39,9 @@ from tradingagents.agents.utils.agent_utils import ( get_segment_fundamentals, get_segment_income_statement, get_segment_news, + get_sizing_fundamentals, + get_sizing_indicator, + get_sizing_price_history, get_valuation_inputs, get_yield_curve, ) @@ -72,6 +75,7 @@ class TradingAgentsGraph: "valuation", "segment", "scenario", + "position_sizing", "macro", "bull_researcher", "bear_researcher", @@ -336,6 +340,14 @@ class TradingAgentsGraph: get_catalyst_calendar, ] ), + "position_sizing": ToolNode( + [ + # Position sizing analysis tools + get_sizing_fundamentals, + get_sizing_indicator, + get_sizing_price_history, + ] + ), "macro": ToolNode( [ # Macroeconomic analysis tools @@ -396,6 +408,8 @@ class TradingAgentsGraph: "macro_report": final_state.get("macro_report", ""), "scenario_catalyst_report": final_state.get("scenario_catalyst_report", ""), "scenario_catalyst_data": final_state.get("scenario_catalyst_data", {}), + "position_sizing_report": final_state.get("position_sizing_report", ""), + "position_sizing_data": final_state.get("position_sizing_data", {}), "investment_debate_state": { "bull_history": final_state["investment_debate_state"]["bull_history"], "bear_history": final_state["investment_debate_state"]["bear_history"],