Skip to content
Join the waitlistWaitlist

Daily loss stop and drawdown halt

The two circuit breakers of the risk engine, how they are measured, when they fire and clear, and why only a person can resume after a drawdown halt.

The risk engine has two circuit breakers. Both refuse new entries. Neither ever blocks an exit. Both are in RiskEngine::observe (crates/zunder-risk/src/engine.rs) and run today in Zunder’s testnet runner.

Daily loss stopDrawdown halt
Measured fromequity at the start of the UTC daythe highest equity seen
Default6%25%
Fires when(day start − equity) / day start ≥ 6%(peak − equity) / peak ≥ 25%
Statehalted_for_daystopped
Clearsby itself, at the first observation of the next UTC dayonly when a person resumes
Thenflatten, refuse entriesflatten, refuse entries

Move the losses and see which breaker fires. The real engine judges each position of the sliders.

Try it · peak 10,000 defaults
today, against the daily stop 6%3.0%
below the peak, against the halt 25%12.7%
active · entries are sized equity now 8,730

The daily stop clears at the first observation of the next UTC day. The drawdown halt clears only when a person resumes (RiskEngine::resume_after_review); nothing does it automatically.

Every few seconds, and always before sizing, the caller tells the engine the account’s equity. The engine then:

  1. Rolls the day forward if a new UTC day has begun. The new day starts from the last equity of the old day, so a gap at midnight counts as the new day’s loss. A late observation from an earlier day never clears today’s halt.
  2. Raises the peak if equity is higher.
  3. Checks the drawdown first, then the day’s loss.

When the state is no longer active, the caller has to flatten. Zunder’s runner closes every position and cancels every order that could open one. The halt is written to the journal before anything is closed.

Equity at 00:00 UTC: 2,000. Default 6%, so the stop fires at a loss of 120.

Time (UTC)EquityDay’s lossState
09:001,9502.5%active
13:001,8905.5%active
15:301,8806.0%halted_for_day
18:001,9104.5%still halted
next day 00:001,910new day starts at 1,910active

A recovery during the day does not clear the halt. The next day starts from 1,910, not from 2,000.

The peak was 2,600. Default 25%, so the halt fires at 1,950.

(2,600 − 1,950) / 2,600 = 650 / 2,600 = 25% → stopped

From here nothing opens, today or any later day, until a person resumes.

RiskEngine::resume_after_review is the only way out of a drawdown halt. It does nothing unless the engine is stopped. It restarts the peak from the current equity, so the next halt is measured from there.

Nothing in Zunder calls it automatically. That is a hard rule of the project (CLAUDE.md, hard rule 2): “never call it automatically”. In Zunder’s runner, a person runs zunder-runner journal-resume --note "..." with the runner stopped, and the note goes into the journal.

Example. The halt fired at 1,950. You look at the trades, find a bug in the bot, fix it, and resume at 1,940. The new peak is 1,940. The next halt fires at 1,940 × 0.75 = 1,455.

The engine’s state is kept in the journal. A restart restores it. A test (400 random paths with random restarts) shows that a restored engine is never less strict than one that kept running (docs/decisions.md, 5 Oct 2026, “The risk engine persists and tracks positions”).

  • They measure equity, not intentions. A loss inside an open position counts as soon as equity shows it. Zunder’s runner reads equity from the venue on every poll.
  • They act after the fact. The stop fires at the first observation at or past the threshold. A fast move between two observations can overshoot it. The stops resting on the venue are what limits each position in between.
  • Withdrawals count as losses. Taking money off the account lowers equity. Stop the bot first, or expect a halt.

This page as plain Markdown, for people and LLMs: /docs/concepts/circuit-breakers.md