Phase 12b — Single-Strategy Observability HTTP Port¶
Summary¶
Phase 12b adds an embedded HTTP server to qkt run. A long-running paper-trade can now be inspected from a browser, scraped by Grafana, or remote-stopped via curl — without keeping the terminal attached. Five JSON endpoints (GET /status, GET /events SSE, GET /logs, GET /health, POST /stop) cover the read-side observability surface and graceful shutdown. Default port 0 uses the kernel's ephemeral allocator (collision-free by construction); the chosen port prints to stdout in two forms ([INFO] observability: http://... and QKT_PORT=…) and optionally writes to a --port-file <path> for external tooling. Bind defaults to 127.0.0.1; --bind 0.0.0.0 is opt-in with a stderr warning. Privileged ports (< 1024) are rejected unless --allow-privileged-port is set. No new external dependencies — com.sun.net.httpserver is JDK built-in and kotlinx.serialization.json was already a project dep.
What's new¶
com.qkt.cli.observe.EventRing(capacity)— bounded ring buffer withappend(kind, payload),snapshot(since, limit), andsubscribe(listener). Listener fan-out is best-effort (one bad listener can't break others). Default capacity 1000.com.qkt.cli.observe.EventEntry(ts, kind, payload)— ring entry with epoch-ms timestamp, string kind (trade/signal/log), andJsonObjectpayload.com.qkt.cli.observe.StatusSnapshot+PositionDto+TradeDto—kotlinx.serialization-annotated DTOs for/status.BigDecimalAsNumberSerializeremitsBigDecimalas a JSON number withtoPlainString()(no scientific notation, preserves scale viaJsonUnquotedLiteral).com.qkt.cli.observe.ObservabilityServer(ring, statusProvider, running, onStop, bind, port)—com.sun.net.httpserver-backed server withstart()/close(). Routes/status,/events,/logs,/health,/stop. Fixed thread pool of 4 (one slot per concurrent SSE client; three for short-lived endpoints).com.qkt.cli.observe.Routes— top-level handler functions, one per route. Hand-rolled JSON for the small endpoints;kotlinx.serializationfor/status.com.qkt.cli.observe.PortPrinter.announce(host, port, portFile?, out?)— emits the two stdout lines ([INFO] observability: …andQKT_PORT=…) and optionally writesportto a file via temp-then-rename atomic write.- New flags on
qkt run: --port <num>— default0(kernel-assigned ephemeral). Privileged ports rejected without--allow-privileged-port.--bind <addr>— default127.0.0.1.0.0.0.0opt-in with stderr warning.--allow-privileged-port— escape hatch for--port < 1024(requires root orCAP_NET_BIND_SERVICE).--port-file <path>— atomic-write the bound port to the file; useful for shell wrappers.--no-observe— disables the embedded server entirely.--ring-size <n>— event ring capacity. Default 1000.LiveSession.onSignal— optional callback (default no-op) wired viabus.subscribe<SignalEvent>. LetsRunCommandpush signal events into the ring alongside trades.BuildInfo.VERSIONbumped to0.12.0(new public HTTP API surface).
Migration from previous phase¶
Purely additive on top of 12a. Existing 12a invocations (qkt run foo.qkt) gain the embedded HTTP server transparently — same default behaviour as before, plus an [INFO] observability: … line and a QKT_PORT=… line printed at startup. To preserve 12a's pre-observability behaviour exactly, pass --no-observe.
LiveSession's constructor gains an optional onSignal: ((Signal) -> Unit)? = null parameter. Existing callers that don't supply it are unaffected.
Usage cookbook¶
Default (kernel-assigned port, localhost-only)¶
$ qkt run strategies/momentum.qkt
[INFO] qkt 0.12.0 — strategy momentum_basket v1 — paper-trading
[INFO] subscribed: BYBIT:BTCUSDT, INTERACTIVE:XAUUSD, ALPACA:AAPL
[INFO] observability: http://127.0.0.1:47291
QKT_PORT=47291
[INFO] running, Ctrl+C to stop
Inspect status from another terminal¶
$ curl -s http://127.0.0.1:47291/status | jq
{
"strategy": "momentum_basket",
"version": 1,
"uptimeMs": 47823,
"startedAt": "2026-05-08T14:31:14Z",
"equity": 0,
"balance": 0,
"realized": 0,
"unrealized": 0,
"positions": [],
"lastTrade": {
"timestamp": "2026-05-08T14:38:05Z",
"side": "SELL",
"symbol": "BTCUSDT",
"qty": 0.001,
"price": 68000.00,
"realized": -2.34
}
}
$ curl -s http://127.0.0.1:47291/health
{"status":"ok"}
(Equity / balance / realized / unrealized return zeros pending full PnL plumbing through LiveSessionHandle — see "Known limitations" below.)
Tail live events via SSE¶
$ curl -N http://127.0.0.1:47291/events
event: trade
data: {"side":"BUY","symbol":"BTCUSDT","qty":"0.001","price":"68234.50","realized":"0.00"}
event: trade
data: {"side":"SELL","symbol":"BTCUSDT","qty":"0.001","price":"68000.00","realized":"-2.34"}
^C
Browser-friendly: EventSource('http://localhost:47291/events').onmessage = e => console.log(e.data).
Query historical events¶
$ curl -s "http://127.0.0.1:47291/logs?limit=5" | jq
[
{"ts": 1715177525000, "kind": "trade", "payload": {"side": "BUY", ...}},
{"ts": 1715177530000, "kind": "trade", "payload": {"side": "SELL", ...}}
]
$ curl -s "http://127.0.0.1:47291/logs?since=1715177528000&limit=10"
Graceful remote shutdown¶
$ curl -s -X POST http://127.0.0.1:47291/stop
{"status":"accepted","action":"graceful_shutdown"}
# Original terminal:
[INFO] graceful shutdown initiated
[INFO] terminated; 4 trades
?flatten=true flattens positions before exit:
Extract the port for a wrapper script¶
Two options:
# Via grep on stdout
qkt run strategies/momentum.qkt 2>&1 | tee qkt.log &
port=$(grep -m1 '^QKT_PORT=' qkt.log | cut -d= -f2)
echo "monitoring on $port"
# Via --port-file (cleaner)
qkt run strategies/momentum.qkt --port-file /tmp/qkt.port &
sleep 1
port=$(cat /tmp/qkt.port)
curl http://127.0.0.1:$port/status
Pin a port (e.g. for a stable Grafana scrape)¶
If port 47291 is in use, the bind fails fast with a clear error.
Privileged port (Docker + CAP_NET_BIND_SERVICE)¶
$ qkt run strategies/momentum.qkt --port 80
qkt: error: port 80 is privileged (< 1024); add --allow-privileged-port to override.
$ qkt run strategies/momentum.qkt --port 80 --allow-privileged-port
[INFO] observability: http://127.0.0.1:80
Minimal-footprint mode¶
$ qkt run strategies/momentum.qkt --no-observe
# No HTTP server. No QKT_PORT line. Same as 12a behaviour.
Testing patterns¶
Endpoint contract via OkHttp¶
val server = ObservabilityServer(ring, statusProvider, { true }, {}, "127.0.0.1", 0)
server.start()
try {
val resp = client.newCall(
Request.Builder().url("http://127.0.0.1:${server.boundPort}/status").build()
).execute()
assertThat(resp.code).isEqualTo(200)
assertThat(resp.body!!.string()).contains("\"strategy\"")
} finally {
server.close()
}
SSE framing¶
Open /events, feed entries to the ring on the test thread, read the streamed bytes via OkHttp's byteStream().bufferedReader(). The SSE handler subscribes synchronously on connection open — use a CountDownLatch keyed off the prelude : line (the heartbeat) to avoid races between the test's subscribe and the test's append.
--port-file atomicity¶
val tmp = Files.createTempDirectory("qkt-test").resolve("port.txt")
PortPrinter.announce("127.0.0.1", 47291, tmp, PrintStream(ByteArrayOutputStream()))
assertThat(Files.readString(tmp)).isEqualTo("47291")
End-to-end via runMain¶
Boot Thread { runMain(arrayOf("run", fixture, "--port", "0")) }.start(). Capture stdout, parse QKT_PORT=, hit /status, POST /stop, join the thread.
Known limitations¶
/statusreturns zeros for equity / balance / realized / unrealized.LiveSession's public surface (LiveSessionHandle) doesn't yet expose live PnL or positions. Strategy / version / uptime / last trade are real; the financial fields are stubbed atBigDecimal.ZERO. Full PnL plumbing lands when 12c wiresStrategyPnLthrough the daemon's status snapshot.- No auth, no TLS. Localhost-only by default.
--bind 0.0.0.0is opt-in and the user's responsibility to front with nginx + auth + TLS. Auth lands in 12c's daemon control plane. - No persistent log. Ring is in-memory; ancient events drop off at capacity. For long-term history, redirect stdout to a file. Persistent logging is 12c+.
- No event replay.
/eventsstreams from connection-open; past events use/logs. - Fixed thread pool of 4. Up to 4 concurrent SSE clients (or fewer if other endpoints in flight). Acceptable for single-user CLI; 12c will switch to a
Selector-based event loop if real load shows up. - No multi-strategy. One strategy per process. Daemon-shape (
qkt deploy / list / logs / status / stop <name>) is 12c. - No Docker base image. Phase 12c.
- No Prometheus exposition format.
/statusis plain JSON; users wanting Prometheus can wrap with a JSON exporter. - No HTML dashboard. API is the deliverable; UIs are tooling-layer concerns.
References¶
- Spec:
docs/superpowers/specs/2026-05-08-trading-engine-phase12b-design.md - Plan:
docs/superpowers/plans/2026-05-08-trading-engine-phase12b.md - Phase 12a (CLI binary):
docs/superpowers/specs/2026-05-08-trading-engine-phase12a-design.md - jdk.httpserver:
com.sun.net.httpserver.HttpServer - SSE spec: https://html.spec.whatwg.org/multipage/server-sent-events.html
- Merge commit: 232e7b2