Skip to content

feat(port): session-aware venues — market clock in the port, closed-session semantics in the engine (#370 B1) - #385

Merged
eaitbrahim merged 2 commits into
mainfrom
feat/370-session-awareness
Aug 19, 2026
Merged

feat(port): session-aware venues — market clock in the port, closed-session semantics in the engine (#370 B1)#385
eaitbrahim merged 2 commits into
mainfrom
feat/370-session-awareness

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

What

Phase B1 of #370 (FR-9, PRD docs/superpowers/specs/2026-08-18-keel-broker-alpaca-prd.md): the broker port gains a first-class session concept, and the engine consumes it — a closed equities market reads market closed, never feed stale.

Equities are not 24/7. keel's feed-staleness rails and the agent's cycle semantics assumed a 24/7 venue: on a weekend or market holiday, an equities feed that is simply closed would be reported STALE (the crypto semantics false-positiving), and cycles would log staleness-gated noise.

The port surface

  • BrokerCapabilities.session_bound: bool — REQUIRED, not defaulted (packages/keel-broker-api/keel_broker_api/capabilities.py). fake/coinbase/robinhood declare False (24/7); alpaca declares True. A default would be an answer to a question only the venue knows, and the likeliest default (24/7) is exactly the one that reads a closed equities venue as a stale feed.
  • Broker.market_clock() -> SessionState (port.py; SessionState in results.py: open | closed | clock_unavailable). Alpaca reuses Phase A's /v2/clock read — is_market_open() now delegates to market_clock() so the two can never disagree. The 24/7 adapters answer OPEN as a constant with no network call (pinned by the no-transport adapter tests).
  • The conformance suite holds all four adapters to the new surface (conformance/suite.py).

FR-9 mapping (engine)

  • Cycle skip with kill-switch parity (keel/agent.py::run_once): the session gate sits directly after the kill-switch and skips exactly like it — no poll, no evaluation, no orders — logging agent.cycle_skipped with reason: market_closed (INFO; a weekend is expected) or the distinct reason: market_clock_unavailable (WARNING; "could not know" and "we know it is shut" are different operator facts). The kill-switch still outranks.
  • Fail-closed clock rule: a session-bound venue's clock that cannot be read answers clock_unavailable — never an exception, never a guess of open. The engine belt-and-braces the same way (a third-party adapter that raises instead of answering still fails closed); the cycle never crashes on a clock read.
  • Recording for broker-free surfaces: each cycle writes the venue's answer to agent_state (market_session / market_session_ts).
  • fetch --check (still offline) reads that recording via agent.recorded_market_closed: stale-but-present series render as the distinct CLOSED state and do NOT count as missing/stale for the exit code — a weekend must not page an operator. Deliberate carve-outs: missing still alerts (a closed venue serves history); clock_unavailable does not defuse alerts (fail-closed for trading, fail-loud for alerting); the recording is trusted only for rail 12's feed-heartbeat window (interval_sec * FEED_STALENESS_CYCLES) so a dead agent cannot silence alerts forever.
  • keel status / keel tui: a distinct session line directly under the kill-switch line (the feat(status): surface rail-17 attestation expiry before it vetoes, and a rules enable verb (#340) #345 rail-17 line precedent; no paper carve-out — the session gate skips paper cycles too, so the same line is truthful in every mode).

Crypto unchanged — guaranteed at every seam

A broker that does not implement the port (today's CoinbaseClient) or declares session_bound=False is never asked its clock, writes no session state, and every existing behavior, output, and test stays byte-identical (pinned by engine tests at both seams: no-port broker and port-but-24/7 broker).

Test-first

Conformance, adapter, agent, freshness, fetch-CLI, status, and TUI tests were written red first — collection failures on the missing SessionState / session_bound / market_clock / MarketSessionStatus, plus failing CLOSED-label and exit-code assertions — and drove the implementation.

Gates

  • uv run pytest -q — 3161 passed, 2 skipped (both expected skips: session-bound venue in the 24/7 clock test; the pre-existing no-granularity skip)
  • uv run ruff check keel tests packages — clean
  • uv run mypy — clean (249 files)

Refs #370 (Phase B continues — not a fix)

…ession semantics in the engine (#370 B1)

FR-9 (PRD §3, keel-broker-alpaca): equities are not 24/7. Until now the
feed-staleness rails and the agent's cycle semantics assumed a 24/7 venue, so
a weekend or market holiday would report a closed equities feed as STALE (the
crypto semantics false-positiving) and cycles would log staleness-gated noise.

The port gains a first-class session concept:

- `BrokerCapabilities.session_bound: bool` — REQUIRED, not defaulted. Crypto
  venues (fake/coinbase/robinhood) declare False; alpaca declares True. A
  default would be an answer to a question only the venue knows, and the
  likeliest default (24/7) is exactly the one that reads a closed equities
  venue as a stale feed.
- `Broker.market_clock() -> SessionState` (`open` | `closed` |
  `clock_unavailable`). The alpaca implementation REUSES Phase A's `/v2/clock`
  read (`is_market_open()` now delegates to it, so the two can never
  disagree); the 24/7 adapters answer `OPEN` as a constant with no network
  call (proven by the no-transport adapter tests).
- The conformance suite holds all four adapters to the new surface.

Fail-closed clock rule: a session-bound venue's clock that cannot be read
(transport error, missing transport, no clock in the response) answers
`clock_unavailable` — never an exception, never a guess of open. The engine
belt-and-braces the same way: a third-party adapter that raises instead of
answering still fails closed; the cycle never crashes on a clock read.

Engine consumption (`keel/agent.py::run_once`):

- The session gate sits directly after the kill-switch and skips EXACTLY like
  it — no poll, no evaluation, no orders — logging `agent.cycle_skipped` with
  `reason: market_closed` (INFO; a weekend is expected) or the distinct
  `reason: market_clock_unavailable` (WARNING; "could not know" and "we know
  it is shut" are different operator facts). The kill-switch still outranks.
- Each cycle records the venue's answer into `agent_state`
  (`market_session`/`market_session_ts`), so the broker-free surfaces can
  render it without a clock call of their own.
- `fetch --check` (still offline) reads that recording via
  `agent.recorded_market_closed`: a stale-but-present series renders as the
  distinct `CLOSED` state and does NOT count as missing/stale for the exit
  code — a weekend must not page an operator. `missing` still alerts (a closed
  venue serves history), `clock_unavailable` does NOT defuse alerts
  (fail-closed for trading, fail-loud for alerting), and the recording is
  trusted only for rail 12's feed-heartbeat window
  (`interval_sec * FEED_STALENESS_CYCLES`) so a dead agent cannot silence
  alerts forever.
- `keel status`/`keel tui` render a distinct session line under the
  kill-switch line (the #345 rail-17 precedent; no paper carve-out — the
  session gate skips paper cycles too).

Crypto unchanged, guaranteed at every seam: a broker that does not implement
the port (today's `CoinbaseClient`) or declares `session_bound=False` is never
asked its clock, writes no session state, and every existing behavior,
output, and test stays byte-identical.

Test-first: the conformance, adapter, agent, freshness, fetch-CLI, status and
TUI tests were written red first (collection failures on the missing
`SessionState`/`session_bound`/`market_clock`/`MarketSessionStatus` plus
failing CLOSED/exit-code assertions) and drive the implementation.

Refs #370
…fail-loud clocks

Ten adversarial-review findings on PR #385 (branch feat/370-session-awareness):

- Record the venue session (state/ts/effective interval) BEFORE the kill-switch
  return: a halted-but-healthy deployment keeps tracking the venue clock, so a
  weekend under a kill switch no longer false-positives STALE and a Friday
  pre-close halt no longer freezes market_session=open all weekend.
- Namespace the session record by venue (market_session:{venue} plus ts and
  interval twins); recorded_market_closed takes the venue it is answering for,
  so one venue's CLOSED never defuses another's staleness and two deployments
  with different cadences carry their own trust windows.
- Alpaca market_clock: a 2xx body without a usable boolean is_open is
  CLOCK_UNAVAILABLE, never CLOSED (closed defuses alerting forever; a malformed
  body must not buy that silence). Only an actual bool answers OPEN/CLOSED.
- _venue_session validates market_clock()'s answer is a SessionState: a
  None-returning third-party adapter fails closed with the distinct
  market_clock_unavailable skip instead of AttributeError killing the loop.
- The trust window derives from the RECORDED cycle interval (loop/monitor
  --interval overrides included), falling back to config only when absent.
- TUI freshness styling is session-aware: closed AND inside the trust window
  renders behind series muted, not warn -- same source of truth as the session
  line; no-data cells keep warn (a closed venue still serves history).
- recorded_market_closed documents the pre-close staleness attenuation
  (bounded silence until reopen) with an arc test: alerts pre-close, silenced
  during closure, re-alerts once the record expires post-reopen.
- Trust-window boundary tests (age == window defuses, window+1 does not,
  fresh-nonzero-age defuses, junk ts does not) with a frozen clock.
- Truthful fetch wording: the summary says 'market closed -- staleness does
  not alert' when closed-explained staleness exists, and a plain fetch no
  longer calls a behind cache 'all series current' to justify the skip.
- keel monitor --loop is session-aware: closed venues skip polling (logged
  once per state change) and record the session so fetch --check stays quiet
  over the weekend; crypto venues poll exactly as before.
@eaitbrahim
eaitbrahim merged commit 8b49b98 into main Aug 19, 2026
5 checks passed
@eaitbrahim
eaitbrahim deleted the feat/370-session-awareness branch August 19, 2026 06:55
eaitbrahim added a commit that referenced this pull request Aug 20, 2026
Minor, not patch. Three things since v0.9.3 change what an implementor or a
deployment can rely on:

* A NEW DISTRIBUTION. `keel-broker-alpaca` (#382, #384) plus the
  paper-equities profile that selects it (#386), so a deployment can now be
  US equities via the broker port rather than crypto only.
* THE PORT CONTRACT MOVED TWICE. `market_clock`/`market_schedule` made
  venues session-aware (#385), and `place_order` gained `idempotency_key`
  (#419). Both carry defaults so no CALLER breaks, but a third-party adapter
  that does not accept them is no longer a `Broker` -- the conformance suite
  now says so. That is exactly the kind of change a patch bump must not
  hide.
* THE OPERATOR CONSOLE. The TUI became keel's console across #399-#408, and
  `keel update` (#415/#417) makes a deployment self-updating.

Every pinned sibling moves with it. The four production distributions are
required `==` at this exact version (`RELEASING.md`, "Release assets"), so a
bump that missed one would install a mixed set -- the `keel-trader 0.5.7`
against `keel-core 0.5.5` failure `keel versions` exists to catch, and which
`~/keel` actually ran across two releases.

Also in this window, on the Robinhood adapter: the best_bid_ask fixture
corrected against the live venue (#414), a credential guard that catches the
error it only claimed to (#416), pre-flight sizing reported on the preview
(#418), transport backoff (#420), and the fenced one-order probe (#421).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant