Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
745 changes: 745 additions & 0 deletions docs/superpowers/plans/2026-07-02-runtime-tunable-risk-controls.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Design: Runtime-tunable risk controls

**Date:** 2026-07-02
**Status:** Approved (design) — pending implementation plan
**Scope:** Engine + web. Make the five global risk limits in `engine/trading/risk.py`
enableable and tunable at runtime from the Settings UI, without an engine restart.

## Problem

`engine/trading/risk.py` already implements a complete, correct `RiskManager`:
stop-loss, take-profit, per-order notional cap, daily realized-loss limit, and a
drawdown kill switch. But every limit is wired only through `config.py` env
settings (`risk_*`), read **once at engine boot** (`engine.py:110`,
`self._risk = risk or RiskManager()`), and defaults to `0` = disabled. There is
no Settings UI for them, and the backtest does not simulate them.

Net effect: the safety net that most protects a solo operator's capital ships
**off**, and turning it on requires editing env + restarting the live engine —
so in practice it stays off. This design closes that gap for live trading. It
does **not** change the enforcement math, only how the limits are configured,
surfaced, and applied.

## Goals

- Risk limits are editable at runtime from the Settings page (no restart).
- Changes take effect on the next engine tick, in both sim and live modes.
- Zero surprise: nothing auto-closes until the operator explicitly enables a limit.
- Removing the "what number do I put here?" friction via recommended pre-fills.

## Non-goals (deferred)

- **Backtest simulation of the limits** — a strong follow-up (lets the operator
tune from the historical equity curve instead of intuition), but its own effort.
- **Per-strategy risk overrides** — the `RiskManager` stays global (engine-wide),
matching current architecture. (Per-strategy allocation + max-loss already exist
separately via `StrategyAllocation` / `_loss_cap_breached`.)
- **Kill-switch notifications** — could reuse the notifier later; out of scope now.

## Decisions (from brainstorming)

1. **Scope B** — runtime-editable via Settings UI (not env-only, not backtest-sim).
2. **Approach #1** — the engine reads the limits from the settings store **per
tick**, the same way `ai_action_mode` / AI spend-cap already flow store → engine.
3. **Defaults = C (hybrid)** — every limit defaults to `0` (disabled); the UI
pre-fills a recommended value next to each toggle so behavior changes only on
an explicit save.

## Design

### 1. Storage — settings store, no migration

The five limits become key-value entries in the existing `appsettings` store
(generic KV `settings` table + the `_decimal_setting` pattern the AI spend cap
uses), so **no Alembic migration is required** — only new keys.

| Store key | Unit | Recommended pre-fill |
|---|---|---|
| `risk_stop_loss_pct` | % of position entry value | 5% |
| `risk_take_profit_pct` | % of position entry value | 10% |
| `risk_max_drawdown_pct` | % from equity peak | 15% |
| `risk_daily_loss_limit` | quote currency (e.g. USDT) | hint: ~2% of equity (absolute; not auto-filled) |
| `risk_max_position_notional` | quote currency | hint: ~20% of equity (absolute; not auto-filled) |

Add typed getters/setters to `engine/appsettings/store.py`:
`get_risk_stop_loss_pct(session)` … `set_risk_stop_loss_pct(session, value)` etc.

**Backward-compatibility:** each getter returns the stored value when set, else
falls back to the env `settings.risk_*` value (0 unless already configured). Any
existing env-configured deployment keeps working; the store becomes the source of
truth once a value is saved through the UI.

### 2. Engine wiring — per-tick read

- Add `RiskManager.from_store(session)` (parallel to `from_settings`) reading the
five store getters.
- In `engine.py` `_tick_strategy` (which already opens a session), build the
manager from the store at the top of the tick rather than using a boot-time
singleton. The uses at `engine.py:194` (`stop_order`) and `:260` (`review`)
consume this per-tick instance.
- The `TradingEngine` constructor keeps an **optional injected `RiskManager`**
(`risk: RiskManager | None`). When injected (tests), use it; when `None`
(production), read from the store per tick. This keeps existing tests working.
- Enforcement math in `risk.py` is unchanged.

### 3. Settings UI — a "Risk controls" card

