<!-- https://zunderlabs.com/docs/concepts/rules · Markdown version of the page -->

# The nine rules

Exact definitions, formulas, defaults and bounds of Guard's nine rules, where each lives in the code, and what Backtest and Watch can and cannot judge.

Every order that opens or grows a position passes nine rules, in this order. The first refusal wins. If nothing refuses, the smallest allowed size wins. Orders that reduce or close a position pass without a check: **exits are never blocked**.

Where the rules live today:

- **Engine** rules are `zunder-risk`'s `RiskEngine`, the code Zunder trades with on testnet. The website runs the same code, compiled to WebAssembly (`crates/zunder-risk-wasm`).
- **Policy** rules are not in the engine. Today they exist in the website's judge (`crates/zunder-risk-wasm/src/judge.rs`). In Guard they will live in the policy file.

:::note[Planned]
The website's judge and the WebAssembly build are being built in parallel with these docs. Guard's policy file is planned for Guard 1.0. The engine rules (c, e, g, h, i) are true today.
:::

## Your rules

| Rule | Your setting |
|---|---|
| Max leverage | 5x |
| Max loss at the stop | 2% |
| Without a stop | Guard sets one, 2% away |
| Min distance to liquidation | 10% |
| Max position size | 200% of equity |
| Max open risk | 6% |
| Daily loss stop | 6% |
| Drawdown halt | 25% |
| Markets | all |

