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() == {}