- New card on `web/src/pages/Settings.tsx`, following existing card/section and
design-system patterns. Five rows: each = label + one-line description +
a `Toggle` + a numeric input with its unit.
- Off ⇒ value `0` (disabled). Toggling on reveals the input **pre-filled with the
recommended value** (percentages) or focused with a placeholder hint (the two
absolute-currency limits). Numbers rendered mono/tabular per DESIGN.md.
- New typed settings endpoints on `engine/api/settings.py`:
`RiskSettingsRead` (GET) and `RiskSettingsUpdate` (using the same HTTP method
and shape as the existing `AiActionModeUpdate` / Polymarket settings endpoints —
match the established convention rather than introducing a new one).
- Regenerate the web API types (`npm run gen:api`) after the endpoint lands; add
a `fetchRiskSettings` / `updateRiskSettings` client in `web/src/lib/api/`.

### 4. Safety

- **No surprise:** all limits default to `0` / disabled; behavior changes only on save.
- **Mid-session application (documented in the UI):** changes take effect on the
next engine tick — this is existing behavior (`stop_order` runs every tick;
the kill switch checks live). The card carries an explicit copy line:
*"Applies on the next tick — a stop-loss you enable may immediately close an
already-losing position."*
- **Kill switch blocks new exposure only;** closing trades stay allowed (existing).
- **Server-side validation** in the update endpoint: reject negatives; clamp
percentages to 0–100; require notional / loss-limit ≥ 0.
- **Works in sim and live** (the checks run inside the engine tick, which runs for
both modes), so the operator can arm limits in simulation first.

### 5. Testing

- **Unit (engine):** store getters/setters — default, set/read round-trip, and the
env fallback; `RiskManager.from_store` maps the five fields correctly.
- **Engine integration:** a limit changed mid-run is enforced on the next tick
(set a stop-loss → a breaching open position closes; set a drawdown limit past
the current drawdown → kill switch blocks new exposure). Existing `risk.py`
enforcement tests remain the math baseline.
- **API:** `RiskSettingsUpdate` validation rejects negative / >100% values.
- **Web:** the Risk card renders, toggles pre-fill the recommended value, and save
calls the update endpoint (following existing Settings test patterns).

## Affected files

- `engine/trading/risk.py` — add `from_store`.
- `engine/appsettings/store.py` — five getters/setters with env fallback.
- `engine/trading/engine.py` — per-tick `RiskManager.from_store` in `_tick_strategy`;
keep optional injected override.
- `engine/api/settings.py` — `RiskSettingsRead` / `RiskSettingsUpdate` endpoints + validation.
- `web/src/pages/Settings.tsx` — Risk controls card.
- `web/src/lib/api/` — settings client additions + regenerated schema.
- Tests across the above.

## Open items to confirm at plan time

- Exact recommended pre-fill values (table above — starting point, operator-tunable).
- Whether the two absolute-currency limits should offer an equity-relative
suggestion computed client-side, or stay a plain hint (design assumes plain hint).
3 changes: 1 addition & 2 deletions engine/ai/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

from sqlmodel import Field, Session, SQLModel, select

