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
This commit is contained in:
copilot-swe-agent[bot] 2026-03-23 21:12:01 +00:00
parent 8567f08b91
commit 8e48bb4906
12 changed files with 186 additions and 7 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,15 +221,35 @@ 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>
<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>
{t.rationale && (

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

@ -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

@ -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",