In the tables below, `E` is account equity and percentages are fractions of it. In the config, fractions are written as fractions: `0.02` means 2%. In a [rules code](https://zunderlabs.com/docs/reference/rules-schema), they are written as percent: `2` means 2%.

---

## (a) Market allowlist

**Definition.** An entry in a coin that is not on your list is refused.

| | |
|---|---|
| Formula | `coin ∈ markets`, or the list is "all" |
| Default | all markets |
| Bounds | at most 1,000 coins |
| Refusal | `coin_not_allowed` |
| Lives in | policy (`SiteRules::allowed_coins`) |

**Example.** Your list is BTC and ETH. A bot buys HYPE. Refused: "HYPE is not on the market allowlist".

**Backtest:** judged. **Watch:** judged.

## (b) Protective stop

**Definition.** Every entry needs a stop that protects the whole position on the venue. If the bot sends none, Guard sets one `defaultStopDistancePct` away from the entry and sizes the order from it (`stopPolicy` = attach, the default), or refuses the entry (`stopPolicy` = refuse). A stop counts if it is a stop trigger order (not a take-profit) on the closing side, reduce-only or attached to the position, below the price for a long (above for a short), and together with other stops covers the whole position size. Of several, the loosest counts.

| | |
|---|---|
| Formula | `stop exists ∧ stop on the losing side ∧ Σ stop sizes ≥ position size` |
| Default | `stopPolicy` = attach, stop 2% away |
| Bounds | attach or refuse; the attached stop 0.1% to 25% away |
| Refusal | `no_protective_stop` (refuse policy only) |
| Lives in | policy; Zunder's `Session` enforces the same for its own bot (every position gets a stop on the venue, or is closed at once) |

:::note[Planned]
In Guard itself, attaching the stop is part of the proxy, which is being built. The website's judge already applies both policies.
:::

**Example.** A bot buys 1 ETH and places no stop. With the default, Guard sets a stop 2% below the entry and sizes the order so the loss there is your max loss at the stop. With `stopPolicy` = refuse: refused, "no stop order protects the ETH position".

**Backtest:** judged from the account's order history. A stop placed up to 60 s after the entry still counts (a bot places it once the entry fills). Entries older than the order history are kept as traded, not judged. **Watch:** judged; a stop placed up to 5 s after the fill counts.

## (c) Max leverage

**Definition.** The value of all open positions plus the new entry may be at most `max_leverage` times equity. This is exposure, not Hyperliquid's margin setting: a 3x position next to a 4x position is 7x here.

| | |
|---|---|
| Formula | `open value + entry quantity × entry price ≤ E × max_leverage` |
| Default | 5 |
| Bounds | above 0, at most 100 |
| Outcome | resize, or `leverage_exhausted` when no room is left |
| Lives in | engine (`RiskEngine::size_entry`) |

**Example.** Equity 2,000, positions worth 9,000. Room left: 2,000 × 5 − 9,000 = 1,000. A buy worth 3,000 is cut to 1,000.

**Backtest:** judged; other positions are valued at the last fill price seen in their coin. **Watch:** judged from the account's live positions.

## (d) Minimum distance to liquidation

**Definition.** After the trade, the liquidation price must be at least this far from the trade price.

| | |
|---|---|
| Formula | `\|price − liquidation price\| / price ≥ min_liquidation_distance` |
| Default | 10% |
| Bounds | 0 (off) up to, but not including, 100% |
| Refusal | `liquidation_too_close` |
| Lives in | policy |

No liquidation price passes: Hyperliquid reports none when a position cannot be liquidated at any positive price.

**Example.** A long at 100 with liquidation at 93 is 7% away. Refused: "liquidation is 7% from the price; the minimum is 10%".

**Backtest:** not judged. Past liquidation prices are not in the public history. **Watch:** judged, from the liquidation price Hyperliquid reports after the trade.

:::note[Planned]
Guard has to judge this before the order exists, so it must compute the liquidation price itself. Zunder's own session already does something stronger for its bot: every perp entry is put on isolated margin at a leverage that places liquidation beyond the stop's worst fill, with 10% of that distance and 5% of the price to spare (`isolated_leverage` in `crates/zunder-exec/src/session.rs`).
:::

## (e) Max open risk

**Definition.** All stops together may lose at most this share of equity, the new entry included. A position without a stop has unbounded risk, so nothing new opens next to it.

| | |
|---|---|
| Formula | `Σ qty × \|price − stop\| over open positions + entry risk ≤ E × max_open_risk` |
| Default | 6% |
| Bounds | above 0, at most 100%; never below max loss at the stop |
| Outcome | resize, or `open_risk_exhausted`; `unprotected_position` when a position has no stop or its price is at or through it |
| Lives in | engine (`RiskEngine::size_entry`, `combined_exposure`) |

**Example.** Equity 2,000, open stops risk 100. Budget left: 120 − 100 = 20. A trade that would risk 40 is halved ([Example 2](https://zunderlabs.com/docs/concepts/sizing#example-2-open-risk-binds)).

**Backtest:** judged. **Watch:** judged; an account-level warning fires when open risk is already over the cap.

## (f) Max position size

**Definition.** One coin's whole position, after the trade, may be worth at most this multiple of equity.

| | |
|---|---|
| Formula | `(held quantity + entry quantity) × price ≤ E × max_position_fraction` |
| Default | 200% (2× equity) |
| Bounds | above 0, at most 10,000% (100×) |
| Outcome | resize; `position_cap_reached` when the room left is below the venue's minimum order |
| Lives in | policy |

**Example.** Equity 2,000, cap 200%: at most 4,000 in one coin. Holding 3,500 of SOL, a buy of 1,000 is cut to 500.

**Backtest:** judged. **Watch:** judged.

## (g) Max loss at the stop

**Definition.** If the stop is hit, this trade may lose at most this share of equity, round-trip costs included. This is the rule that sizes most trades.

| | |
|---|---|
| Formula | `quantity × (\|entry − stop\| + round-trip cost per unit) ≤ E × risk_per_trade` |
| Default | 2% |
| Bounds | above 0, at most 100%; at most max open risk |
| Outcome | resize; `stop_on_wrong_side`, `below_minimum` |
| Lives in | engine (`RiskEngine::size_entry`) |

The round-trip cost defaults to 12 basis points of the price ([why](https://zunderlabs.com/docs/concepts/sizing#example-1-the-plain-case)), bounded to 0–500.

**Example.** [Example 1 on Sizing](https://zunderlabs.com/docs/concepts/sizing#example-1-the-plain-case): 0.5 BTC becomes 0.03144 BTC.

**Backtest:** judged where the entry had a stop. Without a stop, rule (b) decides first. **Watch:** judged.

## (h) Daily loss stop

**Definition.** Once today's loss reaches this share of the equity at the start of the UTC day, no entry opens until the next UTC day.

| | |
|---|---|
| Formula | `(day start − equity) / day start ≥ daily_loss_stop` (with an [equity cap](https://zunderlabs.com/docs/concepts/equity-cap): divided by `min(day start, cap)`) |
| Default | 6% |
| Bounds | above 0, at most 100% |
| Refusal | `halted_for_day` |
| Lives in | engine (`RiskEngine::observe`) |

Details and examples: [Daily loss stop and drawdown halt](https://zunderlabs.com/docs/concepts/circuit-breakers).

**Backtest:** judged on realised equity (closed PnL, fees, funding); an open loss counts when the trade closes. **Watch:** judged from Hyperliquid's PnL history, read every five minutes.

## (i) Drawdown halt

**Definition.** Once equity has fallen this far below its peak, no entry opens until a person has reviewed and resumed.

| | |
|---|---|
| Formula | `(peak − equity) / peak ≥ drawdown_stop` (with a cap: divided by `min(peak, cap)`) |
| Default | 25% |
| Bounds | above 0, at most 100% |
| Refusal | `stopped` |
| Lives in | engine (`RiskEngine::observe`, `resume_after_review`) |

**Backtest:** judged on realised equity; once halted, the guarded account opens nothing again. **Watch:** judged from the PnL history.

---

## What no browser tool can judge

Two of Guard's promises cannot be seen from outside:

- **[Stops only tighten.](https://zunderlabs.com/docs/concepts/stops-only-tighten)** A public account can loosen its own stops; Backtest and Watch show the result, not a refusal.
- **[Only a person resumes](https://zunderlabs.com/docs/concepts/circuit-breakers#only-a-person-resumes).** A public account just keeps trading.

Deposits and withdrawals are left out of the equity history in both tools, so a withdrawal does not look like a loss.

## Where the bounds come from

The bounds are what `validate()` accepts (`RiskLimits::validate` in `crates/zunder-risk/src/limits.rs`, `SiteRules::validate` in `judge.rs`). They stop nonsense such as `25` instead of `0.25`. They are not advice: a 100% loss at the stop is a valid setting and a bad idea.
