# Plan: Close DSL Gaps for Real-World Backtesting Utility

## Context

The rule-based DSL has five gaps that limit backtesting realism: no trailing stops / dynamic sizing, no portfolio logic, no position state access (entry price, PnL, bars since entry), no conditional order types, and long-only or short-only with no hedging. This plan addresses them in three phases ordered by value/effort ratio.

## Phase 1: Position State Access + Dynamic Quantity (implement now)

High value, moderate effort. Enables DSL-based stop-losses, take-profits, time exits, ATR-based sizing — all without engine changes.

### Phase 1A: Expose position state to DSL

**Problem**: `EvalCtx` only passes `is_flat/is_long/is_short`. Barter's `Position` has `price_entry_average`, `time_enter`, `pnl_unrealised`, `quantity_abs` — none accessible to conditions.

**Add five new `Expr` variants:**

**File: `crates/swym-dal/src/models/strategy_config.rs`**
```rust
// New unit-like Expr variants (tag = "kind"):
EntryPrice,          // → position.price_entry_average, None when flat
PositionQuantity,    // → position.quantity_abs, None when flat
UnrealisedPnl,       // → position.pnl_unrealised, None when flat
BarsSinceEntry,      // → (now - time_enter) / interval_secs, None when flat
Balance { asset: String },  // → free balance for named asset
```

All backward-compatible (additive Expr variants, no existing JSON affected).

Tree-walking (`warmup_from_expr`, `collect_from_expr`): no-op for these — no warmup or timeframe contribution.

**File: `services/paper-executor/src/strategy/signal.rs`**

Expand `EvalCtx`:
```rust
pub struct EvalCtx<'a> {
    // existing fields...
    pub entry_price: Option<Decimal>,
    pub position_quantity: Option<Decimal>,
    pub unrealised_pnl: Option<Decimal>,
    pub time_enter: Option<DateTime<Utc>>,
    pub current_time: Option<DateTime<Utc>>,
    pub balances: Option<&'a HashMap<String, Decimal>>,
}
```

In `eval_expr_at_offset`, add match arms returning the corresponding `Option<Decimal>`. For `BarsSinceEntry`: compute `(current_time - time_enter).num_seconds() / interval_secs`. For `Balance`: look up `ctx.balances?.get(asset)`.

**File: `services/paper-executor/src/strategy/rule_strategy.rs`**

In `generate_algo_orders`, before the rule loop:
- Extract position data from `instrument_state.position.current` into the new `EvalCtx` fields
- Build a `HashMap<String, Decimal>` from `state.assets.0.iter()` mapping `asset.name_internal → balance.free`

**Schema + docs:**
- `assets/strategy/schema.json`: Add `ExprEntryPrice`, `ExprPositionQuantity`, `ExprUnrealisedPnl`, `ExprBarsSinceEntry`, `ExprBalance` to definitions + `Expr.oneOf`
- `docs/strategy-dsl.md`: New "Position state" expression section, warmup table entries, common patterns (5% stop-loss, time-based exit, ATR trailing stop via conditions)

### Phase 1B: Dynamic quantity expressions

**Problem**: `Action.quantity` is a fixed `Decimal`. No ATR-based sizing, no percent-of-balance.

**File: `crates/swym-dal/src/models/strategy_config.rs`**

```rust
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum QuantitySpec {
    Fixed(Decimal),   // "0.001" — tried first, backward-compatible
    Expr(Box<Expr>),  // {"kind": "bin_op", ...} — dynamic
}
```

Change `Action.quantity` from `Decimal` to `QuantitySpec`. All existing JSON configs with `"quantity": "0.001"` still deserialize via `Fixed`.

**File: `services/paper-executor/src/strategy/rule_strategy.rs`**

Resolve quantity before order emission:
```rust
let base_quantity = match &rule.then.quantity {
    QuantitySpec::Fixed(d) => Some(*d),
    QuantitySpec::Expr(e) => eval_expr(e, &ctx).map(|v| v.max(Decimal::ZERO)),
};
let Some(base_quantity) = base_quantity else { continue };
// ... existing sell-side override logic uses base_quantity as fallback
```

**Schema + docs:**
- `schema.json`: Change `Action.quantity` to `oneOf: [DecimalString, Expr]`
- `strategy-dsl.md`: Update "Rule" section, add sizing patterns (ATR-based, percent-of-balance)

### What Phase 1 enables

