Merge branch 'main' into feature/portfolio-resumability-and-cleanup

This commit is contained in:
ahmet guzererler 2026-03-24 03:32:09 +01:00 committed by GitHub
commit 2a05e4b5a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 646 additions and 111 deletions

View File

@ -149,6 +149,8 @@ async def get_latest_portfolio_state(
"price": d.get("price", 0.0),
"executed_at": d.get("trade_date", ""),
"rationale": d.get("rationale"),
"stop_loss": d.get("stop_loss"),
"take_profit": d.get("take_profit"),
})
return {

View File

@ -46,6 +46,8 @@ interface Trade {
price: number;
executed_at?: string;
rationale?: string;
stop_loss?: number | null;
take_profit?: number | null;
[key: string]: unknown;
}
@ -219,14 +221,34 @@ export const PortfolioViewer: React.FC<PortfolioViewerProps> = ({ defaultPortfol
border="1px solid"
borderColor="whiteAlpha.100"
justify="space-between"
align="center"
align="flex-start"
>
<HStack spacing={3}>
<HStack spacing={3} align="flex-start">
<Badge colorScheme={t.action?.toUpperCase() === 'BUY' ? 'green' : t.action?.toUpperCase() === 'SELL' ? 'red' : 'gray'}>
{t.action?.toUpperCase()}
</Badge>
<Code colorScheme="cyan" fontSize="sm">{t.ticker}</Code>
<Text fontSize="sm">{t.quantity} @ ${(t.price ?? 0).toFixed(2)}</Text>
<VStack align="flex-start" spacing={0}>
<HStack spacing={2}>
<Code colorScheme="cyan" fontSize="sm">{t.ticker}</Code>
<Text fontSize="sm">{t.quantity} @ ${(t.price ?? 0).toFixed(2)}</Text>
</HStack>
{(t.stop_loss != null || t.take_profit != null) && (
<HStack spacing={3} mt={1}>
{t.stop_loss != null && (
<HStack spacing={1}>
<Text fontSize="2xs" color="red.400">SL:</Text>
<Text fontSize="2xs" color="red.300" fontWeight="semibold">${t.stop_loss.toFixed(2)}</Text>
</HStack>
)}
{t.take_profit != null && (
<HStack spacing={1}>
<Text fontSize="2xs" color="green.400">TP:</Text>
<Text fontSize="2xs" color="green.300" fontWeight="semibold">${t.take_profit.toFixed(2)}</Text>
</HStack>
)}
</HStack>
)}
</VStack>
</HStack>
<VStack align="flex-end" spacing={0}>
<Text fontSize="2xs" color="whiteAlpha.400">{t.executed_at || '—'}</Text>

View File

@ -1,9 +1,10 @@
# Current Milestone
AgentOS visual observability layer shipped. Portfolio Manager fully implemented (Phases 110). All 725 tests passing (14 skipped).
AgentOS visual observability layer shipped. Portfolio Manager fully implemented (Phases 110). All 725 tests passing (14 skipped). Stop-loss / take-profit fields added to trades.
# Recent Progress
- **Stop-loss & Take-profit on trades**: Added `stop_loss` and `take_profit` optional fields to the `Trade` model, SQL migration, PM agent prompt, trade executor, repository, API route, and frontend Trade History tab.
- **AgentOS (current PR)**: Full-stack visual observability layer for agent execution
- `agent_os/backend/` — FastAPI backend (port 8088) with REST + WebSocket streaming
- `agent_os/frontend/` — React + Vite 8 + Chakra UI + ReactFlow dashboard

View File

@ -119,6 +119,8 @@ Immutable record of a single mock trade execution. Never modified after creation
| `trade_date` | `str` | Yes | ISO-8601 UTC datetime of execution |
| `rationale` | `str \| None` | No | PM agent rationale for this trade |
| `signal_source` | `str \| None` | No | `"scanner"`, `"holding_review"`, `"pm_agent"` |
| `stop_loss` | `float \| None` | No | Price level at which the position should be exited to limit loss |
| `take_profit` | `float \| None` | No | Price target at which the position should be sold for profit |
| `metadata` | `dict` | No | Free-form JSON |
### Methods

View File

@ -76,6 +76,8 @@ CREATE TABLE IF NOT EXISTS trades (
trade_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
rationale TEXT,
signal_source TEXT,
stop_loss NUMERIC(18,4) CHECK (stop_loss IS NULL OR stop_loss > 0),
take_profit NUMERIC(18,4) CHECK (take_profit IS NULL OR take_profit > 0),
metadata JSONB NOT NULL DEFAULT '{}',
CONSTRAINT trades_action_values CHECK (action IN ('BUY', 'SELL'))
@ -85,6 +87,8 @@ CREATE TABLE IF NOT EXISTS trades (
**Constraints:**
- `action IN ('BUY', 'SELL')` — only two valid actions
- `shares > 0`, `price > 0` — all quantities positive
- `stop_loss > 0` (when set) — stop-loss price must be positive; NULL means not specified
- `take_profit > 0` (when set) — take-profit target must be positive; NULL means not specified
- No `updated_at` — trades are immutable
---

View File

@ -113,9 +113,58 @@ def test_trade_to_dict_round_trip(sample_trade):
assert restored.trade_date == sample_trade.trade_date
assert restored.rationale == sample_trade.rationale
assert restored.signal_source == sample_trade.signal_source
assert restored.stop_loss == sample_trade.stop_loss
assert restored.take_profit == sample_trade.take_profit
assert restored.metadata == sample_trade.metadata
def test_trade_stop_loss_take_profit_round_trip(sample_portfolio_id):
"""Trade with stop_loss and take_profit serialises and deserialises correctly."""
trade = Trade(
trade_id="t-risk-1",
portfolio_id=sample_portfolio_id,
ticker="NVDA",
action="BUY",
shares=10.0,
price=800.0,
total_value=8_000.0,
stop_loss=720.0,
take_profit=960.0,
)
d = trade.to_dict()
assert d["stop_loss"] == 720.0
assert d["take_profit"] == 960.0
restored = Trade.from_dict(d)
assert restored.stop_loss == 720.0
assert restored.take_profit == 960.0
def test_trade_stop_loss_take_profit_default_none(sample_trade):
"""Trade defaults stop_loss and take_profit to None when not provided."""
assert sample_trade.stop_loss is None
assert sample_trade.take_profit is None
d = sample_trade.to_dict()
assert d["stop_loss"] is None
assert d["take_profit"] is None
def test_trade_from_dict_missing_risk_levels_defaults_none():
"""from_dict() gracefully handles missing stop_loss/take_profit keys."""
data = {
"trade_id": "t-1",
"portfolio_id": "p-1",
"ticker": "AAPL",
"action": "BUY",
"shares": 5.0,
"price": 150.0,
"total_value": 750.0,
}
trade = Trade.from_dict(data)
assert trade.stop_loss is None
assert trade.take_profit is None
# ---------------------------------------------------------------------------
# PortfolioSnapshot round-trip
# ---------------------------------------------------------------------------

View File

@ -167,7 +167,7 @@ def test_execute_buy_success():
}
result = executor.execute_decisions("p1", decisions, PRICES)
repo.add_holding.assert_called_once_with("p1", "MSFT", 10.0, 300.0, sector="Technology")
repo.add_holding.assert_called_once_with("p1", "MSFT", 10.0, 300.0, sector="Technology", stop_loss=None, take_profit=None)
assert len(result["executed_trades"]) == 1
assert result["executed_trades"][0]["action"] == "BUY"
@ -247,3 +247,56 @@ def test_execute_decisions_takes_snapshot():
repo.take_snapshot.assert_called_once_with("p1", PRICES)
assert "snapshot" in result
assert result["snapshot"]["snapshot_id"] == "snap-1"
# ---------------------------------------------------------------------------
# Stop loss / take profit tests
# ---------------------------------------------------------------------------
def test_execute_buy_with_stop_loss_and_take_profit():
"""BUY with stop_loss and take_profit passes them to add_holding and executed_trades."""
portfolio = _make_portfolio(cash=50_000.0, total_value=60_000.0)
repo = _make_repo(portfolio=portfolio)
executor = TradeExecutor(repo=repo, config=_DEFAULT_CONFIG)
decisions = {
"sells": [],
"buys": [{
"ticker": "MSFT",
"shares": 5.0,
"sector": "Technology",
"rationale": "Breakout",
"stop_loss": 270.0,
"take_profit": 360.0,
}],
}
result = executor.execute_decisions("p1", decisions, PRICES)
repo.add_holding.assert_called_once_with(
"p1", "MSFT", 5.0, 300.0,
sector="Technology",
stop_loss=270.0,
take_profit=360.0,
)
assert len(result["executed_trades"]) == 1
trade = result["executed_trades"][0]
assert trade["stop_loss"] == 270.0
assert trade["take_profit"] == 360.0
def test_execute_buy_without_risk_levels_stores_none():
"""BUY with no stop_loss/take_profit stores None for both fields."""
portfolio = _make_portfolio(cash=50_000.0, total_value=60_000.0)
repo = _make_repo(portfolio=portfolio)
executor = TradeExecutor(repo=repo, config=_DEFAULT_CONFIG)
decisions = {
"sells": [],
"buys": [{"ticker": "MSFT", "shares": 5.0, "sector": "Technology", "rationale": "Entry"}],
}
result = executor.execute_decisions("p1", decisions, PRICES)
trade = result["executed_trades"][0]
assert trade["stop_loss"] is None
assert trade["take_profit"] is None

View File

@ -1,4 +1,4 @@
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import pytest
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.runnables import Runnable
@ -7,6 +7,7 @@ from tradingagents.agents.analysts.market_analyst import create_market_analyst
from tradingagents.agents.analysts.social_media_analyst import create_social_media_analyst
from tradingagents.agents.analysts.news_analyst import create_news_analyst
class MockRunnable(Runnable):
def __init__(self, invoke_responses):
self.invoke_responses = invoke_responses
@ -17,6 +18,7 @@ class MockRunnable(Runnable):
self.call_count += 1
return response
class MockLLM(Runnable):
def __init__(self, invoke_responses):
self.runnable = MockRunnable(invoke_responses)
@ -29,6 +31,7 @@ class MockLLM(Runnable):
self.tools_bound = tools
return self.runnable
@pytest.fixture
def mock_state():
return {
@ -37,8 +40,10 @@ def mock_state():
"company_of_interest": "AAPL",
}
@pytest.fixture
def mock_llm_with_tool_call():
"""LLM that makes one tool call then writes the final report (iterative loop)."""
# 1. First call: The LLM decides to use a tool
tool_call_msg = AIMessage(
content="",
@ -52,22 +57,108 @@ def mock_llm_with_tool_call():
)
return MockLLM([tool_call_msg, final_report_msg])
@pytest.fixture
def mock_llm_direct_report():
"""LLM that returns the final report directly (no tool calls — full pre-fetch path)."""
final_report_msg = AIMessage(
content="This is the final report after running the tool."
)
return MockLLM([final_report_msg])
def test_fundamentals_analyst_tool_loop(mock_state, mock_llm_with_tool_call):
"""Fundamentals analyst: pre-fetches 4 tools, runs iterative loop for raw statements."""
node = create_fundamentals_analyst(mock_llm_with_tool_call)
result = node(mock_state)
assert "This is the final report after running the tool." in result["fundamentals_report"]
def test_market_analyst_tool_loop(mock_state, mock_llm_with_tool_call):
"""Market analyst: pre-fetches macro + stock data, keeps indicator selection iterative."""
node = create_market_analyst(mock_llm_with_tool_call)
result = node(mock_state)
assert "This is the final report after running the tool." in result["market_report"]
def test_social_media_analyst_tool_loop(mock_state, mock_llm_with_tool_call):
node = create_social_media_analyst(mock_llm_with_tool_call)
def test_social_media_analyst_direct_invoke(mock_state, mock_llm_direct_report):
"""Social analyst: full pre-fetch, direct LLM invoke (no tool loop)."""
node = create_social_media_analyst(mock_llm_direct_report)
result = node(mock_state)
assert "This is the final report after running the tool." in result["sentiment_report"]
def test_news_analyst_tool_loop(mock_state, mock_llm_with_tool_call):
node = create_news_analyst(mock_llm_with_tool_call)
def test_news_analyst_direct_invoke(mock_state, mock_llm_direct_report):
"""News analyst: full pre-fetch, direct LLM invoke (no tool loop)."""
node = create_news_analyst(mock_llm_direct_report)
result = node(mock_state)
assert "This is the final report after running the tool." in result["news_report"]
def test_market_analyst_macro_regime_from_prefetch(mock_state, mock_llm_with_tool_call):
"""Market analyst populates macro_regime_report from pre-fetched data when available."""
with patch(
"tradingagents.agents.analysts.market_analyst.prefetch_tools_parallel",
return_value={
"Macro Regime Classification": "## Risk-On\nMarket is RISK-ON.",
"Stock Price Data": "Date,Close\n2024-05-14,189.0",
},
):
node = create_market_analyst(mock_llm_with_tool_call)
result = node(mock_state)
assert result["macro_regime_report"] == "## Risk-On\nMarket is RISK-ON."
def test_social_media_analyst_no_bind_tools(mock_state, mock_llm_direct_report):
"""Social analyst must not call bind_tools since there are no tools."""
node = create_social_media_analyst(mock_llm_direct_report)
node(mock_state)
# bind_tools should never have been called (no tools in the list)
assert mock_llm_direct_report.tools_bound is None
def test_prefetched_context_injected_into_prompt(mock_state, mock_llm_with_tool_call):
"""Market analyst injects pre-fetched context into the prompt sent to the LLM."""
captured_inputs = []
class CapturingRunnable(Runnable):
def invoke(self, input, config=None, **kwargs):
captured_inputs.append(input)
# Return final report directly to end the loop early
return AIMessage(content="This is the final report after running the tool.")
class CapturingLLM(Runnable):
def invoke(self, input, config=None, **kwargs):
captured_inputs.append(input)
return AIMessage(content="This is the final report after running the tool.")
def bind_tools(self, tools):
return CapturingRunnable()
with patch(
"tradingagents.agents.analysts.market_analyst.prefetch_tools_parallel",
return_value={
"Macro Regime Classification": "**RISK-ON** regime detected.",
"Stock Price Data": "Date,Close\n2024-05-14,189.0",
},
):
node = create_market_analyst(CapturingLLM())
node(mock_state)
# The prompt was captured; find the system message and verify injected context
assert captured_inputs, "LLM was never called"
# The input to the runnable is a list of messages; find the system message text
messages = captured_inputs[0]
full_text = " ".join(
m.content if hasattr(m, "content") else str(m)
for m in messages
)
assert "RISK-ON" in full_text
assert "Pre-loaded Context" in full_text
def test_news_analyst_no_bind_tools(mock_state, mock_llm_direct_report):
"""News analyst must not call bind_tools since there are no tools."""
node = create_news_analyst(mock_llm_direct_report)
node(mock_state)
assert mock_llm_direct_report.tools_bound is None

View File

@ -1,47 +1,100 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
import time
import json
from tradingagents.agents.utils.agent_utils import (
build_instrument_context,
format_prefetched_context,
prefetch_tools_parallel,
)
from tradingagents.agents.utils.fundamental_data_tools import (
get_fundamentals,
get_balance_sheet,
get_cashflow,
get_fundamentals,
get_income_statement,
get_ttm_analysis,
get_peer_comparison,
get_sector_relative,
get_ttm_analysis,
)
from tradingagents.agents.utils.news_data_tools import get_insider_transactions
from tradingagents.agents.utils.tool_runner import run_tool_loop
from tradingagents.agents.utils.agent_utils import build_instrument_context
from tradingagents.dataflows.config import get_config
def create_fundamentals_analyst(llm):
def fundamentals_analyst_node(state):
current_date = state["trade_date"]
instrument_context = build_instrument_context(state["company_of_interest"])
ticker = state["company_of_interest"]
instrument_context = build_instrument_context(ticker)
tools = [
get_ttm_analysis,
get_fundamentals,
get_balance_sheet,
get_cashflow,
get_income_statement,
get_peer_comparison,
get_sector_relative,
]
# ── Pre-fetch the four mandatory foundational datasets in parallel ────
# get_ttm_analysis, get_fundamentals, get_peer_comparison, and
# get_sector_relative are always called — pre-fetching them removes
# 4 LLM round-trips. The raw financial statements (balance sheet,
# cashflow, income statement) stay iterative: the LLM may request them
# only if it spots anomalies worth investigating in the pre-loaded data.
prefetched = prefetch_tools_parallel(
[
{
"tool": get_ttm_analysis,
"args": {"ticker": ticker, "curr_date": current_date},
"label": "TTM Analysis (8-Quarter Trend)",
},
{
"tool": get_fundamentals,
"args": {"ticker": ticker, "curr_date": current_date},
"label": "Fundamental Ratios Snapshot",
},
{
"tool": get_peer_comparison,
"args": {"ticker": ticker, "curr_date": current_date},
"label": "Peer Comparison",
},
{
"tool": get_sector_relative,
"args": {"ticker": ticker, "curr_date": current_date},
"label": "Sector Relative Performance",
},
]
)
prefetched_context = format_prefetched_context(prefetched)
# ── Only the raw statement tools remain iterative ─────────────────────
tools = [get_balance_sheet, get_cashflow, get_income_statement]
system_message = (
"You are a researcher tasked with performing deep fundamental analysis of a company over the last 8 quarters (2 years) to support medium-term investment decisions."
" Follow this sequence:"
" 1. Call `get_ttm_analysis` first — this provides a Trailing Twelve Months (TTM) trend report covering revenue growth (QoQ and YoY), margin trajectories (gross, operating, net), return on equity trend, debt/equity trend, and free cash flow over 8 quarters."
" 2. Call `get_fundamentals` for the latest snapshot of key ratios (PE, PEG, price-to-book, beta, 52-week range)."
" 3. Call `get_peer_comparison` to see how the company ranks against sector peers over 1-week, 1-month, 3-month, and 6-month periods."
" 4. Call `get_sector_relative` to compute the company's alpha vs its sector ETF benchmark."
" 5. Optionally call `get_balance_sheet`, `get_cashflow`, or `get_income_statement` for additional detail."
" Write a comprehensive report covering: multi-quarter revenue and margin trends, TTM metrics, relative valuation vs peers, sector outperformance or underperformance, and a clear medium-term fundamental thesis."
" Do not simply state trends are mixed — provide detailed, fine-grained analysis that identifies inflection points, acceleration or deceleration in growth, and specific risks and opportunities."
" Make sure to append a Markdown summary table at the end of the report organising key metrics for easy reference.",
"You are a researcher tasked with performing deep fundamental analysis of a company "
"over the last 8 quarters (2 years) to support medium-term investment decisions.\n\n"
"## Pre-loaded Foundational Data\n\n"
"The following datasets have already been fetched and are provided in the "
"**Pre-loaded Context** section below. Do NOT call `get_ttm_analysis`, "
"`get_fundamentals`, `get_peer_comparison`, or `get_sector_relative` — "
"that data is already available:\n\n"
"- **TTM Analysis**: 8-quarter Trailing Twelve Months trends — revenue growth "
"(QoQ and YoY), margin trajectories (gross, operating, net), ROE trend, "
"debt/equity trend, and free cash flow.\n"
"- **Fundamental Ratios**: Latest snapshot of key ratios (PE, PEG, price-to-book, "
"beta, 52-week range).\n"
"- **Peer Comparison**: How the company ranks against sector peers over 1-week, "
"1-month, 3-month, and 6-month periods.\n"
"- **Sector Relative Performance**: The company's alpha vs its sector ETF benchmark.\n\n"
"## Your Task\n\n"
"Interpret the pre-loaded data analytically. Look for:\n"
"- Revenue and margin inflection points — acceleration, deceleration, or trend reversals\n"
"- Suspicious deviations in FCF vs reported net income (earnings quality signals)\n"
"- Peer divergence — is the company outperforming or underperforming its sector?\n"
"- Valuation anomalies vs growth trajectory (PEG vs actual growth rate)\n\n"
"If you identify anything suspicious in the TTM or fundamentals data that warrants "
"deeper investigation — for example, a margin inflection without an obvious revenue "
"driver, an FCF deviation from net income, or an unusual balance-sheet move — you "
"may call `get_balance_sheet`, `get_cashflow`, or `get_income_statement` to examine "
"the raw quarterly data directly.\n\n"
"Write a comprehensive report covering: multi-quarter revenue and margin trends, "
"TTM metrics, relative valuation vs peers, sector outperformance or underperformance, "
"and a clear medium-term fundamental thesis. "
"Do not simply state trends are mixed — provide detailed, fine-grained analysis that "
"identifies inflection points, acceleration or deceleration in growth, and specific "
"risks and opportunities. "
"Make sure to append a Markdown summary table at the end of the report organising "
"key metrics for easy reference."
)
prompt = ChatPromptTemplate.from_messages(
@ -55,7 +108,8 @@ def create_fundamentals_analyst(llm):
" 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}",
"For your reference, the current date is {current_date}. {instrument_context}\n\n"
"## Pre-loaded Context\n\n{prefetched_context}",
),
MessagesPlaceholder(variable_name="messages"),
]
@ -65,6 +119,7 @@ def create_fundamentals_analyst(llm):
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)
prompt = prompt.partial(prefetched_context=prefetched_context)
chain = prompt | llm.bind_tools(tools)

View File

@ -1,10 +1,16 @@
from datetime import datetime, timedelta
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
import time
from tradingagents.agents.utils.agent_utils import (
build_instrument_context,
format_prefetched_context,
prefetch_tools_parallel,
)
from tradingagents.agents.utils.core_stock_tools import get_stock_data
from tradingagents.agents.utils.technical_indicators_tools import get_indicators
from tradingagents.agents.utils.fundamental_data_tools import get_macro_regime
from tradingagents.agents.utils.technical_indicators_tools import get_indicators
from tradingagents.agents.utils.tool_runner import run_tool_loop
from tradingagents.agents.utils.agent_utils import build_instrument_context
from tradingagents.dataflows.config import get_config
@ -12,43 +18,107 @@ def create_market_analyst(llm):
def market_analyst_node(state):
current_date = state["trade_date"]
instrument_context = build_instrument_context(state["company_of_interest"])
ticker = state["company_of_interest"]
instrument_context = build_instrument_context(ticker)
tools = [
get_macro_regime,
get_stock_data,
get_indicators,
]
# ── Pre-fetch macro regime and stock price data in parallel ──────────
# Both are always required; fetching them upfront removes 2 LLM round-
# trips and lets the LLM focus its single tool-call budget on choosing
# the right indicators based on the macro regime it sees.
trade_date = datetime.strptime(current_date, "%Y-%m-%d")
stock_start = (trade_date - timedelta(days=365)).strftime("%Y-%m-%d")
prefetched = prefetch_tools_parallel(
[
{
"tool": get_macro_regime,
"args": {"curr_date": current_date},
"label": "Macro Regime Classification",
},
{
"tool": get_stock_data,
"args": {
"symbol": ticker,
"start_date": stock_start,
"end_date": current_date,
},
"label": "Stock Price Data",
},
]
)
prefetched_context = format_prefetched_context(prefetched)
# ── Only get_indicators remains iterative ─────────────────────────────
# The LLM reads the macro regime from the pre-loaded context and decides
# which indicators are most relevant before calling get_indicators.
tools = [get_indicators]
system_message = (
"""You are a trading assistant tasked with analyzing financial markets. Start by calling `get_macro_regime` to classify the current macro environment as risk-on, risk-off, or transition. Use this macro context to frame all subsequent technical analysis — for example, in risk-off environments weight bearish signals more heavily, and in risk-on environments favour momentum and breakout signals.
Then, select the **most relevant indicators** for the given market condition from the following list. The goal is to choose up to **8 indicators** that provide complementary insights without redundancy. Categories and each category's indicators are:
Moving Averages:
- close_50_sma: 50 SMA: A medium-term trend indicator. Usage: Identify trend direction and serve as dynamic support/resistance. Tips: It lags price; combine with faster indicators for timely signals.
- close_200_sma: 200 SMA: A long-term trend benchmark. Usage: Confirm overall market trend and identify golden/death cross setups. Tips: It reacts slowly; best for strategic trend confirmation rather than frequent trading entries.
- close_10_ema: 10 EMA: A responsive short-term average. Usage: Capture quick shifts in momentum and potential entry points. Tips: Prone to noise in choppy markets; use alongside longer averages for filtering false signals.
MACD Related:
- macd: MACD: Computes momentum via differences of EMAs. Usage: Look for crossovers and divergence as signals of trend changes. Tips: Confirm with other indicators in low-volatility or sideways markets.
- macds: MACD Signal: An EMA smoothing of the MACD line. Usage: Use crossovers with the MACD line to trigger trades. Tips: Should be part of a broader strategy to avoid false positives.
- macdh: MACD Histogram: Shows the gap between the MACD line and its signal. Usage: Visualize momentum strength and spot divergence early. Tips: Can be volatile; complement with additional filters in fast-moving markets.
Momentum Indicators:
- rsi: RSI: Measures momentum to flag overbought/oversold conditions. Usage: Apply 70/30 thresholds and watch for divergence to signal reversals. Tips: In strong trends, RSI may remain extreme; always cross-check with trend analysis.
Volatility Indicators:
- boll: Bollinger Middle: A 20 SMA serving as the basis for Bollinger Bands. Usage: Acts as a dynamic benchmark for price movement. Tips: Combine with the upper and lower bands to effectively spot breakouts or reversals.
- boll_ub: Bollinger Upper Band: Typically 2 standard deviations above the middle line. Usage: Signals potential overbought conditions and breakout zones. Tips: Confirm signals with other tools; prices may ride the band in strong trends.
- boll_lb: Bollinger Lower Band: Typically 2 standard deviations below the middle line. Usage: Indicates potential oversold conditions. Tips: Use additional analysis to avoid false reversal signals.
- atr: ATR: Averages true range to measure volatility. Usage: Set stop-loss levels and adjust position sizes based on current market volatility. Tips: It's a reactive measure, so use it as part of a broader risk management strategy.
Volume-Based Indicators:
- vwma: VWMA: A moving average weighted by volume. Usage: Confirm trends by integrating price action with volume data. Tips: Watch for skewed results from volume spikes; use in combination with other volume analyses.
- Select indicators that provide diverse and complementary information. Avoid redundancy (e.g., do not select both rsi and stochrsi). Also briefly explain why they are suitable for the given market context. When you tool call, please use the exact name of the indicators provided above as they are defined parameters, otherwise your call will fail. Please make sure to call get_stock_data first to retrieve the CSV that is needed to generate indicators. Then use get_indicators with the specific indicator names. Write a very detailed and nuanced report of the trends you observe. Provide specific, actionable insights with supporting evidence to help traders make informed decisions."""
+ """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read."""
"You are a trading assistant tasked with analyzing financial markets.\n\n"
"## Pre-loaded Data\n\n"
"The macro regime classification and recent stock price data for the company under "
"analysis have already been fetched and are provided in the **Pre-loaded Context** "
"section below. "
"Do NOT call `get_macro_regime` or `get_stock_data` — the data is already available.\n\n"
"## Your Task\n\n"
"1. Read the macro regime classification from the pre-loaded context. "
"The macro regime has been classified above — use it to weight your indicator "
"choices before calling `get_indicators`. For example, in risk-off environments "
"favour ATR, Bollinger Bands, and long-term SMAs; in risk-on environments favour "
"momentum indicators like MACD and short EMAs.\n\n"
"2. Select the **most relevant indicators** for the given market condition from "
"the list below. Choose up to **8 indicators** that provide complementary insights "
"without redundancy.\n\n"
"Moving Averages:\n"
"- close_50_sma: 50 SMA: A medium-term trend indicator. Usage: Identify trend "
"direction and serve as dynamic support/resistance. Tips: It lags price; combine "
"with faster indicators for timely signals.\n"
"- close_200_sma: 200 SMA: A long-term trend benchmark. Usage: Confirm overall "
"market trend and identify golden/death cross setups. Tips: It reacts slowly; best "
"for strategic trend confirmation rather than frequent trading entries.\n"
"- close_10_ema: 10 EMA: A responsive short-term average. Usage: Capture quick "
"shifts in momentum and potential entry points. Tips: Prone to noise in choppy "
"markets; use alongside longer averages for filtering false signals.\n\n"
"MACD Related:\n"
"- macd: MACD: Computes momentum via differences of EMAs. Usage: Look for "
"crossovers and divergence as signals of trend changes. Tips: Confirm with other "
"indicators in low-volatility or sideways markets.\n"
"- macds: MACD Signal: An EMA smoothing of the MACD line. Usage: Use crossovers "
"with the MACD line to trigger trades. Tips: Should be part of a broader strategy "
"to avoid false positives.\n"
"- macdh: MACD Histogram: Shows the gap between the MACD line and its signal. "
"Usage: Visualize momentum strength and spot divergence early. Tips: Can be "
"volatile; complement with additional filters in fast-moving markets.\n\n"
"Momentum Indicators:\n"
"- rsi: RSI: Measures momentum to flag overbought/oversold conditions. Usage: "
"Apply 70/30 thresholds and watch for divergence to signal reversals. Tips: In "
"strong trends, RSI may remain extreme; always cross-check with trend analysis.\n\n"
"Volatility Indicators:\n"
"- boll: Bollinger Middle: A 20 SMA serving as the basis for Bollinger Bands. "
"Usage: Acts as a dynamic benchmark for price movement. Tips: Combine with the "
"upper and lower bands to effectively spot breakouts or reversals.\n"
"- boll_ub: Bollinger Upper Band: Typically 2 standard deviations above the middle "
"line. Usage: Signals potential overbought conditions and breakout zones. Tips: "
"Confirm signals with other tools; prices may ride the band in strong trends.\n"
"- boll_lb: Bollinger Lower Band: Typically 2 standard deviations below the middle "
"line. Usage: Indicates potential oversold conditions. Tips: Use additional "
"analysis to avoid false reversal signals.\n"
"- atr: ATR: Averages true range to measure volatility. Usage: Set stop-loss "
"levels and adjust position sizes based on current market volatility. Tips: It's "
"a reactive measure, so use it as part of a broader risk management strategy.\n\n"
"Volume-Based Indicators:\n"
"- vwma: VWMA: A moving average weighted by volume. Usage: Confirm trends by "
"integrating price action with volume data. Tips: Watch for skewed results from "
"volume spikes; use in combination with other volume analyses.\n\n"
"3. Select indicators that provide diverse and complementary information. Avoid "
"redundancy (e.g., do not select both rsi and stochrsi). Briefly explain why each "
"chosen indicator is suitable for the current macro context. When calling "
"`get_indicators`, use the exact indicator names listed above — they are defined "
"parameters and any deviation will cause the call to fail.\n\n"
"4. Write a very detailed and nuanced report of the trends you observe. Provide "
"specific, actionable insights with supporting evidence to help traders make "
"informed decisions. Make sure to append a Markdown table at the end of the report "
"to organise key points, making it easy to read."
)
prompt = ChatPromptTemplate.from_messages(
@ -62,7 +132,8 @@ Volume-Based Indicators:
" 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}",
"For your reference, the current date is {current_date}. {instrument_context}\n\n"
"## Pre-loaded Context\n\n{prefetched_context}",
),
MessagesPlaceholder(variable_name="messages"),
]
@ -72,6 +143,7 @@ Volume-Based Indicators:
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)
prompt = prompt.partial(prefetched_context=prefetched_context)
chain = prompt | llm.bind_tools(tools)
@ -80,8 +152,16 @@ Volume-Based Indicators:
report = result.content or ""
macro_regime_report = ""
# Extract macro regime section if present
if report and ("Macro Regime Classification" in report or "RISK-ON" in report.upper() or "RISK-OFF" in report.upper() or "TRANSITION" in report.upper()):
# Extract macro regime section if present (from pre-loaded context or report)
regime_data = prefetched.get("Macro Regime Classification", "")
if regime_data and not regime_data.startswith("[Error"):
macro_regime_report = regime_data
elif report and (
"Macro Regime Classification" in report
or "RISK-ON" in report.upper()
or "RISK-OFF" in report.upper()
or "TRANSITION" in report.upper()
):
macro_regime_report = report
return {

View File

@ -1,24 +1,65 @@
from datetime import datetime, timedelta
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
import json
from tradingagents.agents.utils.news_data_tools import get_news, get_global_news
from tradingagents.agents.utils.tool_runner import run_tool_loop
from tradingagents.agents.utils.agent_utils import build_instrument_context
from tradingagents.agents.utils.agent_utils import (
build_instrument_context,
format_prefetched_context,
prefetch_tools_parallel,
)
from tradingagents.agents.utils.news_data_tools import get_global_news, get_news
from tradingagents.dataflows.config import get_config
def create_news_analyst(llm):
def news_analyst_node(state):
current_date = state["trade_date"]
instrument_context = build_instrument_context(state["company_of_interest"])
ticker = state["company_of_interest"]
instrument_context = build_instrument_context(ticker)
tools = [
get_news,
get_global_news,
]
# ── Pre-fetch company-specific and global news in parallel ────────────
trade_date = datetime.strptime(current_date, "%Y-%m-%d")
start_date = (trade_date - timedelta(days=7)).strftime("%Y-%m-%d")
prefetched = prefetch_tools_parallel(
[
{
"tool": get_news,
"args": {
"ticker": ticker,
"start_date": start_date,
"end_date": current_date,
},
"label": "Company-Specific News (Last 7 Days)",
},
{
"tool": get_global_news,
"args": {
"curr_date": current_date,
"look_back_days": 7,
"limit": 5,
},
"label": "Global Macroeconomic News (Last 7 Days)",
},
]
)
prefetched_context = format_prefetched_context(prefetched)
system_message = (
"You are a news researcher tasked with analyzing recent news and trends over the past week. Please write a comprehensive report of the current state of the world that is relevant for trading and macroeconomics. Use the available tools: get_news(query, start_date, end_date) for company-specific or targeted news searches, and get_global_news(curr_date, look_back_days, limit) for broader macroeconomic news. Provide specific, actionable insights with supporting evidence to help traders make informed decisions."
+ """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read."""
"You are a news researcher tasked with analyzing recent news and trends over "
"the past week.\n\n"
"## Pre-loaded Data\n\n"
"Both company-specific news and global macroeconomic news for the past 7 days "
"have already been fetched and are provided in the **Pre-loaded Context** section "
"below. Do NOT call `get_news` or `get_global_news` — the data is already available.\n\n"
"## Your Task\n\n"
"Synthesize the pre-loaded news feeds into a comprehensive report covering the "
"current state of the world as it is relevant to trading and macroeconomics. "
"Cross-reference company-specific developments with the broader macro backdrop. "
"Provide specific, actionable insights with supporting evidence to help traders "
"make informed decisions. "
"Make sure to append a Markdown table at the end of the report to organise key "
"points, making it easy to read."
)
prompt = ChatPromptTemplate.from_messages(
@ -26,25 +67,27 @@ def create_news_analyst(llm):
(
"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}",
"\n{system_message}"
"For your reference, the current date is {current_date}. {instrument_context}\n\n"
"## Pre-loaded Context\n\n{prefetched_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)
prompt = prompt.partial(prefetched_context=prefetched_context)
chain = prompt | llm.bind_tools(tools)
result = run_tool_loop(chain, state["messages"], tools)
# No tools remain — use direct invocation (no bind_tools, no tool loop)
chain = prompt | llm
result = chain.invoke(state["messages"])
report = result.content or ""

View File

@ -1,24 +1,59 @@
from datetime import datetime, timedelta
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
import time
import json
from tradingagents.agents.utils.agent_utils import (
build_instrument_context,
format_prefetched_context,
prefetch_tools_parallel,
)
from tradingagents.agents.utils.news_data_tools import get_news
from tradingagents.agents.utils.tool_runner import run_tool_loop
from tradingagents.agents.utils.agent_utils import build_instrument_context
from tradingagents.dataflows.config import get_config
def create_social_media_analyst(llm):
def social_media_analyst_node(state):
current_date = state["trade_date"]
instrument_context = build_instrument_context(state["company_of_interest"])
ticker = state["company_of_interest"]
instrument_context = build_instrument_context(ticker)
tools = [
get_news,
]
# ── Pre-fetch company news for the past 7 days ────────────────────────
trade_date = datetime.strptime(current_date, "%Y-%m-%d")
start_date = (trade_date - timedelta(days=7)).strftime("%Y-%m-%d")
prefetched = prefetch_tools_parallel(
[
{
"tool": get_news,
"args": {
"ticker": ticker,
"start_date": start_date,
"end_date": current_date,
},
"label": "Company News & Social Media (Last 7 Days)",
},
]
)
prefetched_context = format_prefetched_context(prefetched)
system_message = (
"You are a social media and company specific news researcher/analyst tasked with analyzing social media posts, recent company news, and public sentiment for a specific company over the past week. You will be given a company's name your objective is to write a comprehensive long report detailing your analysis, insights, and implications for traders and investors on this company's current state after looking at social media and what people are saying about that company, analyzing sentiment data of what people feel each day about the company, and looking at recent company news. Use the get_news(query, start_date, end_date) tool to search for company-specific news and social media discussions. Try to look at all sources possible from social media to sentiment to news. Provide specific, actionable insights with supporting evidence to help traders make informed decisions."
+ """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read."""
"You are a social media and company-specific news researcher/analyst tasked with "
"analyzing social media posts, recent company news, and public sentiment for a "
"specific company over the past week.\n\n"
"## Pre-loaded Data\n\n"
"Company-specific news and social media discussions for the past 7 days have already "
"been fetched and are provided in the **Pre-loaded Context** section below. "
"Do NOT call `get_news` — the data is already available.\n\n"
"## Your Task\n\n"
"Using the pre-loaded news and social media data, write a comprehensive long report "
"detailing your analysis, insights, and implications for traders and investors on "
"this company's current state. Cover:\n"
"- Social media sentiment and what people are saying about the company\n"
"- Daily sentiment shifts over the past week\n"
"- Recent company news and its implications\n\n"
"Provide specific, actionable insights with supporting evidence to help traders make "
"informed decisions. Make sure to append a Markdown table at the end of the report "
"to organise key points, making it easy to read."
)
prompt = ChatPromptTemplate.from_messages(
@ -26,26 +61,27 @@ def create_social_media_analyst(llm):
(
"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}",
"\n{system_message}"
"For your reference, the current date is {current_date}. {instrument_context}\n\n"
"## Pre-loaded Context\n\n{prefetched_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)
prompt = prompt.partial(prefetched_context=prefetched_context)
chain = prompt | llm.bind_tools(tools)
# No tools remain — use direct invocation (no bind_tools, no tool loop)
chain = prompt | llm
result = run_tool_loop(chain, state["messages"], tools)
result = chain.invoke(state["messages"])
report = result.content or ""

View File

@ -54,10 +54,14 @@ def create_pm_decision_agent(llm):
"produce a structured JSON investment decision. "
"Consider: reducing risk where metrics are poor, acting on SELL recommendations, "
"and adding positions in high-conviction candidates that pass constraints. "
"For every BUY you MUST set a stop_loss price (maximum acceptable loss level, "
"typically 5-15% below entry) and a take_profit price (expected sell target, "
"typically 10-30% above entry based on your thesis). "
"Output ONLY valid JSON matching this exact schema:\n"
"{\n"
' "sells": [{"ticker": "...", "shares": 0.0, "rationale": "..."}],\n'
' "buys": [{"ticker": "...", "shares": 0.0, "price_target": 0.0, '
'"stop_loss": 0.0, "take_profit": 0.0, '
'"sector": "...", "rationale": "...", "thesis": "..."}],\n'
' "holds": [{"ticker": "...", "rationale": "..."}],\n'
' "cash_reserve_pct": 0.10,\n'

View File

@ -1,6 +1,57 @@
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from langchain_core.messages import HumanMessage, RemoveMessage
def prefetch_tools_parallel(tool_calls: list[dict]) -> dict[str, str]:
"""Pre-fetch multiple LangChain tools in parallel using ThreadPoolExecutor.
Each entry in *tool_calls* must be a dict with:
- ``"tool"``: the LangChain tool object (must have an ``.invoke()`` method)
- ``"args"``: dict of keyword arguments to pass to ``tool.invoke()``
- ``"label"``: human-readable section header for the injected context block
Per-tool exceptions are caught so that a failure in one pre-fetch never
crashes the analyst node. The failing entry is replaced with an error
placeholder so the LLM can fall back to calling that tool itself.
Returns:
dict mapping ``label`` result string (or error placeholder)
"""
results: dict[str, str] = {}
def _fetch_one(tc: dict) -> tuple[str, str]:
label: str = tc["label"]
try:
result = tc["tool"].invoke(tc["args"])
return label, str(result)
except Exception as exc: # noqa: BLE001
return label, f"[Error fetching {label}: {exc}]"
with ThreadPoolExecutor() as executor:
futures = {executor.submit(_fetch_one, tc): tc["label"] for tc in tool_calls}
for future in as_completed(futures):
label, result = future.result()
results[label] = result
return results
def format_prefetched_context(results: dict[str, str]) -> str:
"""Format a prefetched-results dict into a clean Markdown block.
Each key becomes a ``## <label>`` section header, with the corresponding
content beneath it. Sections are separated by a horizontal rule so the
LLM can locate each dataset quickly.
Returns:
A single multi-line string ready for injection into a system prompt.
"""
sections = [f"## {label}\n\n{content}" for label, content in results.items()]
return "\n\n---\n\n".join(sections)
def build_instrument_context(ticker: str) -> str:
"""Describe the exact instrument so agents preserve exchange-qualified tickers."""

View File

@ -0,0 +1,17 @@
-- =============================================================================
-- Portfolio Manager Agent — Migration 002
-- Migration: 002_add_trade_risk_levels.sql
-- Description: Adds stop_loss and take_profit columns to the trades table so
-- that the PM agent can record risk-management price levels for
-- every BUY trade.
-- Safe to re-run: uses ADD COLUMN IF NOT EXISTS.
-- =============================================================================
ALTER TABLE trades
ADD COLUMN IF NOT EXISTS stop_loss NUMERIC(18,4) CHECK (stop_loss IS NULL OR stop_loss > 0),
ADD COLUMN IF NOT EXISTS take_profit NUMERIC(18,4) CHECK (take_profit IS NULL OR take_profit > 0);
COMMENT ON COLUMN trades.stop_loss IS
'Price level at which the position should be exited to limit downside loss.';
COMMENT ON COLUMN trades.take_profit IS
'Price target at which the position should be sold to realise the expected profit.';

View File

@ -219,6 +219,8 @@ class Trade:
trade_date: str = ""
rationale: str | None = None
signal_source: str | None = None # "scanner" | "holding_review" | "pm_agent"
stop_loss: float | None = None # Price level at which the position should be exited to limit loss
take_profit: float | None = None # Price target at which the position should be sold for profit
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
@ -234,12 +236,16 @@ class Trade:
"trade_date": self.trade_date,
"rationale": self.rationale,
"signal_source": self.signal_source,
"stop_loss": self.stop_loss,
"take_profit": self.take_profit,
"metadata": self.metadata,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Trade":
"""Deserialise from a DB row or JSON dict."""
raw_sl = data.get("stop_loss")
raw_tp = data.get("take_profit")
return cls(
trade_id=data["trade_id"],
portfolio_id=data["portfolio_id"],
@ -251,6 +257,8 @@ class Trade:
trade_date=data.get("trade_date", ""),
rationale=data.get("rationale"),
signal_source=data.get("signal_source"),
stop_loss=float(raw_sl) if raw_sl is not None else None,
take_profit=float(raw_tp) if raw_tp is not None else None,
metadata=data.get("metadata") or {},
)

View File

@ -117,6 +117,8 @@ class PortfolioRepository:
price: float,
sector: str | None = None,
industry: str | None = None,
stop_loss: float | None = None,
take_profit: float | None = None,
) -> Holding:
"""Buy shares and update portfolio cash and holdings."""
if shares <= 0:
@ -173,6 +175,8 @@ class PortfolioRepository:
total_value=cost,
trade_date=datetime.now(timezone.utc).isoformat(),
signal_source="pm_agent",
stop_loss=stop_loss,
take_profit=take_profit,
)
self._client.record_trade(trade)

View File

@ -137,6 +137,10 @@ class TradeExecutor:
shares = float(buy.get("shares") or 0)
sector = buy.get("sector")
rationale = buy.get("rationale") or ""
raw_sl = buy.get("stop_loss")
raw_tp = buy.get("take_profit")
stop_loss = float(raw_sl) if raw_sl is not None else None
take_profit = float(raw_tp) if raw_tp is not None else None
if not ticker or shares <= 0:
failed_trades.append({
@ -196,6 +200,8 @@ class TradeExecutor:
shares,
price,
sector=sector,
stop_loss=stop_loss,
take_profit=take_profit,
)
executed_trades.append({
"action": "BUY",
@ -204,9 +210,16 @@ class TradeExecutor:
"price": price,
"sector": sector,
"rationale": rationale,
"stop_loss": stop_loss,
"take_profit": take_profit,
"trade_date": trade_date,
})
logger.info("BUY %s x %.2f @ %.2f", ticker, shares, price)
logger.info(
"BUY %s x %.2f @ %.2f (SL=%s TP=%s)",
ticker, shares, price,
f"{stop_loss:.2f}" if stop_loss is not None else "N/A",
f"{take_profit:.2f}" if take_profit is not None else "N/A",
)
except (InsufficientCashError, PortfolioError) as exc:
failed_trades.append({
"action": "BUY",