Phase 15 — DSL LOG with levels, placeholders, structured fields¶
Released: 2026-05-10 Version: 0.17.0
Summary¶
Phase 15 turns the DSL LOG action from a literal-only print statement into a primary observability surface for live strategies. Levels (INFO/WARN/ERROR/DEBUG) let critical events stand out, {name} placeholders interpolate indicator and price values into messages, and trailing key=expr kvs become MDC entries ready for a future JSON appender. Stdout now shows the strategy name prefix ([mybook/trend]) so output from many strategies is distinguishable. As part of the logback configuration pass, a latent Phase 14 bug is fixed: child strategy names containing / no longer create unsafe subdirectories under logs/.
What's new¶
- DSL:
LOG [LEVEL] "msg" (name=expr)*—LEVELis one ofWARN,ERROR,DEBUG, defaulting toINFOwhen omitted. - DSL:
{name}placeholders in the message string, validated at parse time against the kvs. - DSL: trailing
name=exprkvs become structured fields. Numeric, boolean, and string-literal expressions accepted. - DSL: string literals usable in expression position (
side="BUY"). No string operators. - AST: new
Value.Strvariant; newStringLitExprAstnode;Logreshaped to(level, messageFormat, fields); newLogLevelenum. - Compiler:
compileLogvalidates placeholders at compile time, evaluates fields per tick, setslog.<name>MDC keys for the SLF4J call, dispatches to the chosen level, clears MDC even on exception. - Kotlin DSL parity:
log("msg", "k" to expr),warn(...),error(...),debug(...)builders. - Logback console pattern adds
[%X{strategy:-main}]so stdout shows which strategy emitted each line. - Logback
SiftingAppenderuses a customStrategyFilenameDiscriminatorthat substitutes/→__in the file name, matchingStateDir.logFile.
Migration from Phase 14¶
AST shape changed. Log("msg") → Log(LogLevel.INFO, "msg", emptyMap()). Affects only test code that constructs the AST directly. The DSL surface (LOG "msg") is unchanged for users.
Stdout pattern changed. Lines now contain [%X{strategy}] between the level and the logger name. Anything regex-matching the old level logger - msg shape needs to be updated to level [strategy] logger - msg. No CI assertions exist on this format today.
Child file paths changed. A child strategy named mybook/trend previously caused logback to write to logs/mybook/trend.log (creating a mybook/ subdirectory). Now writes to logs/mybook__trend.log, matching StateDir.logFile. No production data is invalidated since Phase 14 hasn't run live.
No DSL backward-compat shim. LOG "literal" continues to parse with INFO level and no fields.
Usage cookbook¶
Simple log¶
Stdout:
File logs/my-strategy.log:
Levels — WARN and ERROR¶
WHEN account.equity < 9000
THEN LOG WARN "drawdown crossing 10%"
WHEN risk.haltActive
THEN LOG ERROR "risk halt: trading suspended"
Stdout (one line per emit):
12:34:56.789 [qkt-live-engine] WARN [my-strategy] com.qkt.dsl.strategy - drawdown crossing 10%
12:34:57.012 [qkt-live-engine] ERROR [my-strategy] com.qkt.dsl.strategy - risk halt: trading suspended
Placeholder interpolation¶
Renders:
Structured fields (no placeholder)¶
Message: trade. The kvs are attached as MDC entries (log.qty=1, log.price=50125.00, log.side=BUY) for the duration of the SLF4J call — visible to any logback appender that reads MDC, including a future JSON appender.
Combined¶
Renders broker rejected order 42 at ERROR level with log.id=42 and log.retry=3 in MDC.
DEBUG (filtered by default)¶
The root logback level is INFO, so DEBUG lines are written but suppressed by the default filter. Override the root level in logback.xml (or via JVM property) to see them.
Composition with portfolios¶
A child strategy mybook/trend emitting:
Stdout:
12:34:56.789 [qkt-live-engine] WARN [mybook/trend] com.qkt.dsl.strategy - trend reversal at 50125.00
File: logs/mybook__trend.log (NOT logs/mybook/trend.log).
Kotlin DSL parity¶
strategy("my-strategy", 1) {
val btc = stream("btc", "BACKTEST", "BTCUSDT", "1m")
rule {
whenever(btc.close gt 100.bd)
then { warn("buy at {price}", "price" to btc.close) }
}
}
Round-trips byte-for-byte with the equivalent text DSL.
Testing patterns¶
Capture log events with a logback AppenderBase for unit tests:
val captured = mutableListOf<ILoggingEvent>()
val appender = object : AppenderBase<ILoggingEvent>() {
override fun append(eventObject: ILoggingEvent) { captured.add(eventObject) }
}
appender.context = LoggerFactory.getILoggerFactory() as LoggerContext
appender.start()
val logger = LoggerFactory.getLogger("test") as ch.qos.logback.classic.Logger
logger.addAppender(appender)
logger.level = Level.DEBUG
ActionCompiler(ExprCompiler(), logger).compile(action).invoke(ctx)
assertThat(captured[0].level).isEqualTo(Level.WARN)
assertThat(captured[0].mdcPropertyMap).containsEntry("log.price", "50125")
Discriminator substitution is unit-testable directly:
val discriminator = StrategyFilenameDiscriminator().also { it.start() }
assertThat(discriminator.getDiscriminatingValue(eventWithMdc("strategy" to "mybook/trend")))
.isEqualTo("mybook__trend")
Round-trip equivalence between text DSL and Kotlin DSL is enforced in RoundTripEquivalenceTest.
Known limitations¶
- No string concatenation in expression grammar.
LOG "msg " + btc.closeis not supported. Use placeholder syntax:LOG "msg {x}" x=btc.close. - No escape syntax for
{. Logging a literal{requires a future enhancement; for now, avoid{inLOGmessage text unless used as a placeholder. - JSON appender plumbing deferred.
log.<name>MDC keys are emitted during each LOG call but the consuming JSON appender is not enabled by default. Adding one is a one-block addition tologback.xml. - Default root level is
INFO.LOG DEBUGlines are filtered out unless the root level is changed. - Logback discriminator runtime API. The
StrategyFilenameDiscriminatorextendsMDCBasedDiscriminatorfromlogback-classic 1.4.x. Future logback major versions may move the base class; lock the dependency version ingradle/libs.versions.toml.
References¶
- Spec:
docs/superpowers/specs/2026-05-10-trading-engine-phase15-dsl-log-design.md - Plan:
docs/superpowers/plans/2026-05-10-trading-engine-phase15-dsl-log.md - Phase 14 changelog (slash-name origin):
docs/phases/phase-14.md