feat: wire up stop levels, circuit breaker daily loss, profit-taking, real portfolio/decisions/history endpoints

This commit is contained in:
Celes Renata
2026-04-16 15:52:46 +00:00
parent 1329df0bbf
commit 63287903d0
3 changed files with 318 additions and 57 deletions
+79 -4
View File
@@ -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 []
# ---------------------------------------------------------------------------
+170
View File
@@ -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: