diff --git a/docs/design/pages/e2e/config_save.spec.js b/docs/design/pages/e2e/config_save.spec.js
new file mode 100644
index 0000000..2d7396d
--- /dev/null
+++ b/docs/design/pages/e2e/config_save.spec.js
@@ -0,0 +1,60 @@
+// M1 — Config de Risco não salvava pela UI: o front disparava PATCH a cada onChange
+// e sem confirm, e o backend exige confirm=true (400 confirmation_required). Fluxo
+// novo: edição acumula num draft; "Salvar" abre a confirmação (before→after); só ao
+// confirmar o PATCH é enviado, com confirm:true. O fixture espelha o gate (400 sem
+// confirm) — se o front regredir e parar de mandar confirm, este teste quebra.
+import { test, expect } from "@playwright/test";
+import { installMockApi } from "./fixtures/mockApi.js";
+
+test("Config de Risco: edição acumula, Salvar confirma before→after, PATCH com confirm:true", async ({ page }) => {
+ await installMockApi(page, { authMode: "user", role: "admin" });
+ const riskPatches = [];
+ page.on("request", (r) => {
+ if (r.method() === "PATCH" && r.url().includes("/v1/risk/config")) {
+ riskPatches.push(r.postDataJSON());
+ }
+ });
+ await page.goto("/#settings");
+ await expect(page.locator(".page-title")).toContainText("Configurações");
+
+ const riskCard = page.locator(".card", { hasText: "Gestão de Risco" });
+ const saveBtn = riskCard.getByRole("button", { name: "Salvar risco" });
+
+ // Sem alterações → Salvar desabilitado.
+ await expect(saveBtn).toBeDisabled();
+
+ // Editar (toggle do circuit breaker) só acumula no draft — NENHUM PATCH no onChange.
+ await riskCard.locator(".toggle").click();
+ await expect(saveBtn).toBeEnabled();
+ expect(riskPatches).toHaveLength(0);
+
+ // Salvar abre a confirmação com o resumo before→after.
+ await saveBtn.click();
+ const diff = page.getByTestId("config-confirm-diff");
+ await expect(diff).toBeVisible();
+ await expect(diff).toContainText("Circuit breaker");
+
+ // Confirmar dispara o PATCH — com confirm:true (senão o fixture responde 400).
+ await page.getByRole("button", { name: "Confirmar e salvar" }).click();
+ await expect(page.getByTestId("config-confirm-diff")).toHaveCount(0); // modal fechou
+ await expect(page.getByText("Risco salvo")).toBeVisible(); // sucesso (200)
+
+ expect(riskPatches).toHaveLength(1);
+ expect(riskPatches[0].confirm).toBe(true);
+ expect(riskPatches[0]).toHaveProperty("circuit_breaker_enabled");
+});
+
+test("Config de Risco: cancelar a confirmação não envia PATCH", async ({ page }) => {
+ await installMockApi(page, { authMode: "user", role: "admin" });
+ const riskPatches = [];
+ page.on("request", (r) => {
+ if (r.method() === "PATCH" && r.url().includes("/v1/risk/config")) riskPatches.push(r.url());
+ });
+ await page.goto("/#settings");
+ const riskCard = page.locator(".card", { hasText: "Gestão de Risco" });
+ await riskCard.locator(".toggle").click();
+ await riskCard.getByRole("button", { name: "Salvar risco" }).click();
+ await page.getByRole("button", { name: "Cancelar" }).click();
+ await expect(page.getByTestId("config-confirm-diff")).toHaveCount(0);
+ expect(riskPatches).toHaveLength(0);
+});
diff --git a/docs/design/pages/e2e/fixtures/mockApi.js b/docs/design/pages/e2e/fixtures/mockApi.js
index c8dcaec..2b5cf0e 100644
--- a/docs/design/pages/e2e/fixtures/mockApi.js
+++ b/docs/design/pages/e2e/fixtures/mockApi.js
@@ -60,7 +60,9 @@ function baseline({ authMode, role }) {
"GET /v1/process/events": () => processEvents(),
// Config (screen_settings)
"GET /v1/config": () => SYS_CONFIG,
+ "PATCH /v1/config": (req) => ({ ...SYS_CONFIG, ...(req.postDataJSON() || {}) }),
"GET /v1/risk/config": () => RISK_CONFIG,
+ // PATCH /v1/risk/config é tratado no route handler (gate confirm=true, M1).
"GET /v1/agents": () => AGENTS,
// Diário (screen_journal)
"GET /v1/journal": () => JOURNAL,
@@ -153,6 +155,20 @@ export async function installMockApi(page, scenario = {}) {
}
}
+ // --- M1: PATCH /v1/risk/config exige confirm=true (espelha risk.py:310) -
+ if (path === "/v1/risk/config" && method === "PATCH") {
+ const body = req.postDataJSON() || {};
+ if (body.confirm !== true) {
+ return route.fulfill({
+ status: 400, contentType: "application/json",
+ body: JSON.stringify({ error: "confirmation_required",
+ message: "Defina confirm=true para alterar parâmetros de risco." }),
+ });
+ }
+ const { confirm, ...patch } = body;
+ return fulfillJson(route, { ...RISK_CONFIG, ...patch });
+ }
+
// --- stateful desk summary (reflete pausar/retomar da Mesa, N9) --------
if (path === "/v1/desk/summary" && method === "GET") {
return fulfillJson(route, deskState);
diff --git a/docs/design/pages/screen_settings.jsx b/docs/design/pages/screen_settings.jsx
index 05a142e..c897841 100644
--- a/docs/design/pages/screen_settings.jsx
+++ b/docs/design/pages/screen_settings.jsx
@@ -91,6 +91,55 @@ function PairGroupsManager({ operated, canEdit, addToast }) {
);
}
+// M1: rótulos legíveis dos parâmetros para o resumo before→after da confirmação.
+const CONFIG_FIELD_LABELS = {
+ initial_capital: 'Capital inicial ($)',
+ orchestrator_interval_seconds: 'Intervalo do orquestrador (s)',
+ max_position_size_pct: 'Tamanho máximo de posição (%)',
+ stop_loss_default_pct: 'Stop loss padrão (%)',
+ max_daily_loss_pct: 'Drawdown máximo diário (%)',
+ max_weekly_loss_pct: 'Drawdown máximo semanal (%)',
+ max_monthly_loss_pct: 'Drawdown máximo mensal (%)',
+ kelly_fraction: 'Fração Kelly',
+ circuit_breaker_enabled: 'Circuit breaker',
+ revenge_size_multiplier: 'Multiplicador revenge trading',
+ euphoria_size_multiplier: 'Multiplicador euforia',
+ overconfidence_margin: 'Gap overconfidence',
+ risk_of_ruin_alert_pct: 'Alerta risco de ruína',
+};
+const fmtCfgVal = (v) => (typeof v === 'boolean' ? (v ? 'ativado' : 'desativado') : String(v));
+
+// M1: confirmação explícita antes de gravar config — resumo before→after (mesmo
+// padrão do aviso do A5). Só depois de confirmar é que o PATCH é enviado (risco
+// exige confirm=true no backend; sys/alertas não têm o gate mas ganham o mesmo
+// fluxo, eliminando o PATCH-a-cada-onChange).
+function ConfigConfirmModal({ title, changes, onConfirm, onClose }) {
+ return (
+
+
e.stopPropagation()} style={{ maxWidth: 480 }}>
+
{title}
+
Confira as alterações antes de aplicar:
+
+ | Parâmetro | Antes | Depois |
+
+ {changes.map(c => (
+
+ | {c.label} |
+ {fmtCfgVal(c.before)} |
+ {fmtCfgVal(c.after)} |
+
+ ))}
+
+
+
+ Cancelar
+ Confirmar e salvar
+
+
+
+ );
+}
+
function ScreenSettings({ addToast }) {
const [sysConfig, setSysConfig] = useState(null);
const [riskConfig, setRiskConfig] = useState(null);
@@ -102,6 +151,12 @@ function ScreenSettings({ addToast }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [saved, setSaved] = useState(null);
+ // M1: edições acumulam num draft por card; "Salvar" abre a confirmação (before→
+ // after) e SÓ ENTÃO dispara o PATCH — nada de PATCH a cada onChange de slider.
+ const [sysDraft, setSysDraft] = useState(null);
+ const [riskDraft, setRiskDraft] = useState(null);
+ const [alertDraft, setAlertDraft] = useState(null);
+ const [pendingSave, setPendingSave] = useState(null);
// N8¹: pares operados, da fonte dinâmica /v1/pairs (N1). N8²: editável abaixo.
const reloadPairs = () => loadPairsRich(true).then(setPairsRich).catch(() => {});
@@ -145,28 +200,55 @@ function ScreenSettings({ addToast }) {
setTimeout(() => setSaved(null), 2500);
};
- const saveSysConfig = async (patch) => {
- try {
- const updated = await CT_API.patchConfig(patch);
- setSysConfig(updated);
- flash('Config salva');
- } catch (e) { console.error(e); addToast?.('Erro ao salvar configuração', 'alert'); }
+ // M1: os drafts espelham a config salva (no load e após cada save bem-sucedido).
+ useEffect(() => { setSysDraft(sysConfig ? { ...sysConfig } : null); }, [sysConfig]);
+ useEffect(() => { setRiskDraft(riskConfig ? { ...riskConfig } : null); }, [riskConfig]);
+ useEffect(() => { setAlertDraft(alertConfig ? { ...alertConfig } : null); }, [alertConfig]);
+
+ const changedKeys = (draft, cfg) =>
+ (draft && cfg) ? Object.keys(draft).filter(k => draft[k] !== cfg[k]) : [];
+
+ // "Salvar" de cada card abre a confirmação com o resumo before→after; o apply()
+ // (que contém o PATCH real) só roda ao confirmar.
+ const requestSave = (title, keys, cfg, draft, apply) => {
+ if (!keys.length) return;
+ const changes = keys.map(k => ({
+ key: k, label: CONFIG_FIELD_LABELS[k] ?? k, before: cfg[k], after: draft[k],
+ }));
+ setPendingSave({ title, changes, apply });
+ };
+ const confirmSave = async () => {
+ const p = pendingSave;
+ setPendingSave(null);
+ if (!p) return;
+ try { await p.apply(); }
+ catch (e) { console.error(e); addToast?.(e?.message ?? 'Erro ao salvar configuração', 'alert'); }
};
- const saveRiskConfig = async (patch) => {
- try {
- const updated = await CT_API.patchRiskConfig(patch);
- setRiskConfig(updated);
+ const saveSys = () => {
+ const keys = changedKeys(sysDraft, sysConfig);
+ requestSave('Salvar configuração do sistema', keys, sysConfig, sysDraft, async () => {
+ const patch = Object.fromEntries(keys.map(k => [k, sysDraft[k]]));
+ setSysConfig(await CT_API.patchConfig(patch));
+ flash('Config salva');
+ });
+ };
+ const saveRisk = () => {
+ const keys = changedKeys(riskDraft, riskConfig);
+ requestSave('Salvar parâmetros de risco', keys, riskConfig, riskDraft, async () => {
+ // A5: o backend exige confirm=true (400 confirmation_required sem ele).
+ const patch = { ...Object.fromEntries(keys.map(k => [k, riskDraft[k]])), confirm: true };
+ setRiskConfig(await CT_API.patchRiskConfig(patch));
flash('Risco salvo');
- } catch (e) { console.error(e); addToast?.('Erro ao salvar parâmetros de risco', 'alert'); }
+ });
};
-
- const saveAlertConfig = async (patch) => {
- try {
- const updated = await CT_API.patchAlertsConfig(patch);
- setAlertConfig(updated);
+ const saveAlert = () => {
+ const keys = changedKeys(alertDraft, alertConfig);
+ requestSave('Salvar guardrails comportamentais', keys, alertConfig, alertDraft, async () => {
+ const patch = Object.fromEntries(keys.map(k => [k, alertDraft[k]]));
+ setAlertConfig(await CT_API.patchAlertsConfig(patch));
flash('Alertas salvos');
- } catch (e) { console.error(e); addToast?.('Erro ao salvar alertas', 'alert'); }
+ });
};
if (loading) return ;
@@ -221,22 +303,26 @@ function ScreenSettings({ addToast }) {
saveSysConfig({ initial_capital: v })}
+ value={(sysDraft ?? sysConfig).initial_capital}
+ onChange={v => setSysDraft(d => ({ ...(d ?? sysConfig), initial_capital: v }))}
min={100}
step={100}
unit="$"
/>
saveSysConfig({ orchestrator_interval_seconds: v })}
+ value={(sysDraft ?? sysConfig).orchestrator_interval_seconds}
+ onChange={v => setSysDraft(d => ({ ...(d ?? sysConfig), orchestrator_interval_seconds: v }))}
min={10}
max={300}
step={10}
unit="s"
/>
+
+ Salvar
+
@@ -251,54 +337,58 @@ function ScreenSettings({ addToast }) {
saveRiskConfig({ max_position_size_pct: v })}
+ value={(riskDraft ?? riskConfig).max_position_size_pct}
+ onChange={v => setRiskDraft(d => ({ ...(d ?? riskConfig), max_position_size_pct: v }))}
min={0.5} max={10} step={0.5}
unit="%"
/>
saveRiskConfig({ stop_loss_default_pct: v })}
+ value={(riskDraft ?? riskConfig).stop_loss_default_pct}
+ onChange={v => setRiskDraft(d => ({ ...(d ?? riskConfig), stop_loss_default_pct: v }))}
min={0.5} max={10} step={0.5}
unit="%"
/>
saveRiskConfig({ max_daily_loss_pct: v })}
+ value={(riskDraft ?? riskConfig).max_daily_loss_pct}
+ onChange={v => setRiskDraft(d => ({ ...(d ?? riskConfig), max_daily_loss_pct: v }))}
min={1} max={10} step={0.5}
unit="%"
/>
saveRiskConfig({ max_weekly_loss_pct: v })}
+ value={(riskDraft ?? riskConfig).max_weekly_loss_pct}
+ onChange={v => setRiskDraft(d => ({ ...(d ?? riskConfig), max_weekly_loss_pct: v }))}
min={2} max={20} step={1}
unit="%"
/>
saveRiskConfig({ max_monthly_loss_pct: v })}
+ value={(riskDraft ?? riskConfig).max_monthly_loss_pct}
+ onChange={v => setRiskDraft(d => ({ ...(d ?? riskConfig), max_monthly_loss_pct: v }))}
min={5} max={30} step={1}
unit="%"
/>
saveRiskConfig({ kelly_fraction: v })}
+ value={(riskDraft ?? riskConfig).kelly_fraction}
+ onChange={v => setRiskDraft(d => ({ ...(d ?? riskConfig), kelly_fraction: v }))}
min={0.1} max={1} step={0.05}
/>
Circuit breaker
+
+ Salvar risco
+
@@ -313,33 +403,37 @@ function ScreenSettings({ addToast }) {
saveAlertConfig({ revenge_size_multiplier: 1 + v / 100 })}
+ value={parseFloat((((alertDraft ?? alertConfig).revenge_size_multiplier - 1) * 100).toFixed(0))}
+ onChange={v => setAlertDraft(d => ({ ...(d ?? alertConfig), revenge_size_multiplier: 1 + v / 100 }))}
min={10} max={100} step={5}
unit="%"
/>
saveAlertConfig({ euphoria_size_multiplier: 1 + v / 100 })}
+ value={parseFloat((((alertDraft ?? alertConfig).euphoria_size_multiplier - 1) * 100).toFixed(0))}
+ onChange={v => setAlertDraft(d => ({ ...(d ?? alertConfig), euphoria_size_multiplier: 1 + v / 100 }))}
min={5} max={80} step={5}
unit="%"
/>
saveAlertConfig({ overconfidence_margin: v / 100 })}
+ value={parseFloat(((alertDraft ?? alertConfig).overconfidence_margin * 100).toFixed(0))}
+ onChange={v => setAlertDraft(d => ({ ...(d ?? alertConfig), overconfidence_margin: v / 100 }))}
min={5} max={40} step={5}
unit="%"
/>
saveAlertConfig({ risk_of_ruin_alert_pct: v })}
+ value={(alertDraft ?? alertConfig).risk_of_ruin_alert_pct}
+ onChange={v => setAlertDraft(d => ({ ...(d ?? alertConfig), risk_of_ruin_alert_pct: v }))}
min={1} max={15} step={0.5}
unit="%"
/>
+
+ Salvar
+
{/* A6: entrega externa (e-mail/Telegram/Slack/webhook) mora em
Notificações & Canais — link da seção, conforme o card. */}
{CT_AUTH.can('edit_settings') && (
@@ -464,6 +558,15 @@ function ScreenSettings({ addToast }) {
);
})()}
+
+ {pendingSave && (
+ setPendingSave(null)}
+ />
+ )}
);
}