Skip to content
Join the waitlistWaitlist

Rules codes (shared rules schema v1)

The one format for a set of Guard rules, shared by the website, the docs and `zunder-guard init --rules`. Fields, units, defaults, bounds, the zr1_ encoding, and examples.

A set of Guard rules travels as a short text, a rules code, that starts with zr1_. The website stores your rules as one; the docs fill it into commands; zunder-guard init --rules reads it.

zr1_eyJ2IjoxLCJtYXhMZXZlcmFnZSI6NSwibWF4TG9zc0F0U3RvcFBjdCI6Miwic3RvcFBvbGljeSI6ImF0dGFjaCIsImRlZmF1bHRTdG9wRGlzdGFuY2VQY3QiOjIsIm1pbkxpcURpc3RhbmNlUGN0IjoxMCwibWF4UG9zaXRpb25QY3QiOjIwMCwibWF4T3BlblJpc2tQY3QiOjYsImRhaWx5TG9zc1N0b3BQY3QiOjYsImRyYXdkb3duSGFsdFBjdCI6MjUsIm1hcmtldHMiOlsiKiJdfQ

That is the defaults. Decoded:

{"v":1,"maxLeverage":5,"maxLossAtStopPct":2,"stopPolicy":"attach","defaultStopDistancePct":2,"minLiqDistancePct":10,"maxPositionPct":200,"maxOpenRiskPct":6,"dailyLossStopPct":6,"drawdownHaltPct":25,"markets":["*"]}

Percentages are percent: 2 means 2%. The code and guard.toml use fractions (0.02). The conversion is exact decimal division by 100, never floating point.

FieldTypeUnitDefaultAcceptedKey in guard.tomlRule
vinteger—1exactly 1——
maxLeveragenumber× equity5> 0, ≤ 10policy.max_leverage(c)
maxLossAtStopPctnumber% of equity2> 0, ≤ 5, ≤ maxOpenRiskPctpolicy.max_loss_at_stop = value / 100(g)
stopPolicystring—"attach""attach", "refuse"policy.stop(b)
defaultStopDistancePctnumber, optional% of price2> 0, ≤ 50, < minLiqDistancePctpolicy.default_stop_distance = value / 100(b)
minLiqDistancePctnumber% of price10≥ 1, ≤ 50, > defaultStopDistancePctpolicy.min_liquidation_distance = value / 100(d)
maxPositionPctnumber% of equity200> 0, ≤ 1000, ≤ maxLeverage × 100policy.max_position_of_account = value / 100(f)
maxOpenRiskPctnumber% of equity6> 0, ≤ 20, ≥ maxLossAtStopPctpolicy.max_open_risk = value / 100(e)
dailyLossStopPctnumber% of day-start equity6> 0, ≤ 15policy.daily_loss_stop = value / 100(h)
drawdownHaltPctnumber% of peak equity25> 0, ≤ 50policy.drawdown_halt = value / 100(i)
marketsarray of strings—["*"]["*"], or 1–32 distinct market namespolicy.markets: "all" for ["*"], else the list(a)

Where the defaults and bounds come from:

  • All of them: Policy::default() and the bounds in crates/zunder-guard-core/src/policy.rs, which the rules crate (deploy/guard/rules) reads. The defaults of the five engine rules (maxLeverage, maxLossAtStopPct, maxOpenRiskPct, dailyLossStopPct, drawdownHaltPct) are Guard’s own preset (Policy::guard_defaults()), not read from RiskLimits::default() (the limits of Zunder’s own bot), though equal in value today; their upper bounds are RiskLimits::aggressive().
  • The website’s judge (SiteRules in crates/zunder-risk-wasm/src/judge.rs) has the same defaults and accepts wider values; a code outside the bounds above is refused by Guard.
  • There is no requireStop. Every entry needs a stop: with "attach" (the default) Guard sets one defaultStopDistancePct away from the entry and sizes from it; with "refuse" an entry without a stop is vetoed.

The accepted ranges stop nonsense (such as 0.25 meant as 25%, or 25 meant as 0.25). They are not advice. The website’s sliders offer narrower ranges; anything a slider produces is inside these bounds.

  • Numbers are finite, written in plain decimal (no exponent), with at most 4 decimal places. Decoders read the number’s text as a decimal, not as a float, so 0.1 stays 0.1.
  • defaultStopDistancePct may be left out (it then takes its default); it is used only with "attach" but must be inside its bounds either way.
  • maxPositionPct may not exceed maxLeverage × 100: a position larger than the leverage cap could never be opened.
  • markets: ["*"] means every market of Hyperliquid’s main dex. Otherwise 1 to 32 entries: Hyperliquid market names exactly as the venue writes them (BTC, kPEPE, xyz:GOLD), each 1–32 characters from A–Z a–z 0–9 : @ / . _ -, starting with a letter, a digit or @, no duplicates, and dex:* for every market of a HIP-3 dex (xyz:*). A HIP-3 market is allowed only by name, never by "*". "*" may stand beside HIP-3 entries (["*","xyz:*"]), not beside main-dex names; a dex’s market may not stand beside its dex:*. Guard reads at most two HIP-3 dexes and refuses a code that names more. An empty list is refused: it would refuse every entry, which is the kill switch’s job.
  • The network. A code never moves anyone to testnet or mainnet. init defaults to paper.
  • The equity cap (policy.max_trading_equity_usd), the cost assumptions (policy.fee_bps, default 4.5, and policy.slippage_bps, default 1, per side) and the execution settings (entry_price_bound, stop_slippage, exit_slippage). They depend on the account and the machine, not on a shareable rule set; init --rules keeps their defaults.
  • Anything personal. No address, no key, no name.
