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:
parent
8567f08b91
commit
8e48bb4906
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
@ -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 {},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue