Rules codes (shared rules schema v1)
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_eyJ2IjoxLCJtYXhMZXZlcmFnZSI6NSwibWF4TG9zc0F0U3RvcFBjdCI6Miwic3RvcFBvbGljeSI6ImF0dGFjaCIsImRlZmF1bHRTdG9wRGlzdGFuY2VQY3QiOjIsIm1pbkxpcURpc3RhbmNlUGN0IjoxMCwibWF4UG9zaXRpb25QY3QiOjIwMCwibWF4T3BlblJpc2tQY3QiOjYsImRhaWx5TG9zc1N0b3BQY3QiOjYsImRyYXdkb3duSGFsdFBjdCI6MjUsIm1hcmtldHMiOlsiKiJdfQThat 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":["*"]}Fields
Section titled “Fields”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.
| Field | Type | Unit | Default | Accepted | Code key (guard.toml) | Rule |
|---|---|---|---|---|---|---|
v | integer | — | 1 | exactly 1 | — | — |
maxLeverage | number | × equity | 5 | > 0, ≤ 100 | risk.max_leverage | (c) |
maxLossAtStopPct | number | % of equity | 2 | > 0, ≤ 100, ≤ maxOpenRiskPct | risk.risk_per_trade = value / 100 | (g) |
stopPolicy | string | — | "attach" | "attach", "refuse" | policy.stop_policy | (b) |
defaultStopDistancePct | number, optional | % of price | 2 | ≥ 0.1, ≤ 25 | policy.default_stop_distance = value / 100 | (b) |
minLiqDistancePct | number | % of price | 10 | ≥ 1, ≤ 50 | policy.min_liquidation_distance = value / 100 | (d) |
maxPositionPct | number | % of equity | 200 | > 0, ≤ 5000, ≤ maxLeverage × 100 | policy.max_position_fraction = value / 100 | (f) |
maxOpenRiskPct | number | % of equity | 6 | > 0, ≤ 100, ≥ maxLossAtStopPct | risk.max_open_risk = value / 100 | (e) |
dailyLossStopPct | number | % of day-start equity | 6 | > 0, ≤ 100 | risk.daily_loss_stop = value / 100 | (h) |
drawdownHaltPct | number | % of peak equity | 25 | > 0, ≤ 100 | risk.drawdown_stop = value / 100 | (i) |
markets | array of strings | — | ["*"] | ["*"], or 1–1,000 distinct coin names | policy.allowed_coins (unset for ["*"]) | (a) |
Where the defaults and bounds come from:
maxLeverage,maxLossAtStopPct,maxOpenRiskPct,dailyLossStopPct,drawdownHaltPct:RiskLimits::default()andRiskLimits::validate(). These defaults are Zunder’s recorded risk frame (docs/decisions.md, 4 Oct 2026).stopPolicy,defaultStopDistancePct,minLiqDistancePct,maxPositionPct,markets:SiteRules::default()andSiteRules::validate()(site_defaultsincrates/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 onedefaultStopDistancePctaway 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.
Rules for each field
Section titled “Rules for each field”- 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.1stays0.1. defaultStopDistancePctmay be left out (it then takes its default); it is used only with"attach"but must be inside its bounds either way.maxPositionPctmay not exceedmaxLeverage× 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 fromA–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.
What is not in v1, on purpose
Section titled “What is not in v1, on purpose”- The network. A code never moves anyone to testnet or mainnet.
initdefaults 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.
Encoding
Section titled “Encoding”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, not2.0). The same rules give the same code, so codes can be compared as text. - Reading a code:
- It must start with
zr1_. Another prefix is another version: refuse it with a message, never guess. - Decode base64url (add the padding back if your decoder needs it). The decoded text must be valid UTF-8 and at most 32 KiB.
- Parse JSON. It must be one object.
- Unknown fields are refused. A typo is an error, not a silently ignored rule.
- Missing fields take their default.
zr1_eyJ2IjoxfQ({"v":1}) is the defaults. - Check every field against its bounds, and the two cross-checks (
maxLossAtStopPct ≤ maxOpenRiskPct;"*"alone). - On any failure, refuse the whole code. Never apply part of a rule set.
- It must start with
- 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 (
/backtest#rules=zr1_…), never the query string, so it is never sent to a server.
Reference implementation (TypeScript)
Section titled “Reference implementation (TypeScript)”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.
Examples
Section titled “Examples”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_eyJ2IjoxLCJtYXhMZXZlcmFnZSI6MywibWF4TG9zc0F0U3RvcFBjdCI6MSwic3RvcFBvbGljeSI6InJlZnVzZSIsImRlZmF1bHRTdG9wRGlzdGFuY2VQY3QiOjIsIm1pbkxpcURpc3RhbmNlUGN0IjoxMCwibWF4UG9zaXRpb25QY3QiOjIwMCwibWF4T3BlblJpc2tQY3QiOjYsImRhaWx5TG9zc1N0b3BQY3QiOjQsImRyYXdkb3duSGFsdFBjdCI6MjUsIm1hcmtldHMiOlsiQlRDIiwiRVRIIl19zunder-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:
| JSON | Why |
|---|---|
{"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 |