Skip to content

Daily loss stop and 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

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.