feat: enable short position support in DSL backtests

The sell-quantity override in rule_strategy.rs was asymmetric: sell
orders always closed the full position, but buy orders had no equivalent
logic for closing a short. This made shorts broken in practice.

Replace 18 lines with 8 lines of symmetric logic:
- Order side opposite to current position → use full position quantity
  (sell closes long, buy closes short)
- Otherwise (flat or same-side) → use configured base_quantity

Behavior is identical for existing long-only strategies. Shorts were
already supported by the DSL types and barter engine — only the
executor's quantity logic needed fixing.

Also updates docs/api.md:
- Replaces "Sell order sizing" notes with symmetric "Closing orders" note
- Removes spot-only caveat from the position condition docs
- Adds Example 7: long/short Bollinger mean reversion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 16:08:54 +02:00
parent 4248bb0d1f
commit b535207150
2 changed files with 90 additions and 23 deletions

View File

@@ -1155,9 +1155,10 @@ condition is true simultaneously fire their `then` action.
| `rule.then.side` | `"buy"` or `"sell"` | | `rule.then.side` | `"buy"` or `"sell"` |
| `rule.then.quantity` | Fixed decimal string (e.g. `"0.001"`) or dynamic `Expr` object | | `rule.then.quantity` | Fixed decimal string (e.g. `"0.001"`) or dynamic `Expr` object |
**Sell order sizing:** The requested quantity for sell orders is automatically capped to the open **Closing orders:** When an order's side is opposite to the current position (sell while long,
position quantity. To close the entire position regardless of size, use a large fixed quantity or buy while short), the engine automatically uses the full position quantity, ignoring the
(e.g. `"9999"`) or `{ "kind": "position_quantity" }`. configured sizing. This ensures clean position closes. When flat or adding to an existing
position, the configured quantity is used as-is.
**Warm-up:** Indicators require historical candles to compute. The executor automatically **Warm-up:** Indicators require historical candles to compute. The executor automatically
pre-loads candles before `starts_at` based on the maximum indicator period in the strategy. You do pre-loads candles before `starts_at` based on the maximum indicator period in the strategy. You do
@@ -1278,8 +1279,8 @@ True when the current position matches the required state.
Values: `"flat"` (no open position), `"long"` (long position held), `"short"` (short position held). Values: `"flat"` (no open position), `"long"` (long position held), `"short"` (short position held).
> Swym's paper trading engine operates on spot markets, so `"short"` positions are not achievable > All three states are fully supported. A `"sell"` action while flat opens a short position;
> in the standard configuration. Use `"flat"` and `"long"` for spot strategies. > `{ "kind": "position", "state": "short" }` then matches until the short is closed.
--- ---
@@ -1737,8 +1738,9 @@ in intent.
--- ---
**Sell order sizing:** For all sizing methods, sell orders automatically close the full open **Closing orders:** When an order's side is opposite to the current position (sell while long,
position. The computed quantity is only used as a fallback if no position is currently open. or buy while short), the engine automatically uses the full position quantity. The configured
sizing is used when flat (opening a new position) or when adding to an existing same-side position.
#### Dynamic quantity #### Dynamic quantity
@@ -2111,3 +2113,76 @@ POST /api/v1/paper-runs
] ]
} }
``` ```
---
### Example 7: Long/Short Bollinger Mean Reversion
A bidirectional strategy: go long on a lower-band touch, short on an upper-band touch, exit
each when price crosses back through the 20-period SMA (middle band).
```json
{
"type": "rule_based",
"candle_interval": "1h",
"rules": [
{
"comment": "Open long: price below lower Bollinger Band while flat",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "bollinger", "period": 20, "num_std_dev": "2.0", "band": "below_lower" },
{ "kind": "position", "state": "flat" }
]
},
"then": { "side": "buy", "quantity": { "method": "percent_of_balance", "percent": "10", "asset": "usdt" } }
},
{
"comment": "Close long: price crosses above SMA20 (middle band)",
"when": {
"kind": "all_of",
"conditions": [
{
"kind": "cross_over",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "sma", "field": "close", "period": 20 }
},
{ "kind": "position", "state": "long" }
]
},
"then": { "side": "sell", "quantity": { "kind": "position_quantity" } }
},
{
"comment": "Open short: price above upper Bollinger Band while flat",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "bollinger", "period": 20, "num_std_dev": "2.0", "band": "above_upper" },
{ "kind": "position", "state": "flat" }
]
},
"then": { "side": "sell", "quantity": { "method": "percent_of_balance", "percent": "10", "asset": "usdt" } }
},
{
"comment": "Close short: price crosses below SMA20 (middle band)",
"when": {
"kind": "all_of",
"conditions": [
{
"kind": "cross_under",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "sma", "field": "close", "period": 20 }
},
{ "kind": "position", "state": "short" }
]
},
"then": { "side": "buy", "quantity": { "kind": "position_quantity" } }
}
]
}
```
Short positions are opened with `"side": "sell"` while flat. The engine computes the quantity
from the sizing method (`percent_of_balance`) and uses it as the short size. The close-short
rule uses `"side": "buy"` — since the order side is opposite to the open short position, the
engine automatically closes the full position.

View File

@@ -218,23 +218,15 @@ impl AlgoStrategy for RuleStrategy {
QuantitySpec::Expr(e) => eval_expr(e, &ctx).map(|v| v.max(Decimal::ZERO)), QuantitySpec::Expr(e) => eval_expr(e, &ctx).map(|v| v.max(Decimal::ZERO)),
}; };
// For sell orders, always close the full open position. // When the order side is opposite to the open position, close
// base_quantity is only used as a fallback when no position exists, and for // the full position (sell closes long, buy closes short).
// buy orders where a None result (insufficient data / missing balance) skips // Otherwise use the configured base_quantity.
// the order entirely. let quantity = match instrument_state.position.current.as_ref() {
let quantity = if side == Side::Sell { Some(pos) if pos.side != side => pos.quantity_abs,
let position_qty = instrument_state _ => {
.position let Some(q) = base_quantity else { continue };
.current q
.as_ref()
.map(|p| p.quantity_abs);
match position_qty.or(base_quantity) {
Some(q) => q,
None => continue,
} }
} else {
let Some(q) = base_quantity else { continue };
q
}; };
opens.push(OrderEvent { opens.push(OrderEvent {