From 63287903d0e9061f6e45a73c59c426f7b22775cb Mon Sep 17 00:00:00 2001 From: Celes Renata Date: Thu, 16 Apr 2026 15:52:46 +0000 Subject: [PATCH] feat: wire up stop levels, circuit breaker daily loss, profit-taking, real portfolio/decisions/history endpoints --- .../pages/trading/PortfolioComposition.tsx | 122 +++++++------ services/trading/app.py | 83 ++++++++- services/trading/engine.py | 170 ++++++++++++++++++ 3 files changed, 318 insertions(+), 57 deletions(-) diff --git a/frontend/src/pages/trading/PortfolioComposition.tsx b/frontend/src/pages/trading/PortfolioComposition.tsx index a07e167..c778011 100644 --- a/frontend/src/pages/trading/PortfolioComposition.tsx +++ b/frontend/src/pages/trading/PortfolioComposition.tsx @@ -1,4 +1,6 @@ import { useMemo } from 'react'; +import { usePositions } from '../../api/hooks'; +import type { Position } from '../../api/hooks'; import { useTradingStatus } from '../../api/tradingHooks'; import { Card, LoadingSpinner } from '../../components/ui'; import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'; @@ -8,57 +10,54 @@ const COLORS = [ '#ec4899', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', ]; -interface Position { - ticker: string; - entry_price: number; - current_price: number; - unrealized_pnl: number; - stop_loss: number | null; - take_profit: number | null; - sector: string | null; - quantity: number; -} - function fmtUsd(v: number | null | undefined) { if (v == null) return '—'; - return `$${v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + return `$${Number(v).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; } function pnlColor(v: number | null | undefined) { if (v == null) return 'text-gray-400'; - return v >= 0 ? 'text-green-400' : 'text-red-400'; + return Number(v) >= 0 ? 'text-green-400' : 'text-red-400'; } export function PortfolioComposition() { - const { data: status, isLoading } = useTradingStatus(); + const { data: positions, isLoading: posLoading } = usePositions(); + const { data: status } = useTradingStatus(); - // Extract positions from the status — the API may embed them or we derive from open_position_count - // For now, we use a typed cast since the status object may carry positions in an extended response - const positions: Position[] = useMemo(() => { - if (!status) return []; - const raw = (status as unknown as Record)['positions']; - if (Array.isArray(raw)) return raw as Position[]; - return []; - }, [status]); - - const sectorData = useMemo(() => { - const map = new Map(); - for (const p of positions) { - const sector = p.sector ?? 'Unknown'; - const value = (p.current_price ?? 0) * (p.quantity ?? 0); - map.set(sector, (map.get(sector) ?? 0) + value); - } - return Array.from(map.entries()).map(([name, value]) => ({ name, value })); + const tickerData = useMemo(() => { + if (!positions) return []; + return positions.map((p: Position) => ({ + name: p.ticker, + value: Math.abs(Number(p.quantity) * Number(p.avg_entry_price)), + })); }, [positions]); - if (isLoading) return ; + const totalInvested = useMemo(() => { + if (!positions) return 0; + return positions.reduce((sum: number, p: Position) => sum + Math.abs(Number(p.quantity) * Number(p.avg_entry_price)), 0); + }, [positions]); + + const totalUnrealized = useMemo(() => { + if (!positions) return 0; + return positions.reduce((sum: number, p: Position) => sum + Number(p.unrealized_pnl ?? 0), 0); + }, [positions]); + + if (posLoading) return ; return (
+ {/* Summary Stats */} +
+ + + + +
+ {/* Positions Table */} -

Current Positions