code = "zr1_" + base64url(utf8(json))
  • base64url is RFC 4648 section 5 (- and _ instead of + and /), without = padding.
  • Producing a code: the JSON object with the fields in the order of the table above, no whitespace, numbers in their shortest plain form (2, not 2.0). The same rules give the same code, so codes can be compared as text.
  • Reading a code:
    1. It must start with zr1_. Another prefix is another version: refuse it with a message, never guess.
    2. Decode base64url (add the padding back if your decoder needs it). The decoded text must be valid UTF-8 and at most 32 KiB.
    3. Parse JSON. It must be one object.
    4. Unknown fields are refused. A typo is an error, not a silently ignored rule.
    5. Missing fields take their default. zr1_eyJ2IjoxfQ ({"v":1}) is the defaults.
    6. Check every field against its bounds, and the cross-checks (maxLossAtStopPct ≤ maxOpenRiskPct; maxPositionPct ≤ maxLeverage × 100; minLiqDistancePct > defaultStopDistancePct; "*" alone).
    7. On any failure, refuse the whole code. Never apply part of a rule set.
  • Versioning. Any change to a field’s meaning, unit or set of fields makes a new version with a new prefix (zr2_). Because unknown fields are refused, even adding a field needs a new version.
  • In URLs, put a code in the fragment (/connect#rules=zr1_…), never the query string, so it is never sent to a server.
const PREFIX = "zr1_";
export function encodeRules(rules: RulesV1): string {
const json = JSON.stringify({
v: 1,
maxLeverage: rules.maxLeverage,
maxLossAtStopPct: rules.maxLossAtStopPct,
stopPolicy: rules.stopPolicy,
defaultStopDistancePct: rules.defaultStopDistancePct,
minLiqDistancePct: rules.minLiqDistancePct,
maxPositionPct: rules.maxPositionPct,
maxOpenRiskPct: rules.maxOpenRiskPct,
dailyLossStopPct: rules.dailyLossStopPct,
drawdownHaltPct: rules.drawdownHaltPct,
markets: rules.markets,
});
const bytes = new TextEncoder().encode(json);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return PREFIX + btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export function decodeRulesText(code: string): string {
if (!code.startsWith(PREFIX)) throw new Error("not a zr1_ rules code");
const body = code.slice(PREFIX.length).replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(body + "=".repeat((4 - (body.length % 4)) % 4));
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
if (bytes.length > 32 * 1024) throw new Error("rules code too long");
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
// then: parse strictly, refuse unknown fields, fill defaults, check bounds (steps 3–7)
}

JSON.stringify writes whole numbers without a decimal point and never writes an exponent for values in these ranges, so it produces the canonical form.

Defaults (above).

Tighter: 3x, 1% at the stop, refuse entries without a stop, 4% daily stop, BTC and ETH only.

{"v":1,"maxLeverage":3,"maxLossAtStopPct":1,"stopPolicy":"refuse","defaultStopDistancePct":2,"minLiqDistancePct":10,"maxPositionPct":200,"maxOpenRiskPct":6,"dailyLossStopPct":4,"drawdownHaltPct":25,"markets":["BTC","ETH"]}
zr1_eyJ2IjoxLCJtYXhMZXZlcmFnZSI6MywibWF4TG9zc0F0U3RvcFBjdCI6MSwic3RvcFBvbGljeSI6InJlZnVzZSIsImRlZmF1bHRTdG9wRGlzdGFuY2VQY3QiOjIsIm1pbkxpcURpc3RhbmNlUGN0IjoxMCwibWF4UG9zaXRpb25QY3QiOjIwMCwibWF4T3BlblJpc2tQY3QiOjYsImRhaWx5TG9zc1N0b3BQY3QiOjQsImRyYXdkb3duSGFsdFBjdCI6MjUsIm1hcmtldHMiOlsiQlRDIiwiRVRIIl19

zunder-guard init --rules with this code writes these rules into the [policy] section of guard.toml (the cost and execution keys keep their defaults; decimals may be written with or without trailing zeros):

[policy]
max_leverage = "3"
max_loss_at_stop = "0.01"
stop = "refuse"
default_stop_distance = "0.02"
min_liquidation_distance = "0.1"
max_position_of_account = "2"
max_open_risk = "0.06"
daily_loss_stop = "0.04"
drawdown_halt = "0.25"
markets = ["BTC", "ETH"]

Codes that fail, or that do not mean what they seem:

JSONWhy
{"v":1,"maxLossAtStopPct":8}above the bound 5, and above maxOpenRiskPct (default 6)
{"v":1,"drawdownHaltPct":0.25}accepted, but means 0.25%: check percent versus fraction
{"v":1,"maxLeverage":0}must be above 0
{"v":1,"markets":[]}empty list
{"v":1,"markets":["*","BTC"]}"*" mixed with main-dex names
{"v":1,"markets":["xyz:*","xyz:GOLD"]}a market beside its own dex’s dex:*
{"v":1,"maxleverage":5}unknown field (maxleverage, lower-case l)
{"v":1,"requireStop":true}unknown field: requireStop is not in v1 (stopPolicy covers it)
{"v":1,"maxLeverage":1,"maxPositionPct":200}200% > 1 × 100
{"v":1,"minLiqDistancePct":0}below 1: the rule cannot be switched off
{"v":1,"maxLeverage":11}above 10
{"v":1,"defaultStopDistancePct":10}not below minLiqDistancePct (default 10): the liquidation must lie beyond the attached stop
{"v":2}wrong version

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