from config import settings
from trading.executor_router import ExecutorRouter
from trading.executors.base import Order
from trading.models import FillSide
Expand Down Expand Up @@ -127,7 +126,7 @@ def execute_signal(
side=FillSide(signal.action),
quantity=signal.quantity,
)
reviewed = RiskManager.from_settings(settings).review(
reviewed = RiskManager.from_store(session).review(
session, order, position, price
)
if reviewed is None:
Expand Down
3 changes: 1 addition & 2 deletions engine/api/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
from auth.audit import record_audit
from auth.deps import SessionDep, require_admin
from auth.models import User
from config import settings
from trading.executor_router import ExecutorRouter
from trading.executors.base import ExecutionError, Fill
from trading.manual_order import ManualOrderRequest, OrderBlocked, submit_manual_order
Expand Down Expand Up @@ -50,7 +49,7 @@ def place_manual_order(
body,
venues=venues,
executor_router=_executor_router(),
risk=RiskManager.from_settings(settings),
risk=RiskManager.from_store(session),
)
except VenueError as exc:
raise HTTPException(
Expand Down
72 changes: 72 additions & 0 deletions engine/api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
get_research_interval_hours,
get_research_symbols,
get_research_writer,
get_risk_daily_loss_limit,
get_risk_max_drawdown_pct,
get_risk_max_position_notional,
get_risk_stop_loss_pct,
get_risk_take_profit_pct,
llm_provider_configured,
set_ai_action_mode,
set_ai_settings,
Expand All @@ -57,12 +62,18 @@
set_research_interval_hours,
set_research_symbols,
set_research_writer,
set_risk_daily_loss_limit,
set_risk_max_drawdown_pct,
set_risk_max_position_notional,
set_risk_stop_loss_pct,
set_risk_take_profit_pct,
set_venue_credentials,
venue_credentials_configured,
)
from auth.audit import record_audit
from auth.deps import SessionDep, require_admin
from auth.models import User
from config import settings as _env_settings
from trading.portfolio import list_positions
from venues.registry import AVAILABLE_VENUES, get_venue

Expand Down Expand Up @@ -112,6 +123,12 @@ class SettingsRead(BaseModel):
polymarket_min_confidence: Decimal
polymarket_screen_top: int
polymarket_stake: Decimal
# Global risk limits (0 = disabled). Enforced by trading/risk.py each tick.
risk_stop_loss_pct: Decimal
risk_take_profit_pct: Decimal
risk_max_drawdown_pct: Decimal
risk_daily_loss_limit: Decimal
risk_max_position_notional: Decimal


class VenueCredentialsUpdate(BaseModel):
Expand Down Expand Up @@ -187,6 +204,17 @@ class PolymarketSettingsUpdate(BaseModel):
stake: Decimal = Field(default=Decimal("100"), gt=0)


class RiskSettingsUpdate(BaseModel):
"""Global risk limits in percent (SL/TP/drawdown) or quote currency
(loss limit, notional). 0 disables a limit."""

stop_loss_pct: Decimal = Field(ge=0, le=100)
take_profit_pct: Decimal = Field(ge=0, le=100)
max_drawdown_pct: Decimal = Field(ge=0, le=100)
daily_loss_limit: Decimal = Field(ge=0)
max_position_notional: Decimal = Field(ge=0)


def _read(session: SessionDep) -> SettingsRead:
ai = get_ai_settings(session)
writer = get_research_writer(session)
Expand Down Expand Up @@ -220,6 +248,21 @@ def _read(session: SessionDep) -> SettingsRead:
polymarket_min_confidence=get_polymarket_min_confidence(session),
polymarket_screen_top=get_polymarket_screen_top(session),
polymarket_stake=get_polymarket_stake(session),
risk_stop_loss_pct=get_risk_stop_loss_pct(
session, _env_settings.risk_stop_loss_pct
),
risk_take_profit_pct=get_risk_take_profit_pct(
session, _env_settings.risk_take_profit_pct
),
risk_max_drawdown_pct=get_risk_max_drawdown_pct(
session, _env_settings.risk_max_drawdown_pct
),
risk_daily_loss_limit=get_risk_daily_loss_limit(
session, _env_settings.risk_daily_loss_limit
),
risk_max_position_notional=get_risk_max_position_notional(
session, _env_settings.risk_max_position_notional
),
)