- {positions.length === 0 ? ( +

Current Positions ({positions?.length ?? 0})

+ {!positions?.length ? (

No open positions

) : (
@@ -66,40 +65,48 @@ export function PortfolioComposition() { Ticker + Qty Entry Current + Market Value Unrealized P&L - Stop-Loss - Take-Profit - Sector + Return % - {positions.map((p) => ( - - {p.ticker} - {fmtUsd(p.entry_price)} - {fmtUsd(p.current_price)} - {fmtUsd(p.unrealized_pnl)} - {fmtUsd(p.stop_loss)} - {fmtUsd(p.take_profit)} - {p.sector ?? '—'} - - ))} + {positions.map((p: Position) => { + const qty = Number(p.quantity); + const entry = Number(p.avg_entry_price); + const current = Number(p.current_price ?? entry); + const marketValue = qty * current; + const pnl = Number(p.unrealized_pnl ?? 0); + const returnPct = entry > 0 ? ((current - entry) / entry) * 100 : 0; + return ( + + {p.ticker} + {qty} + {fmtUsd(entry)} + {fmtUsd(current)} + {fmtUsd(marketValue)} + {fmtUsd(pnl)} + {returnPct.toFixed(2)}% + + ); + })}
)}
- {/* Sector Allocation Pie Chart */} - {sectorData.length > 0 && ( + {/* Allocation Pie Chart */} + {tickerData.length > 0 && ( -

Sector Allocation

+

Position Allocation

`${name} ${((percent ?? 0) * 100).toFixed(0)}%`} > - {sectorData.map((_, i) => ( + {tickerData.map((_, i) => ( ))} @@ -123,3 +130,12 @@ export function PortfolioComposition() {
); } + +function StatCard({ label, value, color = 'text-gray-100' }: { label: string; value: string; color?: string }) { + return ( + +
{value}
+
{label}
+
+ ); +} diff --git a/services/trading/app.py b/services/trading/app.py index 935856f..223e712 100644 --- a/services/trading/app.py +++ b/services/trading/app.py @@ -363,11 +363,60 @@ async def set_capital(body: CapitalRequest) -> dict[str, Any]: async def list_decisions( ticker: Optional[str] = None, decision: Optional[str] = None, + is_micro_trade: Optional[bool] = None, limit: int = Query(default=50, le=200), offset: int = 0, ) -> list[dict[str, Any]]: - """Return recent trading decisions (placeholder — paginated).""" - return [] + """Return recent trading decisions from the database.""" + if engine is None or engine.pool is None: + return [] + + conditions = ["1=1"] + params: list[Any] = [] + idx = 1 + + if ticker: + conditions.append(f"ticker = ${idx}") + params.append(ticker.upper()) + idx += 1 + if decision: + conditions.append(f"decision = ${idx}") + params.append(decision) + idx += 1 + if is_micro_trade is not None: + conditions.append(f"is_micro_trade = ${idx}") + params.append(is_micro_trade) + idx += 1 + + where = " AND ".join(conditions) + params.extend([limit, offset]) + + try: + rows = await engine.pool.fetch( + f"SELECT id, recommendation_id, decision, skip_reason, ticker, " + f"computed_position_size, computed_share_quantity, " + f"risk_tier_at_decision, portfolio_heat_at_decision, " + f"active_pool_at_decision, reserve_pool_at_decision, " + f"circuit_breaker_status, is_micro_trade, created_at " + f"FROM trading_decisions WHERE {where} " + f"ORDER BY created_at DESC LIMIT ${idx} OFFSET ${idx + 1}", + *params, + ) + from decimal import Decimal as _Dec + result = [] + for r in rows: + d = dict(r) + for k, v in d.items(): + if isinstance(v, _Dec): + d[k] = float(v) + elif isinstance(v, datetime): + d[k] = v.isoformat() + elif hasattr(v, '__str__') and not isinstance(v, (str, int, float, bool, type(None))): + d[k] = str(v) + result.append(d) + return result + except Exception: + return [] # --------------------------------------------------------------------------- @@ -401,8 +450,34 @@ async def current_metrics() -> dict[str, Any]: async def metrics_history( limit: int = Query(default=30, le=365), ) -> list[dict[str, Any]]: - """Return historical daily snapshots (placeholder).""" - return [] + """Return historical daily portfolio snapshots.""" + if engine is None or engine.pool is None: + return [] + + try: + rows = await engine.pool.fetch( + "SELECT id, snapshot_date, portfolio_value, active_pool, reserve_pool, " + "daily_return, cumulative_return, unrealized_pnl, realized_pnl, " + "win_count, loss_count, win_rate, sharpe_ratio, max_drawdown, " + "current_drawdown_pct, portfolio_heat, risk_tier, created_at " + "FROM portfolio_snapshots ORDER BY snapshot_date DESC LIMIT $1", + limit, + ) + from decimal import Decimal as _Dec + result = [] + for r in rows: + d = dict(r) + for k, v in d.items(): + if isinstance(v, _Dec): + d[k] = float(v) + elif isinstance(v, datetime): + d[k] = v.isoformat() + elif hasattr(v, '__str__') and not isinstance(v, (str, int, float, bool, type(None))): + d[k] = str(v) + result.append(d) + return result + except Exception: + return [] # --------------------------------------------------------------------------- diff --git a/services/trading/engine.py b/services/trading/engine.py index 2ab7ce3..2623535 100644 --- a/services/trading/engine.py +++ b/services/trading/engine.py @@ -786,6 +786,33 @@ class TradingEngine: self.portfolio_state.active_pool if self.portfolio_state else 0, ) + # Create stop-loss levels for the new position + if self.pool is not None: + try: + price = rec.get("current_price", 0.0) + atr_est = price * 0.02 # 2% ATR estimate + tier = self._active_risk_tier + sl_price = price * (1 - 0.02 * tier.stop_loss_atr_multiplier) + tp_price = price * (1 + 0.02 * tier.stop_loss_atr_multiplier * tier.reward_risk_ratio) + await self.pool.execute( + "INSERT INTO position_stop_levels " + "(ticker, entry_price, stop_loss_price, take_profit_price, " + "trailing_stop_active, atr_value, atr_multiplier, " + "reward_risk_ratio, signal_confidence, is_micro_trade, active) " + "VALUES ($1, $2, $3, $4, FALSE, $5, $6, $7, $8, FALSE, TRUE) " + "ON CONFLICT (ticker) WHERE active = TRUE DO UPDATE SET " + "entry_price = EXCLUDED.entry_price, " + "stop_loss_price = EXCLUDED.stop_loss_price, " + "take_profit_price = EXCLUDED.take_profit_price, " + "updated_at = NOW()", + decision.ticker, price, sl_price, tp_price, + atr_est, tier.stop_loss_atr_multiplier, + tier.reward_risk_ratio, + rec.get("confidence", 0.8), + ) + except Exception: + logger.debug("Could not create stop levels for %s", decision.ticker) + # Persist decision await self._persist_decision(decision) @@ -955,6 +982,18 @@ class TradingEngine: except Exception: logger.debug("Could not refresh correlation matrix") + # Profit-taking: sell positions that have exceeded the take-profit threshold + try: + await self._check_profit_taking() + except Exception: + logger.debug("Could not run profit-taking check") + + # Circuit breaker: check daily loss threshold + try: + await self._check_circuit_breaker_daily_loss() + except Exception: + logger.debug("Could not run circuit breaker daily loss check") + except asyncio.CancelledError: break except Exception: @@ -1588,6 +1627,137 @@ class TradingEngine: logger.debug("Could not load stop levels — table may not exist") return {} + async def _check_circuit_breaker_daily_loss(self) -> None: + """Check if daily unrealized loss exceeds the circuit breaker threshold. + + If the portfolio has lost more than circuit_breaker_daily_loss_pct + (default 5%) from its start-of-day value, activate the circuit breaker + to halt all new trades. + """ + if self.pool is None or self.portfolio_state is None: + return + + # Already active — nothing to do + if self._cb_state.active: + return + + try: + # Get total unrealized P&L from positions + row = await self.pool.fetchrow( + "SELECT COALESCE(SUM(unrealized_pnl), 0) AS total_pnl FROM positions WHERE quantity > 0" + ) + total_pnl = float(row["total_pnl"]) if row else 0.0 + total_value = self.portfolio_state.total_value + + if total_value <= 0: + return + + daily_loss_pct = abs(min(0, total_pnl)) / total_value + + # Check against threshold (default 5%) + threshold = getattr(self.config, 'circuit_breaker_daily_loss_pct', 0.05) or 0.05 + if isinstance(threshold, str): + threshold = float(threshold) + + if daily_loss_pct >= threshold: + # Activate circuit breaker + now = datetime.now(tz=timezone.utc) + cooldown_hours = getattr(self.config, 'circuit_breaker_volatility_pause_hours', 2) or 2 + cooldown_expires = now + timedelta(hours=int(cooldown_hours)) + + self._cb_state = CircuitBreakerState( + active=True, + trigger_type="daily_loss_limit", + triggered_at=now, + cooldown_expires=cooldown_expires, + ) + + # Persist to DB + try: + await self.pool.execute( + "INSERT INTO circuit_breaker_events " + "(trigger_type, triggered_at, cooldown_expires, trigger_data) " + "VALUES ($1, $2, $3, $4)", + "daily_loss_limit", + now, + cooldown_expires, + json.dumps({ + "daily_loss_pct": round(daily_loss_pct, 4), + "threshold": threshold, + "total_pnl": round(total_pnl, 2), + "total_value": round(total_value, 2), + }), + ) + except Exception: + logger.debug("Could not persist circuit breaker event") + + logger.warning( + "CIRCUIT BREAKER ACTIVATED: daily loss %.1f%% exceeds %.1f%% threshold " + "(P&L=$%.2f, value=$%.2f). Trading halted until %s", + daily_loss_pct * 100, threshold * 100, + total_pnl, total_value, cooldown_expires.isoformat(), + ) + + # Create alert + self.create_alert( + "circuit_breaker_activated", + f"Daily loss {daily_loss_pct:.1%} exceeded {threshold:.1%} threshold. " + f"Trading halted until {cooldown_expires.strftime('%H:%M ET')}.", + ) + except Exception: + logger.debug("Could not check circuit breaker daily loss") + + async def _check_profit_taking(self) -> None: + """Check positions for profit-taking opportunities. + + Sells positions that have gained more than the take-profit threshold + defined by the risk tier's reward_risk_ratio. For moderate tier with + 2.0 ATR stop and 1.5 reward/risk, that's roughly a 3% gain target. + + Also sells positions that have gained > 10% regardless of tier + (absolute profit cap to lock in gains). + """ + if self.pool is None: + return + + try: + rows = await self.pool.fetch( + "SELECT ticker, quantity, avg_entry_price, current_price, unrealized_pnl " + "FROM positions WHERE quantity > 0" + ) + except Exception: + return + + for row in rows: + ticker = row["ticker"] + qty = int(row["quantity"]) + entry = float(row["avg_entry_price"]) + current = float(row["current_price"] or 0) + + if entry <= 0 or current <= 0 or qty <= 0: + continue + + gain_pct = (current - entry) / entry + + # Absolute profit cap: sell if gain > 10% + # Or tier-based: reward_risk_ratio * stop_loss_atr_multiplier * ~2% base ATR + tier_target = self._active_risk_tier.reward_risk_ratio * self._active_risk_tier.stop_loss_atr_multiplier * 0.02 + should_sell = gain_pct >= 0.10 or gain_pct >= tier_target + + if should_sell: + logger.info( + "Profit-taking: %s gained %.1f%% (target=%.1f%%) — selling %d shares", + ticker, gain_pct * 100, max(10.0, tier_target * 100), qty, + ) + await self._submit_sell_order(ticker, qty, f"profit_taking_{gain_pct:.1%}") + + # Update portfolio state + if self.portfolio_state: + self.portfolio_state.open_position_count = max( + 0, self.portfolio_state.open_position_count - 1 + ) + self.portfolio_state.active_pool += current * qty + async def _submit_sell_order( self, ticker: str, quantity: int, reason: str ) -> None: