From 82be68cfc525b61c6769db8c7a4ab995f82ef60d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 01:28:22 +0000 Subject: [PATCH] =?UTF-8?q?fix(console):=20Config=20de=20Risco=20salva=20c?= =?UTF-8?q?om=20confirma=C3=A7=C3=A3o=20expl=C3=ADcita=20(M1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug revelado pelo uso real na VPS (dividendo do de-mock 5.x — a UI passou a bater na API real): a Config de Risco não salvava. Duas causas em screen_settings: (1) cada slider/campo disparava PATCH direto no onChange (chatty), e (2) patchRiskConfig não enviava confirm → o backend (risk.py:310) responde 400 confirmation_required a cada arraste. Fix (padrão do aviso do A5): as edições dos 3 cards (Sistema, Risco, Guardrails) acumulam num DRAFT local; um botão "Salvar" por card abre um modal de confirmação com o resumo before→after; só ao confirmar o PATCH é enviado — e o de risco vai com confirm:true. Nada mais de PATCH-a-cada-onChange. Auditoria dos irmãos (pedida): só /v1/risk/config tem o gate confirm=true; /v1/config e /v1/alerts/config não têm o gate, mas ganham o mesmo fluxo acumular+confirmar (elimina a chattiness). /v1/agents/{id}/config é editado no drawer de Agentes (fora deste screen, sem gate). Nota: o card de Guardrails hoje não renderiza (alertConfig nunca é buscado no load) — refatorado por consistência/futuro, mas é caminho morto. Fixture (regressão trava para sempre): PATCH /v1/risk/config passa a EXIGIR confirm===true (400 confirmation_required sem ele), espelhando o backend; +PATCH /v1/config canned. Novo spec config_save.spec: edição só acumula (0 PATCH no onChange) → Salvar mostra o diff before→after → Confirmar envia 1 PATCH com confirm:true (sucesso; sem confirm o fixture 400 → o teste quebraria); + cancelar não envia PATCH. Validação: config_save 2/2; suíte completa 77 verdes (retries=1); build+guard OK. Diff 100% frontend (docs/design/pages/); backend intacto. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UZ3vLNTHKekjQVtjFVRi5D --- docs/design/pages/e2e/config_save.spec.js | 60 +++++++ docs/design/pages/e2e/fixtures/mockApi.js | 16 ++ docs/design/pages/screen_settings.jsx | 189 +++++++++++++++++----- 3 files changed, 222 insertions(+), 43 deletions(-) create mode 100644 docs/design/pages/e2e/config_save.spec.js 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:

+ + + + {changes.map(c => ( + + + + + + ))} + +
ParâmetroAntesDepois
{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)} + /> + )} ); }