From 8e48bb4906031113961c7d96c3ce0374fec5ca3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:12:01 +0000 Subject: [PATCH] Add stop_loss and take_profit fields to Trade entries in database, API, and UI Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com> Agent-Logs-Url: https://github.com/aguzererler/TradingAgents/sessions/3b6a6fcc-30ac-48fa-970f-995a3bed80ed --- agent_os/backend/routes/portfolios.py | 2 + .../src/components/PortfolioViewer.tsx | 30 ++++++++-- docs/agent/CURRENT_STATE.md | 3 +- docs/portfolio/02_data_models.md | 2 + docs/portfolio/03_database_schema.md | 4 ++ tests/portfolio/test_models.py | 49 +++++++++++++++++ tests/portfolio/test_trade_executor.py | 55 ++++++++++++++++++- .../agents/portfolio/pm_decision_agent.py | 4 ++ .../migrations/002_add_trade_risk_levels.sql | 17 ++++++ tradingagents/portfolio/models.py | 8 +++ tradingagents/portfolio/repository.py | 4 ++ tradingagents/portfolio/trade_executor.py | 15 ++++- 12 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 tradingagents/portfolio/migrations/002_add_trade_risk_levels.sql diff --git a/agent_os/backend/routes/portfolios.py b/agent_os/backend/routes/portfolios.py index 7f2a590a..3deb5439 100644 --- a/agent_os/backend/routes/portfolios.py +++ b/agent_os/backend/routes/portfolios.py @@ -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 { diff --git a/agent_os/frontend/src/components/PortfolioViewer.tsx b/agent_os/frontend/src/components/PortfolioViewer.tsx index 8af38609..8f27af58 100644 --- a/agent_os/frontend/src/components/PortfolioViewer.tsx +++ b/agent_os/frontend/src/components/PortfolioViewer.tsx @@ -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 = ({ defaultPortfol border="1px solid" borderColor="whiteAlpha.100" justify="space-between" - align="center" + align="flex-start" > - + {t.action?.toUpperCase()} - {t.ticker} - {t.quantity} @ ${(t.price ?? 0).toFixed(2)} + + + {t.ticker} + {t.quantity} @ ${(t.price ?? 0).toFixed(2)} + + {(t.stop_loss != null || t.take_profit != null) && ( + + {t.stop_loss != null && ( + + SL: + ${t.stop_loss.toFixed(2)} + + )} + {t.take_profit != null && ( + + TP: + ${t.take_profit.toFixed(2)} + + )} + + )} + {t.executed_at || '—'} diff --git a/docs/agent/CURRENT_STATE.md b/docs/agent/CURRENT_STATE.md index fad695dc..613f1f42 100644 --- a/docs/agent/CURRENT_STATE.md +++ b/docs/agent/CURRENT_STATE.md @@ -1,9 +1,10 @@ # Current Milestone -AgentOS visual observability layer shipped. Portfolio Manager fully implemented (Phases 1–10). All 725 tests passing (14 skipped). +AgentOS visual observability layer shipped. Portfolio Manager fully implemented (Phases 1–10). 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 diff --git a/docs/portfolio/02_data_models.md b/docs/portfolio/02_data_models.md index b4ac86f5..4bc6d838 100644 --- a/docs/portfolio/02_data_models.md +++ b/docs/portfolio/02_data_models.md @@ -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 diff --git a/docs/portfolio/03_database_schema.md b/docs/portfolio/03_database_schema.md index 757f4549..80e198e6 100644 --- a/docs/portfolio/03_database_schema.md +++ b/docs/portfolio/03_database_schema.md @@ -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 --- diff --git a/tests/portfolio/test_models.py b/tests/portfolio/test_models.py index bcd2258a..09a76fd5 100644 --- a/tests/portfolio/test_models.py +++ b/tests/portfolio/test_models.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/portfolio/test_trade_executor.py b/tests/portfolio/test_trade_executor.py index 9786f3a0..55effe9f 100644 --- a/tests/portfolio/test_trade_executor.py +++ b/tests/portfolio/test_trade_executor.py @@ -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 diff --git a/tradingagents/agents/portfolio/pm_decision_agent.py b/tradingagents/agents/portfolio/pm_decision_agent.py index 4c42cd96..c83ec672 100644 --- a/tradingagents/agents/portfolio/pm_decision_agent.py +++ b/tradingagents/agents/portfolio/pm_decision_agent.py @@ -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' diff --git a/tradingagents/portfolio/migrations/002_add_trade_risk_levels.sql b/tradingagents/portfolio/migrations/002_add_trade_risk_levels.sql new file mode 100644 index 00000000..76213c31 --- /dev/null +++ b/tradingagents/portfolio/migrations/002_add_trade_risk_levels.sql @@ -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.'; diff --git a/tradingagents/portfolio/models.py b/tradingagents/portfolio/models.py index ed3a379b..f00f09b4 100644 --- a/tradingagents/portfolio/models.py +++ b/tradingagents/portfolio/models.py @@ -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 {}, ) diff --git a/tradingagents/portfolio/repository.py b/tradingagents/portfolio/repository.py index d39c1144..4ec71f4c 100644 --- a/tradingagents/portfolio/repository.py +++ b/tradingagents/portfolio/repository.py @@ -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) diff --git a/tradingagents/portfolio/trade_executor.py b/tradingagents/portfolio/trade_executor.py index bd14bc98..7e240e5b 100644 --- a/tradingagents/portfolio/trade_executor.py +++ b/tradingagents/portfolio/trade_executor.py @@ -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",