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.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
position quantity. To close the entire position regardless of size, use a large fixed quantity
(e.g. `"9999"`) or `{ "kind": "position_quantity" }`.
**Closing orders:** When an order's side is opposite to the current position (sell while long,
or buy while short), the engine automatically uses the full position quantity, ignoring the
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
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).
> Swym's paper trading engine operates on spot markets, so `"short"` positions are not achievable
> in the standard configuration. Use `"flat"` and `"long"` for spot strategies.
> All three states are fully supported. A `"sell"` action while flat opens a short position;
> `{ "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
position. The computed quantity is only used as a fallback if no position is currently open.
**Closing orders:** When an order's side is opposite to the current position (sell while long,
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
@@ -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)),
};
// For sell orders, always close the full open position.
// base_quantity is only used as a fallback when no position exists, and for
// buy orders where a None result (insufficient data / missing balance) skips
// the order entirely.
let quantity = if side == Side::Sell {
let position_qty = instrument_state
.position
.current
.as_ref()
.map(|p| p.quantity_abs);
match position_qty.or(base_quantity) {
Some(q) => q,
None => continue,
// When the order side is opposite to the open position, close
// the full position (sell closes long, buy closes short).
// Otherwise use the configured base_quantity.
let quantity = match instrument_state.position.current.as_ref() {
Some(pos) if pos.side != side => pos.quantity_abs,
_ => {
let Some(q) = base_quantity else { continue };
q
}
} else {
let Some(q) = base_quantity else { continue };
q
};
opens.push(OrderEvent {