For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make Capital's five global risk limits (stop-loss, take-profit, order-size cap, daily-loss limit, drawdown kill switch) editable at runtime from the Settings UI, applied on the next engine tick, without an engine restart.
Architecture: The RiskManager enforcement math in engine/trading/risk.py is unchanged. Limits move from boot-time env config into the runtime appsettings key-value store (no DB migration — the store is generic KV). The engine builds a fresh RiskManager from the store each tick (same store→engine flow as ai_action_mode). A typed settings endpoint + a "Risk controls" card on the Settings page read/write them. Limits default to 0/disabled; the UI pre-fills recommended values (no behavior change until saved). Getters fall back to the env risk_* value so existing env-configured deployments keep working.
Tech Stack: Python 3.12, SQLModel, FastAPI, Pydantic; React 19 + TypeScript + Vite; openapi-typescript for the client types; pytest.
Spec: docs/superpowers/specs/2026-07-02-runtime-tunable-risk-controls-design.md
Conventions (verified in the codebase):
- Store getters/setters use
get_setting/set_setting+_decimal_setting(session, key, default)(engine/appsettings/store.py). - API: per-setting
*UpdatePydantic model +@router.put("/<name>", response_model=SettingsRead)that calls the store setter(s),record_audit(...), then returns_read(session). Deps:AdminUser,SessionDep. - Web client:
const { data, error } = await api.PUT("/api/settings/<name>", { body }); if (error) ...; return data;(web/src/lib/api/settings.ts). - Tests use the
sessionandclient/loginfixtures inengine/tests/conftest.py.
Files:
-
Modify:
engine/appsettings/store.py -
Test:
engine/tests/test_appsettings.py -
Step 1: Write the failing tests
Add to engine/tests/test_appsettings.py (imports at top: extend the existing from appsettings.store import (...) block with the five getters/setters):
def test_risk_limits_default_to_zero(session: Session) -> None:
from appsettings.store import (
get_risk_stop_loss_pct,
get_risk_take_profit_pct,
get_risk_max_drawdown_pct,
get_risk_daily_loss_limit,
get_risk_max_position_notional,
)
assert get_risk_stop_loss_pct(session) == Decimal(0)
assert get_risk_take_profit_pct(session) == Decimal(0)
assert get_risk_max_drawdown_pct(session) == Decimal(0)
assert get_risk_daily_loss_limit(session) == Decimal(0)
assert get_risk_max_position_notional(session) == Decimal(0)
def test_risk_limit_round_trip(session: Session) -> None:
from appsettings.store import get_risk_stop_loss_pct, set_risk_stop_loss_pct
set_risk_stop_loss_pct(session, Decimal("5"))
assert get_risk_stop_loss_pct(session) == Decimal("5")
def test_risk_getter_falls_back_to_env_default_when_unset(session: Session) -> None:
from appsettings.store import get_risk_stop_loss_pct
# No stored value → the caller-supplied default (env fallback) is returned.
assert get_risk_stop_loss_pct(session, Decimal("3")) == Decimal("3")
def test_risk_explicit_zero_overrides_env_default(session: Session) -> None:
from appsettings.store import get_risk_stop_loss_pct, set_risk_stop_loss_pct
set_risk_stop_loss_pct(session, Decimal("0")) # operator deliberately disabled it
assert get_risk_stop_loss_pct(session, Decimal("3")) == Decimal("0")- Step 2: Run the tests to verify they fail
Run: cd engine && python -m pytest tests/test_appsettings.py -k risk -v
Expected: FAIL — ImportError: cannot import name 'get_risk_stop_loss_pct'.
- Step 3: Add the key constants and getters/setters
In engine/appsettings/store.py, add the key constants alongside the existing ones (near _AI_SPEND_CAP = "ai_spend_cap_usd"):
_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"Add the getters/setters near the other _decimal_setting helpers (e.g. below set_ai_spend_cap). 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))- Step 4: Run the tests to verify they pass
Run: cd engine && python -m pytest tests/test_appsettings.py -k risk -v
Expected: PASS (4 tests).
- Step 5: Commit
git add engine/appsettings/store.py engine/tests/test_appsettings.py
git commit -m "feat(risk): risk-limit getters/setters in the settings store"Files:
-
Modify:
engine/trading/risk.py -
Test:
engine/tests/test_risk.py -
Step 1: Write the failing tests
Add to engine/tests/test_risk.py (it already imports RiskManager, Decimal, Session):
def test_from_store_reads_stored_limits(session: Session) -> None:
from appsettings.store import set_risk_stop_loss_pct, set_risk_max_position_notional
set_risk_stop_loss_pct(session, Decimal("5"))
set_risk_max_position_notional(session, Decimal("1000"))
rm = RiskManager.from_store(session)
assert rm.stop_loss_pct == Decimal("5")
assert rm.max_position_notional == Decimal("1000")
assert rm.take_profit_pct == Decimal(0) # unset → disabled
def test_from_store_falls_back_to_env_settings(session: Session) -> None:
from config import Settings
env = Settings(risk_stop_loss_pct=Decimal("7")) # nothing stored
rm = RiskManager.from_store(session, env)
assert rm.stop_loss_pct == Decimal("7")- Step 2: Run the tests to verify they fail
Run: cd engine && python -m pytest tests/test_risk.py -k from_store -v
Expected: FAIL — AttributeError: type object 'RiskManager' has no attribute 'from_store'.
- Step 3: Implement
from_store
In engine/trading/risk.py, add the import of the env singleton near the existing from config import Settings:
from config import Settings, settings as _env_settingsAdd the classmethod directly below the existing from_settings classmethod:
@classmethod
def from_store(
cls, session: Session, settings: Settings | None = None
) -> "RiskManager":
"""Build from the runtime settings store, falling back to env config
for any limit the operator has not set through the UI."""
from appsettings.store import (
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,
)
env = settings if settings is not None else _env_settings
return cls(
max_position_notional=get_risk_max_position_notional(
session, env.risk_max_position_notional
),
stop_loss_pct=get_risk_stop_loss_pct(session, env.risk_stop_loss_pct),
take_profit_pct=get_risk_take_profit_pct(session, env.risk_take_profit_pct),
daily_loss_limit=get_risk_daily_loss_limit(session, env.risk_daily_loss_limit),
max_drawdown_pct=get_risk_max_drawdown_pct(session, env.risk_max_drawdown_pct),
)(The appsettings.store import is inside the method to avoid any import-order coupling with the store module.)
- Step 4: Run the tests to verify they pass
Run: cd engine && python -m pytest tests/test_risk.py -k from_store -v
Expected: PASS (2 tests). Also run the full file to confirm no regressions: python -m pytest tests/test_risk.py -v → all PASS.
- Step 5: Commit
git add engine/trading/risk.py engine/tests/test_risk.py
git commit -m "feat(risk): RiskManager.from_store with env fallback"Files:
-
Modify:
engine/trading/engine.py(constructor ~line 110;_tick_strategy~lines 162-260) -
Test:
engine/tests/test_trading_engine.py -
Step 1: Confirm the public tick method name
Run: cd engine && grep -n "def tick\|def _tick_strategy\|self._risk" trading/engine.py
Expected: a public def tick(self) that iterates strategies and calls _tick_strategy; self._risk used at ~line 194 (stop_order) and ~line 260 (review). Use the confirmed public method name in Step 2's test (below it is written as eng.tick()).
- Step 2: Write the failing test
Add to engine/tests/test_trading_engine.py (imports: add from appsettings.store import set_risk_max_position_notional; list_positions, Market, BuyWhenFlat, _engine, factory already exist in the file):
def test_engine_applies_store_risk_limits(factory: Any) -> None:
# BuyWhenFlat buys qty 1 at the FakeVenue price of 100 → notional 100.
# A stored per-order notional cap of 50 must clip the fill to qty 0.5.
strat = BuyWhenFlat("buyer", "BTCUSDT", market=Market.spot)
eng = _engine(factory, [strat])
with factory() as session:
set_risk_max_position_notional(session, Decimal("50"))
session.commit()
eng.tick()
with factory() as session:
positions = list_positions(session)
assert len(positions) == 1
assert positions[0].qty == Decimal("0.5")- Step 3: Run the test to verify it fails
Run: cd engine && python -m pytest tests/test_trading_engine.py -k store_risk_limits -v
Expected: FAIL — position qty is 1 (the boot-time RiskManager() has all limits disabled, so the order is not capped).
- Step 4: Refactor the engine to read per tick
In engine/trading/engine.py constructor, replace the boot-time singleton line:
self._risk = risk or RiskManager() # all limits disabled by defaultwith an override field (do NOT build a default here):
# Optional injected RiskManager (tests). When None, limits are read from
# the settings store on every tick so UI changes apply without a restart.
self._risk_override = riskIn _tick_strategy, immediately after the session is opened (with self._session_factory() as session:), build the per-tick manager:
risk = self._risk_override or RiskManager.from_store(session)Then change the two use sites in _tick_strategy from self._risk to the local risk:
stop = risk.stop_order(position, price) order = risk.review(session, order, position, price)- Step 5: Run the test to verify it passes
Run: cd engine && python -m pytest tests/test_trading_engine.py -k store_risk_limits -v
Expected: PASS (qty == 0.5).
- Step 6: Run the full engine + risk suites for regressions
Run: cd engine && python -m pytest tests/test_trading_engine.py tests/test_risk.py -v
Expected: all PASS (tests that inject risk=RiskManager(...) still work via _risk_override).
- Step 7: Commit
git add engine/trading/engine.py engine/tests/test_trading_engine.py
git commit -m "feat(risk): engine reads risk limits from the store each tick"Files:
-
Modify:
engine/api/settings.py -
Test:
engine/tests/test_settings_api.py -
Step 1: Write the failing tests
Add to engine/tests/test_settings_api.py (follow the file's existing pattern; it uses the client fixture + login). If the file already has an auth helper, reuse it; otherwise use the login(client, "admin", ADMIN_PASSWORD) helper from conftest.py and send the token as a Bearer header:
from conftest import ADMIN_PASSWORD, login
def _auth(client) -> dict[str, str]:
return {"Authorization": f"Bearer {login(client, 'admin', ADMIN_PASSWORD)}"}
def test_read_settings_exposes_risk_limits_defaulting_to_zero(client) -> None:
r = client.get("/api/settings", headers=_auth(client))
assert r.status_code == 200
body = r.json()
assert body["risk_stop_loss_pct"] == "0"
assert body["risk_max_position_notional"] == "0"
def test_update_risk_settings_persists_and_returns(client) -> None:
r = client.put(
"/api/settings/risk",
headers=_auth(client),
json={
"stop_loss_pct": "5",
"take_profit_pct": "10",
"max_drawdown_pct": "15",
"daily_loss_limit": "250",
"max_position_notional": "1000",
},
)
assert r.status_code == 200
assert r.json()["risk_stop_loss_pct"] == "5"
# persisted: a fresh read reflects it
r2 = client.get("/api/settings", headers=_auth(client))
assert r2.json()["risk_daily_loss_limit"] == "250"
def test_update_risk_settings_rejects_negative_and_over_100(client) -> None:
r = client.put(
"/api/settings/risk",
headers=_auth(client),
json={
"stop_loss_pct": "-1",
"take_profit_pct": "10",
"max_drawdown_pct": "15",
"daily_loss_limit": "0",
"max_position_notional": "0",
},
)
assert r.status_code == 422 # Pydantic validation
r = client.put(
"/api/settings/risk",
headers=_auth(client),
json={
"stop_loss_pct": "150", # > 100%
"take_profit_pct": "10",
"max_drawdown_pct": "15",
"daily_loss_limit": "0",
"max_position_notional": "0",
},
)
assert r.status_code == 422- Step 2: Run the tests to verify they fail
Run: cd engine && python -m pytest tests/test_settings_api.py -k risk -v
Expected: FAIL — the read has no risk_stop_loss_pct key and PUT /api/settings/risk is 404/405.
- Step 3: Add the read fields
In engine/api/settings.py:
- Ensure
Fieldis imported: changefrom pydantic import BaseModeltofrom pydantic import BaseModel, Field(ifFieldis not already imported). - Extend the
from appsettings.store import (...)block with the five getters and setters:get_risk_stop_loss_pct, get_risk_take_profit_pct, get_risk_max_drawdown_pct, get_risk_daily_loss_limit, get_risk_max_position_notional, set_risk_stop_loss_pct, set_risk_take_profit_pct, set_risk_max_drawdown_pct, set_risk_daily_loss_limit, set_risk_max_position_notional. - Import the env singleton for the UI-read fallback:
from config import settings as _env_settings. - Add fields to
SettingsRead(belowpolymarket_stake):
# 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- Populate them in
_read(session)(inside theSettingsRead(...)call), passing the env values as fallbacks so the UI shows what the engine would use:
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),- Step 4: Add the update model + endpoint
Add the model alongside the other *Update models (e.g. below PolymarketSettingsUpdate):
class RiskSettingsUpdate(BaseModel):
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)Add the endpoint alongside the other @router.put handlers (e.g. below update_polymarket_settings):
@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)- Step 5: Run the tests to verify they pass
Run: cd engine && python -m pytest tests/test_settings_api.py -k risk -v
Expected: PASS (3 tests). Then run the whole engine suite: python -m pytest -q → all PASS.
- Step 6: Commit
git add engine/api/settings.py engine/tests/test_settings_api.py
git commit -m "feat(risk): settings API to read and update risk limits"Files:
-
Regenerate:
web/openapi.json,web/src/lib/api/schema.d.ts -
Modify:
web/src/lib/api/settings.ts -
Step 1: Regenerate the OpenAPI schema + TS types
Find the FastAPI app object: cd engine && grep -rn "FastAPI(" . (expected in main.py, e.g. app = FastAPI(...)). Dump the spec and regenerate the client types:
cd engine
python -c "import json, main; open('../web/openapi.json','w').write(json.dumps(main.app.openapi()))"
cd ../web
npm run gen:apiExpected: web/openapi.json and web/src/lib/api/schema.d.ts now include RiskSettingsUpdate and the five risk_* fields on SettingsRead. Verify: grep -n "risk_stop_loss_pct\|RiskSettingsUpdate" web/src/lib/api/schema.d.ts prints matches.
- Step 2: Add the client function
Append to web/src/lib/api/settings.ts, mirroring updatePolymarketSettings:
export async function updateRiskSettings(body: {
stop_loss_pct: string;
take_profit_pct: string;
max_drawdown_pct: string;
daily_loss_limit: string;
max_position_notional: string;
}): Promise<Settings> {
const { data, error } = await api.PUT("/api/settings/risk", { body });
if (error) throw new Error("Failed to update risk settings");
return data;
}- Step 3: Typecheck
Run: cd web && npm run typecheck
Expected: exit 0 (the new client + regenerated types compile).
- Step 4: Commit
git add web/openapi.json web/src/lib/api/schema.d.ts web/src/lib/api/settings.ts
git commit -m "feat(risk): regenerate API types + risk settings client"Files:
- Modify:
web/src/pages/Settings.tsx
Recommended pre-fill values (percentages pre-fill on enable; the two absolute-currency limits use a placeholder hint, since a sane number depends on the operator's capital): stop-loss 5, take-profit 10, max-drawdown 15; daily-loss-limit and max-position-notional start blank with hint text.
- Step 1: Import the client + add component state
In web/src/pages/Settings.tsx, add updateRiskSettings to the settings-client import block (which already imports updateAiSpendCap, updatePolymarketSettings, etc.). Add state near the other setting groups (e.g. below the polymarket pm* state):
// Risk controls — value "" or "0" means the limit is disabled.
const [riskStopLoss, setRiskStopLoss] = useState("");
const [riskTakeProfit, setRiskTakeProfit] = useState("");
const [riskMaxDrawdown, setRiskMaxDrawdown] = useState("");
const [riskDailyLoss, setRiskDailyLoss] = useState("");
const [riskMaxNotional, setRiskMaxNotional] = useState("");- Step 2: Hydrate the state from loaded settings
In the effect/handler that copies loaded settings into local state (where setAiSpendCap(...) etc. are called after fetchSettings()), add:
setRiskStopLoss(nonZero(s.risk_stop_loss_pct));
setRiskTakeProfit(nonZero(s.risk_take_profit_pct));
setRiskMaxDrawdown(nonZero(s.risk_max_drawdown_pct));
setRiskDailyLoss(nonZero(s.risk_daily_loss_limit));
setRiskMaxNotional(nonZero(s.risk_max_position_notional));Add this helper near the top of the module (a "0"/"0.00" limit shows as an empty, disabled field):
const nonZero = (v: string): string => (Number(v) > 0 ? v : "");- Step 3: Add the save handler
Add near the other save handlers (e.g. savePolymarket), sending "0" for any blank (disabled) field:
const saveRisk = async () => {
setBusy(true);
setError(null);
try {
const next = await updateRiskSettings({
stop_loss_pct: riskStopLoss || "0",
take_profit_pct: riskTakeProfit || "0",
max_drawdown_pct: riskMaxDrawdown || "0",
daily_loss_limit: riskDailyLoss || "0",
max_position_notional: riskMaxNotional || "0",
});
setSettings(next);
setNotice("Risk controls saved — applied on the next engine tick.");
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save risk controls");
} finally {
setBusy(false);
}
};- Step 4: Render the card
Add a "Risk controls" Card in the settings layout (follow the existing card markup — Card + SectionHeader + rows + a Button). Each row is a labeled numeric Input; a Toggle arms a percentage limit by pre-filling the recommended value (blank = disabled). Use the design tokens/components already in the file:
<Card>
<SectionHeader
title="Risk controls"
subtitle="Global limits enforced before every order. 0 / off disables a limit."
/>
<div style={{ padding: 16, display: "flex", flexDirection: "column", gap: 14 }}>
<div style={{ fontSize: 12, color: "var(--text-2)" }}>
Changes apply on the next engine tick — a stop-loss you enable may
immediately close an already-losing position.
</div>
<RiskRow
label="Stop-loss"
hint="% of entry value; force-closes a losing position"
unit="%"
value={riskStopLoss}
onChange={setRiskStopLoss}
recommended="5"
/>
<RiskRow
label="Take-profit"
hint="% of entry value; force-closes a winning position"
unit="%"
value={riskTakeProfit}
onChange={setRiskTakeProfit}
recommended="10"
/>
<RiskRow
label="Max drawdown (kill switch)"
hint="% from equity peak; halts new exposure"
unit="%"
value={riskMaxDrawdown}
onChange={setRiskMaxDrawdown}
recommended="15"
/>
<RiskRow
label="Daily loss limit (kill switch)"
hint="quote currency; halts new exposure for the day"
unit="USDT"
value={riskDailyLoss}
onChange={setRiskDailyLoss}
placeholder="e.g. 2% of equity"
/>
<RiskRow
label="Max position notional"
hint="quote currency; caps a single order's size"
unit="USDT"
value={riskMaxNotional}
onChange={setRiskMaxNotional}
placeholder="e.g. 20% of equity"
/>
<div>
<Button kind="primary" onClick={() => void saveRisk()} disabled={busy}>
Save risk controls
</Button>
</div>
</div>
</Card>Add the RiskRow helper component at the bottom of the module (a toggle that pre-fills/clears the value, plus the numeric input):
function RiskRow({
label,
hint,
unit,
value,
onChange,
recommended,
placeholder,
}: {
label: string;
hint: string;
unit: string;
value: string;
onChange: (v: string) => void;
recommended?: string;
placeholder?: string;
}) {
const enabled = Number(value) > 0;
return (
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<Toggle
checked={enabled}
onChange={(on) => onChange(on ? (recommended ?? "") : "")}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: "var(--text)" }}>{label}</div>
<div style={{ fontSize: 11.5, color: "var(--text-2)" }}>{hint}</div>
</div>
<div style={{ width: 160 }}>
<Input
value={value}
onChange={onChange}
suffix={unit}
placeholder={placeholder ?? "off"}
/>
</div>
</div>
);
}Note: confirm Toggle's prop names and Input's onChange/suffix/placeholder props against web/src/components/ui.tsx and adjust the two lines if they differ (e.g. Toggle may take label; Input onChange is (value: string) => void). These components are already used elsewhere in Settings.tsx, so mirror an existing call site.
- Step 5: Typecheck + lint
Run: cd web && npm run typecheck && npm run lint
Expected: exit 0 for both (pre-existing react-refresh warnings are acceptable; no new errors).
- Step 6: Commit
git add web/src/pages/Settings.tsx
git commit -m "feat(risk): Risk controls card on the Settings page"- Step 1: Full engine test suite
Run: cd engine && python -m pytest -q
Expected: all PASS.
- Step 2: Web typecheck + lint + build
Run: cd web && npm run typecheck && npm run lint && npm run build
Expected: typecheck/lint exit 0; vite build succeeds.
- Step 3: Manual smoke (optional but recommended)
Start the app (cd web && npm run dev + the engine), open Settings → Risk controls, toggle stop-loss on (pre-fills 5), save, confirm the notice, reload, confirm the value persists. Switch to sim mode and confirm an armed limit is respected on the next tick.
- Spec coverage: storage (T1), from_store + env fallback (T2), per-tick engine read + injected override preserved (T3), API read/update + validation (T4), regenerated client (T5), Settings UI card with hybrid recommended pre-fills + "applies next tick" warning (T6), tests throughout, final verification (T7). Non-goals (backtest sim, per-strategy, notifications) intentionally excluded.
- No migration — the store is generic KV; confirmed via
_decimal_setting/set_setting. - Backward-compat — getters take an env-default arg;
from_storeand_readboth pass the envrisk_*values, so a value shows/enforces identically whether it came from env or the store. - Open confirmations flagged inline: the public tick method name (T3 S1), the FastAPI app import path for the openapi dump (T5 S1), and
Toggle/Inputprop names (T6 S4) — each has a concrete grep/verification step rather than an assumption.