Auto-Rebalance
Keep a basket on its target weights automatically, in weights mode or beta mode.
A basket drifts as prices move: the winning leg grows and the losing one shrinks, so the exposure you opened is not the exposure you hold. Auto-rebalance watches one position and trades the legs back to target when drift passes a threshold you set.
This page is the API. For what the feature does and when to reach for each mode, see Auto-Rebalance.
| Action | Endpoint | SDK |
|---|---|---|
| Create a config | POST /rebalance/auto | sdk.core.rebalance.auto.create |
| List configs | GET /rebalance/auto | sdk.core.rebalance.auto.list |
| Change one in place | PATCH /rebalance/auto/{id} | sdk.core.rebalance.auto.update |
| Swap modes atomically | POST /rebalance/auto/{id}/replace | sdk.core.rebalance.auto.replace |
| Cancel | POST /rebalance/auto/{id}/cancel | sdk.core.rebalance.auto.cancel |
| Rebalance once, now | POST /rebalance/manual/{positionId} | sdk.core.rebalance.manual |
| Read the history | GET /rebalance/{positionId}/events | sdk.core.rebalance.listEvents |
Create, patch and replace all answer with { "config": … }. The list answers with { "items": [ … ], "limit": …, "nextCursor": … }, and takes status, weightSource, positionId, cursor and limit as filters.
Read what it did on Rebalance Activity.
The two modes
weightSource picks which target the position is pulled back to.
INITIAL_WEIGHT | LIVE_BETA | |
|---|---|---|
| Restores | Each leg to the weight the position opened with | The position to beta-neutral |
| Needs | Nothing else | A timeframe: 1h, 4h, or 1d |
| Works on | Any basket | Exactly one long and one short leg |
{
"positionId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"weightSource": "INITIAL_WEIGHT",
"minimumDriftPct": "0.05"
}{
"positionId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"weightSource": "LIVE_BETA",
"minimumDriftPct": "0.05",
"timeframe": "4h"
}A position carries one config at a time. Create is rejected when:
| Rejected when | Status |
|---|---|
| The position already has an active config | 409 ACTIVE_CONFIG_EXISTS |
The position is not OPEN | 409 POSITION_NOT_OPEN |
weightSource is LIVE_BETA and the exposure is not exactly one long and one short non-zero leg | 400 INVALID_LIVE_BETA_EXPOSURE |
minimumDriftPct means different things in each mode
The field name is the same, the measurement is not. Both are fractions, greater than 0 and at most 1, and both are compared against the drift the engine computes.
In INITIAL_WEIGHT it is an absolute weight difference, in percentage points:
targetWeight_i = |initialExposureUSD_i| / Σ|initialExposureUSD|
currentWeight_i = (|exposure_i| × price_i) / Σ(|exposure| × price)
drift = max over legs of |currentWeight_i − targetWeight_i|Drift is the worst single leg, not an average. On a 50/50 pair, "0.05" fires once a leg reaches 55%.
In LIVE_BETA it is a relative change in beta:
drift = |liveBeta − baselineBeta| / baselineBetaSo "0.05" fires when beta has moved 5% away from its baseline, whatever the baseline is. A 0.90 baseline fires at 0.945 or 0.855.
The same "0.05" therefore means different sensitivities in the two modes. Pick the number per mode, not once.
How beta mode tracks drift
LIVE_BETA compares two betas: a frozen baseline and a live one.
- The baseline is
impliedEntryBeta, the implied beta of the leg split the position actually achieved. It is frozen at activation and re-snapshotted after every successful rebalance, so drift is always measured from where the last rebalance left the position. - The live side is
lastDerivedBeta, a Kalman beta computed from the candle feed yourtimeframeselects.
The config reports where it is in that progression through beta.state:
beta.state | Meaning |
|---|---|
PENDING | Activated, no baseline yet. |
BASELINED | The baseline is frozen, but no candle has been processed, so there is nothing to compare. |
TRACKING | Both sides are live. driftPct is populated and comparable to minimumDriftPct. |
{
"config": {
"id": "9c1f2b7e-2f3a-4a1f-9c7e-0b8a4d2f6a11",
"positionId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "ACTIVE",
"weightSource": "LIVE_BETA",
"minimumDriftPct": "0.05",
"timeframe": "4h",
"beta": {
"state": "TRACKING",
"impliedEntryBeta": "0.9120",
"lastDerivedBeta": "0.9642",
"lastProcessedBetaTime": "2026-01-01T08:00:00.000Z",
"driftPct": "0.0572"
},
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T08:00:00.000Z"
}
}driftPct here is 0.0572, past the 0.05 threshold, so the next evaluation rebalances. lastProcessedBetaTime tells you how fresh that reading is: on a 4h timeframe it will be up to four hours old.
driftPct is absent, not zero, whenever the comparison is undefined — before a candle is processed, or against a degenerate zero baseline. Treat absence as "no signal yet", not as "no drift".
Pausing
A paused config stops evaluating and does not rebalance. status becomes PAUSED and pausedReason says why:
pausedReason | Cause |
|---|---|
USER | You paused it, with PATCH /rebalance/auto/{id}. |
POSITION_CHANGED | The position's exposure moved. See below. |
INVALID_LIVE_BETA_EXPOSURE | A LIVE_BETA config whose position is no longer exactly one long and one short. Rather than retry forever, it stops. |
REPEATED_FAILURES | Rebalances failed three times in a row. A single success resets the streak. |
The circuit breaker matters most for a bot: without it a config whose rebalance fails every bar would retry indefinitely. It applies to both modes, and the threshold counts consecutive FAILED rebalance events.
POSITION_CHANGED is the common one
POSITION_CHANGED does not mean the position closed. It is written on any fill that moves the watched position's exposure:
- An adjust.
- A close, including a close that only partially filled.
- A reverse.
- A manual rebalance on that position.
- Netting, when another trade on the same asset changes this position's legs.
- The fill sync that reconciles trades made on the exchange outside Pear.
A closed position also lands on POSITION_CHANGED, and can never rebalance again.
So POST /rebalance/manual/{positionId} pauses the auto config on that position, even though the config asked for nothing. A rebalance the config performs itself does not pause it. Plan for this: after any hand-driven change to a watched position, resume the config explicitly.
Pause and resume through the same endpoint, PATCH /rebalance/auto/{id}:
{ "status": "PAUSED" }Resuming
Sending { "status": "ACTIVE" } resumes it. Resume keeps the existing baseline. It does not re-snapshot impliedEntryBeta and does not return a LIVE_BETA config to PENDING: a config that was tracking before the pause is tracking again, with the same baseline and the same drift reading.
Resume re-validates the watched position first, and is rejected rather than silently reactivated:
| Rejected when | Status |
|---|---|
| The position no longer exists | 404 |
The position is not OPEN | 409 POSITION_NOT_OPEN |
A LIVE_BETA config whose position is no longer exactly one long and one short | 400 INVALID_LIVE_BETA_EXPOSURE |
| Another config took the position's single active slot while this one was paused | 409 ACTIVE_CONFIG_EXISTS |
A rejected resume changes nothing. The config stays PAUSED with the same pausedReason.
Changing a running config
Unlike triggers and schedules, an auto-rebalance config can be edited in place.
| To | Use |
|---|---|
Change minimumDriftPct, timeframe, or status | PATCH /rebalance/auto/{id} |
Switch weightSource | POST /rebalance/auto/{id}/replace |
PATCH cannot change the mode, because the two carry different fields. It also cannot set timeframe on an INITIAL_WEIGHT config: timeframe belongs to LIVE_BETA only, and sending it anywhere else is rejected.
replace mints a new config id
replace does not edit the row. It stops the config you name and inserts a new one, in one step, so the position is never left unwatched between a cancel and a create. See Managing Open Order, where every other resting order works the opposite way.
The new config has a new id. Read it from the config in the response and use it from then on. The old id is stopped: it drops out of GET /rebalance/auto, and every later call against it returns 404.
The new config carries over the old one's status and pausedReason, but starts with no beta state. A LIVE_BETA replacement therefore begins again at beta.state: "PENDING" and takes a fresh baseline.
The replace body is not the create body. It carries no positionId, because the position comes from the config being replaced:
{
"weightSource": "LIVE_BETA",
"minimumDriftPct": "0.05",
"timeframe": "4h"
}An INITIAL_WEIGHT replacement is the same body without timeframe.
Cancelling
POST /rebalance/auto/{id}/cancel returns 204 with no body. The config stops polling and drops out of the list; the rebalance history stays readable. A second cancel of the same id returns 404.
Rebalancing once, by hand
POST /rebalance/manual/{positionId} trades to weights you name, with no config and no threshold:
{
"orderType": "MARKET",
"weightings": [
{ "symbol": "0", "weight": 0.5 },
{ "symbol": "1", "weight": 0.5 }
]
}weightings must name the position's non-zero legs exactly. A missing symbol or an extra one is rejected with Rebalance symbols must exactly match the position symbols. There is no way to rebalance a subset of the legs.
Each weight must be greater than 0 and at most 1, the set must sum to exactly 1, and each leg names a distinct instrument ID. orderType accepts only "MARKET". The position must be OPEN; anything else is 409.
The call is synchronous. It answers 200 once the rebalance has run, with a plan:
{ "plan": { "outcome": "full_rebalance" } }outcome | Meaning |
|---|---|
full_rebalance | Every leg traded back to its target weight. |
partial_rebalance | Some legs traded and some did not. messages says which, and why. |
no_rebalance | Nothing traded. messages says why. |
failed | Background runs only. A manual rebalance reports its failure as an HTTP error instead. |
Every outcome except full_rebalance carries a non-empty messages array.
A manual rebalance pauses the position's auto-rebalance config with POSITION_CHANGED. Resume it with PATCH /rebalance/auto/{id} and { "status": "ACTIVE" }.
Costs
Every rebalance is a real trade. It places market orders on both sides and pays the usual fees, so a tighter minimumDriftPct means more rebalances and more cost. See Trading Fees.