```json
// 5% stop-loss
{"kind": "compare", "left": {"kind": "field", "field": "close"}, "op": "<",
 "right": {"kind": "bin_op", "op": "mul",
   "left": {"kind": "entry_price"}, "right": {"kind": "literal", "value": "0.95"}}}

// Time exit after 20 bars
{"kind": "compare", "left": {"kind": "bars_since_entry"},
 "op": ">=", "right": {"kind": "literal", "value": "20"}}

// ATR-based position sizing (risk 1% of balance)
"quantity": {"kind": "bin_op", "op": "div",
  "left": {"kind": "bin_op", "op": "mul",
    "left": {"kind": "balance", "asset": "usdt"},
    "right": {"kind": "literal", "value": "0.01"}},
  "right": {"kind": "bin_op", "op": "mul",
    "left": {"kind": "literal", "value": "2"},
    "right": {"kind": "func", "name": "atr", "period": 14}}}
```

### Phase 1 verification
- `cargo check` / `cargo clippy`
- `pnpm tsc --noEmit`
- Unit tests: serde round-trip for new Expr variants; eval_expr tests with/without position
- Backtest: strategy with entry_price-based stop-loss, verify positions exit at expected bars

---

## Phase 2: Conditional Order Types + True Trailing Stops (plan only — implement later)

High value, high effort. Requires a pending order book in `BacktestEngine`.

### Phase 2A: Pending order book in BacktestEngine

**New file: `crates/swym-backtest/src/pending.rs`**

```rust
pub enum PendingOrderKind {
    Limit { limit_price: Decimal },
    Stop { stop_price: Decimal },
    StopLimit { stop_price: Decimal, limit_price: Decimal },
    TrailingStop { trail_amount: Decimal, is_percent: bool, best_price: Decimal },
}
```

Add `pending_orders: Vec<PendingOrder>` to `BacktestEngine`. In `process_chunk`, after each market event and before generating new orders: check pending orders against current price, fill triggered ones, update trailing stop `best_price`, expire old orders.

### Phase 2B: DSL `OrderType` on `Action`

```rust
#[serde(default)]
pub order_type: OrderType,  // Market (default), Limit{price: Expr}, Stop{stop_price: Expr}, TrailingStop{...}
```

Strategy emits pending signals via a side-channel (`Arc<Mutex<Vec<PendingSignal>>>`), drained by `BacktestEngine` after `generate_algo_orders`. This avoids modifying barter's `AlgoStrategy` trait.

### Phase 2C: Auto-cancel on position close

When a position is closed, cancel all pending orders for that instrument. Prevents orphaned stop-loss orders after an indicator-based exit.

---

## Phase 3: Multi-Instrument + Hedging (plan only — implement later)

Very high effort, widest blast radius.

### Phase 3A: Multi-instrument

- Extend `RunConfig` with `instruments: Option<Vec<Value>>` (backward-compat with singular `instrument`)
- `runner.rs`: load and interleave candle data for N instruments
- Add optional `instrument: Option<String>` field to `Expr::Field`, `Expr::Func`, etc.
- `EvalCtx`: add `all_instruments: Option<&HashMap<String, &SwymInstrumentData>>` for cross-instrument references
- Dashboard: multi-instrument selector on paper run creation page

### Phase 3B: Hedging / multiple positions

- Barter fork: change `PositionManager.current` from `Option<Position>` to `HashMap<Option<String>, Position>` (keyed by strategy tag)
- DSL: add `position_tag: Option<String>` to `Action` — orders with different tags maintain separate positions
- `Condition::Position`: add `tag: Option<String>` to check state of a specific position slot

---

## Implementation order

Only **Phase 1** is to be implemented now. Phases 2 and 3 are documented for future work.

### Phase 1 file changes

| File | Changes |
|------|---------|
| `crates/swym-dal/src/models/strategy_config.rs` | Add 5 Expr variants + `QuantitySpec` enum + change `Action.quantity` type |
| `services/paper-executor/src/strategy/signal.rs` | Expand `EvalCtx` with 6 new fields + 5 new eval_expr arms |
| `services/paper-executor/src/strategy/rule_strategy.rs` | Build balance map, populate expanded `EvalCtx`, resolve `QuantitySpec` |
| `assets/strategy/schema.json` | 5 new Expr definitions, update `Action.quantity` to oneOf |
| `docs/strategy-dsl.md` | Position state expressions section, quantity expressions, common patterns |

### Verification
1. `cargo check` + `cargo clippy`
2. `pnpm tsc --noEmit` (no dashboard code changes needed for Phase 1)
3. Unit tests in strategy_config.rs: serde round-trip for new variants
4. Unit tests in signal.rs: eval_expr for each new variant
5. Create a test strategy JSON using entry_price stop-loss + dynamic quantity, run a backtest
