Skip to content
Join the waitlistWaitlist

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.
RuleYour setting
Max leverage5x
Max loss at the stop2%
Without a stopGuard sets one, 2% away
Min distance to liquidation10%
Max position size200% of equity
Max open risk6%
Daily loss stop6%
Drawdown halt25%
Marketsall

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, they are written as percent: 2 means 2%.


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

Formulacoin ∈ markets, or the list is “all”
Defaultall markets
Boundsat most 1,000 coins
Refusalcoin_not_allowed
Lives inpolicy (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.

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.

Formulastop exists ∧ stop on the losing side ∧ Σ stop sizes ≥ position size
DefaultstopPolicy = attach, stop 2% away
Boundsattach or refuse; the attached stop 0.1% to 25% away
Refusalno_protective_stop (refuse policy only)
Lives inpolicy; Zunder’s Session enforces the same for its own bot (every position gets a stop on the venue, or is closed at once)

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.

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.

Formulaopen value + entry quantity × entry price ≤ E × max_leverage
Default5
Boundsabove 0, at most 100
Outcomeresize, or leverage_exhausted when no room is left
Lives inengine (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.

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
Default10%
Bounds0 (off) up to, but not including, 100%
Refusalliquidation_too_close
Lives inpolicy

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.

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
Default6%
Boundsabove 0, at most 100%; never below max loss at the stop
Outcomeresize, or open_risk_exhausted; unprotected_position when a position has no stop or its price is at or through it
Lives inengine (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).

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

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
Default200% (2× equity)
Boundsabove 0, at most 10,000% (100×)
Outcomeresize; position_cap_reached when the room left is below the venue’s minimum order
Lives inpolicy

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.

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.

Formulaquantity × (|entry − stop| + round-trip cost per unit) ≤ E × risk_per_trade
Default2%
Boundsabove 0, at most 100%; at most max open risk
Outcomeresize; stop_on_wrong_side, below_minimum
Lives inengine (RiskEngine::size_entry)

The round-trip cost defaults to 12 basis points of the price (why), bounded to 0–500.

Example. Example 1 on Sizing: 0.5 BTC becomes 0.03144 BTC.

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

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: divided by min(day start, cap))
Default6%
Boundsabove 0, at most 100%
Refusalhalted_for_day
Lives inengine (RiskEngine::observe)

Details and examples: Daily loss stop and drawdown halt.

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.

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))
Default25%
Boundsabove 0, at most 100%
Refusalstopped
Lives inengine (RiskEngine::observe, resume_after_review)

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


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

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

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.

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