Expand Down Expand Up @@ -443,6 +486,35 @@ def update_polymarket_settings(
return _read(session)


@router.put("/risk", response_model=SettingsRead)
def update_risk_settings(
body: RiskSettingsUpdate, admin: AdminUser, session: SessionDep
) -> SettingsRead:
"""Set the global risk limits (0 disables a limit).

Changes apply on the next engine tick — a stop-loss you enable may
immediately close an already-losing position.
"""
set_risk_stop_loss_pct(session, body.stop_loss_pct)
set_risk_take_profit_pct(session, body.take_profit_pct)
set_risk_max_drawdown_pct(session, body.max_drawdown_pct)
set_risk_daily_loss_limit(session, body.daily_loss_limit)
set_risk_max_position_notional(session, body.max_position_notional)
record_audit(
session,
actor=admin.username,
action="settings.risk",
detail={
"stop_loss_pct": str(body.stop_loss_pct),
"take_profit_pct": str(body.take_profit_pct),
"max_drawdown_pct": str(body.max_drawdown_pct),
"daily_loss_limit": str(body.daily_loss_limit),
"max_position_notional": str(body.max_position_notional),
},
)
return _read(session)


@router.put("/council", response_model=SettingsRead)
def update_council_settings(
body: CouncilSettingsUpdate, admin: AdminUser, session: SessionDep
Expand Down
58 changes: 58 additions & 0 deletions engine/appsettings/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ class TradingMode(StrEnum):
_AI_API_KEY = "ai_api_key"
_AI_SPEND_CAP = "ai_spend_cap_usd"
_AI_ACTION_MODE = "ai_action_mode"
_RISK_STOP_LOSS = "risk_stop_loss_pct"
_RISK_TAKE_PROFIT = "risk_take_profit_pct"
_RISK_MAX_DRAWDOWN = "risk_max_drawdown_pct"
_RISK_DAILY_LOSS = "risk_daily_loss_limit"
_RISK_MAX_NOTIONAL = "risk_max_position_notional"

#: How an AI strategy's decision is applied. `notify` (the default) surfaces it
#: for operator confirmation; `auto` executes it straight through the risk gate.
Expand Down Expand Up @@ -204,6 +209,59 @@ def set_ai_spend_cap(session: Session, cap: Decimal) -> None:
set_setting(session, _AI_SPEND_CAP, str(cap))


# -- risk limits ----------------------------------------------------------------
# Runtime-editable risk controls. Each getter takes a `default` so callers can
# supply the env fallback; `_decimal_setting` returns `default` only when the
# key is unset — an explicitly-stored `"0"` is returned as `Decimal(0)`.


def get_risk_stop_loss_pct(session: Session, default: Decimal = Decimal(0)) -> Decimal:
"""Stop-loss as a percent of a position's entry value (0 = disabled)."""
return _decimal_setting(session, _RISK_STOP_LOSS, default)


def set_risk_stop_loss_pct(session: Session, pct: Decimal) -> None:
set_setting(session, _RISK_STOP_LOSS, str(pct))


def get_risk_take_profit_pct(session: Session, default: Decimal = Decimal(0)) -> Decimal:
"""Take-profit as a percent of a position's entry value (0 = disabled)."""
return _decimal_setting(session, _RISK_TAKE_PROFIT, default)


def set_risk_take_profit_pct(session: Session, pct: Decimal) -> None:
set_setting(session, _RISK_TAKE_PROFIT, str(pct))


def get_risk_max_drawdown_pct(session: Session, default: Decimal = Decimal(0)) -> Decimal:
"""Kill-switch drawdown limit as a percent from the equity peak (0 = disabled)."""
return _decimal_setting(session, _RISK_MAX_DRAWDOWN, default)


def set_risk_max_drawdown_pct(session: Session, pct: Decimal) -> None:
set_setting(session, _RISK_MAX_DRAWDOWN, str(pct))


def get_risk_daily_loss_limit(session: Session, default: Decimal = Decimal(0)) -> Decimal:
"""Kill-switch daily realized-loss limit in quote currency (0 = disabled)."""
return _decimal_setting(session, _RISK_DAILY_LOSS, default)


def set_risk_daily_loss_limit(session: Session, amount: Decimal) -> None:
set_setting(session, _RISK_DAILY_LOSS, str(amount))


def get_risk_max_position_notional(
session: Session, default: Decimal = Decimal(0)
) -> Decimal:
"""Per-order notional cap in quote currency (0 = disabled)."""
return _decimal_setting(session, _RISK_MAX_NOTIONAL, default)


def set_risk_max_position_notional(session: Session, amount: Decimal) -> None:
set_setting(session, _RISK_MAX_NOTIONAL, str(amount))


# -- per-provider LLM credentials ---------------------------------------------
# Each LLM provider stores its own credentials, so a Claude strategy and a
# local-Ollama strategy can run side by side. Keys: `llm:{provider}:api_key`
Expand Down
2 changes: 0 additions & 2 deletions engine/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
from strategies.builtin import all_strategies_with_instances, seed_allocations
from trading.engine import TradingEngine
from trading.executor_router import ExecutorRouter
from trading.risk import RiskManager

setup_logging(settings.log_level)
log = logging.getLogger("capital")
Expand Down Expand Up @@ -89,7 +88,6 @@ def session_factory() -> Session:
session_factory=session_factory,
router=ExecutorRouter(),
strategies=strategies,
risk=RiskManager.from_settings(settings),
notifier=TelegramNotifier.from_settings(settings),
retention_candle_days=settings.retention_candle_days,
retention_equity_days=settings.retention_equity_days,
Expand Down
Loading
Loading