Phase 27 — Conditional Bracketed Stacks (STACK_AT)¶
Status: Shipped on phase-27-impl. Open in PR #9.
Spec: ../superpowers/specs/2026-05-12-phase27-conditional-bracketed-stacks-design.md
Plan: ../superpowers/plans/2026-05-12-phase27-conditional-bracketed-stacks.md
Summary¶
Phase 27 lets a strategy attach N independent "stack" sub-trades to a primary entry. Each STACK_AT clause fires once when the primary leg's max favorable excursion crosses a threshold within a time window — emitting a fresh bracketed market order that tracks as its own leg with its own SL/TP. Closing the primary does NOT close the stacks; a stack hitting its own TP does not affect the primary or other stacks. This is the multi-leg pattern from the production hedge-straddle and unlocks the ~148% P&L driver the pa-quant analysis attributes to stacking.
Two architectural changes carry the feature: a singular Position per (strategy, symbol) becomes a LegBook of PositionLegs (PRIMARY + N STACK), and StrategyPositionTracker.applyFill learns to route stack-tagged fills as STACK legs instead of averaging them into the primary. Existing POSITION.<stream> accessors keep returning the netted view, so strategies that don't use STACK_AT see no behavior change.
What's new¶
DSL surface¶
STACK_AT MFE >= <threshold> WITHIN <duration> SIZING <qty> BRACKET { ... }— clause that attaches to aBUY/SELLaction. Multiple clauses per action allowed; each fires independently.POSITION.<stream>.mfe— DSL expression returning the PRIMARY leg's current MFE (price units;0when no primary exists).
AST¶
com.qkt.dsl.ast.StackAtClause—(mfeThreshold: ExprAst, withinDuration: DurationAst, sizing: SizingAst, bracket: BracketAst).com.qkt.dsl.ast.ActionOpts.stackAts: List<StackAtClause>— populated by the parser; empty when noSTACK_ATappears.com.qkt.dsl.ast.StateSource.POSITION_MFE— new state-accessor source.
Compile-time¶
com.qkt.dsl.compile.StackAtCompiler— folds each clause's threshold / sizing / bracket distances toBigDecimalconstants. Rejects non-literal sizing, non-ChildBybrackets, and references / indicators /NOW.<field>in the threshold expression.com.qkt.dsl.compile.CompiledStackTier—(mfeThreshold, withinMs, stackQuantity, slDistance, tpDistance). Consumed only byStackEngine.com.qkt.dsl.compile.PendingStacks— per-strategy registry mapping a primary'sclientOrderId→ tiers + closeWatchIds. Populated by the action compiler atSignal.Submitemit time, consumed by the pipeline at the matchingBrokerEvent.OrderFilled.com.qkt.dsl.compile.DslCompiledStrategy.multiPositionPerSymbolSymbols: Set<String>— symbols this strategy will stack on. Used by the deploy-time capability gate.- Compile-time rejection of
STACK_ATcombined with the inlineOCO {...}option or with theSTACKpyramiding clause — both would have silently dropped the conditional clauses.
Runtime¶
com.qkt.dsl.compile.StackEngine— one per active PRIMARY leg withSTACK_ATclauses. Owns anMfeTracker; on every tick, fires-or-abandons each tier permfe >= threshold && elapsed <= within.com.qkt.dsl.compile.StackOrchestrator— per-strategy registry of engines. HandlesonPrimaryFilled(construct),onTick(dispatch by symbol),onPrimaryClosed/onPossibleClose(destroy). Wraps the engine's emit so each stack signal pre-registers its entry/TP/SL ids with the position tracker for leg-aware fill routing.com.qkt.app.TradingPipeline.wireStackOrchestrator— wires the orchestrator per DSL strategy: subscribes toTickEventandBrokerEvent.OrderFilled, routes fills to eitheronPrimaryFilled(pending stack found) oronPossibleClose(close-watch id) and ticks toonTick.com.qkt.app.TradingPipeline.requireMultiPositionCapability— at startup, refuses to deploy a strategy whoseSTACK_ATsymbols route to a broker that doesn't declareMULTI_POSITION_PER_SYMBOL.
Position tracker¶
com.qkt.positions.PositionLeg—(legId, symbol, side, quantity, entryPrice, openedAt, role, parentLegId?).LegRole ∈ {PRIMARY, STACK}. STACK requiresparentLegId.com.qkt.positions.LegBook— container of legs; enforces single-PRIMARY invariant per symbol.netView()derives the legacy singularPositionso existing readers continue to work.com.qkt.positions.MfeTracker— high-water-mark of favorable excursion; side-aware. Used both by the stack engine and by the per-primary tracker onStrategyPositionTracker.com.qkt.positions.StrategyPositionTracker:- Internal storage migrated from
Map<String, Position>toMap<String, LegBook>. registerStackOpen(strategyId, clientOrderId, stackLegId, parentLegId)andregisterStackClose(strategyId, clientOrderId, stackLegId)— pre-register stack entry / close ids soapplyFillroutes them toaddStackLeg/closeLegrather than averaging into PRIMARY.onTick(symbol, price)— drives per-primaryMfeTrackers.primaryMfeFor(strategyId, symbol)— backs the DSLPOSITION.<stream>.mfeaccessor.legBookFor(strategyId, symbol)— direct multi-leg view for reconciliation / testing.
Broker capability¶
com.qkt.broker.OrderTypeCapability.MULTI_POSITION_PER_SYMBOL— declared byPaperBrokerandMT5Protocol. Bybit Linear advertises it only in hedge mode; Bybit Spot does not.
Example¶
examples/hedge-straddle/hedge-straddle.qkt— pre-existing strategy now carries threeSTACK_ATtiers on eachOCO_ENTRYleg (matching the pa-quant production shape).
Migration from previous phases¶
No code-level migration needed for strategies that don't use STACK_AT — the Position-returning surface is preserved via LegBook.netView(). Strategies that do opt in:
| Before | After |
|---|---|
| (no equivalent) | STACK_AT MFE >= <threshold> WITHIN <duration> SIZING <qty> BRACKET { ... } on a BUY/SELL action |
| (no equivalent) | POSITION.<stream>.mfe in expressions |
StrategyPositionTracker.applyFill(event) averaged everything into PRIMARY |
Same call, but fills whose clientOrderId matches a registered stack entry/close are routed leg-aware. Default path unchanged. |
StrategyPositionTracker had no tick hook |
New onTick(symbol, price) updates per-primary MFE; TradingPipeline already calls this for every TickEvent |
Broker.capabilities did not include multi-position semantics |
New MULTI_POSITION_PER_SYMBOL capability; strategies with STACK_AT are rejected at deploy time if the routing broker lacks it |
Usage cookbook¶
One stack tier on a plain BUY¶
The minimum useful shape. Primary opens at market with no bracket; a stack fires when MFE crosses 50 within 30 minutes, with its own SL/TP.
STRATEGY one_tier VERSION 1
DEFAULTS { SIZING = 0.1 }
SYMBOLS
btc = BACKTEST:BTCUSDT EVERY 1m
RULES
WHEN btc.close > 0 AND POSITION.btc = 0 THEN BUY btc
STACK_AT MFE >= 50 WITHIN 30m SIZING 0.05
BRACKET { STOP LOSS BY 20, TAKE PROFIT BY 100 }
Three-tier hedge-straddle (production shape)¶
Two opposite-side OCO_ENTRY legs; whichever fills attaches three stacks. The full pattern from examples/hedge-straddle/hedge-straddle.qkt:
THEN OCO_ENTRY {
BUY gold ORDER_TYPE = STOP AT gold.close + 5
BRACKET { STOP LOSS BY 18, TAKE PROFIT BY 15 }
TIF GTD NOW + 10m
STACK_AT MFE >= 10 WITHIN 30m SIZING 0.06 BRACKET { STOP LOSS BY 2, TAKE PROFIT BY 20 }
STACK_AT MFE >= 20 WITHIN 60m SIZING 0.06 BRACKET { STOP LOSS BY 2, TAKE PROFIT BY 20 }
STACK_AT MFE >= 30 WITHIN 90m SIZING 0.06 BRACKET { STOP LOSS BY 2, TAKE PROFIT BY 20 },
SELL gold ORDER_TYPE = STOP AT gold.close - 5
BRACKET { STOP LOSS BY 18, TAKE PROFIT BY 15 }
TIF GTD NOW + 10m
STACK_AT MFE >= 10 WITHIN 30m SIZING 0.06 BRACKET { STOP LOSS BY 2, TAKE PROFIT BY 20 }
STACK_AT MFE >= 20 WITHIN 60m SIZING 0.06 BRACKET { STOP LOSS BY 2, TAKE PROFIT BY 20 }
STACK_AT MFE >= 30 WITHIN 90m SIZING 0.06 BRACKET { STOP LOSS BY 2, TAKE PROFIT BY 20 }
}
Gating other rules on MFE¶
POSITION.<stream>.mfe is a regular expression — usable in any WHEN clause or LOG:
WHEN POSITION.gold.mfe > 25
THEN LOG "primary up 25 points" mfe=POSITION.gold.mfe duration=POSITION.gold.holding_duration
Constant-folded threshold¶
The threshold supports arithmetic over literals — handy for pip-vs-price-unit conversions:
-- 30 pips on a 5-decimal pair (0.0001 per pip)
STACK_AT MFE >= 30 * 0.0001 WITHIN 15m SIZING 0.05
BRACKET { STOP LOSS BY 10 * 0.0001, TAKE PROFIT BY 50 * 0.0001 }
References, indicators, and NOW.<field> in the threshold are rejected at compile time — the per-tick path stays free of expression evaluation.
Inspecting the leg book in tests¶
The multi-leg view is observable via legBookFor:
val tracker = StrategyPositionTracker()
tracker.applyFill(primaryFill)
tracker.registerStackOpen("alpha", "stack-entry-1", "stack-1", "primary-1")
tracker.applyFill(stackFill)
val book = tracker.legBookFor("alpha", "EURUSD")!!
assertThat(book.primary()!!.role).isEqualTo(LegRole.PRIMARY)
assertThat(book.stacks()).hasSize(1)
assertThat(book.stacks()[0].parentLegId).isEqualTo("primary-1")
Testing patterns¶
- Stack engine unit tests (
StackEngineTest) — driveonTickwith hand-crafted prices and aFixedClock; assert tiers fire on threshold crossings, abandon on window expiry, and that the same tier never fires twice. - Tracker stack-routing tests (
StrategyPositionTrackerStackTest) — callregisterStackOpen/registerStackClosebeforeapplyFill; assert the LegBook has a STACK leg with the rightparentLegId, that PRIMARY entry/qty are untouched, and that close fills realize the correct PnL. - Tracker MFE tests (
StrategyPositionTrackerMfeTest) — driveonTick(symbol, price); assertprimaryMfeForrises monotonically on favorable ticks, stays on unfavorable ones, re-anchors on flip or averaging, returns null after a full close. - End-to-end through
TradingPipeline(TradingPipelineStackTest) — publish a primaryOrderFilled, publish aTickEventthat crosses MFE, then observe both the bracket signal on the bus and the resulting STACK leg in the LegBook. - Capability gate (
TradingPipelineMultiPositionCapabilityTest) — construct the pipeline with a broker that does/doesn't declareMULTI_POSITION_PER_SYMBOL; assert the init throws with the strategy id, symbol, and broker name in the message.
The canonical fakes:
- PaperBroker for backtest-like fills.
- FixedClock so engine windows and MFE timestamps are deterministic.
- A small StubDslStrategy in TradingPipelineStackTest for tests that don't need a real DSL compile.
Known limitations¶
These ship deliberately and are tracked for follow-up phases:
- Parent-close detection is narrow. The
closeWatchIdsmechanism only fires when the parent's bracket TP or SL fills viaOrderManager.submitBracketFallback's deterministic${b.id}-tp/${b.id}-slnaming. Plain Market parents (no bracket), native MT5 brackets (broker-assigned ticket ids), and strategy-emitted manual closes are not detected. Engines on those parents keep firing until tiers exhaust by their own fire/abandon semantics. Clean fix needs leg-id correlation on the fill event stream. - No retroactive fire. The engine first evaluates on the tick after the primary fill. If the primary fills already past a tier's threshold, the tier fires on the next tick rather than at fill time.
SIZINGforSTACK_ATis literal lots only. Risk-fraction, notional, and percent-of-equity sizing on stack tiers will land alongside leg-level risk accounting in a later phase.BRACKETforSTACK_ATisBY <distance>only.AT/PCT/RRforms are rejected — the stack's entry isn't known until fire time, so absolute and ratio-based forms don't translate cleanly.- No leg-level realized PnL aggregation per symbol.
POSITION.<stream>.realized_pnlstill reports strategy-level totals; per-symbol per-leg realized requires lot-level accounting (existing Phase 7d backlog item; not introduced by Phase 27). - Engine state is in-memory only. On a live-session restart the orchestrator starts empty; the position tracker reconciles from broker fills but stack engines do not rebuild.
- Portfolio child stacks are not consumed by the top-level pipeline.
PortfolioStrategy.pendingStacksis an empty stub; children withSTACK_ATregister on their own registries which the runtime doesn't currently inspect. The capability gate does aggregate child stack symbols, so deploy-time rejection still works.
References¶
- Spec:
docs/superpowers/specs/2026-05-12-phase27-conditional-bracketed-stacks-design.md - Plan:
docs/superpowers/plans/2026-05-12-phase27-conditional-bracketed-stacks.md - DSL reference:
docs/reference/dsl/stack-at.md,docs/reference/dsl/expressions.md(POSITION.<stream>.mfe) - Example:
examples/hedge-straddle/hedge-straddle.qktand itsREADME.md - Commit range on
phase-27-impl(rooted atmain): all commits from7d8f8d4(PositionLeg + LegBook scaffolding) through702a9d8(this changelog and the DSL reference page). The architectural beats are: 2752778— action compiler registers PendingStacks for STACK_AT emits02b6e40— TradingPipeline wires StackOrchestrator per DSL strategy8ec1cd1— StackOrchestrator close detection + compile-time guardsd78f4be—MULTI_POSITION_PER_SYMBOLcapability gate4093cbb— stack-leg fills routed to the LegBook (Gap #1)8edf25f—POSITION.<stream>.mfeaccessor