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.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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user