# X-Trader auto_sell: TP/SL stages, tp_trigger_basis, tp_multi_hit, trailing TP

URL: https://docs.blizz.id/xtrader/auto-sell/
Updated: 2026-10-16

`auto_sell` is the required object inside `config` that controls automatic exits: take-profit stages (`tp_orders`), stop-loss stages (`sl_orders`), trailing take-profit, sells that follow the target wallet, and an expiry. `tp_trigger_basis` decides whether each TP trigger is measured from the entry cost or from the previous stage, and `tp_multi_hit` decides what happens when one price move crosses several stages. The schema is the same for sniper, copy-trade and transfer-snipe configurations.

Primary source: [X-Trader API reference](https://x-trader.ai/api/docs). This page is an independent explanation of X-Trader Public API v1.0.0; it is not affiliated with X-Trader.

## Where auto_sell lives

`auto_sell` is a required key of `config` in `SniperCreate`, `SniperPatch`, `CopyTradeCreate`, `CopyTradePatch`, `TransferSnipeCreate`, `TransferSnipePatch` and both template schemas. The JSON schema of the object is byte-for-byte identical in all of them, so there is no sniper-only or copy-trade-only field. Its siblings are [`auto_strategy`](/xtrader/auto-strategy/) and [`timed_strategy`](/xtrader/timed-strategy/). See [sniper config](/xtrader/config-sniper/) and [copy-trade config](/xtrader/config-copy-trade/) for the rest of `config`.

The object is strict (`additionalProperties: false`): unknown keys return `validation_error`. All percentages, durations and amounts are decimal strings (for example `"50"`, not `50`), at most 32 characters and at most 12 fractional digits. See [write rules](/xtrader/write-rules/).

## Top-level fields

| Field | Type | Required | Values and limits | Meaning |
|---|---|---|---|---|
| `enabled` | boolean | yes | | Turns automatic selling on or off. |
| `expiry_time` | string | yes | `"0"`, or minutes `"1"` to `"525600"` | How long the auto-sell rules stay active. `"0"` means permanent. 525,600 minutes is 365 days. |
| `tp_orders` | array | yes | no fixed count limit (64 KiB body limit applies) | Take-profit stages. May be empty. |
| `sl_orders` | array | yes | no fixed count limit | Stop-loss stages. May be empty. |
| `follow_sell` | boolean | yes | | Sell when the followed wallet sells. |
| `proportional_sell` | boolean | yes | | When follow-sell fires, sell the same ratio the followed wallet sold. |
| `tp_trigger_basis` | string | no | `entry_price` (default), `previous_stage` | How TP triggers are measured. |
| `tp_multi_hit` | string | no | `merge` (default), `highest_only` | Behaviour when one jump covers several TP stages. `entry_price` only. |
| `tp_loop` | boolean | no | | Turns the last `tp_orders` item into a repeating, compounding rule. |
| `trailing_take_profit` | object | no | default `{"enabled": false, "tiers": []}` | Trailing TP tiers. |
| `ignore_fee_for_auto_sell` | boolean | no | omitted means global setting | Whether trigger PnL ignores Gas/Tip. |
| `tab` | string | no | `tpsl`, `dev` | Stored auto-sell mode identifier. |

## tp_orders stage fields

| Field | Type | Required | Values and limits |
|---|---|---|---|
| `trigger` | string | yes | PnL percentage, greater than 0. Meaning depends on `tp_trigger_basis`. |
| `sell` | string | yes | Percentage of the current position, `"0"` to `"100"`. `"0"` advances the stage without selling. |
| `timeout` | object | no | See [Per-stage timeout](#per-stage-timeout). |

`sell` is a share of the position held at the moment the stage fires, not of the original buy. Two stages of `"50"` therefore leave 25% of the original position, not 0%.

## tp_trigger_basis: entry_price vs previous_stage

| Value | Ordering | What `trigger` measures |
|---|---|---|
| `entry_price` (default; omit for legacy configs) | Stages are sorted by `trigger` ascending, array order does not matter. | Total PnL relative to entry cost for every stage. |
| `previous_stage` | Array order is kept as sent. | TP1: PnL from entry. TP2 and later: percent rise from the previous stage's confirmed trigger or fill price. |

### Worked example: same numbers, different prices

Take entry cost normalised to 1.00 and this `tp_orders` array:

```json
[
  { "trigger": "50",  "sell": "30" },
  { "trigger": "100", "sell": "30" },
  { "trigger": "200", "sell": "40" }
]
```

| Stage | `entry_price` target | `previous_stage` target |
|---|---|---|
| TP1 (`50`) | 1.00 x 1.50 = **1.50** (+50%) | 1.00 x 1.50 = **1.50** (+50%) |
| TP2 (`100`) | 1.00 x 2.00 = **2.00** (+100%) | 1.50 x 2.00 = **3.00** (+200% from entry) |
| TP3 (`200`) | 1.00 x 3.00 = **3.00** (+200%) | 3.00 x 3.00 = **9.00** (+800% from entry) |

Under `previous_stage` the base for TP2 and TP3 is the previous stage's confirmed trigger or fill price, so with slippage the real targets differ slightly from the arithmetic above. The spec does not state which of the two (trigger or fill) is used in which case.

To express "sell at 1.5x, 2x and 3x of entry" under `previous_stage`, the triggers would have to be `50`, `33.33`, `50` (1.5 x 1.3333 = 2.0, 2.0 x 1.5 = 3.0).

### Switching basis

The product UI locks the basis while `tp_orders` is non-empty, but the API accepts any combination. Flipping `tp_trigger_basis` alone silently changes every target after TP1. When changing the basis, rewrite `tp_orders` in the same PATCH so the numbers match the new meaning.

## tp_multi_hit: merge vs highest_only

A single price tick can jump past several stages at once. `tp_multi_hit` decides how many sells that produces. It applies only when `tp_trigger_basis` is `entry_price` or omitted; sending it together with `previous_stage` is rejected with the reason `tp_multi_hit_requires_entry_price`.

| Value | Behaviour |
|---|---|
| `merge` (default; omit for legacy) | One sell whose size is the sum of the `sell` values of all covered stages, capped at 100%. |
| `highest_only` | Intermediate covered stages are marked complete without selling; only the highest covered stage's `sell` is executed. |

### Example

Stages under `entry_price`: TP1 `+50%` sell `25`, TP2 `+100%` sell `25`, TP3 `+200%` sell `50`. Price moves from +30% to +120% in one tick, crossing TP1 and TP2.

| Mode | Result of the tick | Stages left |
|---|---|---|
| `merge` | One sell of 25 + 25 = **50%** of the current position. | TP3 |
| `highest_only` | TP1 marked done, no sell. One sell of TP2's **25%**. | TP3 |

If the same tick reached +250% (all three stages): `merge` sells 25 + 25 + 50 = 100% (the cap); `highest_only` sells TP3's 50% and keeps the rest.

## tp_loop

With `tp_loop: true`, the raw last item of `tp_orders` is not an ordinary stage. It becomes an independent loop rule that keeps generating new targets while a balance remains.

| Rule | Detail |
|---|---|
| Position of the loop rule | Always the last array item. Under `entry_price` only the items before it are sorted; the loop item stays last and is never included in fixed-stage sorting. |
| First loop target | Preceding ordinary target x (1 + `trigger` / 100). With no ordinary stage, entry x (1 + `trigger` / 100). |
| Later targets | Each compounds from the preceding loop target by the same factor. |
| `sell` | Must be below 100. `"0"` advances to the next target without submitting a trade. |
| `timeout` | Restarts for every generated target; `seconds` may be `"0"` when enabled. |

Example under `entry_price`: ordinary stages `+50%` and `+100%`, loop item `{ "trigger": "50", "sell": "20" }`. The last ordinary target is 2.00x entry, so loop targets are 3.00x, 4.50x, 6.75x and so on, each selling 20% of what is left. The spec describes the first-target formula explicitly for `entry_price`; it does not give a separate formula for `previous_stage`.

## Per-stage timeout

`tp_orders[].timeout` sells part of the position if the next tier is not reached in time.

| Field | Type | Required | Values and limits |
|---|---|---|---|
| `enabled` | boolean | yes | |
| `seconds` | string | yes | `"0"` to `"86400"`, 0.1-second precision |
| `sell` | string | yes | `"1"` to `"100"` when enabled |

The schema requires all three keys whenever `timeout` is present, even with `enabled: false`. The spec does not state exactly when the countdown starts; the description ties it to reaching the next tier, so the practical reading is that it runs after the stage fires. For loop targets it restarts at each generated target.

## sl_orders

| Field | Type | Required | Values and limits |
|---|---|---|---|
| `trigger` | string | yes | `"-100"` to `"-1"` (PnL percent) |
| `sell` | string | yes | `"1"` to `"100"`, percent of the current position |
| `confirm_seconds` | string | no | Max 32 characters. The spec gives no description or range. |

Stop-loss stages fire immediately when their trigger is hit. Delayed and two-phase stop-losses are separate rules in [`auto_strategy`](/xtrader/auto-strategy/) (`sl_protection`, `phased_sl`) and do not replace `sl_orders`.

## follow_sell and proportional_sell

Both are required booleans.

| `follow_sell` | `proportional_sell` | Behaviour |
|---|---|---|
| `false` | ignored | No sells triggered by the followed wallet. |
| `true` | `false` | Sell when the followed wallet sells. The spec does not state the size in this case. |
| `true` | `true` | Sell the same ratio of the position that the followed wallet sold. |

The field is defined in the shared schema for sniper configs as well; the spec does not describe how "followed trader" is resolved for a sniper target.

## trailing_take_profit

| Field | Type | Required | Values and limits |
|---|---|---|---|
| `enabled` | boolean | yes | |
| `tiers` | array | yes | No fixed tier-count limit |
| `tiers[].activation` | string | yes | Profit percentage that arms the tier |
| `tiers[].drawdown` | string | yes | Greater than 0, at most 100: drop from the observed peak |
| `tiers[].sell` | string | yes | Greater than 0, percent of the current position |

A tier is armed once profit reaches `activation`; after that, a fall of `drawdown` percent from the highest observed point sells `sell` percent. The object defaults to `{"enabled": false, "tiers": []}`. The spec does not document how tiers are ordered or whether several armed tiers can fire together.

## ignore_fee_for_auto_sell

Optional boolean that changes the PnL used for triggers of TP, SL, trailing TP and profit protection (`auto_strategy.profit_protection`):

| Value | Trigger PnL |
|---|---|
| `true` | Ignores Gas and Tip. |
| `false` | Includes Gas and Tip. |
| omitted | Follows the account's global setting. |

Positions, trade details and Telegram notifications always use the real cost regardless of this flag.

## tab

`tab` stores the auto-sell mode identifier, `tpsl` or `dev`. The spec does not describe how the two modes behave differently.

## Example inside a create body

```json
{
  "target_wallet": "<solana-address>",
  "config": {
    "auto_sell": {
      "enabled": true,
      "expiry_time": "1440",
      "tp_trigger_basis": "entry_price",
      "tp_multi_hit": "merge",
      "tp_loop": false,
      "ignore_fee_for_auto_sell": false,
      "tp_orders": [
        { "trigger": "50",  "sell": "30",
          "timeout": { "enabled": true, "seconds": "600", "sell": "50" } },
        { "trigger": "100", "sell": "30" },
        { "trigger": "200", "sell": "100" }
      ],
      "sl_orders": [
        { "trigger": "-30", "sell": "100" }
      ],
      "follow_sell": true,
      "proportional_sell": true,
      "trailing_take_profit": {
        "enabled": true,
        "tiers": [
          { "activation": "80", "drawdown": "20", "sell": "50" }
        ]
      }
    },
    "auto_strategy": { "migration_clear": false, "timed_sell": false, "sell_delay": "0" },
    "timed_strategy": { "enabled": false, "orders": [] }
  }
}
```

The other required `config` keys are omitted here; they differ between [sniper](/xtrader/config-sniper/) and [copy-trade](/xtrader/config-copy-trade/).

## Updating auto_sell on an existing config

PATCH replaces the complete `config`, so the flow is: GET the config, take its `ETag`, edit `auto_sell` locally, PATCH the full `config` with `If-Match`. The config must be paused, otherwise the API returns `config_must_be_paused`. A stale ETag returns `version_conflict`. Snapshots returned by GET may contain legacy web-UI fields that the strict write schema rejects; remove any key reported in `error.fields` before retrying. Scopes: `sniper:write` for `/sniper/configs`, `copytrade:write` for `/copy-trade/configs`.

### curl

```bash
ID='<config-uuid>'
BASE='https://x-trader.ai/api/v1'

# 1. Read the current config and its ETag
curl -sD headers.txt "$BASE/copy-trade/configs/$ID" \
  --header "Authorization: Bearer $X_TRADER_API_TOKEN" \
  | jq '.data.config' > config.json
ETAG=$(grep -i '^etag:' headers.txt | cut -d' ' -f2 | tr -d '\r')

# 2. Replace auto_sell inside the full config
jq '.auto_sell.tp_trigger_basis = "previous_stage"
    | del(.auto_sell.tp_multi_hit)
    | .auto_sell.tp_orders = [
        {"trigger":"50","sell":"30"},
        {"trigger":"33.33","sell":"30"},
        {"trigger":"50","sell":"100"}]' config.json \
  | jq '{config: .}' > patch.json

# 3. Send it with If-Match
curl --request PATCH "$BASE/copy-trade/configs/$ID" \
  --header "Authorization: Bearer $X_TRADER_API_TOKEN" \
  --header 'Content-Type: application/json' \
  --header "If-Match: $ETAG" \
  --data @patch.json
```

### Python

```python
import os
import requests

BASE = "https://x-trader.ai/api/v1"
CONFIG_ID = "<config-uuid>"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['X_TRADER_API_TOKEN']}"

url = f"{BASE}/copy-trade/configs/{CONFIG_ID}"   # or /sniper/configs/{id}
resp = session.get(url)
resp.raise_for_status()
etag = resp.headers["ETag"]                       # e.g. "v7"
resource = resp.json()["data"]
if resource["is_active"]:
    raise SystemExit("Pause the config first (POST .../pause with If-Match)")

config = resource["config"]
config["auto_sell"].update({
    "tp_trigger_basis": "entry_price",
    "tp_multi_hit": "highest_only",
    "tp_orders": [
        {"trigger": "50", "sell": "25"},
        {"trigger": "100", "sell": "25"},
        {"trigger": "200", "sell": "50"},
    ],
    "sl_orders": [{"trigger": "-35", "sell": "100"}],
})

patch = session.patch(url, json={"config": config}, headers={"If-Match": etag})
if patch.status_code >= 400:
    print(patch.json()["error"])     # code, message, fields[]
patch.raise_for_status()
print("new version:", patch.headers["ETag"])
```

## FAQ

### Do the same tp_orders numbers mean the same prices under entry_price and previous_stage?

No. Under `entry_price` every trigger is total PnL from entry cost, so `50/100/200` means 1.5x, 2x and 3x. Under `previous_stage` each stage after the first is a rise from the previous stage's confirmed price, so `50/100/200` means 1.5x, 3x and 9x.

### Can tp_multi_hit be used with previous_stage?

No. `tp_multi_hit` only applies under `entry_price` (or an omitted basis). Combining it with `previous_stage` is rejected with the reason `tp_multi_hit_requires_entry_price`.

### Is auto_sell different between sniper and copy-trade configs?

No. The `auto_sell` schema is identical in `SniperCreate`, `CopyTradeCreate`, their Patch schemas, transfer-snipe and both template schemas.

### How do I keep auto-sell active with no expiry?

Set `expiry_time` to `"0"`. Any other value is a number of minutes from 1 to 525,600.

### Can I PATCH only auto_sell?

No. When `config` is present in a PATCH it replaces the whole nested config and must pass the full schema, so read the current config, change `auto_sell` and send the complete `config` with `If-Match`.
