diff --git a/.env.example b/.env.example
index d17b51e..373bf56 100644
--- a/.env.example
+++ b/.env.example
@@ -8,6 +8,16 @@ CDP_API_SECRET=
ROBINHOOD_API_KEY=
ROBINHOOD_PRIVATE_KEY=
+# Alpaca keys for the equities paper profile (issue #370 B2). Read by `load_alpaca_secrets`
+# from the environment first and this file second, and consumed only when a config's
+# `broker:` section selects `name: alpaca` (config.paper-equities.yaml does). PAPER keys
+# suffice for the paper profile: generate them from the Alpaca dashboard's paper trading
+# account, and `broker.endpoint: paper` selects paper-api.alpaca.markets -- the adapter
+# derives the host from that word and accepts no URL, so these cannot be pointed at the
+# live venue by any configuration.
+ALPACA_API_KEY_ID=
+ALPACA_API_SECRET_KEY=
+
# Optional. Where CRITICAL escalations are POSTed as JSON (ntfy, Pushover, a Slack/Discord
# webhook, anything that accepts a POST body). Unset means alerting is off entirely and keel
# makes no network call for it -- but then `reconcile.position_unprotected` ("this tranche is
diff --git a/com.keel.paper-equities.plist b/com.keel.paper-equities.plist
new file mode 100644
index 0000000..cb4b1fd
--- /dev/null
+++ b/com.keel.paper-equities.plist
@@ -0,0 +1,97 @@
+
+
+
+
+ Label
+ com.keel.paper-equities
+
+
+ ProgramArguments
+
+ /bin/bash
+ /Users/elmehdiaitbrahim/keel/paper-equities-run.sh
+
+
+ StartCalendarInterval
+
+ Hour10Minute0
+ Hour11Minute0
+ Hour12Minute0
+ Hour13Minute0
+ Hour14Minute0
+ Hour15Minute0
+
+
+
+ RunAtLoad
+
+
+ WorkingDirectory
+ /Users/elmehdiaitbrahim/keel
+
+ StandardOutPath
+ /Users/elmehdiaitbrahim/keel/logs/paper-equities.out.log
+ StandardErrorPath
+ /Users/elmehdiaitbrahim/keel/logs/paper-equities.err.log
+
+
diff --git a/config.paper-equities.yaml b/config.paper-equities.yaml
new file mode 100644
index 0000000..d51b240
--- /dev/null
+++ b/config.paper-equities.yaml
@@ -0,0 +1,167 @@
+# keel runtime configuration — the EQUITIES paper profile (issue #370, Phase B2).
+#
+# The fourth deployment profile, and the first on a SECOND ASSET CLASS: keel's daily turtle
+# rules running on US equities through Alpaca's PAPER trading API (`broker:` below selects
+# the alpaca adapter, its paper endpoint and the IEX data feed). It runs on its OWN database,
+# keel-equities.db, pinned to this config by the keel-equities wrapper — never in keel.db
+# (the daily crypto paper account) or keel-live.db. Bootstrap commands are in
+# docs/operator-runbook.md, "The equities paper profile".
+#
+# THE ALLOWLIST IS PAPER CANDIDATES, NOTHING MORE. MSFT, AAPL, GOOGL, NVDA and COST are
+# chosen for LIQUIDITY (deep daily books, long split-adjusted history, IEX data quality on
+# mega-caps) and for being the kind of liquid large cap an equity screen can be RUN on —
+# not because any of them has been screened, and not because any of them is expected to be
+# profitable. Classification is OPERATOR-ATTESTED per (alpaca, SYMBOL), from attributed
+# sources, before any live consideration (see the runbook's attestation semantics: AAOIFI /
+# IFSB-class standards are the watch); the engine never classifies, and this file
+# asserts nothing religiously. Trading these names here is paper evidence collection, full
+# stop.
+#
+# HONEST CAVEAT, STATED UP FRONT: there is NO PROVEN EDGE on any asset class. The crypto
+# configurations are measured net-negative on their own clocks, and these rules have never
+# been measured on equities at all — nothing in this file changes either fact. This profile
+# exists to accrue ADMISSIBLE EVIDENCE — rail vetoes, outcomes, pending lifespans, intent
+# divergence, this time on a session-bound venue and a second asset class — not profit. Do
+# not read a positive stretch as anything but noise.
+#
+# A DAILY CLOCK. market_data.granularities is ONE_DAY ONLY: the daily turtle rules trade
+# ONE_DAY bars, and hourly bars exist only within sessions (Alpaca mints them 09:30–16:00 ET
+# on trading days), so they are not needed by daily rules and polling them would add rate-
+# limit surface for nothing. interval_sec: 86400 is the matching cadence — and it scales the
+# feed-staleness window, which B1's session awareness (#385) reads closed-explained through:
+# a weekend is "market closed", never "feed stale".
+#
+# `allowlist` and `caps` are required and validated by keel.config.load_config; missing or
+# invalid values raise ConfigError naming the offending key rather than silently defaulting.
+
+allowlist:
+ - MSFT
+ - AAPL
+ - GOOGL
+ - NVDA
+ - COST
+
+# Flat 20% each, summing to exactly 1.000. Flatness states NO view: nobody has earned a
+# conviction weighting on an asset class nothing has been measured on, and every name's
+# shariah classification is unrecorded (that is the operator's attestation, not this file).
+# It is the sizing half of the same guardrail logic as the hourly profile's flat Tier-2
+# caps — a candidate can only ever be a 20% position while it is still a candidate.
+target_weights:
+ MSFT: 0.200
+ AAPL: 0.200
+ GOOGL: 0.200
+ NVDA: 0.200
+ COST: 0.200
+
+risk_pct: 0.01
+
+# The venue this deployment talks to — the whole of the venue-selection surface. `name`
+# resolves through the keel.brokers entry points (the deployment must have keel-broker-alpaca
+# installed); `endpoint: paper` selects Alpaca's PAPER host (paper-api.alpaca.markets — the
+# adapter derives the host from this word and accepts no URL, so a paper credential cannot
+# be pointed at the live venue); `data_feed: iex` declares the free data tier (sip is the
+# subscribed tier — the choice is declared, never assumed, because the venue's server-side
+# default silently fails for keys without the subscription). Omitting the section entirely
+# selects Coinbase, byte-compatibly with every other profile.
+broker:
+ name: alpaca
+ endpoint: paper
+ data_feed: iex
+
+caps:
+ # Same non-binding internal limits as the crypto paper profiles — deliberately NOT tighter,
+ # for the same reason: this profile's evidence is partly ABOUT rail behaviour (rail vetoes
+ # are admissible evidence), so silently clamping sizing here would bias exactly the thing
+ # being measured.
+ max_exposure_usd: 5000
+ max_per_asset_pct: 0.50
+
+market_data:
+ # See the header: ONE_DAY ONLY — a daily-clock equities profile. history_days mirrors the
+ # crypto paper profiles so a year of warm cache backs the daily lookbacks.
+ granularities:
+ - ONE_DAY
+ history_days: 365
+
+auto_trade:
+ mode: paper
+ # NOTE: currently UNUSED by any code path -- it is NOT a kill-switch and setting it
+ # true or false changes nothing. Use `keel kill` to halt trading.
+ enabled: false
+ # One cycle per UTC day. The LaunchAgent (com.keel.paper-equities.plist) triggers daily
+ # at 10:00-15:00 local, inside the US regular session (the runner's window is 10:00
+ # inclusive to 16:00 exclusive, so the 15:00 trigger runs), because B1's session gate
+ # skips any cycle the venue clock answers closed — see that plist's comment for the full
+ # reasoning. That schedule is correct on an ET-anchored host (or one within ±4h of ET);
+ # on any other host, re-anchor the trigger hours to land 10:00-15:00 ET — the runner's
+ # local-hours guard is a backstop against off-schedule boots, not a drift absorber. This
+ # value also scales the staleness window (86400 x FEED_STALENESS_CYCLES), which is what
+ # keeps weekends and holidays reading market-closed instead of stale.
+ interval_sec: 86400
+
+promotion:
+ min_trades: 100
+ min_expectancy: 0.0
+ min_rr: 1.5
+ min_win_rate: 0.55
+
+money_mgmt:
+ profit_trigger_pct: 0.10
+ acceleration_pct: 0.05
+ max_total_dd_pct: 0.20
+ max_weekly_dd_pct: 0.08
+ # Rail 16 (consecutive-loss breaker) — DISABLED by default (0 = off), same as the siblings.
+ max_consecutive_losses: 0
+ streak_cooloff_days: 0
+
+dca:
+ budget_usd: 50
+ cadence_days: 7
+
+paper:
+ # Same seed/contribution as the crypto paper profiles, deliberately: a like-for-like
+ # comparison of rail behaviour across venues wants the same sizing basis, and changing it
+ # here would confound exactly that. At $10k+ synthetic equity, rail 14's $500/month
+ # allowance can veto large sized setups — those vetoes are themselves evidence.
+ starting_equity_usd: 10000
+ monthly_contribution_usd: 500
+
+# Equities settle in USD; Alpaca only trades USD-quoted symbols (the adapter refuses any
+# other quote leg), so this is not a choice so much as a restatement of the venue.
+quote_currency: USD
+
+subscription:
+ # Rail 14's simulator assumptions. Alpaca has no subscription tiers — commission-free
+ # trading with regulatory fees on sells — so the tier CATALOGUE below stays at its
+ # Coinbase defaults (rail 14 reads the ATTESTED record, not this catalogue, and a live
+ # equities path would restate all of this before it mattered; see the runbook).
+ assumed_free_volume_usd: 500
+ unsubscribed_allowance_usd: 0
+ pacing: opportunistic
+
+# Alpaca equities are commission-free: $0 per trade, so paper fills price at a 0% commission
+# here — the honest statement about commission on this venue. What this does NOT model is
+# the sell-side regulatory fees (SEC Section 31 + FINRA TAF, passed through on sells) and
+# the spread; the adapter's preview estimates the former from the venue's own published
+# formulas, and Phase C's cost-fidelity work re-measures all of it before any strategy
+# evaluation on this asset class is believed (PRD §6.3).
+fees:
+ taker_pct: 0.0
+ maker_pct: 0.0
+
+# Daily cadence means one cycle per trading day, but rail vetoes and no-signal reasons still
+# arrive every cycle and verbose=false would make "why no order today?" a manual database
+# replay. Rotation bounds the volume. (Weekends and holidays are quiet by design — B1.)
+logging:
+ verbose: true
+ file: logs/keel-equities.log
+ max_file_mb: 25
+ file_count: 5
+
+# G4 overfitting gate (KB §78). NEVER tune these to obtain a desired verdict. Same floors as
+# every other profile; this profile's rows are EXPECTED to fail them — an unmeasured asset
+# class is the definition of no edge, and that failure is the finding, not a configuration
+# problem.
+research:
+ pbo_max: 0.05
+ slope_floor: -0.5
diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md
index 15ff056..98ccfbd 100644
--- a/docs/operator-runbook.md
+++ b/docs/operator-runbook.md
@@ -187,20 +187,20 @@ histories. **A figure from one says nothing about the other.** Checking a paper
against live account equity — or a live cap against paper cash — yields a confident wrong answer,
and has already produced one. Establish which account a number came from before reasoning about it.
-| | paper | live | paper-hourly |
-| --- | --- | --- | --- |
-| config | `config.paperforward.yaml` | `config.live-sandbox.yaml` | `config.paper-hourly.yaml` |
-| database | `keel.db` (the `--db` default) | `keel-live.db` (must be passed) | `keel-paperhourly.db` (must be passed) |
-| `auto_trade.mode` | `paper` | `confirm` | `paper` |
-| allowlist | BTC, ETH, PAXG, SOL, XLM, LTC, ADA, LINK (8) | BTC, ETH, PAXG, ADA, XLM (5) | paper's 8 + 11 Tier-2 = 19 (#351) |
-| `caps.max_exposure_usd` | 5000 | 200 | 5000 |
-| money spent | synthetic `paper_cash_usdc` | the real broker balance | synthetic `paper_cash_usdc` |
-| sizing basis | the paper account's own equity | `caps.max_exposure_usd`, as a proxy | the hourly account's own equity |
-| rail 14 allowance | $500/month (Basic tier) | $200/month | $500/month |
-| `equity_state_mode` | `paper` | `live` | `paper` |
-| launchd job | `com.keel.paperforward` | `com.keel.live` | `com.keel.paper-hourly` |
-| cadence | daily (day-stamp) | daily, UTC (UTC day-stamp) | **hourly**, UTC (UTC hour-stamp) |
-| rules traded | daily turtle, `paper` | daily turtle + DCA, `live` | **hourly** turtle, `paper` |
+| | paper | live | paper-hourly | paper-equities |
+| --- | --- | --- | --- | --- |
+| config | `config.paperforward.yaml` | `config.live-sandbox.yaml` | `config.paper-hourly.yaml` | `config.paper-equities.yaml` |
+| database | `keel.db` (the `--db` default) | `keel-live.db` (must be passed) | `keel-paperhourly.db` (must be passed) | `keel-equities.db` (must be passed) |
+| `auto_trade.mode` | `paper` | `confirm` | `paper` | `paper` |
+| allowlist | BTC, ETH, PAXG, SOL, XLM, LTC, ADA, LINK (8) | BTC, ETH, PAXG, ADA, XLM (5) | paper's 8 + 11 Tier-2 = 19 (#351) | 5 US large caps, **unattested paper candidates** |
+| `caps.max_exposure_usd` | 5000 | 200 | 5000 | 5000 |
+| money spent | synthetic `paper_cash_usdc` | the real broker balance | synthetic `paper_cash_usdc` | synthetic `paper_cash_usdc` |
+| sizing basis | the paper account's own equity | `caps.max_exposure_usd`, as a proxy | the hourly account's own equity | the equity account's own equity |
+| rail 14 allowance | $500/month (Basic tier) | $200/month | $500/month | $500/month (simulator assumption; Alpaca has no tiers) |
+| `equity_state_mode` | `paper` | `live` | `paper` | `paper` |
+| launchd job | `com.keel.paperforward` | `com.keel.live` | `com.keel.paper-hourly` | `com.keel.paper-equities` |
+| cadence | daily (day-stamp) | daily, UTC (UTC day-stamp) | **hourly**, UTC (UTC hour-stamp) | daily, in the US session (UTC day-stamp) |
+| rules traded | daily turtle, `paper` | daily turtle + DCA, `live` | **hourly** turtle, `paper` | daily turtle on equities, `paper` |
**Which one am I looking at.** On any dashboard (`keel status`, `keel insights`, `keel tui`) the
`equity_state_mode` line names the account the equity, high-water mark and drawdown figures
@@ -221,7 +221,9 @@ place. Autonomy changes who is asked, never what is allowed; check the flag befo
live cycle is supervised, rather than inferring it from `confirm`.
**Both fire hourly; both run once a day** (the third job, `com.keel.paper-hourly`, is the
-exception that runs once per UTC *hour* — see "The hourly evidence profile" below). Each
+exception that runs once per UTC *hour* — see "The hourly evidence profile" below — and the
+fourth, `com.keel.paper-equities`, runs once per day *inside the US regular session*; see
+"The equities paper profile"). Each
launchd job has a list of hourly triggers plus
`RunAtLoad`, and each runner is day-stamped: the first eligible trigger that finds no stamp for
today runs the cycle and writes the stamp, and every later trigger that day is a no-op. The
@@ -339,6 +341,201 @@ against the then-newest bar. An hour lost to the machine being powered off is lo
cannot replay bars that closed while it was down; that is an hour of evidence, not an hour of
money, and it is why the profile's duty cycle matters more than its exact schedule.
+## The equities paper profile (paper-equities)
+
+A fourth deployment, `config.paper-equities.yaml` + `keel-equities.db`, running the **same**
+daily turtle rules on a different asset class: US equities through Alpaca's **paper** API
+(`broker: {name: alpaca, endpoint: paper, data_feed: iex}` — the config's `broker:` section is
+the whole venue-selection surface; omitting it keeps Coinbase, byte-compatibly). One paper
+cycle per day, fired *inside* the US regular session by `com.keel.paper-equities.plist`
+(10:00–15:00 local/ET) and stamped on the UTC day by `paper-equities-run.sh`. Use
+`./keel-equities ` so the config and database always travel as a pair.
+
+**Why it exists: evidence on a session-bound venue, nothing more.** Every profile so far
+exercises the engine on one venue and one asset class. This one accrues the same admissible
+evidence — rail vetoes, outcomes, pending lifespans, intent divergence — where the venue has
+a *clock*: weekends and holidays are read "market closed," never "feed stale" (#370 B1), and
+the rails meet a second asset class for the first time. **The honest caveat, which changes
+nothing: there is NO PROVEN EDGE on any asset class.** The crypto configurations are measured
+net-negative on their own clocks, and these rules have never been measured on equities at all.
+Do not promote from this profile on a positive stretch; a new asset class is a new
+measurement, not a fresh start for unproven rules (Phase C's cost-fidelity work comes before
+any strategy evaluation is believed).
+
+**The allowlist is PAPER CANDIDATES, and asserts nothing religiously.** MSFT, AAPL, GOOGL,
+NVDA and COST are liquid US large caps, chosen so a screen *could* be run on them — not
+because any has been screened (leverage and the other screening ratios are the operator's
+attestation to make, not a fact this file asserts). Trading them here is paper evidence
+collection, full stop; see the attestation semantics below for what live consideration would
+additionally demand.
+
+### Bootstrap
+
+Deployment to the operator's machine is **out of scope here** (it needs the operator's own
+Alpaca paper credentials); the steps, once you have them:
+
+1. **Alpaca paper account.** Create a paper trading key pair in the Alpaca dashboard's paper
+ account, and put the values in `.env` (or the environment):
+
+ ```bash
+ ALPACA_API_KEY_ID=...
+ ALPACA_API_SECRET_KEY=...
+ ```
+
+ Paper keys suffice — `endpoint: paper` selects `paper-api.alpaca.markets`, and the adapter
+ derives the host from that word and accepts no URL, so these cannot be pointed at the live
+ venue by any configuration.
+
+2. **Install the adapter wheel.** The deployment must have `keel-broker-alpaca` installed —
+ venue selection resolves `name: alpaca` through the `keel.brokers` entry points, and the
+ error names what is installed when it is missing. The equities deployment's wheel list is
+ the usual four plus this one.
+
+3. **Migrate + seed + warm:**
+
+ ```bash
+ keel migrate --db keel-equities.db # schema only; never seeds
+ for s in MSFT AAPL GOOGL NVDA COST; do
+ keel --config config.paper-equities.yaml --db keel-equities.db rules add \
+ --kind turtle_breakout --product "${s}-USD" --params '{"granularity": "ONE_DAY"}'
+ done
+ keel --config config.paper-equities.yaml --db keel-equities.db rules promote --force
+ keel --config config.paper-equities.yaml --db keel-equities.db fetch
+ ```
+
+ The `rules add` form (explicit per-symbol rows, granularity stated even though ONE_DAY is
+ the constructor default) mirrors the hourly bootstrap so the clock each row trades is
+ visible in the row itself. `--force` is the documented bypass for a rule whose backtest
+ cannot clear the gate; for equity turtle the gate has not been evaluated on this asset
+ class at all — the bypass is deliberate and the warning it prints is the caveat above
+ restated. `fetch` warms ONE_DAY × 365d for the five symbols; run it on a weekend and it is
+ quiet — B1's session awareness records the closed clock and `--check` does not alert on
+ closed-explained staleness.
+
+**Scheduling, in one paragraph.** The plist triggers at 10:00–15:00 local (ET), on the hour —
+*inside* the 09:30–16:00 regular session, deliberately not shortly after the close: B1's
+session gate skips the whole cycle whenever the venue clock answers closed, so an
+after-close trigger would log `market_closed` and never evaluate a bar. The daily bar that
+closes at 16:00 ET is evaluated at the *next* session's open — the conventional
+daily-system semantics (signal on close, execute next open) — and the 10:00 anchor gives the
+open thirty minutes to settle. The runner stamps the **UTC day** (Alpaca keys a session's
+ONE_DAY bar to that UTC date, and the UTC rollover at 19:00/20:00 local is always after the
+window), refuses to run outside its window — 10:00 inclusive to 16:00 exclusive, local (the
+15:00 trigger runs; a closed-market skip exits 0 and must never be stamped as the day's
+work) — and writes the stamp only after a successful cycle (a cycle that skipped because the
+venue clock could not be *read* exits nonzero, so a transient clock outage is retried by the
+next trigger rather than recorded as the day's work).
+
+**Where that schedule is actually correct.** On an ET-anchored host — or one within ±4h of
+ET, where the trigger hours still land inside the 09:30–16:00 ET session. The deployment
+host's local zone is America/New_York, so the fixed local triggers keep their Eastern
+meaning across both US DST transitions: what moves is the UTC instant, never the distance
+from the open. Anywhere else, re-anchor the trigger hours so they land 10:00–15:00 **ET**
+(on a host far enough ahead of ET, all six triggers can fire pre-open, and the runner's
+local-hours guard will still endorse them — it reads the host's clock, not ET — so each day
+would be stamped by a closed-market skip: permanently zero evidence). The guard is a
+backstop against off-schedule boots, not a drift absorber for a mis-anchored schedule.
+
+### Attestation semantics for equities
+
+Equity screening criteria (business-activity screens, leverage ratios, purification) are
+**operator-supplied classifications from attributed sources** — the engine computes market
+facts and never classifies, exactly as on crypto. Attestations are keyed per
+`(alpaca, SYMBOL)`: an equity instrument attests under its own venue namespace and is never
+reused from a Coinbase row. The sources to watch are the ones the fiqh source review (#367)
+already names for this territory: **AAOIFI**'s screening standards and **IFSB**'s
+pronouncements (plus any scholar the operator trusts) — an attestation without a source is
+not evidence. Two honest limits, stated rather than papered over:
+
+- `keel assets attest-instrument --venue alpaca --product MSFT-USD --wrapper spot` records
+ the *instrument* half (what contract the listing is) and works today.
+- The *asset*-level screen (`keel assets screen`) is Coinbase-shaped by construction — its
+ venue constant is deliberately hardcoded to `coinbase` (open item below) — so until the
+ screen generalizes (#233 live-path work), equity classifications live in the operator's
+ records, and this profile trades as **unattested paper candidates**. That is precisely why
+ the config's allowlist carries its disclaimer and why nothing here is live.
+
+**Dividend purification is fenced to Phase B3 — planned, not forgotten.** Purification
+appears above only as a classification input (the ratio the operator attests). The walk the
+fiqh source review implies — corporate actions (dividends, splits) recorded per event as
+they occur (FR-10's recording duty), the purification amount computed against the attested
+ratio under the operator's stated policy, and the disposition (how much, and where it went)
+recorded — is the **B3 slice of this phase** (corporate actions + purification recording).
+Until B3 lands, nothing here computes or records that walk, and a holder of dividend-paying
+candidates carries the purification obligation in their own records.
+
+### Rail 17 (withdrawal capability) for equities
+
+"Can this asset leave this venue?" maps to **transfer-out capability** — for a US brokerage,
+an ACATS transfer to another broker. It is attested like any venue:
+
+```bash
+keel --config config.paper-equities.yaml --db keel-equities.db withdrawals attest --enabled
+```
+
+Rail 17 is a live-state rail (skipped in paper), so this is recorded for the day live is ever
+considered, and it lapses weekly like every deployment's attestation.
+
+### T+1 settlement × daily cadence
+
+US equities settle T+1: sale proceeds become spendable the next business day. On a **daily**
+cadence this is immaterial for entries — the next entry attempt is at least a day after the
+previous buy, by which time it has settled (a weekend makes it longer, never shorter).
+**Exits are never T+1-blocked**: a SELL produces cash rather than spending it, and the engine
+never needs to spend sale proceeds within a cycle. The documented cash-crunch case: on a cash
+account you cannot spend *unsettled* proceeds, so an operator manually redeploying same-day
+sale proceeds (outside the engine, which cycles daily) is the one way to meet the constraint
+— and it is an operator act, not an engine one. The paper profile's synthetic cash does not
+model settlement at all; that honesty is on record for any future live consideration.
+
+### Account posture: cash only — never margin — and the PDT rule
+
+The equities profile runs against a **cash account, never a margin account**. Margin
+borrowing is a loan that charges interest — *riba* — so the posture is categorical, not a
+preference; a cash account also sidesteps the pattern day trader (PDT) rule's $25,000
+minimum-equity requirement, which applies to **margin** accounts only. The PDT rule: FINRA
+flags a **margin** account as a pattern day trader when it executes four or more day trades
+within five business days, and such an account then needs $25,000 of equity to keep day
+trading. A cash account running keel's daily cadence is not that pattern — the rule does not
+bind cash accounts, and in any case keel evaluates a session's bar once and holds overnight
+by construction, so it does not day-trade in the first place; settled-cash funding is what
+makes entries wait for settlement, and that interplay is the T+1 section above (not repeated
+here).
+
+Enforcement in code — the config refusing a margin posture where the venue reports one — is
+issue **#372**'s scope; this runbook carries the operator-facing half now: **create and keep
+the Alpaca account a cash account** (the paper account is one by default), and treat any
+offer to "upgrade" to margin as a posture violation to decline, not a capability to use.
+
+### Operator-verified opt-outs (Alpaca account level)
+
+Two account settings the venue offers conflict with the posture the engine enforces, and
+neither is visible to any rail (no order is placed). **Verify both are OFF in the Alpaca
+dashboard** — under the account's settings, the stock-lending (fully-paid securities lending)
+enrollment and the cash sweep / interest program enrollment:
+
+- **Stock lending is OFF** — lending out held shares conflicts with *qabd* (possession; the
+ engine's own possession rail assumes held means held) and the income is interest-like.
+- **The high-yield cash sweep is OFF** — interest on idle USD is *riba*.
+
+These are operator-verified obligations in the same class as the pre-live checklist's USDC
+Rewards item: account settings no rail can see, re-checked after any account change.
+
+### What is deliberately NOT here
+
+- **`keel/assets` screening venue semantics** stay hardcoded to `coinbase` (`_VENUE` in
+ `keel/cli.py`) — that hardcoding is deliberate pending #233's capability-declaration work
+ on the live path; the paper profile does not need it, and the attestation section above
+ records the consequence.
+- **Deployment to the operator's machine** — needs the operator's Alpaca paper credentials;
+ this section is the bootstrap, and the plist/runner/wrapper are authored for the
+ America/New_York host like every sibling.
+- **Cost fidelity and the DCA benchmark** — Phase C (PRD §6.3): Alpaca's real cost structure
+ (regulatory fees on sells, spread, IEX-vs-SIP data fidelity) is measured and documented
+ before any strategy evaluation on this asset class is believed.
+- **Trademark posture** — unchanged and stated where it lives: the README's standing
+ disclaimer covers Alpaca alongside every other venue, and nothing here duplicates it.
+
## How much money moves
Four settings decide position size and how much can be spent. Three live in `config.yaml`; the
diff --git a/keel-equities b/keel-equities
new file mode 100755
index 0000000..f916c51
--- /dev/null
+++ b/keel-equities
@@ -0,0 +1,27 @@
+#!/bin/bash
+# keel-equities -- run ANY keel command against the EQUITIES paper profile (issue #370 B2),
+# with the config and database pinned together, symmetric with `keel-live`/`keel-paper`/
+# `keel-paperhourly`.
+#
+# Same footgun those wrappers exist to remove: `--db` defaults to keel.db (the daily CRYPTO
+# paper account), so
+# keel --config config.paper-equities.yaml agent
+# without `--db keel-equities.db` would drive the equity rules against the wrong ledger.
+# Config and db must always travel as a pair; here they do.
+#
+# Paper mode places NOTHING real. The rows this profile loads are `paper`-status daily
+# turtle rules with params.granularity="ONE_DAY" on US equities via Alpaca's PAPER API
+# (see docs/operator-runbook.md, "The equities paper profile", for the bootstrap).
+#
+# ./keel-equities status
+# ./keel-equities agent # one daily cycle (same as paper-equities-run.sh)
+# ./keel-equities rules list
+#
+# Authored in the dev repo. DEPLOY (copy) to ~/keel.
+set -euo pipefail
+
+# Resolve the deployment dir from this script's own location, so it works from any cwd.
+DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$DIR"
+
+exec ./.venv/bin/keel --config config.paper-equities.yaml --db keel-equities.db "$@"
diff --git a/keel/agent.py b/keel/agent.py
index 7f4b0ef..3117dd2 100644
--- a/keel/agent.py
+++ b/keel/agent.py
@@ -98,6 +98,19 @@
#: healthy long-running loop over what is usually a transient publication lag.
DATA_NOT_READY_EXIT = 4
+#: `keel agent` (single-cycle, non-`--loop`) exits with this status when the cycle skipped on
+#: `market_clock_unavailable` -- the venue's clock could not be READ (a transient outage: no
+#: network on wake, the clock endpoint erroring), which is a different fact from the venue
+#: ANSWERING closed. It mirrors `DATA_NOT_READY_EXIT`'s contract with a day-stamping wrapper:
+#: `paper-equities-run.sh` stamps the UTC day as done only on a ZERO exit, and stamping a
+#: clock-unavailable skip would record the day as done while nothing was evaluated --
+#: silently losing the trading day to an outage the next trigger would have retried. The
+#: `market_closed` skip deliberately still exits 0: a closed venue is a fact about the
+#: calendar (weekend, holiday), the skip is correct cadence bookkeeping, and nothing more can
+#: happen that day. The `--loop` path never uses this either, for `DATA_NOT_READY_EXIT`'s
+#: reason: a long-running loop just retries next interval.
+MARKET_CLOCK_UNAVAILABLE_EXIT = 5
+
# -- rule reconstruction: DB row (kind, JSON-plain params) -> a real Rule instance -------------
RULE_REGISTRY: dict[str, type[Rule]] = {
diff --git a/keel/cli.py b/keel/cli.py
index 3c91b00..2b4f678 100644
--- a/keel/cli.py
+++ b/keel/cli.py
@@ -51,9 +51,12 @@
always, even when the command errors out or is refused at a confirmation prompt. (Pure-reporting
commands such as `trials *`, `withdrawals show` and `assets list` deliberately omit it.)
-**No live network in tests.** `_build_broker` is the one seam that would construct a real
-`CoinbaseClient` (from `.env` secrets via `coinbase.rest.RESTClient`); tests monkeypatch it
-to inject a fake broker instead, exactly like `tests/test_agent.py`'s `FakeBroker`.
+**No live network in tests.** `_build_broker` is the one seam that would construct a real,
+network-talking broker (a `CoinbaseClient` for the default/absent `broker:` section, or the
+configured venue's adapter otherwise — venue selection, #370 B2); tests monkeypatch it to
+inject a fake broker instead, exactly like `tests/test_agent.py`'s `FakeBroker` (the
+venue-selection branches themselves are driven against fakes and network-free construction
+in `tests/test_paper_equities_profile.py`).
**Module layout.** This file is the composition root: it defines the root `cli` group, the
broker-touching commands (`fetch`, `agent`, `monitor`, `simulate`, `assets`) that share the
@@ -775,12 +778,20 @@ def assets_holdings(ctx: click.Context, min_balance: str, run_screen: bool) -> N
try:
accounts = _build_broker(config).get_accounts()
except Exception as exc: # noqa: BLE001 -- an unreachable venue is an error, not "nothing held"
- # Includes broker CONSTRUCTION, so a missing/!invalid `.env` credential surfaces here
+ # Includes broker CONSTRUCTION, so a missing/invalid `.env` credential surfaces here
# rather than as a raw traceback. Reporting an empty list instead would read as
- # "you hold nothing", which is not what we learned.
+ # "you hold nothing", which is not what we learned. The auth hint names the keys the
+ # CONFIG'S venue actually reads: telling an alpaca operator to check CDP keys sends
+ # them hunting a credential this deployment never uses. Coinbase (the default, and
+ # any venue without dedicated credential wiring) keeps the historical CDP advice.
+ auth_hint = (
+ "ALPACA_API_KEY_ID/ALPACA_API_SECRET_KEY in .env (or the environment)"
+ if config.broker.name == "alpaca"
+ else "CDP_API_KEY/CDP_API_SECRET in .env"
+ )
raise click.ClickException(
f"could not read balances from the broker: {exc}\n"
- " If this is an authentication error, check CDP_API_KEY/CDP_API_SECRET in .env."
+ f" If this is an authentication error, check {auth_hint}."
) from exc
excluded = _FIAT_CURRENCIES | _CASH_EQUIVALENTS | {quote.upper()}
@@ -1738,6 +1749,13 @@ def agent_cmd(
broker, repo, config, now_ts=int(time.time()), confirm_fn=confirm_fn
)
_print_loop_result(result)
+ if result.skipped and result.skip_reason == "market_clock_unavailable":
+ # A clock that could not be READ is a transient outage, not the day's work: exit
+ # nonzero so a day-stamping wrapper (paper-equities-run.sh) declines to stamp and
+ # the next trigger retries -- see `agent.MARKET_CLOCK_UNAVAILABLE_EXIT`'s
+ # docstring. The market_closed skip deliberately falls through to exit 0: stamping
+ # a closed day is correct cadence bookkeeping.
+ ctx.exit(agent.MARKET_CLOCK_UNAVAILABLE_EXIT)
if result.blocked_entries:
# Finding 1 (HIGH): a green exit here is exactly what lets a cron/LaunchAgent
# wrapper stamp the day as done and never retry -- see `agent.DATA_NOT_READY_EXIT`'s
diff --git a/keel/commands/_common.py b/keel/commands/_common.py
index cfb23a0..8d1f6a6 100644
--- a/keel/commands/_common.py
+++ b/keel/commands/_common.py
@@ -34,7 +34,6 @@
from keel.config import Config, load_config
from keel.data.db import connect, migrate
from keel.data.repository import Repository
-from keel.execution.guards import DEFAULT_VENUE
from keel.logging_setup import configure_logging
DISCLAIMER = (
@@ -126,31 +125,92 @@ def _load_cfg(ctx: click.Context) -> Config:
config = replace(config, logging=replace(config.logging, verbose=True))
configure_logging(config.logging)
# Spec §10.2 names `venue` a stable field on every event. Bound once here, at the one
- # process entry point, rather than passed into ~26 `log_event` call sites -- the engine is
- # single-venue today, so threading a constant through every payload would mean revisiting
- # all of them again the moment it stops being one. A process driving several venues rebinds
- # per cycle instead; nothing else changes.
- bind_venue(DEFAULT_VENUE)
+ # process entry point, rather than passed into ~26 `log_event` call sites -- the engine
+ # was single-venue until #370 B2, and threading a constant through every payload would
+ # have meant revisiting all of them again the moment it stopped being one. The venue now
+ # comes from the config's own `broker:` selection (Coinbase for every config that omits
+ # the section, so the bound string is byte-identical to the old `DEFAULT_VENUE`
+ # constant); a process driving several venues rebinds per cycle instead; nothing else
+ # changes.
+ bind_venue(config.broker.name)
return config
-def _build_broker( # pragma: no cover -- exercised only against fakes
+def _build_broker(
config: Config, *, timeout: int | None = None
) -> Any:
- """Construct the real, network-talking `CoinbaseClient`. Tests monkeypatch this function.
-
- `timeout` (seconds) is optional and defaults to `None` -- the SDK's own default (no timeout),
- matching every existing caller (the agent/executor broker path) exactly. Callers that cannot
- tolerate a hung network call (e.g. `keel tui`'s live balance refresh, which must never freeze
- the dashboard) pass an explicit bound.
+ """Construct the real, network-talking broker for the venue `config.broker` selects.
+
+ **Venue selection (issue #370 B2).** The `broker:` config section is the one surface:
+ absent (or `name: coinbase`), this builds exactly what it always built -- a
+ `CoinbaseClient` over a `coinbase.rest.RESTClient` fed by `load_secrets()` -- so every
+ pre-existing config, deployment and test is byte-identical. A named venue resolves
+ through the `keel.brokers` entry points (`keel_broker_api.registry.load_broker`), so
+ installing an adapter is a package install, not a core change; today the CLI knows how
+ to construct CREDENTIALS for one non-Coinbase venue (alpaca: paper/live endpoint, iex/
+ sip feed, `ALPACA_API_KEY_ID`/`ALPACA_API_SECRET_KEY`), and an adapter that resolves but
+ has no wiring is refused by name rather than constructed credential-less.
+
+ Tests monkeypatch this function; the branches are additionally driven against fakes and
+ the real (network-free at construction) Alpaca classes by
+ `tests/test_paper_equities_profile.py`.
+
+ `timeout` (seconds) is optional and defaults to `None` -- the SDK's own default (no
+ timeout), matching every existing caller (the agent/executor broker path) exactly.
+ Callers that cannot tolerate a hung network call (e.g. `keel tui`'s live balance
+ refresh, which must never freeze the dashboard) pass an explicit bound.
"""
- from coinbase.rest import RESTClient
+ venue = config.broker.name
+
+ if venue == "coinbase":
+ from coinbase.rest import RESTClient
- from keel.config import load_secrets
- from keel.data.cb_client import CoinbaseClient
+ from keel.config import load_secrets
+ from keel.data.cb_client import CoinbaseClient
+
+ secrets = load_secrets()
+ transport = RESTClient(
+ api_key=secrets.get("api_key"), api_secret=secrets.get("api_secret"), timeout=timeout
+ )
+ return CoinbaseClient(transport)
- secrets = load_secrets()
- transport = RESTClient(
- api_key=secrets.get("api_key"), api_secret=secrets.get("api_secret"), timeout=timeout
+ # Every other name resolves through the entry points -- the registry is the authority on
+ # which adapters exist, and its LookupError already names what is installed.
+ from keel_broker_api.registry import load_broker
+
+ adapter_cls = load_broker(venue)
+
+ if adapter_cls.__module__.split(".")[0] != "keel_broker_alpaca":
+ raise RuntimeError(
+ f"broker.name {venue!r} resolved to an installed adapter, but the CLI does not "
+ "yet know how to give it credentials -- venue wiring exists for 'coinbase' and "
+ "'alpaca' only. Constructing it anyway would hand the engine a broker that "
+ "cannot reach its venue."
+ )
+
+ from keel.config import load_alpaca_secrets
+
+ secrets = load_alpaca_secrets()
+ if not secrets.get("key_id") or not secrets.get("secret_key"):
+ raise RuntimeError(
+ f"broker {venue!r} needs Alpaca credentials: set ALPACA_API_KEY_ID and "
+ "ALPACA_API_SECRET_KEY in the environment or in .env. Paper keys suffice for the "
+ "paper profile -- generate them from the Alpaca dashboard's paper trading "
+ "account; broker.endpoint selects paper-api.alpaca.markets, and there is no URL "
+ "knob anywhere that could point these at the live venue."
+ )
+
+ from keel_broker_alpaca.transport import AlpacaTransport
+
+ transport = AlpacaTransport(
+ secrets["key_id"] or "",
+ secrets["secret_key"] or "",
+ endpoint=config.broker.endpoint,
+ data_feed=config.broker.data_feed,
+ timeout=10.0 if timeout is None else float(timeout),
+ )
+ # `adapter_cls` IS the class the entry point registered -- constructed through discovery
+ # (not a direct import) so the installed adapter, not a hard dependency, is what runs.
+ return adapter_cls(
+ transport, endpoint=config.broker.endpoint, data_feed=config.broker.data_feed
)
- return CoinbaseClient(transport)
diff --git a/keel/commands/subscription.py b/keel/commands/subscription.py
index e388bd1..df7f160 100644
--- a/keel/commands/subscription.py
+++ b/keel/commands/subscription.py
@@ -13,6 +13,7 @@
import click
from keel_core.subscription import BrokerSubscription, SubscriptionStatus
+from keel_core.telemetry import current_venue
from keel.commands._common import _load_cfg, _open_repo, with_disclaimer
from keel.config import Config
@@ -22,13 +23,30 @@
ATTESTATION_PERIOD_SEC = 365 * 24 * 3600
+def _bound_venue_or_default(venue: str | None) -> str:
+ """An explicit `--venue` wins; otherwise the venue THIS deployment trades.
+
+ That is the same binding rail 14 gates every BUY on -- the one `_load_cfg` makes at
+ process entry for telemetry (`bind_venue(config.broker.name)`) -- with coinbase when
+ nothing is bound. A `--venue` default frozen at coinbase would make an alpaca operator
+ type `--venue alpaca` on every invocation or silently write a record nothing reads.
+
+ Must be called AFTER `_load_cfg(ctx)` has run, or there is nothing bound to read.
+ """
+ if venue is not None:
+ return venue
+ return current_venue() or DEFAULT_VENUE
+
+
@click.group("subscription")
def subscription_group() -> None:
"""View or attest a venue's subscription (the allowance execution.guards rail 14 enforces).
Coinbase exposes no subscription endpoint, so a subscription is *asserted* by the user, not
fetched. `attest` is that assertion. Rail 14 reads the resulting record fresh on every order,
- so an attestation takes effect on the very next one, with no restart.
+ so an attestation takes effect on the very next one, with no restart. An omitted `--venue`
+ means this deployment's bound venue (its `broker:` selection; coinbase when unbound) -- the
+ same key rail 14 gates on, so the default writes the record that will actually be read.
Until a venue is attested, rail 14 caps it at `subscription.unsubscribed_allowance_usd`
(default 0) -- keel ships unable to place a live BUY, deliberately.
@@ -49,7 +67,12 @@ def _resolve_pacing(
@subscription_group.command("attest")
-@click.option("--venue", default=DEFAULT_VENUE, show_default=True, help="Venue to attest.")
+@click.option(
+ "--venue",
+ default=None,
+ help="Venue to attest (default: this config's bound venue -- its `broker:` selection; "
+ "coinbase when unbound).",
+)
@click.option("--tier", "tier_name", required=True, help="Tier name from config.yaml's `tiers`.")
@click.option(
"--pacing",
@@ -60,12 +83,13 @@ def _resolve_pacing(
@click.pass_context
@with_disclaimer
def subscription_attest(
- ctx: click.Context, venue: str, tier_name: str, pacing: str | None
+ ctx: click.Context, venue: str | None, tier_name: str, pacing: str | None
) -> None:
"""Assert which subscription tier this venue is on -- clears `suspect` by asserting a named
tier (`subscription set` also clears it, but names no tier)."""
repo = _open_repo(ctx)
config = _load_cfg(ctx)
+ venue = _bound_venue_or_default(venue)
tier = next((t for t in config.tiers if t.name == tier_name), None)
if tier is None:
@@ -97,7 +121,12 @@ def subscription_attest(
@subscription_group.command("set")
-@click.option("--venue", default=DEFAULT_VENUE, show_default=True, help="Venue to update.")
+@click.option(
+ "--venue",
+ default=None,
+ help="Venue to update (default: this config's bound venue -- its `broker:` selection; "
+ "coinbase when unbound).",
+)
@click.option(
"--free-volume-usd",
"free_volume_raw",
@@ -113,7 +142,7 @@ def subscription_attest(
@click.pass_context
@with_disclaimer
def subscription_set(
- ctx: click.Context, venue: str, free_volume_raw: str, pacing: str | None
+ ctx: click.Context, venue: str | None, free_volume_raw: str, pacing: str | None
) -> None:
"""Set a raw allowance without naming a tier -- an escape hatch, not an attestation.
@@ -122,6 +151,7 @@ def subscription_set(
"""
repo = _open_repo(ctx)
config = _load_cfg(ctx)
+ venue = _bound_venue_or_default(venue)
try:
free_volume_usd = Decimal(free_volume_raw)
@@ -181,10 +211,13 @@ def subscription_show(ctx: click.Context) -> None:
records = repo.list_broker_subscriptions()
if not records:
+ # The advice names the BOUND venue -- the one rail 14 actually gates on for this
+ # deployment -- so the operator's copy-paste writes the record that will be read.
+ venue = _bound_venue_or_default(None)
click.echo(
"no subscription attested for any venue -- rail 14 caps live BUYs at the "
f"unsubscribed allowance {config.subscription.unsubscribed_allowance_usd}. "
- "Run `keel subscription attest --venue coinbase --tier `."
+ f"Run `keel subscription attest --venue {venue} --tier `."
)
return
diff --git a/keel/execution/guards.py b/keel/execution/guards.py
index 7ae5c2e..282c671 100644
--- a/keel/execution/guards.py
+++ b/keel/execution/guards.py
@@ -54,9 +54,11 @@
produces USDC, it doesn't consume it).
- Rail 14 (monthly subscription-allowance) caps this calendar month's live BUY notional (own
spend, from the orders audit log, `_monthly_buy_spend_usd`) plus this order's notional against
- the allowance derived from the venue's **attested subscription record**
- (`repo.get_broker_subscription`, `data/repository.py`) -- read fresh on every `check()` call,
- never cached, so `keel subscription attest` takes effect on the very next order. The cap is
+ the allowance derived from the deployment's bound venue's **attested subscription record**
+ (`repo.get_broker_subscription`, `data/repository.py`; the venue is the one `_load_cfg` binds
+ for telemetry, `coinbase` when nothing is -- see `DEFAULT_VENUE`) -- read fresh on every
+ `check()` call, never cached, so `keel subscription attest` takes effect on the very next
+ order. The cap is
`free_volume_usd` from that record, so upgrading a tier changes exactly one place; it is NOT
typed into config. **Fails closed** like rails 12/13: an unattested venue, a `suspect` or
`lapsed` record, or one whose `attest_due_ts` has passed all fall back to
@@ -109,7 +111,7 @@
from keel_core.products import parse_spot_product_id, quote_currency_of
from keel_core.subscription import SubscriptionStatus
-from keel_core.telemetry import log_event
+from keel_core.telemetry import current_venue, log_event
from keel.config import Config
from keel.data.repository import Repository
@@ -124,10 +126,14 @@
UNCORRELATED_ASSETS = frozenset({"PAXG"}) # gold-backed; not "long crypto beta" (§4.1)
FEED_STALENESS_CYCLES = 3 # rail 12: 3 missed polling cycles = stale feed
-# Rail 14: the engine is single-venue until the broker port lands. `OrderIntent` carries no
-# venue to key on, and inventing one before then would be a guess -- but `broker_subscriptions`
-# is venue-keyed from birth because that costs nothing and is the right shape. This constant is
-# the one line the multi-venue migration deletes (monorepo design spec §8).
+# Rail 14's venue when nothing is bound. The bound venue arrives through the SAME
+# ContextVar binding the CLI makes for telemetry (`_load_cfg` -> `bind_venue(config.broker.name)`,
+# read here via `current_venue()`): one binding at process entry serves both the stamped events
+# and the rail, so they can never disagree about which venue this process is trading -- and
+# guards stays broker-less and config-shape-agnostic (the venue is binding state, not broker
+# state). Unbound (every in-process caller, every pre-existing test) keeps coinbase, the
+# engine's single-venue answer since the rail was born; this constant is that fallback, not
+# the rail's key.
DEFAULT_VENUE = "coinbase"
_ACTIVE_ORDER_STATUSES = ("pending", "filled")
@@ -564,11 +570,17 @@ def check(
# 14. Monthly subscription-allowance — month-to-date live BUY spend + this order must not
# exceed the allowance derived from the venue's *attested* subscription record
# (`repo.get_broker_subscription`), read fresh on every call so an attestation takes
- # effect on the very next order. Fails closed: unattested, suspect, lapsed, or overdue
- # all fall back to `unsubscribed_allowance_usd` (default 0). DCA is NOT exempt -- it is
- # exactly the recurring spend this rail exists to cap (Issue #59).
+ # effect on the very next order. The venue is the DEPLOYMENT'S: the binding `_load_cfg`
+ # makes at process entry (`bind_venue(config.broker.name)` -- the same one telemetry
+ # reads), with coinbase when nothing is bound (see `DEFAULT_VENUE`). Keying on anything
+ # else would gate an alpaca deployment on a coinbase record nothing writes and veto
+ # with advice that sends the operator to attest the wrong venue. Fails closed:
+ # unattested, suspect, lapsed, or overdue all fall back to `unsubscribed_allowance_usd`
+ # (default 0). DCA is NOT exempt -- it is exactly the recurring spend this rail exists
+ # to cap (Issue #59).
if is_buy:
- record = repo.get_broker_subscription(DEFAULT_VENUE)
+ venue = current_venue() or DEFAULT_VENUE
+ record = repo.get_broker_subscription(venue)
unsubscribed = config.subscription.unsubscribed_allowance_usd
if record is None:
@@ -589,7 +601,7 @@ def check(
logger,
logging.WARNING,
"subscription.attestation_overdue",
- venue=DEFAULT_VENUE,
+ venue=venue,
attested_at=record.attested_at,
attest_due_ts=record.attest_due_ts,
)
@@ -629,12 +641,14 @@ def check(
if projected_monthly > effective_cap:
if degraded_reason:
# A user in this state is not over budget -- they have no budget. Telling
- # them "0 exceeds 0" would be true and useless.
+ # them "0 exceeds 0" would be true and useless. The advice names the BOUND
+ # venue: on an alpaca deployment, pointing at coinbase writes a row
+ # nothing reads and leaves every BUY vetoed.
violations.append(
- f"subscription_unattested: {DEFAULT_VENUE} cannot spend because "
+ f"subscription_unattested: {venue} cannot spend because "
f"{degraded_reason}, so its allowance is the unsubscribed default "
f"{unsubscribed}{pacing_note}. Run `keel subscription attest --venue "
- f"{DEFAULT_VENUE} --tier ` to restore it."
+ f"{venue} --tier ` to restore it."
)
else:
remaining = max(effective_cap - monthly_spend, Decimal("0"))
diff --git a/packages/keel-core/keel_core/config.py b/packages/keel-core/keel_core/config.py
index a5a832d..c8a1c87 100644
--- a/packages/keel-core/keel_core/config.py
+++ b/packages/keel-core/keel_core/config.py
@@ -8,6 +8,7 @@
from __future__ import annotations
+import os
import re
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
@@ -304,6 +305,32 @@ class ExecutionConfig:
max_entry_spread_pct: Decimal = Decimal("0.005")
+@dataclass(frozen=True)
+class BrokerConfig:
+ """Which broker adapter this deployment talks to (issue #370 Phase B2, venue selection).
+
+ The section is OPTIONAL, and its absence means Coinbase -- not as a fallback the engine
+ guesses at, but as the statement of how keel has always been built: `_build_broker`'s
+ Coinbase construction predates the section and stays byte-identical when `name` is
+ `"coinbase"`, so every shipped config and test that omits `broker:` behaves exactly as
+ before. A config that NAMES a venue routes through the `keel.brokers` entry points
+ (`keel_broker_api.registry.load_broker`), so installing an adapter is a package install,
+ not a core change.
+
+ `endpoint` ("paper" | "live") and `data_feed` ("iex" | "sip") are validated HERE even
+ though only the Alpaca wiring consumes them today, because they are declared properties
+ of the ADAPTER (FR-11's paper/live host posture, FR-5's data tier), not request-time
+ details: a typo in either should fail at config load, not at the first network call --
+ the same load-time posture the Alpaca adapter itself enforces on its constructor
+ arguments. They are Alpaca's vocabulary; a config that sets them alongside a venue whose
+ wiring has no such knob (Coinbase) is refused rather than silently ignored.
+ """
+
+ name: str = "coinbase"
+ endpoint: str = "paper"
+ data_feed: str = "iex"
+
+
@dataclass(frozen=True)
class Config:
allowlist: list[str]
@@ -343,6 +370,10 @@ class Config:
logging: LoggingConfig = field(default_factory=LoggingConfig)
research: ResearchConfig = field(default_factory=ResearchConfig)
execution: ExecutionConfig = field(default_factory=ExecutionConfig)
+ # Which broker adapter this deployment talks to. Defaulted (not required) so the
+ # pre-existing configs that omit the section parse to the same Coinbase deployment they
+ # always were -- see `BrokerConfig` for why the default is a statement, not a guess.
+ broker: BrokerConfig = field(default_factory=BrokerConfig)
# Only the real, binding caps are required; `max_per_order_usd`/`max_per_day_usd` are optional
@@ -651,6 +682,75 @@ def _parse_research(raw: dict[str, Any]) -> ResearchConfig:
_MAX_ENTRY_SPREAD_PCT_CEILING = Decimal("0.10")
+#: The two Alpaca trading environments an `endpoint:` may name. The vocabulary is exactly
+#: two words because the ADAPTER derives the trading host from it (paper-api vs api) and
+#: accepts no URL of any kind -- so no configuration can point a paper credential at the live
+#: venue (FR-11). Anything else here is a typo that must fail at load.
+_VALID_BROKER_ENDPOINTS = ("paper", "live")
+
+#: The two Alpaca market-data tiers a `data_feed:` may name (FR-5): IEX is the free tier,
+#: SIP the subscribed one. The choice is a DECLARED capability, never an assumption -- the
+#: venue's server-side default is SIP, which silently fails for keys without the
+#: subscription, so the adapter names its feed explicitly and the config names it first.
+_VALID_BROKER_DATA_FEEDS = ("iex", "sip")
+
+
+def _parse_broker(raw: dict[str, Any]) -> BrokerConfig:
+ """Parse `broker:` (issue #370 B2) -- optional; an ABSENT section selects Coinbase
+ unchanged, which is the byte-compatibility contract every pre-existing config and test
+ relies on (see `BrokerConfig`).
+
+ `name` is validated only for shape, not against a venue list: adapters are plugins
+ discovered through the `keel.brokers` entry points, and keel-core cannot know which are
+ installed. A name no adapter answers to fails at broker construction with the registry's
+ own list of what IS installed -- the honest error, from the component that actually
+ knows.
+
+ `endpoint`/`data_feed` are Alpaca wiring keys, and setting them alongside any other
+ venue is refused: the Coinbase construction has no such knob, so a config that sets one
+ there would be silently ignoring an operator's deliberate edit -- the exact
+ dead-knob-in-waiting failure `_parse_settlement_currencies` and friends exist to catch.
+ """
+ broker_raw = raw.get("broker") or {}
+ if not isinstance(broker_raw, dict):
+ raise ConfigError(
+ f"broker: must be a mapping of {{name, endpoint, data_feed}}, got {broker_raw!r}"
+ )
+
+ name = broker_raw.get("name", "coinbase")
+ if not isinstance(name, str) or not name.strip():
+ raise ConfigError(
+ f"broker.name: must be a non-empty venue name (resolved through the keel.brokers "
+ f"entry points), got {name!r}"
+ )
+ name = name.strip()
+
+ endpoint = broker_raw.get("endpoint", "paper")
+ if endpoint not in _VALID_BROKER_ENDPOINTS:
+ raise ConfigError(
+ f"broker.endpoint: invalid value {endpoint!r}; must be one of "
+ f"{_VALID_BROKER_ENDPOINTS!r} -- the trading host is derived from this choice and "
+ f"is never a URL, so a paper credential cannot be pointed at the live venue"
+ )
+
+ data_feed = broker_raw.get("data_feed", "iex")
+ if data_feed not in _VALID_BROKER_DATA_FEEDS:
+ raise ConfigError(
+ f"broker.data_feed: invalid value {data_feed!r}; must be one of "
+ f"{_VALID_BROKER_DATA_FEEDS!r} -- the data tier is a declared capability, not a "
+ f"server-side default to fall back on"
+ )
+
+ if name == "coinbase" and ("endpoint" in broker_raw or "data_feed" in broker_raw):
+ raise ConfigError(
+ f"broker.name: {name!r} has no endpoint/data_feed wiring -- those keys select the "
+ "Alpaca trading host and market-data tier and are silently ignored by every other "
+ "venue. Remove them, or set broker.name: alpaca."
+ )
+
+ return BrokerConfig(name=name, endpoint=endpoint, data_feed=data_feed)
+
+
def _parse_execution(raw: dict[str, Any]) -> ExecutionConfig:
"""Parse `execution:` -- optional, falls back to `ExecutionConfig`'s defaults.
@@ -849,6 +949,7 @@ def load_config(path: str | Path) -> Config:
logging=_parse_logging(raw),
research=_parse_research(raw),
execution=_parse_execution(raw),
+ broker=_parse_broker(raw),
)
@@ -870,6 +971,34 @@ def load_secrets(env_path: str | Path = ".env") -> dict:
return {"api_key": api_key, "api_secret": api_secret}
+def load_alpaca_secrets(env_path: str | Path = ".env") -> dict:
+ """Load Alpaca API credentials from the environment or a git-ignored `.env` file.
+
+ Follows `load_secrets`' shape contract exactly -- `{"key_id": ..., "secret_key": ...}`
+ when both are present, `{}` when neither is, so the caller (venue selection in
+ `_build_broker`) owns the venue-specific "how to fix this" message rather than this
+ loader guessing at one. Two deliberate divergences from `load_secrets`, both stated:
+
+ * The REAL environment is read as well as the file (environment first), so a deployment
+ can inject the paper keys without a `.env` at all. `load_secrets` predates multi-venue
+ support and stays file-only for byte-compatibility; a new loader gets the honest
+ both-sources semantics.
+ * The names are the venue's own (`ALPACA_API_KEY_ID`/`ALPACA_API_SECRET_KEY`, the two
+ headers Alpaca's API documents), namespaced by venue so one deployment's `.env` can
+ hold credentials for several adapters without collisions.
+ """
+ values = dotenv_values(Path(env_path)) if Path(env_path).exists() else {}
+
+ def _read(name: str) -> str | None:
+ return os.environ.get(name) or values.get(name)
+
+ key_id = _read("ALPACA_API_KEY_ID")
+ secret_key = _read("ALPACA_API_SECRET_KEY")
+ if not key_id and not secret_key:
+ return {}
+ return {"key_id": key_id, "secret_key": secret_key}
+
+
__all__ = [
"ConfigError",
"NON_BINDING_CAP_USD",
@@ -887,7 +1016,9 @@ def load_secrets(env_path: str | Path = ".env") -> dict:
"ResearchConfig",
"ExecutionConfig",
"FeesConfig",
+ "BrokerConfig",
"Config",
"load_config",
"load_secrets",
+ "load_alpaca_secrets",
]
diff --git a/paper-equities-run.sh b/paper-equities-run.sh
new file mode 100755
index 0000000..f137d97
--- /dev/null
+++ b/paper-equities-run.sh
@@ -0,0 +1,90 @@
+#!/bin/bash
+# Daily equities paper runner -- ~/keel deployment (keel release install), issue #370 B2.
+# One agent cycle per UTC DAY in PAPER mode against Alpaca's PAPER API; it places NOTHING
+# real. Drives the daily turtle rules (rows stored with params.granularity="ONE_DAY") on US
+# equities in their OWN database, keel-equities.db, pinned to config.paper-equities.yaml.
+#
+# Self-contained: calls the deployment's own venv binary, so no `uv`/asdf PATH is needed.
+# Lives OUTSIDE ~/Documents for the same TCC reason as the sibling runners (launchd-spawned
+# processes are not granted access to ~/Documents; home root is not TCC-protected).
+#
+# EXACTLY ONCE PER **UTC DAY**. The stamp is the UTC date (`date -u '+%Y-%m-%d'`) because
+# the venue's own daily bars are keyed to the UTC session date: Alpaca stamps a session's
+# ONE_DAY bar inside that session's UTC day, so "today's UTC date" is the trading-day
+# identity year-round on an Eastern host, and the 19:00/20:00 local UTC rollover is always
+# AFTER the window below. Every trigger that finds no stamp for the UTC day runs the cycle
+# and stamps it; later triggers the same UTC day are no-ops. The next day always needs, and
+# gets, its own cycle.
+#
+# THE WINDOW GUARD, and why this runner refuses to run outside its window -- 10:00 inclusive
+# to 16:00 exclusive, LOCAL hours (the code reads: hour >= 10 and hour < 16, so the 15:00
+# trigger runs). The US regular session is 09:30 to 16:00 ET and the engine's session gate
+# (#370 B1) skips the WHOLE cycle whenever the venue clock answers closed, exiting 0. So:
+# - BEFORE 10:00 (pre-open boots, RunAtLoad on an early login): a cycle would skip closed
+# and exit 0, and stamping that skip would record the day as done and suppress the real
+# evaluation at 10:00. Refuse, run nothing, stamp nothing.
+# - AT/AFTER 16:00 (after the close): same closed-market skip, same false stamp. Refuse.
+# The plist's six triggers (10:00-15:00 local, on the hour) sit inside the session by
+# construction; the guard exists for RunAtLoad and any manual cron that misses the point.
+# Known edge, documented not fixed: an early-close half-day (13:00 ET close) whose morning
+# cycles all failed could have the day stamped by a 14:00 closed-skip -- a paper-evidence
+# gap, visible in the log, never a money event.
+#
+# SESSION-AWARENESS COMES FREE from B1: weekends and holidays need no calendar here. The
+# agent itself reads the venue clock, records the session (which keeps `fetch --check`
+# non-alerting through the weekend) and skips with reason market_closed; that skip exits 0
+# and stamping it is CORRECT cadence bookkeeping -- nothing more can happen that day.
+#
+# THE TWO SKIP KINDS ARE STAMPED DIFFERENTLY. A clock that cannot be READ (no network on
+# wake, the clock endpoint erroring) is not the day's work either: the agent exits
+# MARKET_CLOCK_UNAVAILABLE_EXIT (nonzero) for exactly that skip, so `set -e` below stops
+# this script short of the stamp and the next trigger retries once the clock answers --
+# a transient outage costs an hour of delay, never the trading day.
+#
+# The stamp is written only AFTER a successful cycle (`set -e`), so a failed run (no network
+# on wake, the venue late publishing the bar, blocked entries or an unreadable clock
+# returning nonzero) is retried by the next trigger rather than being recorded as done. Note
+# what a retry can and cannot do: it evaluates the NEWEST closed bar at its own time, so a
+# day whose cycle failed through is covered by a later retry only in what that later
+# evaluation sees.
+#
+# Authored in the dev repo. DEPLOY (copy) to ~/keel and schedule via
+# com.keel.paper-equities.plist.
+set -euo pipefail
+
+DIR="/Users/elmehdiaitbrahim/keel"
+cd "$DIR"
+
+STAMP="$DIR/logs/.paper-equities-last-run"
+WINDOW_START_HOUR=10
+WINDOW_END_HOUR=16
+
+TODAY="$(date -u '+%Y-%m-%d')"
+# 10# forces base 10: `date +%H` yields 08/09, which arithmetic would otherwise read as octal.
+HOUR="$((10#$(date '+%H')))"
+STAMPED="$(cat "$STAMP" 2>/dev/null || true)"
+
+if [ "$STAMPED" = "$TODAY" ]; then
+ printf '%s [paper-equities] cycle already ran this UTC day (%s) -- skipping\n' \
+ "$(date '+%Y-%m-%d %H:%M')" "$TODAY"
+ exit 0
+fi
+
+if [ "$HOUR" -lt "$WINDOW_START_HOUR" ]; then
+ printf '%s [paper-equities] before %02d:00 local -- the session has not been evaluated yet; leaving the day to the scheduled run\n' \
+ "$(date '+%Y-%m-%d %H:%M')" "$WINDOW_START_HOUR"
+ exit 0
+fi
+
+if [ "$HOUR" -ge "$WINDOW_END_HOUR" ]; then
+ printf '%s [paper-equities] at/after %02d:00 local -- the US session is closed; not running or stamping\n' \
+ "$(date '+%Y-%m-%d %H:%M')" "$WINDOW_END_HOUR"
+ exit 0
+fi
+
+# One cycle per UTC day; the LaunchAgent supplies the cadence and the retries. Paper mode,
+# daily rules on equities, the profile's own database. A failure here stops short of the
+# stamp (`set -e`), so the next trigger retries.
+./.venv/bin/keel --config config.paper-equities.yaml --db keel-equities.db agent
+
+printf '%s\n' "$TODAY" > "$STAMP"
diff --git a/tests/compliance/test_assets_cli.py b/tests/compliance/test_assets_cli.py
index 400e5ef..0e1a4a1 100644
--- a/tests/compliance/test_assets_cli.py
+++ b/tests/compliance/test_assets_cli.py
@@ -15,6 +15,7 @@
from keel.data.db import connect, migrate
from keel.data.repository import Repository
from keel.types import Candle, Granularity
+from tests.conftest import VALID_CONFIG_YAML
_DAY = 86400
@@ -730,6 +731,43 @@ def test_a_broker_failure_is_an_ERROR_not_an_empty_clean_result(
assert "unreachable" in result.output.lower() or "error" in result.output.lower()
+def test_holdings_auth_advice_names_the_coinbase_env_vars(
+ tmp_path, valid_config_path, monkeypatch
+):
+ """The auth hint is actionable only if it names the keys THIS deployment reads: on the
+ default (coinbase) config that is the CDP pair -- the historical advice, unchanged."""
+ db_path = tmp_path / "t.db"
+ _repo_at(db_path)
+ _with_broker(monkeypatch, _FakeBroker([], fail=True))
+
+ result = _holdings(db_path, valid_config_path)
+
+ assert result.exit_code != 0
+ assert "CDP_API_KEY" in result.output
+ assert "CDP_API_SECRET" in result.output
+ assert "ALPACA_API_KEY_ID" not in result.output
+
+
+def test_holdings_auth_advice_names_the_alpaca_env_vars(tmp_path, write_config, monkeypatch):
+ """The alpaca mirror: an operator whose `broker:` section selects alpaca never reads a
+ CDP credential, so the advice must point at the Alpaca pair instead (#386 review: advice
+ that names the wrong venue's keys sends the operator hunting a credential this
+ deployment never uses)."""
+ db_path = tmp_path / "t.db"
+ _repo_at(db_path)
+ config_path = write_config(
+ VALID_CONFIG_YAML + "\nbroker:\n name: alpaca\n endpoint: paper\n data_feed: iex\n"
+ )
+ _with_broker(monkeypatch, _FakeBroker([], fail=True))
+
+ result = _holdings(db_path, config_path)
+
+ assert result.exit_code != 0
+ assert "ALPACA_API_KEY_ID" in result.output
+ assert "ALPACA_API_SECRET_KEY" in result.output
+ assert "CDP_API_KEY" not in result.output
+
+
def test_holdings_marks_assets_already_on_the_allowlist(
tmp_path, valid_config_path, monkeypatch
):
diff --git a/tests/execution/test_guards.py b/tests/execution/test_guards.py
index 130cf36..d0a3710 100644
--- a/tests/execution/test_guards.py
+++ b/tests/execution/test_guards.py
@@ -16,6 +16,7 @@
import pytest
from keel_core.products import parse_spot_product_id
from keel_core.subscription import SubscriptionStatus
+from keel_core.telemetry import bind_venue, unbind_venue
from keel.config import (
DEFAULT_SETTLEMENT_CURRENCIES,
@@ -876,6 +877,59 @@ def test_rail14_unattested_opportunistic_pacing_ignores_the_business_day_pace()
assert result.violations == []
+# -- rail 14: keyed on the DEPLOYMENT'S bound venue, not the hardcoded default (#386 review) -----
+
+
+def test_rail14_reads_the_bound_venues_record_not_the_default_constants() -> None:
+ """On an alpaca deployment (`broker: {name: alpaca}` in config.yaml) the attested record
+ is alpaca-keyed; a rail that kept reading the coinbase slot would gate every BUY on a
+ record nothing writes -- out of the box that is a $0 allowance and a full veto, on the
+ wrong venue, forever. The venue arrives through the SAME binding `_load_cfg` makes for
+ telemetry (`bind_venue(config.broker.name)`), so rail 14 and the stamped events can never
+ disagree about which venue this process is trading."""
+ repo = _unattested_repo()
+ # Alpaca is attested and roomy; coinbase (the rail's historical key) is NOT.
+ attest_subscription(repo, now_ts=NOW_TS, free_volume_usd=_LARGE_ALLOWANCE, venue="alpaca")
+ token = bind_venue("alpaca")
+ try:
+ result = guards.check(_intent(notional=Decimal("50")), repo, _roomy_config(), NOW_TS)
+ finally:
+ unbind_venue(token)
+
+ assert result.ok, f"rail 14 read a venue other than the bound one: {result.violations}"
+
+
+def test_rail14_unattested_veto_names_the_bound_venue_in_its_advice() -> None:
+ """The veto's advice is actionable only if it names the venue the operator must attest:
+ on an unattested alpaca deployment, telling them to `attest --venue coinbase` writes a
+ row nothing reads and leaves every BUY vetoed -- the operator follows the instruction and
+ nothing changes."""
+ repo = _unattested_repo()
+ token = bind_venue("alpaca")
+ try:
+ result = guards.check(_intent(notional=Decimal("50")), repo, _roomy_config(), NOW_TS)
+ finally:
+ unbind_venue(token)
+
+ violation = next(v for v in result.violations if v.startswith("subscription_unattested"))
+ assert "alpaca" in violation
+ assert "--venue alpaca" in violation
+ assert "coinbase" not in violation
+
+
+def test_rail14_with_no_venue_bound_still_reads_coinbase() -> None:
+ """The compatibility pin: nothing bound (every in-process caller, every pre-existing
+ test) keeps coinbase as the answer -- even when some OTHER venue's record exists in the
+ repo. A rail that keyed on any attested record it could find would spend an alpaca
+ allowance on a coinbase deployment."""
+ repo = _unattested_repo()
+ attest_subscription(repo, now_ts=NOW_TS, free_volume_usd=_LARGE_ALLOWANCE, venue="alpaca")
+
+ result = guards.check(_intent(notional=Decimal("50")), repo, _roomy_config(), NOW_TS)
+
+ assert "subscription_unattested" in _keys(result)
+
+
def test_rail14_reads_pacing_from_the_record_not_config(repo: Repository) -> None:
"""even_daily paces the attested allowance across elapsed business days."""
_attest(repo, free_volume_usd=Decimal("10000"), pacing="even_daily")
diff --git a/tests/fixtures/config_golden_defaults.json b/tests/fixtures/config_golden_defaults.json
index f70e6f6..5b02c97 100644
--- a/tests/fixtures/config_golden_defaults.json
+++ b/tests/fixtures/config_golden_defaults.json
@@ -7,6 +7,11 @@
"interval_sec": 900,
"mode": "paper"
},
+ "broker": {
+ "data_feed": "iex",
+ "endpoint": "paper",
+ "name": "coinbase"
+ },
"caps": {
"max_exposure_usd": "1000",
"max_per_asset_pct": "0.5",
diff --git a/tests/fixtures/config_golden_full.json b/tests/fixtures/config_golden_full.json
index 79d1b19..9e65b12 100644
--- a/tests/fixtures/config_golden_full.json
+++ b/tests/fixtures/config_golden_full.json
@@ -9,6 +9,11 @@
"interval_sec": 1800,
"mode": "confirm"
},
+ "broker": {
+ "data_feed": "sip",
+ "endpoint": "live",
+ "name": "alpaca"
+ },
"caps": {
"max_exposure_usd": "4444.44",
"max_per_asset_pct": "0.55",
diff --git a/tests/fixtures/config_golden_full.yaml b/tests/fixtures/config_golden_full.yaml
index 1a7ad78..f801a5e 100644
--- a/tests/fixtures/config_golden_full.yaml
+++ b/tests/fixtures/config_golden_full.yaml
@@ -106,3 +106,11 @@ research:
# Non-default on purpose (the default is 0.005 = 50bp) -- see the `research` note above.
execution:
max_entry_spread_pct: 0.0075
+
+# Venue selection (issue #370 B2). Non-default on purpose (the default name is coinbase,
+# paper, iex) so `test_the_two_goldens_actually_differ` proves this section really parses
+# rather than falling through to its defaults.
+broker:
+ name: alpaca
+ endpoint: live
+ data_feed: sip
diff --git a/tests/test_cli.py b/tests/test_cli.py
index c2bd084..c334e06 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -288,6 +288,76 @@ def test_agent_loop_does_not_exit_the_process_when_a_cycle_is_blocked(
assert "blocked=1" in result.output
+# -- agent: the market-clock skip exits must distinguish unavailable from closed (#386 review) ---
+
+
+class _SessionClockCLIBroker(FakeBroker):
+ """The CLI-side sibling of `test_agent.py::_SessionClockBroker`: a fake that also answers
+ the broker port's session surface, so the single-cycle exit code can be driven through the
+ real `keel agent` entrypoint."""
+
+ def __init__(self, clock_answer: Any) -> None:
+ super().__init__()
+ self._clock_answer = clock_answer
+
+ def capabilities(self) -> Any:
+ return SimpleNamespace(session_bound=True, venue="alpaca")
+
+ def market_clock(self) -> Any:
+ if isinstance(self._clock_answer, Exception):
+ raise self._clock_answer
+ return self._clock_answer
+
+
+def test_agent_exits_clock_unavailable_when_the_session_clock_cannot_be_read(
+ tmp_path, valid_config_path, monkeypatch
+):
+ """A single-cycle `keel agent` that skipped because the venue clock could not be READ
+ (a transient outage: no network on wake, the clock endpoint erroring) must not exit `0`:
+ a green exit is exactly what lets `paper-equities-run.sh` stamp the UTC day as done, which
+ silently loses the trading day to a skip that carried no information. Mirrors
+ `DATA_NOT_READY_EXIT`'s contract; see `agent.MARKET_CLOCK_UNAVAILABLE_EXIT`."""
+ monkeypatch.setattr(
+ cli_module, "_build_broker", lambda config: _SessionClockCLIBroker(
+ SessionState.CLOCK_UNAVAILABLE
+ )
+ )
+ db_path = tmp_path / "test.db"
+ _repo_at(db_path).set_state("kill_switch", False)
+ runner = CliRunner()
+
+ result = runner.invoke(
+ cli, ["--db", str(db_path), "--config", str(valid_config_path), "agent"]
+ )
+
+ assert result.exit_code == agent.MARKET_CLOCK_UNAVAILABLE_EXIT, result.output
+ assert "skipped: market_clock_unavailable" in result.output
+
+
+def test_agent_still_exits_zero_on_a_market_closed_skip(
+ tmp_path, valid_config_path, monkeypatch
+):
+ """The other skip kind, and the reason the two need distinct treatment: a CLOSED venue
+ (weekend, holiday) is a fact about the calendar, the skip is correct cadence bookkeeping
+ -- nothing more can happen that day -- and stamping it is right. Only the degraded
+ clock read must decline to stamp."""
+ monkeypatch.setattr(
+ cli_module, "_build_broker", lambda config: _SessionClockCLIBroker(
+ SessionState.CLOSED
+ )
+ )
+ db_path = tmp_path / "test.db"
+ _repo_at(db_path).set_state("kill_switch", False)
+ runner = CliRunner()
+
+ result = runner.invoke(
+ cli, ["--db", str(db_path), "--config", str(valid_config_path), "agent"]
+ )
+
+ assert result.exit_code == 0, result.output
+ assert "skipped: market_closed" in result.output
+
+
diff --git a/tests/test_cli_subscription.py b/tests/test_cli_subscription.py
index 5dc4f4c..00be94e 100644
--- a/tests/test_cli_subscription.py
+++ b/tests/test_cli_subscription.py
@@ -18,9 +18,17 @@
from keel.cli import cli
from keel.data.db import connect, migrate
from keel.data.repository import Repository
+from tests.conftest import VALID_CONFIG_YAML
ONE_YEAR = 31_536_000
+ALPACA_BROKER_YAML = """
+broker:
+ name: alpaca
+ endpoint: paper
+ data_feed: iex
+"""
+
def _repo_at(db_path: Path) -> Repository:
conn = connect(str(db_path))
@@ -210,3 +218,67 @@ def test_show_reports_an_overdue_record_as_suspect(
result = _run(db_path, valid_config_path, "subscription", "show")
assert "effective_status=suspect" in result.output
+
+
+# -- the venue default follows the config's bound venue (#386 review) ---------------------------
+#
+# Rail 14 gates every BUY on the DEPLOYMENT'S venue record (the binding `_load_cfg` makes via
+# `bind_venue(config.broker.name)`). A `--venue` default frozen at coinbase would make an
+# alpaca operator type `--venue alpaca` on every invocation or silently write a coinbase row
+# that nothing reads.
+
+
+def test_attest_without_venue_defaults_to_the_configs_bound_venue(
+ tmp_path: Path, write_config
+) -> None:
+ """On an alpaca config, `subscription attest` with no `--venue` must write the
+ alpaca-keyed record -- the one rail 14 actually consults on that deployment."""
+ db_path = tmp_path / "keel.db"
+ config_path = write_config(VALID_CONFIG_YAML + ALPACA_BROKER_YAML)
+
+ result = _run(db_path, config_path, "subscription", "attest", "--tier", "Basic")
+
+ assert result.exit_code == 0, result.output
+ repo = _repo_at(db_path)
+ assert repo.get_broker_subscription("alpaca") is not None
+ assert repo.get_broker_subscription("coinbase") is None, "nothing bound writes coinbase"
+
+
+def test_set_without_venue_defaults_to_the_configs_bound_venue(
+ tmp_path: Path, write_config
+) -> None:
+ db_path = tmp_path / "keel.db"
+ config_path = write_config(VALID_CONFIG_YAML + ALPACA_BROKER_YAML)
+
+ result = _run(db_path, config_path, "subscription", "set", "--free-volume-usd", "500")
+
+ assert result.exit_code == 0, result.output
+ repo = _repo_at(db_path)
+ assert repo.get_broker_subscription("alpaca") is not None
+ assert repo.get_broker_subscription("coinbase") is None
+
+
+def test_attest_without_venue_defaults_to_coinbase_when_nothing_is_bound(
+ db_path: Path, valid_config_path: Path
+) -> None:
+ """The compatibility pin: a config without a `broker:` section binds coinbase, so the
+ historical default is what an omitted `--venue` still means there."""
+ result = _run(db_path, valid_config_path, "subscription", "attest", "--tier", "Basic")
+
+ assert result.exit_code == 0, result.output
+ repo = _repo_at(db_path)
+ assert repo.get_broker_subscription("coinbase") is not None
+ assert repo.get_broker_subscription("alpaca") is None
+
+
+def test_show_names_the_bound_venue_in_its_empty_advice(tmp_path: Path, write_config) -> None:
+ """The fresh-database advice must point at the venue THIS deployment trades, or the
+ operator's copy-paste attests a record nothing reads."""
+ db_path = tmp_path / "keel.db"
+ config_path = write_config(VALID_CONFIG_YAML + ALPACA_BROKER_YAML)
+
+ result = _run(db_path, config_path, "subscription", "show")
+
+ assert result.exit_code == 0, result.output
+ assert "--venue alpaca" in result.output
+ assert "--venue coinbase" not in result.output
diff --git a/tests/test_paper_equities_profile.py b/tests/test_paper_equities_profile.py
new file mode 100644
index 0000000..3516e38
--- /dev/null
+++ b/tests/test_paper_equities_profile.py
@@ -0,0 +1,759 @@
+"""The EQUITIES paper profile's tracked assets + the venue-selection wiring it needs (#370 B2).
+
+Two things are pinned here, and they ship together because one cannot run without the other:
+
+1. The profile's deployment assets -- `config.paper-equities.yaml` + `com.keel.paper-equities
+ .plist` + `paper-equities-run.sh` + `keel-equities` -- tracked in-repo exactly like the
+ paperforward/live/paper-hourly ones. Nothing about them is executed by the suite's code
+ paths, so like `tests/test_paper_hourly_profile.py` this file pins them against drift.
+2. The MINIMAL engine wiring the profile needs: config-driven venue selection. Today
+ `_build_broker` constructs a `CoinbaseClient` unconditionally; this profile is the first
+ that must reach a different adapter (`keel-broker-alpaca`, paper host, IEX feed). The
+ `broker:` config section is that surface, and its ABSENCE must leave the Coinbase
+ construction path byte-identical -- pinned here by construction, not by assertion of
+ intent, because every existing profile and test depends on that default.
+
+The runner tests execute the REAL script verbatim through a harness that shims `date` (so
+they do not depend on, or wait on, the wall clock) and stubs the deployment's `.venv/bin/keel`.
+The shim renders "local" time in America/New_York ON PURPOSE: the script's window guard is
+ET-anchored (the US regular session is 09:30 to 16:00 ET and the deployment host lives in
+that zone), and fixing the shim's zone keeps these tests deterministic on any host rather
+than only on an Eastern one.
+"""
+
+from __future__ import annotations
+
+import os
+import plistlib
+import re
+import stat
+import subprocess
+from datetime import UTC, datetime
+from decimal import Decimal
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import pytest
+
+from keel.config import ConfigError, load_config
+from keel.types import Granularity
+
+from .conftest import VALID_CONFIG_YAML
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+CONFIG = REPO_ROOT / "config.paper-equities.yaml"
+PLIST = REPO_ROOT / "com.keel.paper-equities.plist"
+RUN_SCRIPT = REPO_ROOT / "paper-equities-run.sh"
+WRAPPER = REPO_ROOT / "keel-equities"
+RUNBOOK = REPO_ROOT / "docs" / "operator-runbook.md"
+ENV_EXAMPLE = REPO_ROOT / ".env.example"
+
+ET = ZoneInfo("America/New_York")
+
+# The five paper candidates (see the config's header for the disclaimer that governs them):
+# liquid US large caps chosen so a screen COULD be run on them, not ones that have.
+CANDIDATES = ["MSFT", "AAPL", "GOOGL", "NVDA", "COST"]
+
+
+def _et(year: int, month: int, day: int, hour: int, minute: int) -> datetime:
+ """A wall-clock instant in the deployment's zone, for the date shim to render."""
+ return datetime(year, month, day, hour, minute, tzinfo=ET)
+
+
+# -- the config: alpaca paper, ONE_DAY only, daily cadence --------------------------------------
+
+
+def test_config_selects_the_alpaca_paper_venue():
+ """The load-bearing difference from every sibling profile: `broker:` selects Alpaca's
+ PAPER host with the IEX feed. `endpoint: paper` is the whole point -- there is no URL
+ knob anywhere that could point a paper credential at the live venue (FR-11)."""
+ config = load_config(str(CONFIG))
+
+ assert config.broker.name == "alpaca"
+ assert config.broker.endpoint == "paper"
+ assert config.broker.data_feed == "iex"
+ assert config.auto_trade.mode == "paper"
+
+
+def test_config_is_five_flat_paper_candidates_summing_to_one():
+ """Five liquid US large caps at a FLAT 20% each. Flatness states no view -- it is the
+ sizing half of the same guardrail logic as the hourly profile's flat Tier-2 caps, applied
+ to an asset class nothing has been measured on. If this fails because the set moved, move
+ the CANDIDATES list with it or say why here."""
+ config = load_config(str(CONFIG))
+
+ assert config.allowlist == CANDIDATES
+ assert set(config.target_weights) == set(CANDIDATES)
+ assert all(w == Decimal("0.2") for w in config.target_weights.values())
+ assert sum(config.target_weights.values()) == Decimal("1")
+ assert config.risk_pct == Decimal("0.01")
+
+
+def test_config_trades_the_daily_clock_on_one_day_bars_only():
+ """A daily-clock equities profile: ONE_DAY is the ONLY granularity (hourly bars exist
+ only within sessions and daily turtle rules do not read them) and the cadence is 86400s,
+ which also scales the staleness window that B1's session awareness reads closed-explained
+ through."""
+ config = load_config(str(CONFIG))
+
+ assert config.market_data.granularities == [Granularity.ONE_DAY]
+ assert config.market_data.history_days == 365
+ assert config.auto_trade.interval_sec == 86400
+
+
+def test_config_states_the_candidate_disclaimer_and_the_no_edge_caveat():
+ """Two honesty requirements, both in the header so they are read BEFORE the numbers:
+ the allowlist is PAPER CANDIDATES whose classification is operator-attested per
+ (alpaca, SYMBOL) -- the engine never classifies and the file asserts nothing religiously
+ -- and the paper-hourly-style caveat that there is no proven edge on ANY asset class, so
+ the profile exists for evidence, not profit."""
+ text = CONFIG.read_text()
+ assert "PAPER CANDIDATES" in text
+ assert "OPERATOR-ATTESTED" in text
+ assert "(alpaca, SYMBOL)" in text
+ assert "asserts nothing religiously" in text
+ assert "NO PROVEN EDGE" in text
+ assert "ADMISSIBLE EVIDENCE" in text
+ assert "not profit" in text
+ assert "keel-equities.db" in text
+
+
+def test_config_explains_why_one_day_only():
+ """The granularity choice must carry its reason where the next editor meets it: hourly
+ bars exist only within sessions and daily rules do not need them."""
+ text = CONFIG.read_text()
+ assert "ONE_DAY ONLY" in text
+ assert "within sessions" in text
+
+
+# -- the plist: one cycle per day, inside the US regular session --------------------------------
+
+
+def _plist() -> dict:
+ return plistlib.loads(PLIST.read_bytes())
+
+
+def test_plist_is_well_formed_xml():
+ """Same requirement as every sibling plist: parse with a STRICT parser. XML forbids a
+ double hyphen inside a comment, this repo's prose puts one in every other sentence, and
+ Apple's lenient parser accepts it -- so a malformed file would ship silently."""
+ _plist()
+
+
+def test_plist_fires_hourly_through_the_regular_session_window():
+ """Six triggers, at 10:00-15:00 local (ET) on the hour -- every one INSIDE the US regular
+ session (09:30 to 16:00 ET). Deliberately NOT shortly after the 16:00 close: B1's session
+ gate skips the whole cycle whenever the venue clock says closed, so an after-close
+ trigger would log market_closed and never evaluate a bar. The just-closed daily bar is
+ evaluated at the NEXT session's open -- the standard daily-system semantics (signal on
+ close, execute next open) -- and the 10:00 anchor gives the 09:30 open thirty minutes to
+ settle. The extra triggers are catch-up breadth, not extra cycles: the runner is
+ day-stamped."""
+ data = _plist()
+ triggers = [(entry["Hour"], entry["Minute"]) for entry in data["StartCalendarInterval"]]
+ assert triggers == [(hour, 0) for hour in range(10, 16)]
+
+
+def test_plist_still_runs_at_load():
+ """A boot inside the window must run the day's cycle immediately rather than wait for
+ the next trigger; the runner's day-stamp makes a repeated load harmless. A boot OUTSIDE
+ the window is refused by the runner's own guard (an early-morning closed-market skip
+ exits 0 and must not be allowed to stamp the day as done)."""
+ assert _plist()["RunAtLoad"] is True
+
+
+def test_plist_points_at_the_equities_runner_in_the_deployment_dir():
+ data = _plist()
+ assert data["Label"] == "com.keel.paper-equities"
+ assert data["ProgramArguments"] == [
+ "/bin/bash",
+ "/Users/elmehdiaitbrahim/keel/paper-equities-run.sh",
+ ]
+ assert data["WorkingDirectory"] == "/Users/elmehdiaitbrahim/keel"
+
+
+def test_plist_documents_its_schedule_reasoning():
+ """The ET reasoning, the after-open-not-after-close decision, and the DST caveat must
+ live in the plist's comment block where the next reader of the file will meet them."""
+ text = PLIST.read_text()
+ assert "09:30" in text
+ assert "ET" in text
+ assert "DST" in text
+ assert "America/New_York" in text
+ # The decision that contradicts the obvious copy-paste from a close-anchored schedule:
+ assert "session gate" in text or "market_closed" in text
+
+
+def test_plist_dst_caveat_is_honest_about_non_et_hosts():
+ """The caveat must not overstate safety: on a host far enough ahead of ET every trigger
+ can fire pre-open, PASS the runner's local-hours guard, and stamp a closed-market skip
+ as the day's work -- permanently zero evidence. The plist must say the schedule is only
+ correct on an ET-anchored host, that other hosts re-anchor the trigger hours to land
+ 10:00-15:00 ET, and that the local-hours guard is a backstop, not a drift absorber."""
+ text = PLIST.read_text()
+ assert "ET-anchored" in text
+ assert "re-anchor" in text
+ assert "backstop" in text
+
+
+# -- the runner: exactly once per UTC day, only inside the session window ------------------------
+
+
+def _install_date_shim(bin_dir: Path) -> None:
+ """A `date` on PATH that reads its instant from `$KEEL_TEST_NOW` (epoch seconds) instead
+ of the wall clock, rendering LOCAL time in America/New_York (the deployment host's zone
+ -- see the module docstring for why the shim's zone is fixed rather than the host's).
+ Pure Python rather than a shell wrapper around `date -r`, so the harness is portable.
+ Honours the invocation forms `paper-equities-run.sh` actually uses: `date [-u] '+FORMAT'`.
+ """
+ bin_dir.mkdir(parents=True, exist_ok=True)
+ shim = bin_dir / "date"
+ shim.write_text(
+ "#!/usr/bin/env python3\n"
+ "import os, sys, time\n"
+ "from datetime import datetime as dt\n"
+ "from zoneinfo import ZoneInfo\n"
+ "now = int(os.environ.get('KEEL_TEST_NOW', time.time()))\n"
+ "args = sys.argv[1:]\n"
+ "utc = '-u' in args\n"
+ "# the leading '+' is date(1)'s format-string prefix, not part of the format\n"
+ "fmt = next(a for a in args if a.startswith('+'))[1:]\n"
+ "t = dt.fromtimestamp(now, tz=__import__('datetime').timezone.utc)\n"
+ "if not utc:\n"
+ " t = t.astimezone(ZoneInfo('America/New_York'))\n"
+ "print(t.strftime(fmt))\n"
+ )
+ shim.chmod(shim.stat().st_mode | stat.S_IEXEC)
+
+
+def _sandbox(tmp_path: Path, keel_exit_code: int) -> tuple[Path, Path, Path, dict[str, str]]:
+ """Copy the REAL runner into `tmp_path`, repointed at the sandbox, with a stubbed `keel`.
+
+ Only ONE rewrite, load-bearing for safety: `DIR="..."` -> `tmp_path`, so the window
+ guard, the stamp and the invocation all run VERBATIM. No notification redirection and no
+ sandbox-exec are needed (this script places nothing real and notifies nobody -- but the
+ DIR rewrite is still asserted so a test can never run the deployment's own copy).
+ """
+ source = RUN_SCRIPT.read_text()
+ patched, count = re.subn(
+ r'^DIR="[^"]*"$', f'DIR="{tmp_path}"', source, count=1, flags=re.MULTILINE
+ )
+ assert count == 1, "could not repoint DIR -- refusing to run a script aimed at the deployment"
+ assert "/Users/elmehdiaitbrahim/keel" not in patched
+
+ script = tmp_path / "paper-equities-run.sh"
+ script.write_text(patched)
+
+ (tmp_path / "logs").mkdir(parents=True, exist_ok=True)
+ stub_dir = tmp_path / ".venv" / "bin"
+ stub_dir.mkdir(parents=True, exist_ok=True)
+ invocations = stub_dir / "keel.invocations"
+ stub = stub_dir / "keel"
+ stub.write_text(
+ "#!/bin/bash\n"
+ f'printf "%s\\n" "$*" >> "{invocations}"\n'
+ f"exit {keel_exit_code}\n"
+ )
+ stub.chmod(stub.stat().st_mode | stat.S_IEXEC)
+
+ date_bin = tmp_path / "shim-bin"
+ _install_date_shim(date_bin)
+
+ env = dict(os.environ)
+ env["PATH"] = f"{date_bin}:{env.get('PATH', '')}"
+ return script, invocations, tmp_path / "logs" / ".paper-equities-last-run", env
+
+
+def _run(script: Path, env: dict[str, str], now: datetime) -> subprocess.CompletedProcess[str]:
+ run_env = dict(env)
+ run_env["KEEL_TEST_NOW"] = str(int(now.astimezone(UTC).timestamp()))
+ return subprocess.run(
+ ["/bin/bash", str(script)], capture_output=True, text=True, env=run_env
+ )
+
+
+def _count_lines(path: Path) -> int:
+ if not path.exists():
+ return 0
+ return len(path.read_text().splitlines())
+
+
+def test_a_clean_cycle_stamps_the_utc_day_and_the_same_day_is_a_no_op(tmp_path):
+ """The dedupe, end to end, in the real shell: a successful cycle at 10:30 ET stamps
+ THIS UTC day; a later trigger the same UTC day does nothing."""
+ script, invocations, stamp, env = _sandbox(tmp_path, keel_exit_code=0)
+
+ first = _run(script, env, _et(2026, 6, 15, 10, 30))
+ assert first.returncode == 0
+ assert _count_lines(invocations) == 1
+ assert stamp.read_text().strip() == "2026-06-15"
+
+ second = _run(script, env, _et(2026, 6, 15, 12, 45))
+ assert second.returncode == 0
+ assert "already ran" in second.stdout
+ assert _count_lines(invocations) == 1
+
+
+def test_the_next_utc_day_runs_its_own_cycle(tmp_path):
+ """The stamp must be DAY-grained in a way that rolls over: the next day's first
+ in-window trigger is a new cycle, not a no-op against yesterday's stamp."""
+ script, invocations, stamp, env = _sandbox(tmp_path, keel_exit_code=0)
+
+ assert _run(script, env, _et(2026, 6, 15, 10, 30)).returncode == 0
+ assert _run(script, env, _et(2026, 6, 16, 10, 30)).returncode == 0
+
+ assert _count_lines(invocations) == 2
+ assert stamp.read_text().strip() == "2026-06-16"
+
+
+def test_the_cycle_runs_the_equities_config_against_its_own_database(tmp_path):
+ """Config and database must travel as a pair: `--db` defaults to keel.db (the daily
+ CRYPTO paper account), so a runner that dropped the flag would drive equity rows against
+ the wrong ledger -- the exact footgun the `keel-equities` wrapper exists to remove."""
+ script, invocations, _, env = _sandbox(tmp_path, keel_exit_code=0)
+
+ _run(script, env, _et(2026, 6, 15, 10, 30))
+
+ assert invocations.read_text().strip() == (
+ "--config config.paper-equities.yaml --db keel-equities.db agent"
+ )
+
+
+def test_a_failed_cycle_writes_no_stamp_so_the_same_day_retries(tmp_path):
+ """Same failure direction as every sibling runner: a cycle that died must not be
+ recorded as done. A later trigger the SAME UTC day retries; the stamp only appears once
+ a cycle succeeds."""
+ script, invocations, stamp, env = _sandbox(tmp_path, keel_exit_code=4)
+
+ failed = _run(script, env, _et(2026, 6, 15, 10, 30))
+ assert failed.returncode == 4, "the script must surface the cycle's exit code, not mask it"
+ assert not stamp.exists(), "a failed cycle must leave the day unstamped so it is retried"
+
+ retried = _run(script, env, _et(2026, 6, 15, 11, 30))
+ assert retried.returncode == 4
+ assert "already ran" not in retried.stdout
+ assert _count_lines(invocations) == 2
+
+
+def test_a_failed_cycle_is_retried_and_then_stamped_by_a_later_trigger(tmp_path):
+ """The two-step of the failure path, in one sandbox: fail at 10:30 (no stamp), succeed
+ at 11:30 (stamps the same UTC day), no-op at 12:10."""
+ script, invocations, stamp, env = _sandbox(tmp_path, keel_exit_code=4)
+
+ assert _run(script, env, _et(2026, 6, 15, 10, 30)).returncode == 4
+ assert not stamp.exists()
+
+ # Flip the stub to success in place, then re-fire inside the same day.
+ stub = tmp_path / ".venv" / "bin" / "keel"
+ stub.write_text(f'#!/bin/bash\nprintf "cycle\\n" >> "{invocations}"\nexit 0\n')
+ stub.chmod(stub.stat().st_mode | stat.S_IEXEC)
+
+ ok = _run(script, env, _et(2026, 6, 15, 11, 30))
+ assert ok.returncode == 0
+ assert stamp.read_text().strip() == "2026-06-15"
+ assert _count_lines(invocations) == 2
+
+ later = _run(script, env, _et(2026, 6, 15, 12, 10))
+ assert later.returncode == 0
+ assert "already ran" in later.stdout
+ assert _count_lines(invocations) == 2
+
+
+def test_a_boot_before_the_window_neither_runs_nor_stamps(tmp_path):
+ """THE equities-specific regression: B1's session gate makes a pre-open cycle SKIP with
+ market_closed and exit 0 -- a runner without a window guard would stamp that skip as the
+ day's work and suppress the real evaluation at 10:00. A boot before the window must
+ leave the day unstamped and run nothing."""
+ script, invocations, stamp, env = _sandbox(tmp_path, keel_exit_code=0)
+
+ early = _run(script, env, _et(2026, 6, 15, 8, 10))
+ assert early.returncode == 0
+ assert _count_lines(invocations) == 0
+ assert not stamp.exists()
+
+ # The day is still available to its scheduled run.
+ assert _run(script, env, _et(2026, 6, 15, 10, 30)).returncode == 0
+ assert _count_lines(invocations) == 1
+ assert stamp.read_text().strip() == "2026-06-15"
+
+
+def test_a_boot_after_the_close_neither_runs_nor_stamps(tmp_path):
+ """The post-session mirror of the pre-open guard: after 16:00 ET the venue clock says
+ closed, so a cycle would skip and stamp-fail the day. Outside the window, exit quietly."""
+ script, invocations, stamp, env = _sandbox(tmp_path, keel_exit_code=0)
+
+ late = _run(script, env, _et(2026, 6, 15, 16, 30))
+ assert late.returncode == 0
+ assert _count_lines(invocations) == 0
+ assert not stamp.exists()
+
+
+def test_the_runner_script_states_its_stamp_ordering_in_comments():
+ """The acceptance demand is readable in the file itself, not only in tests: `set -e`,
+ the stamp written only AFTER a successful cycle, and the window guard's reason."""
+ text = RUN_SCRIPT.read_text()
+ assert "set -euo pipefail" in text
+ assert "only AFTER a successful cycle" in text
+ # The stamp write must come after the keel invocation in the file: a reorder would
+ # stamp failures as done.
+ invocation_at = text.index("./.venv/bin/keel")
+ stamp_write_at = text.rindex('> "$STAMP"')
+ assert invocation_at < stamp_write_at
+
+
+# -- the runner: the two skip kinds are stamped differently (#386 review) ------------------------
+#
+# B1's session gate skips a cycle two ways, and only one of them is the day's work:
+# market_closed (weekend, holiday) exits 0 and stamping it is CORRECT cadence bookkeeping --
+# nothing more can happen that day; market_clock_unavailable (a transient clock outage) is a
+# degraded read that must NOT stamp, or the day is silently lost while the log says "ran".
+
+
+def _clock_exit() -> int:
+ """The contract the runner depends on: the agent's distinct nonzero exit for a
+ market_clock_unavailable skip (`agent.MARKET_CLOCK_UNAVAILABLE_EXIT`)."""
+ from keel.agent import MARKET_CLOCK_UNAVAILABLE_EXIT
+
+ return MARKET_CLOCK_UNAVAILABLE_EXIT
+
+
+def test_a_clock_unavailable_cycle_writes_no_stamp_and_is_retried(tmp_path):
+ """A transient clock outage at the 10:00 trigger must not be recorded as the day's work:
+ the cycle exits MARKET_CLOCK_UNAVAILABLE_EXIT (nonzero), `set -e` stops the script short
+ of the stamp, and the next trigger retries -- the same recovery shape as any failed
+ cycle."""
+ script, invocations, stamp, env = _sandbox(tmp_path, keel_exit_code=_clock_exit())
+
+ failed = _run(script, env, _et(2026, 6, 15, 10, 30))
+ assert failed.returncode == _clock_exit(), "the script must surface the cycle's exit code"
+ assert not stamp.exists(), "a clock-unavailable skip must leave the day unstamped"
+
+ # Flip the stub to a healthy cycle, then re-fire inside the same day: the retry stamps.
+ stub = tmp_path / ".venv" / "bin" / "keel"
+ stub.write_text(f'#!/bin/bash\nprintf "cycle\\n" >> "{invocations}"\nexit 0\n')
+ stub.chmod(stub.stat().st_mode | stat.S_IEXEC)
+
+ ok = _run(script, env, _et(2026, 6, 15, 11, 30))
+ assert ok.returncode == 0
+ assert stamp.read_text().strip() == "2026-06-15"
+ assert _count_lines(invocations) == 2
+
+
+def test_a_market_closed_skip_still_stamps_the_utc_day(tmp_path):
+ """The other skip kind keeps its historical treatment: a closed venue (here, a Saturday)
+ skips with market_closed and exits 0, and stamping THAT is correct -- nothing more can
+ happen that day, so the day is recorded as done rather than retried forever."""
+ script, invocations, stamp, env = _sandbox(tmp_path, keel_exit_code=0)
+
+ # 2026-06-20 is a Saturday: the (stubbed) cycle would have skipped market_closed.
+ ok = _run(script, env, _et(2026, 6, 20, 11, 0))
+
+ assert ok.returncode == 0
+ assert stamp.read_text().strip() == "2026-06-20"
+
+
+# -- the wrapper --------------------------------------------------------------------------------
+
+
+def test_wrapper_pins_the_config_and_database_together():
+ """`keel-equities` exists for the same reason as `keel-live`/`keel-paperhourly`: `--db`
+ defaults to keel.db, and the equity rows must never touch a crypto ledger."""
+ text = WRAPPER.read_text()
+ assert "--config config.paper-equities.yaml" in text
+ assert "--db keel-equities.db" in text
+
+
+# -- the runbook --------------------------------------------------------------------------------
+
+
+def test_runbook_documents_the_profile_its_database_and_the_caveat():
+ """The operator-facing contract: the fourth profile appears with its bootstrap, its
+ venue, its own database, and the no-edge caveat -- impossible to miss, per the issue."""
+ text = RUNBOOK.read_text()
+ assert "The equities paper profile" in text
+ assert "config.paper-equities.yaml" in text
+ assert "keel-equities.db" in text
+ assert "keel migrate --db keel-equities.db" in text
+ assert '"granularity": "ONE_DAY"' in text
+ assert "NO PROVEN EDGE" in text
+
+
+def test_runbook_documents_attestation_rail17_t_plus_1_and_the_opt_outs():
+ """The equities-specific compliance semantics the issue demands: operator-supplied
+ attestation per (alpaca, SYMBOL) from AAOIFI/IFSB-class sources (the engine never
+ classifies), rail 17's ACATS transfer-out reading, the T+1 x daily-cadence interaction,
+ and the two operator-verified opt-outs (stock lending OFF for qabd, high-yield sweep OFF
+ for riba) with where to verify each."""
+ text = RUNBOOK.read_text()
+ assert "(alpaca, SYMBOL)" in text
+ assert "AAOIFI" in text
+ assert "IFSB" in text
+ assert "never classifies" in text or "engine never infers" in text
+ assert "ACATS" in text
+ assert "T+1" in text
+ assert "Stock lending" in text
+ assert "high-yield" in text.lower()
+ assert "qabd" in text
+ assert "riba" in text
+ assert "Alpaca dashboard" in text
+
+
+def test_runbook_notes_what_is_deliberately_not_here():
+ """The scope fence: `keel/assets` screening venue semantics stay hardcoded to coinbase
+ (deliberate, #233 live-path work), deployment itself is out of scope (it needs the
+ operator's Alpaca paper credentials), and the trademark posture stays in the README
+ rather than duplicated here."""
+ text = RUNBOOK.read_text()
+ assert "#233" in text
+ assert "out of scope" in text or "deliberately NOT here" in text
+ assert "README" in text
+
+
+def test_runbook_states_the_cash_no_margin_pdt_posture():
+ """PRD 5/6.4's account posture, operator-facing half: cash accounts ONLY (margin
+ borrowing is riba; a cash account also sidesteps the PDT rule's $25k margin-account
+ threshold), the PDT rule explained (what it is; why a cash account on keel's daily
+ cadence is not that pattern), the T+1 interplay CROSS-REFERENCED rather than duplicated,
+ and the fence that enforcement-in-code is #372's scope."""
+ text = RUNBOOK.read_text()
+ assert "cash account" in text.lower()
+ assert "margin" in text.lower()
+ assert "pattern day trader" in text.lower() or "PDT" in text
+ assert "25,000" in text or "$25k" in text
+ # The scope fence: config refusing a margin posture is #372's work, not undocumented.
+ assert "#372" in text
+ # And the posture cross-references the T+1 section instead of restating it.
+ assert "T+1 settlement" in text
+
+
+def test_runbook_fences_dividend_purification_as_phase_b3():
+ """Purification must not read as forgotten: the recorded-event + operator-policy walk
+ (corporate actions recorded per FR-10, purification math against the attestation's
+ ratio, disposition recorded) is fenced as the B3 slice of this phase."""
+ text = RUNBOOK.read_text()
+ assert "purification" in text.lower()
+ assert "corporate actions" in text.lower()
+ assert "B3" in text
+ assert "FR-10" in text
+
+
+def test_runbook_profile_table_gains_the_fourth_column():
+ """The paper-vs-live comparison table must carry the equities column, or an operator
+ cross-checking profiles reads a three-profile world."""
+ text = RUNBOOK.read_text()
+ assert "paper-equities" in text
+ assert "com.keel.paper-equities" in text
+ assert "config.paper-equities.yaml" in text
+
+
+def test_env_example_carries_the_alpaca_paper_key_names():
+ """`.env.example` is where a new operator looks first; the new venue's key names and
+ the paper-keys-suffice note must be there."""
+ text = ENV_EXAMPLE.read_text()
+ assert "ALPACA_API_KEY_ID=" in text
+ assert "ALPACA_API_SECRET_KEY=" in text
+ assert "paper" in text
+
+
+# -- venue selection: the engine wiring the profile needs ---------------------------------------
+
+
+def _write_config(tmp_path: Path, extra: str = "") -> Path:
+ path = tmp_path / "config.yaml"
+ path.write_text(VALID_CONFIG_YAML + extra)
+ return path
+
+
+ALPACA_BROKER_YAML = """
+broker:
+ name: alpaca
+ endpoint: paper
+ data_feed: iex
+"""
+
+
+def test_absent_broker_section_defaults_to_coinbase(tmp_path):
+ """The compatibility pin: with no `broker:` section the parsed config selects coinbase,
+ which is the name `_build_broker`'s legacy branch answers to. Every shipped profile and
+ every existing test loads a config without the section."""
+ config = load_config(str(_write_config(tmp_path)))
+
+ assert config.broker.name == "coinbase"
+ assert config.broker.endpoint == "paper"
+ assert config.broker.data_feed == "iex"
+
+
+def test_broker_section_round_trips_alpaca_paper_iex(tmp_path):
+ config = load_config(str(_write_config(tmp_path, ALPACA_BROKER_YAML)))
+
+ assert config.broker.name == "alpaca"
+ assert config.broker.endpoint == "paper"
+ assert config.broker.data_feed == "iex"
+
+
+def test_broker_endpoint_is_validated_at_load(tmp_path):
+ """FR-11's load-time posture: an endpoint outside paper/live is a typo that must fail
+ at config load, not at first request -- and live/paper is the whole vocabulary because
+ the trading host is derived from it, never configured as a URL."""
+ with pytest.raises(ConfigError, match="broker.endpoint"):
+ load_config(
+ str(_write_config(tmp_path, "\nbroker:\n name: alpaca\n endpoint: prod\n"))
+ )
+
+
+def test_broker_data_feed_is_validated_at_load(tmp_path):
+ """The data tier is a DECLARED capability (FR-5): iex or sip, nothing else, refused at
+ load rather than silently falling back to the venue's server-side default."""
+ with pytest.raises(ConfigError, match="broker.data_feed"):
+ load_config(
+ str(_write_config(tmp_path, "\nbroker:\n name: alpaca\n data_feed: cows\n"))
+ )
+
+
+def test_coinbase_rejects_the_alpaca_only_knobs(tmp_path):
+ """`endpoint`/`data_feed` are alpaca wiring keys; on coinbase they would be silently
+ ignored -- the exact silent-dead-knob failure this config module refuses elsewhere."""
+ with pytest.raises(ConfigError, match="broker.name"):
+ load_config(str(_write_config(tmp_path, "\nbroker:\n endpoint: live\n")))
+
+
+def test_build_broker_default_is_byte_compatible_coinbase(
+ tmp_path, monkeypatch
+):
+ """THE default pin, by construction: no `broker:` section -> `_build_broker` takes the
+ unchanged Coinbase path -- `load_secrets()` from `.env`, a `RESTClient` built from those
+ CDP values, wrapped in `CoinbaseClient`. The kwargs and the wrapping are asserted, so a
+ refactor that changed any of it for the default config fails here."""
+ import coinbase.rest
+
+ from keel.commands._common import _build_broker
+ from keel.data import cb_client
+
+ (tmp_path / ".env").write_text("CDP_API_KEY=cb-key\nCDP_API_SECRET=cb-secret\n")
+ monkeypatch.chdir(tmp_path)
+
+ calls: dict[str, object] = {}
+
+ class _FakeRESTClient:
+ def __init__(self, **kwargs: object) -> None:
+ calls["rest_kwargs"] = kwargs
+
+ def _fake_coinbase_client(transport: object) -> object:
+ calls["transport"] = transport
+ return object()
+
+ monkeypatch.setattr(coinbase.rest, "RESTClient", _FakeRESTClient)
+ monkeypatch.setattr(cb_client, "CoinbaseClient", _fake_coinbase_client)
+
+ config = load_config(str(_write_config(tmp_path)))
+ broker = _build_broker(config)
+
+ assert broker is not None
+ assert calls["rest_kwargs"] == {
+ "api_key": "cb-key",
+ "api_secret": "cb-secret",
+ "timeout": None,
+ }
+ assert isinstance(calls["transport"], _FakeRESTClient)
+
+
+def test_build_broker_selects_alpaca_paper_iex(tmp_path, monkeypatch):
+ """The new path: `broker: {name: alpaca, endpoint: paper, data_feed: iex}` resolves
+ through the `keel.brokers` entry points and constructs the adapter against the PAPER
+ trading host with the IEX feed. No network happens at construction -- the assertion is
+ on the built object's own declared properties."""
+ from keel_broker_alpaca import AlpacaAdapter
+ from keel_broker_alpaca.transport import PAPER_TRADING_HOST
+
+ from keel.commands._common import _build_broker
+
+ (tmp_path / ".env").write_text(
+ "ALPACA_API_KEY_ID=paper-key-id\nALPACA_API_SECRET_KEY=paper-secret\n"
+ )
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.delenv("ALPACA_API_KEY_ID", raising=False)
+ monkeypatch.delenv("ALPACA_API_SECRET_KEY", raising=False)
+
+ config = load_config(str(_write_config(tmp_path, ALPACA_BROKER_YAML)))
+ broker = _build_broker(config)
+
+ assert isinstance(broker, AlpacaAdapter)
+ assert broker.endpoint == "paper"
+ assert broker._transport.trading_host == PAPER_TRADING_HOST
+ assert broker._transport.data_feed == "iex"
+
+
+def test_build_broker_alpaca_missing_secrets_names_the_venue_and_env_vars(tmp_path, monkeypatch):
+ """The missing-keys error must name the venue and BOTH env var names, so the operator's
+ next action is a copy-paste rather than a grep. The real secrets loader also honours the
+ environment (not only `.env`), so both sources are cleared here."""
+ from keel.commands._common import _build_broker
+
+ monkeypatch.chdir(tmp_path) # no .env in here
+ monkeypatch.delenv("ALPACA_API_KEY_ID", raising=False)
+ monkeypatch.delenv("ALPACA_API_SECRET_KEY", raising=False)
+
+ config = load_config(str(_write_config(tmp_path, ALPACA_BROKER_YAML)))
+
+ with pytest.raises(RuntimeError) as excinfo:
+ _build_broker(config)
+
+ message = str(excinfo.value)
+ assert "alpaca" in message
+ assert "ALPACA_API_KEY_ID" in message
+ assert "ALPACA_API_SECRET_KEY" in message
+
+
+def test_build_broker_refuses_a_venue_without_cli_wiring(tmp_path, monkeypatch):
+ """A name that RESOLVES to an adapter but has no credential wiring in the CLI (fake,
+ robinhood -- installed in dev) is refused with the two names that do have wiring, rather
+ than constructing an adapter that can never reach its venue."""
+ from keel.commands._common import _build_broker
+
+ monkeypatch.chdir(tmp_path)
+
+ config = load_config(str(_write_config(tmp_path, "\nbroker:\n name: fake\n")))
+
+ with pytest.raises(RuntimeError, match="coinbase.*alpaca"):
+ _build_broker(config)
+
+
+def test_build_broker_unknown_name_surfaces_the_entry_point_list(tmp_path, monkeypatch):
+ """A name with no entry point at all fails through the registry's own error, which
+ lists what IS installed -- discovery stays the authority on what exists."""
+ from keel.commands._common import _build_broker
+
+ monkeypatch.chdir(tmp_path)
+
+ config = load_config(str(_write_config(tmp_path, "\nbroker:\n name: nonsuch\n")))
+
+ with pytest.raises(LookupError, match="no broker adapter registered"):
+ _build_broker(config)
+
+
+def test_load_alpaca_secrets_reads_environment_then_env_file(tmp_path, monkeypatch):
+ """`load_secrets`' shape contract, followed for the new venue: both keys present ->
+ a populated dict; absent everywhere -> `{}`; the ENVIRONMENT wins over the file so a
+ deployment can inject credentials without one."""
+ from keel.config import load_alpaca_secrets
+
+ (tmp_path / ".env").write_text(
+ "ALPACA_API_KEY_ID=file-key-id\nALPACA_API_SECRET_KEY=file-secret\n"
+ )
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.delenv("ALPACA_API_KEY_ID", raising=False)
+ monkeypatch.delenv("ALPACA_API_SECRET_KEY", raising=False)
+
+ assert load_alpaca_secrets() == {"key_id": "file-key-id", "secret_key": "file-secret"}
+
+ monkeypatch.setenv("ALPACA_API_KEY_ID", "env-key-id")
+ monkeypatch.setenv("ALPACA_API_SECRET_KEY", "env-secret")
+ assert load_alpaca_secrets() == {"key_id": "env-key-id", "secret_key": "env-secret"}
+
+ # Absent everywhere: no env vars, no .env in the cwd.
+ monkeypatch.delenv("ALPACA_API_KEY_ID", raising=False)
+ monkeypatch.delenv("ALPACA_API_SECRET_KEY", raising=False)
+ empty = tmp_path / "elsewhere"
+ empty.mkdir()
+ monkeypatch.chdir(empty)
+ assert load_alpaca_secrets() == {}