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:
89
docs/api.md
89
docs/api.md
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user