Skip to content

The nine rules

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.