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.

FieldTypeUnitDefaultAcceptedCode key (guard.toml)Rule
vinteger—1exactly 1——
maxLeveragenumber× equity5> 0, ≤ 100risk.max_leverage(c)
maxLossAtStopPctnumber% of equity2> 0, ≤ 100, ≤ maxOpenRiskPctrisk.risk_per_trade = value / 100(g)
stopPolicystring—"attach""attach", "refuse"policy.stop_policy(b)
defaultStopDistancePctnumber, optional% of price2≥ 0.1, ≤ 25policy.default_stop_distance = value / 100(b)
minLiqDistancePctnumber% of price10≥ 1, ≤ 50policy.min_liquidation_distance = value / 100(d)
maxPositionPctnumber% of equity200> 0, ≤ 5000, ≤ maxLeverage × 100policy.max_position_fraction = value / 100(f)
maxOpenRiskPctnumber% of equity6> 0, ≤ 100, ≥ maxLossAtStopPctrisk.max_open_risk = value / 100(e)
dailyLossStopPctnumber% of day-start equity6> 0, ≤ 100risk.daily_loss_stop = value / 100(h)
drawdownHaltPctnumber% of peak equity25> 0, ≤ 100risk.drawdown_stop = value / 100(i)
marketsarray of strings—["*"]["*"], or 1–1,000 distinct coin namespolicy.allowed_coins (unset for ["*"])(a)

Where the defaults and bounds come from:

  • maxLeverage, maxLossAtStopPct, maxOpenRiskPct, dailyLossStopPct, drawdownHaltPct: RiskLimits::default() and RiskLimits::validate(). These defaults are Zunder’s recorded risk frame (docs/decisions.md, 4 Oct 2026).
  • stopPolicy, defaultStopDistancePct, minLiqDistancePct, maxPositionPct, markets: SiteRules::default() and SiteRules::validate() (site_defaults in crates/zunder-risk-wasm/src/judge.rs), which are Guard’s policy defaults as well; Guard’s policy config is the source of truth. The website reads every default from the engine (default_playground_limits) and never repeats the numbers.
  • 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 all markets. Otherwise Hyperliquid coin names exactly as the venue writes them (BTC, kPEPE, xyz:GOLD), each 1–32 characters from A–Z a–z 0–9 : @ / . _ -, no duplicates. "*" may not be mixed with names. 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 (risk.max_trading_equity_usd), round-trip costs (policy.round_trip_cost_bps, default 11) and execution settings. They depend on the account and the machine, not on a shareable rule set.
  • 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 two cross-checks (maxLossAtStopPct ≤ maxOpenRiskPct; "*" 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:

[risk]
risk_per_trade = "0.01"
max_open_risk = "0.06"
max_leverage = "3"
daily_loss_stop = "0.04"
drawdown_stop = "0.25"
[policy]
stop_policy = "refuse"
default_stop_distance = "0.02"
min_liquidation_distance = "0.1"
max_position_fraction = "2"
allowed_coins = ["BTC", "ETH"]

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

JSONWhy
{"v":1,"maxLossAtStopPct":8}8 > 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 names
{"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":2}wrong version

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