<!-- https://zunder-design-preview.pages.dev/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. On this website they are in the judge (`crates/zunder-risk-wasm/src/judge.rs`). In Guard they are in `crates/zunder-guard-core`, set in the `[policy]` section of `guard.toml` ([Config keys](https://zunder-design-preview.pages.dev/docs/reference/config)).

:::note[Planned]
Guard has no release yet; its policy code runs on testnet in our hands. The engine rules (c, e, g, h, i) are true today. The bounds below are Guard's (`crates/zunder-guard-core/src/policy.rs`); the website's judge accepts wider values.
:::

## 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 | every market of the main dex |

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://zunder-design-preview.pages.dev/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. `*` is every market of Hyperliquid's main dex; a [HIP-3](https://zunder-design-preview.pages.dev/docs/concepts/hip3) dex's markets are allowed only by name: `xyz:*` for every market of dex `xyz`, `xyz:GOLD` for one.

| | |
|---|---|
| Formula | `coin ∈ markets`, or `*` for a main-dex coin, or `dex:*` for a coin of that dex |
| Default | every market of the main dex (`*`); no HIP-3 market |
| Bounds | at most 32 entries; at most two HIP-3 dexes; HIP-3 on paper and testnet only |
| Refusal | `coin_not_allowed` |
| Lives in | policy (`markets`) |

**Example.** Your list is BTC and ETH. A bot buys HYPE. Refused: "HYPE is not on the market allowlist". Your list is `*`. A bot buys `xyz:GOLD`. Refused (`dex_not_allowed`): Guard does not read dex `xyz` unless your list names it.

**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 above 0% and at most 50% away, and closer than the minimum distance to liquidation |
| Refusal | `no_protective_stop` (refuse policy only) |
| Lives in | policy (`stop`, `default_stop_distance`); 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]
Guard attaches the stop in its code today, on testnet in our hands; it is not released. The website's judge applies both policies too.
:::

**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 10 |
| Outcome | resize, or `leverage_exhausted` when no room is left |
| Lives in | engine (`RiskEngine::size_entry`); `max_leverage` in `guard.toml` |

**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 | 1% to 50%, and beyond the attached stop's distance |
| Refusal | `liquidation_too_close` |
| Lives in | policy (`min_liquidation_distance`) |

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 20%; 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`); `max_open_risk` in `guard.toml` |

**Example.** Equity 2,000, open stops risk 100. Budget left: 120 − 100 = 20. A trade that would risk 40 is halved ([Example 2](https://zunder-design-preview.pages.dev/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_of_account` |
| Default | 200% (2× equity) |
| Bounds | above 0, at most 1,000% (10×), and at most max leverage |
| Outcome | resize; `position_cap_reached` when the room left is below the venue's minimum order |
| Lives in | policy (`max_position_of_account`) |

**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 × max_loss_at_stop` |
| Default | 2% |
| Bounds | above 0, at most 5%; at most max open risk |
| Outcome | resize; `stop_on_wrong_side`, `below_minimum` |
| Lives in | engine (`RiskEngine::size_entry`, its `risk_per_trade`); `max_loss_at_stop` in `guard.toml` |

In Guard the round-trip cost is `fee_bps` (default 4.5, 0 to 50) plus `slippage_bps` (default 1, 0 to 100) per side, both ways: 11 basis points of the price by default. The [sizing examples](https://zunder-design-preview.pages.dev/docs/concepts/sizing#example-1-the-plain-case) use 12, the measured cost for the most liquid perps.

**Example.** [Example 1 on Sizing](https://zunder-design-preview.pages.dev/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://zunder-design-preview.pages.dev/docs/concepts/equity-cap): divided by `min(day start, cap)`) |
| Default | 6% |
| Bounds | above 0, at most 15% |
| Refusal | `halted_for_day` |
| Lives in | engine (`RiskEngine::observe`); `daily_loss_stop` in `guard.toml` |

Details and examples: [Daily loss stop and drawdown halt](https://zunder-design-preview.pages.dev/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_halt` (with a cap: divided by `min(peak, cap)`) |
| Default | 25% |
| Bounds | above 0, at most 50% |
| Refusal | `stopped` |
| Lives in | engine (`RiskEngine::observe`, `resume_after_review`); `drawdown_halt` in `guard.toml` |

**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://zunder-design-preview.pages.dev/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://zunder-design-preview.pages.dev/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 Guard's `Policy::validate` accepts (`crates/zunder-guard-core/src/policy.rs`); the upper bounds of the five engine rules are Zunder's aggressive risk frame (`RiskLimits::aggressive()` in `crates/zunder-risk/src/limits.rs`). They stop nonsense such as `25` instead of `0.25`. They are not advice: a 5% loss at the stop is a valid setting and an aggressive one. On mainnet no rule may be looser than its default. The website's judge (`SiteRules::validate` in `judge.rs`) accepts wider values.
