Strategy rules spec
The Custom rules JSON in full: every field, indicator and operator, the limits, and every error the check can return.
Overview
A Custom strategy is a small JSON object: a group of buy conditions, a group of sell conditions, and two optional exits. The Studio builds it for you; this page is for writing it by hand, from the Public API or the MCP server. The same check runs everywhere, so rules that pass here pass in the Studio too.
Every hour, for each market, the agent checks one group:
- No position: the
entrygroup. If it is met, the agent buys. - Holding a position: the
exitgroup. If it is met, the agent sells. The stop loss, the take profit and the max holding time are checked as well, whatever the group says.
{
"entry": { "join": "all" | "any", "conditions": [ condition, ... ] },
"exit": { "join": "all" | "any", "conditions": [ condition, ... ] },
"takeProfitPct": number | null,
"maxHoldHours": number | null
}
condition = { "left": indicator, "op": operator, "right": indicator | value }
indicator = { "kind": "sma", "hours": 48, "offsetPct": -2 }
value = { "kind": "value", "value": 30 }Full example
RSI dip buying with a trend filter. It passes the check as it is, so you can paste it into a validate call.
{
"entry": {
"join": "all",
"conditions": [
{
"left": {
"kind": "rsi",
"hours": 14
},
"op": "below",
"right": {
"kind": "value",
"value": 30
}
},
{
"left": {
"kind": "price"
},
"op": "above",
"right": {
"kind": "sma",
"hours": 72,
"offsetPct": -5
}
}
]
},
"exit": {
"join": "any",
"conditions": [
{
"left": {
"kind": "rsi",
"hours": 14
},
"op": "crossesAbove",
"right": {
"kind": "value",
"value": 55
}
}
]
},
"takeProfitPct": 6,
"maxHoldHours": 72
}- entry, join "all": both buy conditions must be true in the same hour.
- Buy condition 1: the 14 hour RSI is under 30, so the market has fallen hard lately.
- Buy condition 2: the price is still above its 72 hour average minus 5%, so it is a dip and not a crash.
- exit, join "any": one sell condition is enough.
- Sell condition: the 14 hour RSI crosses back above 55. True only in the hour it crosses.
- takeProfitPct 6: sell once the price is 6% above where it was bought.
- maxHoldHours 72: sell after 3 days whatever happens.
curl -X POST https://zolvaagent.app/api/public/v1/me/rules/validate \
-H "Authorization: Bearer $ZOLVA_KEY" \
-H "Content-Type: application/json" \
-d '{ "rules": {"entry":{"join":"all","conditions":[{"left":{"kind":"rsi","hours":14},"op":"below","right":{"kind":"value","value":30}},{"left":{"kind":"price"},"op":"above","right":{"kind":"sma","hours":72,"offsetPct":-5}}]},"exit":{"join":"any","conditions":[{"left":{"kind":"rsi","hours":14},"op":"crossesAbove","right":{"kind":"value","value":55}}]},"takeProfitPct":6,"maxHoldHours":72} }'The answer is { "valid": true, "rules": {...}, "lookbackHours": 73, "errors": [] }, or valid: false with the list of errors below. lookbackHours is how much history the rules need before the first decision.
Fields
| Field | Type | What it does |
|---|---|---|
entry | group | When to buy. Checked every hour the agent holds nothing in that market. |
exit | group | When to sell. Checked every hour the agent holds a position. May have no conditions if a take profit or max holding time is set. |
join | "all" or "any" | On a group. all: every condition must be true. any: one is enough. Anything else counts as "all". |
conditions | array | On a group: 0 to 3 conditions. The buy group needs at least one. |
left | indicator | On a condition: the indicator being tested. Its unit decides what the right side may be. |
op | above, below, crossesAbove, crossesBelow | On a condition: the comparison. See the operators table. |
right | indicator or value | On a condition: what the left side is compared with. A price line when the left side is a price line, otherwise a value. |
kind | price, sma, high, low, change, zscore, rsi, value | On an indicator: which one. "value" marks a plain number on the right side. |
hours | integer | On an indicator: the period. Must be one of the periods in the indicators table; leave it out for price. |
offsetPct | number, −50 to 50 | On an indicator, optional: shifts the line by a percentage. −3 on a 48h average means 3% below it. 0 is the same as leaving it out. |
value | number | On a value side: the number, in the left indicator's unit (percent for change, standard deviations for z score, 0 to 100 for RSI). |
takeProfitPct | number, 1 to 500, or null | Sell once the price is this many percent above the entry price. |
maxHoldHours | number, 1 to 720, or null | Sell after holding a position this many hours, whatever the rules say. |
Indicators
The kind of a side. Price lines (unit USD) can only be compared with other price lines; everything else is compared with a value.
| kind | What it measures | hours | Unit | Compare with |
|---|---|---|---|---|
pricePrice | The latest hourly close. | none (leave hours out) | USD price line | another price line |
smaAverage | The average close over the last N hours (simple moving average). | 12, 24, 48, 72 | USD price line | another price line |
highHighest | The highest close of the N hours before this one. The current hour is left out, so price can cross above it. | 12, 24, 48, 72 | USD price line | another price line |
lowLowest | The lowest close of the N hours before this one, current hour left out. | 12, 24, 48, 72 | USD price line | another price line |
changeChange | How much the price moved over the last N hours, in percent. | 1, 4, 12, 24, 48, 72 | percent | a number |
zscoreZ score | How far the price sits from its N hour average, in standard deviations. Around −2 or lower is an unusually deep dip. | 24, 48, 72, 96 | standard deviations | a number |
rsiRSI | Relative strength index over N hours: the share of the movement that was up. Below 30 is often read as oversold, above 70 as overbought. | 14, 24 | 0 to 100 | a number |
Operators
| op | Studio label | True when |
|---|---|---|
above | above | True every hour the left side is higher than the right side. |
below | below | True every hour the left side is lower than the right side. |
crossesAbove | crosses above | True only in the hour the left side moves from at or below the right side to above it. |
crossesBelow | crosses below | True only in the hour the left side moves from at or above the right side to below it. |
Agent settings next to the rules
Markets, venue, stop loss and position size are not part of the rules object. They are set when the agent is made (POST /me/agents or create_paper_agent) and in a backtest.
| Field | Type | What it does |
|---|---|---|
markets | array of string, up to 42 characters (1 to 14) | Market symbols (NVDA, ETH, BTC-PERP) or token addresses, from list_markets |
venue | one of: spot, perps | spot (default): stock and crypto tokens, long only. perps: Lighter perps, paper only |
capital | number, 1,000 to 1,000,000 | Virtual dollars, 1000 to 1000000 |
maxPositionPct | integer, 10 to 100 | Most of the capital in one market, 10 to 100 |
stopLossPct | integer, 2 to 25 | Stop loss per position, 2 to 25 |
The stop loss always applies: a position that falls this far below its entry price is sold that hour. See Guardrails for every other limit.
Limits
| Limit | Value |
|---|---|
| Conditions per group (buy or sell) | 3 |
| Longest look back | 168 hours (7 days) |
| Price line shift (offsetPct) | ±50% |
| Take profit | 1 to 500% |
| Max holding time | 1 to 720 hours (30 days) |
Error messages
The check returns every problem at once, in plain words. These are all of them. Through the API they come back in errors (validate) or in the 400 invalid_input message (backtest, create).
| Message | Cause | Fix |
|---|---|---|
| A group has more than 3 conditions. | Drop the weakest condition, or split the idea into two agents. | |
| entry.conditions is empty or missing. | Add at least one buy condition. | |
| No exit conditions, no takeProfitPct and no maxHoldHours. | Add a sell condition, a take profit or a max holding time. | |
| The rules need more than 168 hours of history. | Use shorter periods. | |
| A left or right side has an unknown kind, an hours value outside its periods, or an offsetPct outside the limit. | Check kind, hours and offsetPct against the indicators table. | |
| op is missing or not one of the four operators. | Use above, below, crossesAbove or crossesBelow. | |
| A price line (price, average, highest, lowest) is compared with a plain value. | Compare it with another price line, and use offsetPct for a gap such as 2% below the average. | |
| A value side has no number, or one that is not finite. | Set value to a number. | |
| Two indicators that are not both price lines are compared, such as RSI with change. | Compare change, z score and RSI with a value instead. | |
| takeProfitPct or maxHoldHours is outside its range or not a number. | Use a number in range, or null to turn it off. |