Phase 26a — Pending-entry OCO DSL surface and clock accessors¶
Summary¶
Phase 26a adds DSL surface for pending-entry OCO (OCO_ENTRY { leg1, leg2 }) and DSL-visible clock accessors (NOW.<field>), plus the NOW + duration relative-deadline form for GTD expiry on pending orders. The execution-layer primitive (OrderRequest.StandaloneOCO) was already present in qkt's engine; this phase exposes it to authors writing .qkt files.
Hedge-straddle and other pending-OCO strategies are now expressible, parseable, and backtestable in qkt. Live MT5 runtime support ships separately as Phase 26b (the MT5 broker today translates only Market and Bracket orders).
What's new¶
DSL surface¶
OCO_ENTRY { leg1, leg2 }— two pending entries linked one-cancels-other. Each leg is a normalBUY/SELLwith full modifier set (SIZING, ORDER_TYPE, BRACKET, TIF). Parser invariants: exactly two legs, each must beBUYorSELL.LOG/CLOSE/CANCELinside the block is a parse error.NOW.hour_utc,NOW.minute_utc,NOW.weekday,NOW.date_utc,NOW.epoch_ms— clock accessors readable from any expression position. Field names are case-insensitive. Backtest reads the simulated clock, so the values are deterministic.NOW + <duration>— relative epoch-ms arithmetic for GTD expiry. Pair withTIF GTD UNTILfor self-expiring pending orders. Duration suffixes:s,m,h,d.- Bare
NOW— equivalent toNOW.epoch_ms. LetsNOW + 10mparse without an explicit field.
AST additions¶
NowAccessor(field: NowField)inExprAst.ktNowFieldenum:HOUR_UTC,MINUTE_UTC,WEEKDAY,DATE_UTC,EPOCH_MSOcoEntry(leg1: ActionAst, leg2: ActionAst)inRuleAst.kt
Compiler¶
ExprCompiler.compileNow(NowAccessor)readsctx.strategyContext.clock.now(), projects the requested field viajava.time.ZoneOffset.UTCActionCompiler.compileOcoEntry(OcoEntry)compiles each leg (reusing the existing per-action compile path), extracts each leg'sSignal.Submit.request, wraps both inOrderRequest.StandaloneOCO, emits oneSignal.SubmitDefaultsMerge,IterVarSubstitution, andAstCompilerrecurse into bothOcoEntrylegs
Engine integration¶
No engine changes. The OrderManager at src/main/kotlin/com/qkt/app/OrderManager.kt:693 already splits StandaloneOCO into two atomic sub-orders, registers them as siblings, and cancels the survivor on fill. The DSL surface added in this phase routes through that pre-existing machinery.
Migration from previous phase¶
Pure additions — no breaking changes.
| Before | After |
|---|---|
NOW was an unrecognized identifier (parse error) |
NOW.<field> and bare NOW parse as clock accessors |
OCO_ENTRY was an unrecognized identifier (parse error) |
OCO_ENTRY { leg1, leg2 } parses as a two-leg OCO action |
TIF GTD UNTIL <absolute-epoch-ms> was the only way to express GTD |
TIF GTD UNTIL NOW + 10m works as a relative deadline |
Existing strategies parse identically. Tests confirm no regression in the existing parser, lexer, and compiler suites.
Usage cookbook¶
Session-hour gated single-direction entry¶
NOW.hour_utc is an integer 0–23 in UTC. Combine with POSITION.<stream> = 0 to gate entries to specific hours.
Sub-hour precision¶
Strategy fires only during the first 5 minutes of hour 14 UTC.
Pending-pair OCO at session open¶
WHEN NOW.hour_utc IN [6, 7, 12, 13, 14, 15]
AND NOW.minute_utc = 55
AND POSITION.gold = 0
THEN OCO_ENTRY {
BUY gold SIZING 0.20 ORDER_TYPE = STOP AT gold.close + 5
BRACKET { STOP LOSS BY 18, TAKE PROFIT BY 15 }
TIF GTD UNTIL NOW + 10m,
SELL gold SIZING 0.20 ORDER_TYPE = STOP AT gold.close - 5
BRACKET { STOP LOSS BY 18, TAKE PROFIT BY 15 }
TIF GTD UNTIL NOW + 10m
}
Five minutes before each session-hour open, place BUY STOP above and SELL STOP below. The first one to trigger fills with its bracket; the other auto-cancels. The 10-minute GTD ensures neither leg outlives the session window if the price stays flat.
Time-based exit via holding_duration¶
WHEN POSITION.gold != 0
AND POSITION.gold.holding_duration > 7200
THEN CLOSE gold ; LOG "winner timeout" pnl=POSITION.gold.pnl
holding_duration is seconds since position open (Phase 23 accessor). Closes the position 2 hours after fill regardless of P&L state.
Weekday filter¶
ISO weekday: Monday = 0, Sunday = 6. Useful for FX strategies that should skip weekend gaps.
Testing patterns¶
NOW eval against a FixedClock¶
val ctx = EvalContext(
candle = ...,
streams = ...,
lets = emptyMap(),
strategyContext = testStrategyContext(clock = FixedClock(time = mondayMs)),
)
val compiled = ExprCompiler().compile(NowAccessor(NowField.HOUR_UTC))
assertThat((compiled.evaluate(ctx) as Value.Num).v).isEqualByComparingTo("13")
FixedClock makes the test deterministic. The compiled expression reads the clock at evaluation time.
OcoEntry compile assertion¶
val action = OcoEntry(
leg1 = Buy("gold", ActionOpts(sizing = ..., orderType = Stop(NumLit(BigDecimal("2010"))))),
leg2 = Sell("gold", ActionOpts(sizing = ..., orderType = Stop(NumLit(BigDecimal("1990"))))),
)
val sigs = ActionCompiler(ExprCompiler(), logger).compile(action).invoke(ctx)
val oco = (sigs[0] as Signal.Submit).request as OrderRequest.StandaloneOCO
assertThat((oco.leg1 as OrderRequest.Stop).stopPrice).isEqualByComparingTo("2010")
assertThat((oco.leg2 as OrderRequest.Stop).stopPrice).isEqualByComparingTo("1990")
The compiler builds a Signal.Submit(StandaloneOCO) regardless of which underlying order shapes the legs use. The OrderManager handles routing in tests like OrderManagerOcoTest.
Known limitations¶
- Live MT5 routing — Phase 26b.
MT5OrderTranslator.kt:12-20today rejects everything exceptMarketandBracket. Pending stops, limits, andStandaloneOCOare not yet routed to MT5 native order types. Phase 26b addstranslateStop,translateLimit, andtranslateStandaloneOCOplus cancel-on-fill verification. - Stacks — Phase 27. Hedge-straddle's
stackLevels/stackTiersneed per-layer brackets, simultaneous firing on a state transition (cut → WINNER), and MFE+elapsed-time gating. qkt's existingSTACKclause models pyramid-into-trend with shared brackets and sequential triggering. Different concept. Per the production analysis, stacks contribute ~148% P&L on 6-month windows. Seedocs/planned.mdfor the full Phase 27 entry. - Broker-local time deferred.
NOW.hour_utcis the only timezone. There's noNOW.hour_<broker>. Strategies that reason in broker-local time translate at strategy-author time. - Same-bar dual breach tiebreak. When a single candle crosses both stop prices, the backtest picks the leg whose trigger is closer to the candle's open. Real-world fills depend on tick sequence; backtest is an approximation.
- Multi-leg positions not yet modeled. Pending-OCO mode doesn't need them (only one leg ever fills). Legacy market-mode hedge-straddle (both legs go live, cut loser, ride winner) needs a real position-model change. Deferred indefinitely — production uses pending mode.
References¶
- Spec:
docs/superpowers/specs/2026-05-11-phase26-pending-oco-and-clock-design.md - Plan:
docs/superpowers/plans/2026-05-11-phase26-pending-oco-and-clock.md - Worked example:
examples/hedge-straddle/hedge-straddle.qkt - Engine OCO routing:
src/main/kotlin/com/qkt/app/OrderManager.kt:693 - Phase 26b placeholder:
docs/planned.md— "Phase 26b — MT5 native pending + OCO routing" - Phase 27 placeholder:
docs/planned.md— "Phase 27 — conditional bracketed stacks"