Phase 13a — STACK: Pyramiding / Scaling-In Order Modifier¶
Summary¶
Phase 13a adds a STACK keyword to both the external DSL parser and the internal Kotlin DSL that collapses an entire scaling-in plan into a single BUY or SELL action. Where strategies previously needed explicit state machines built from multiple WHEN/THEN rules to pyramid into a trend, STACK expresses that intent in one line: "enter N times at price increments, cancel leftovers if the trend stalls." Brokers see only ordinary Market/Stop/Limit orders; the engine handles sequencing, anchor capture, and per-stack lifecycle internally via a new StackTracker owned by OrderManager.
What's new¶
DSL keywords (external parser)¶
STACK— introduces a scaling-in plan inside aBUYorSELLaction.SPACING— compact form:STACK <n> SPACING <distance>. Generates N layers spaced<distance>apart.WITHIN— time fence:WITHIN <duration>. Cancels unfired pending layers after the deadline.ABOVE/BELOW— explicit direction override on the spacing form (already existed as tokens; now carry stack semantics).DURATIONliteral — e.g.,1h,30m,15s,2d. Parsed greedily; no whitespace between number and unit.entrymagic identifier — valid inside layer-listAT <expr>clauses; resolves to layer 1's actual fill price at runtime.
AST (com.qkt.dsl.ast)¶
StackAst— sealed interface; two implementations below.StackSpacing— compact form:count,spacing: ExprAst,direction: StackDirection,within: DurationAst?.StackLayers— explicit form:layers: List<StackLayer>,within: DurationAst?.StackDirection— enum:TRADE_DIRECTION,ABOVE,BELOW.StackLayer— one entry in a layer list:sizing: SizingAst,orderType: OrderTypeAst?,at: ExprAst?.DurationAst— wraps a millisecond count; validated> 0at construction.StackEntryRef— singletonExprAstnode; the magicentryidentifier in AST form.ActionOpts.stack: StackAst?— new optional field on the existing action-options carrier.
Runtime IR (com.qkt.execution)¶
StackPlan— flattened list ofLayerSpecplus optionalouterBracket: BracketAst?andwithinMillis: Long?.LayerSpec— one layer:index: Int,sizing: SizingAst,orderType: OrderTypeAst,trigger: LayerTrigger,resolvedQuantity: BigDecimal?.LayerTrigger— sealed interface:Immediate(layer 1) orAt(price: ExprAst, direction: StackDirection).OrderRequest.Stack— new variant of the existingOrderRequestsealed interface carrying theStackPlan.
Kotlin DSL helpers (com.qkt.dsl.kotlin)¶
stack(count, spacing, direction, within)— buildsStackSpacing.stackOf(vararg layers, within)— buildsStackLayers; validates layers 2..N haveatclauses.layer(qty: ExprAst, orderType?, at?)— buildsStackLayerfrom a quantity expression.layer(sizing: SizingAst, orderType?, at?)— buildsStackLayerfrom any sizing form.entryPrice— top-levelvalequal toStackEntryRef; use inatexpressions.duration(text: String)— parses"1h"/"30m"/"15s"/"2d"intoDurationAst.
Compiler (com.qkt.dsl.compile)¶
StackCompiler— folds aStackAstinto aStackPlan.StackSpacingbecomes NLayerSpecs withAt(entry + spacing * i, direction)triggers;StackLayersis preserved as-is with index assignment.
Runtime (com.qkt.app)¶
StackTracker— internal class owned byOrderManager. Tracks per-stack state: anchor price, deadline epoch, layer-1 order id, pending/filled/closed layer id sets. Exposesregister,setAnchor,addPending,markFilled,markLayerClosed,stackOwning,terminate,all.OrderManager.submitStack— dispatchesOrderRequest.Stack: submits layer 1 immediately, registers the stack inStackTracker, materializes pending layers on layer-1 fill, evaluates WITHIN deadline each tick, cancels pending layers when the position is flat.OrderManager.pendingStackLayerInfos()— returns a snapshot list ofPendingStackLayerInfoDTOs for all currently-pending stack layers.
Observability¶
PendingStackLayerInfoDTO —stackId,layer,triggerPrice,side,quantity. Nested inOrderManager.StatusSnapshot— the 12b observability HTTP/statusresponse now includespendingStackLayers: List<PendingStackLayerInfo>per strategy.
Migration from previous phase¶
No breaking changes. All additions are opt-in:
ActionOptsgains a nullablestackfield (defaultnull); existing callers compile unchanged.OrderRequest.Stackis a new sealed-interface variant; existingwhenexhaustive branches are unaffected because the finalelseclause inOrderManager.dispatchremains.LiveSessionis unchanged.- No renames, no removed APIs.
Usage cookbook¶
1. Simple pyramid — SPACING form¶
The most common use: buy N times as price climbs, with a shared bracket per layer.
STRATEGY btc_pyramid VERSION 1
SYMBOLS
btc = BYBIT:BTCUSDT EVERY 1m
RULES
WHEN ema(btc.close, 9) CROSSES ABOVE ema(btc.close, 21)
THEN BUY btc SIZING 0.1
STACK 3 SPACING 100
BRACKET { STOP LOSS BY 50, TAKE PROFIT BY 200 }
Layer 1 fires at market on the EMA cross. Assume fill at 50000 (the anchor). Layers 2 and 3 become pending Stop orders at 50100 and 50200. Each filled layer inherits its own Stop-Loss at fill - 50 and Take-Profit at fill + 200. If layer 1's SL fires at 49950 before 50100 is reached, layers 2 and 3 cancel automatically. Full exposure at three layers: 0.3 BTC.
Kotlin DSL equivalent:
buy(btc, sizing = sizeQty(num("0.1"))) {
stack = stack(count = 3, spacing = num("100"))
bracket = bracket(sl = byDistance(num("50")), tp = byDistance(num("200")))
}
2. Average-down with BELOW¶
Enter more as price drops, anticipating a bounce.
WHEN rsi(btc.close, 14) < 30
THEN BUY btc SIZING 0.1
STACK 3 SPACING 100 BELOW
BRACKET { STOP LOSS BY 200, TAKE PROFIT BY 300 }
BELOW overrides the default (with-trend = upward for BUY). Layers 2 and 3 trigger at anchor - 100 and anchor - 200. Combined exposure: 0.3 BTC averaging down.
Kotlin DSL:
buy(btc, sizing = sizeQty(num("0.1"))) {
stack = stack(count = 3, spacing = num("100"), direction = StackDirection.BELOW)
bracket = bracket(sl = byDistance(num("200")), tp = byDistance(num("300")))
}
3. Layer-list with explicit per-layer prices and order types¶
Full control: each layer specifies its own sizing, order type, and trigger relative to entry.
WHEN ema(btc.close, 9) CROSSES ABOVE ema(btc.close, 21)
THEN BUY btc STACK [
0.1, -- layer 1: market at signal
0.2 AT entry + 100, -- layer 2: market triggered at +100
0.3 LIMIT AT entry + 200, -- layer 3: limit at +200
]
BRACKET { STOP LOSS BY 50, TAKE PROFIT BY 200 }
entry is the magic identifier for layer 1's fill price. Layer 3 uses LIMIT so the order becomes a resting limit rather than a stop-market. Note: no outer SIZING clause when using the layer-list form — sizing lives inside each layer.
Kotlin DSL:
buy(btc) {
stack = stackOf(
layer(qty = num("0.1")),
layer(qty = num("0.2"), at = entryPrice + num("100")),
layer(qty = num("0.3"), orderType = Limit, at = entryPrice + num("200")),
)
bracket = bracket(sl = byDistance(num("50")), tp = byDistance(num("200")))
}
4. Mixed sizing in a layer list¶
Each layer can use a different sizing form. Sizing is evaluated when that layer fires, not when the stack is submitted.
WHEN ema(btc.close, 9) CROSSES ABOVE ema(btc.close, 21)
THEN BUY btc STACK [
0.1 AT entry, -- fixed qty
RISK 0.01 AT entry + 100, -- 1% fractional risk
5000 USD AT entry + 200, -- notional
2 % OF EQUITY AT entry + 300, -- % of equity at fire time
]
BRACKET { STOP LOSS BY 50 }
Kotlin DSL:
buy(btc) {
stack = stackOf(
layer(sizing = sizeQty(num("0.1")), at = entryPrice),
layer(sizing = sizeRiskFrac(num("0.01")), at = entryPrice + num("100")),
layer(sizing = sizeNotional(num("5000")), at = entryPrice + num("200")),
layer(sizing = sizeEquityPct(num("2")), at = entryPrice + num("300")),
)
bracket = bracket(sl = byDistance(num("50")))
}
5. WITHIN time fence¶
Cancel pending layers if price never reaches them within a time window.
WHEN ema(btc.close, 9) CROSSES ABOVE ema(btc.close, 21)
THEN BUY btc SIZING 0.1
STACK 3 SPACING 100 WITHIN 1h
BRACKET { STOP LOSS BY 50 }
Layer 1 fills. If layers 2 and 3 have not triggered within 1 hour of layer 1's fill, both are cancelled. Layer 1's bracket continues to live its lifecycle normally — WITHIN only cancels unfired pending layers.
Kotlin DSL:
buy(btc, sizing = sizeQty(num("0.1"))) {
stack = stack(count = 3, spacing = num("100"), within = duration("1h"))
bracket = bracket(sl = byDistance(num("50")))
}
Duration units: s (seconds), m (minutes), h (hours), d (days).
6. SELL with STACK¶
STACK works symmetrically on the short side. Default direction for SELL is downward (with-trend = price falling).
WHEN rsi(btc.close, 14) > 70
THEN SELL btc SIZING 0.1
STACK 3 SPACING 100
BRACKET { STOP LOSS BY 50, TAKE PROFIT BY 200 }
Layer 1 sells at market. Layers 2 and 3 trigger at anchor - 100 and anchor - 200 (price falling). Each layer's SL is at fill + 50; TP is at fill - 200. Use ABOVE to flip to short average-up semantics.
7. FOR/EACH composition¶
STACK composes naturally with the FOR/EACH iteration construct; each symbol gets its own independent stack.
RULES
FOR EACH s IN [btc, eth, sol] DO
WHEN ema(s.close, 9) CROSSES ABOVE ema(s.close, 21)
THEN BUY s SIZING 0.1
STACK 3 SPACING 100
BRACKET { STOP LOSS BY 50, TAKE PROFIT BY 200 }
Each symbol generates its own rule. Stacks for BTC, ETH, and SOL are tracked independently in StackTracker. One symbol's layer-cancel event does not affect another's pending layers.
Testing patterns¶
Synthetic-tick backtest approach¶
The canonical test pattern for stack strategies. Build the StackPlan by hand (or via DSL), construct a one-shot Strategy that emits the OrderRequest.Stack signal on the first qualifying tick, then feed a controlled tick stream to Backtest.
@Test
fun `pyramid happy path fills three layers at entry plus spacing`() {
val plan = StackPlan(
layers = listOf(
LayerSpec(1, SizeQty(NumLit(BigDecimal("0.1"))), Market, Immediate),
LayerSpec(
2, SizeQty(NumLit(BigDecimal("0.1"))), Market,
At(BinaryOp(BinOp.ADD, StackEntryRef, NumLit(BigDecimal("100"))),
StackDirection.TRADE_DIRECTION),
),
LayerSpec(
3, SizeQty(NumLit(BigDecimal("0.1"))), Market,
At(BinaryOp(BinOp.ADD, StackEntryRef, NumLit(BigDecimal("200"))),
StackDirection.TRADE_DIRECTION),
),
),
)
val ticks = listOf(
Tick("btcusdt", Money.of("49500"), 1L),
Tick("btcusdt", Money.of("50000"), 2L), // layer 1 fills here
Tick("btcusdt", Money.of("50100"), 3L), // layer 2 triggers
Tick("btcusdt", Money.of("50200"), 4L), // layer 3 triggers
)
val result = Backtest(
strategies = listOf("e2e" to onceStrategy("btcusdt", plan)),
ticks = ticks,
).run()
val buys = result.trades.filter { it.trade.side == Side.BUY }
assertThat(buys).hasSize(3)
val prices = buys.map { it.trade.price }.sortedBy { it }
assertThat(prices[0]).isEqualByComparingTo(BigDecimal("50000"))
assertThat(prices[1]).isEqualByComparingTo(BigDecimal("50100"))
assertThat(prices[2]).isEqualByComparingTo(BigDecimal("50200"))
assertThat(result.finalPositions["btcusdt"]?.quantity)
.isEqualByComparingTo(BigDecimal("0.3"))
}
Asserting per-layer fills at expected prices¶
Sort fills by price to avoid order-of-arrival sensitivity, then assert each expected fill price:
val prices = result.trades
.filter { it.trade.side == Side.BUY }
.map { it.trade.price }
.sortedBy { it }
assertThat(prices).hasSize(3)
assertThat(prices[0]).isEqualByComparingTo(BigDecimal("50000"))
assertThat(prices[1]).isEqualByComparingTo(BigDecimal("50100"))
assertThat(prices[2]).isEqualByComparingTo(BigDecimal("50200"))
Asserting SL-triggered cancellation of pending layers¶
A plan with a bracket SL that fires before layer 2's trigger price: assert only one buy trade and one sell trade (the SL exit), and that the final position is flat.
val plan = StackPlan(
layers = ...,
outerBracket = BracketAst(stopLoss = ChildBy(NumLit(BigDecimal("50")))),
)
val ticks = listOf(
Tick("btcusdt", Money.of("50000"), 2L), // layer 1 fills at anchor
Tick("btcusdt", Money.of("49950"), 3L), // SL fires, pending layers cancel
)
val result = Backtest(...).run()
assertThat(result.trades.filter { it.trade.side == Side.BUY }).hasSize(1)
assertThat(result.trades.filter { it.trade.side == Side.SELL }).hasSize(1)
assertThat(result.finalPositions["btcusdt"]).isNull()
Mocking equity for RISK sizing tests¶
OrderManagerStackTest uses FakeBroker directly to test dispatch logic at unit level. For RISK-sizing layers that require equity resolution, provide a StrategyContext or a test PnLProvider with a fixed equity value, then assert the submitted quantity matches the expected fractional-risk calculation.
Known limitations¶
- Per-layer bracket override deferred. The outer
BRACKETclause applies identically to every layer. There is no syntax to give layer 2 a different SL distance than layer 1. Per-layer bracket overrides are a v2 concern. - Bare
STACK Nnot supported.STACK 3without aSPACINGclause (iceberg / TWAP slicing) is a different primitive and is out of scope. ASPACINGor layer-list is always required. - WHEN-condition per-layer triggers not supported. Layers 2..N can only use price triggers (
AT <expr>). Arbitrary boolean conditions per layer would require a full condition engine inside the layer scheduler and are deferred. - PIPS unit not supported.
SPACING 100means 100 raw price units (e.g., 100 USDT for a BTC pair quoted in USDT). A PIPS / points suffix is a language-wide feature affecting all distance clauses; it is out of scope for 13a. - Per-layer sizing evaluated at action-execute time.
resolvedQuantityinLayerSpecis populated byActionCompilerwhen the strategy's action is compiled at signal time, not when each layer individually fires. For RISK/EQUITY% strategies this means layer 2's quantity is computed using the account state at signal time rather than at layer-2-fire time. The approximation is small in practice (equity doesn't change dramatically tick-to-tick) but is a known deviation from the ideal. - Stack state not persisted across daemon restarts. In-flight stacks are not serialized. A daemon restart drops all pending layers. Strategies restart fresh with no knowledge of the prior stack.
References¶
- Spec:
docs/superpowers/specs/2026-05-08-trading-engine-phase13a-stack-design.md - Plan:
docs/superpowers/plans/2026-05-08-trading-engine-phase13a.md - Merge commit SHA: TBD (filled in after merge)