Skip to content

Commit 2e5219b

Browse files
authored
Merge pull request #586 from CodeGateSoftware/feat-372-cash-posture
feat(brokers): alpaca refuses margin accounts, cash-only by posture (#372)
2 parents 4aa926c + 6d7b36f commit 2e5219b

20 files changed

Lines changed: 705 additions & 44 deletions

File tree

‎config.paper-equities.yaml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,16 @@ risk_pct: 0.01
6363
# subscribed tier — the choice is declared, never assumed, because the venue's server-side
6464
# default silently fails for keys without the subscription). Omitting the section entirely
6565
# selects Coinbase, byte-compatibly with every other profile.
66+
#
67+
# CASH-ACCOUNT POSTURE (#372): this venue is traded CASH-ONLY — margin borrowing is riba
68+
# and the cash posture keeps the account outside the PDT rule's $25k margin-account
69+
# threshold — and it is ENFORCED, not a knob here: every broker build reads the venue's own
70+
# account classification (/v2/account's `multiplier`) and refuses a margin-postured account
71+
# (fail-closed on an unreadable one). Note the venue's default cuts against the posture: a
72+
# fresh paper account's $100k equity classifies it reg T MARGIN (multiplier 2), so the
73+
# account's max margin multiplier must be set to 1 in the Alpaca dashboard before the first
74+
# cycle will run. See the runbook's "Account posture" section for the full split of what is
75+
# enforced in code versus what the operator must keep verified.
6676
broker:
6777
name: alpaca
6878
endpoint: paper

‎docs/operator-runbook.md‎

Lines changed: 74 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -622,34 +622,84 @@ considered, and it lapses weekly like every deployment's attestation.
622622

623623
### T+1 settlement × daily cadence
624624

625-
US equities settle T+1: sale proceeds become spendable the next business day. On a **daily**
626-
cadence this is immaterial for entries — the next entry attempt is at least a day after the
627-
previous buy, by which time it has settled (a weekend makes it longer, never shorter).
628-
**Exits are never T+1-blocked**: a SELL produces cash rather than spending it, and the engine
629-
never needs to spend sale proceeds within a cycle. The documented cash-crunch case: on a cash
630-
account you cannot spend *unsettled* proceeds, so an operator manually redeploying same-day
631-
sale proceeds (outside the engine, which cycles daily) is the one way to meet the constraint
632-
— and it is an operator act, not an engine one. The paper profile's synthetic cash does not
633-
model settlement at all; that honesty is on record for any future live consideration.
625+
US equities settle T+1: sale proceeds become spendable the next business day. Settlement is
626+
**venue-side** — nothing keel can enforce about *when* the venue settles, so this section is
627+
the documented half of a split whose enforced half already exists:
628+
629+
- **What keel enforces/surfaces**: the balances read reports the spendable figure honestly —
630+
`available` is the account's buying power clamped at cash, so unsettled T+1 proceeds show
631+
up as the gap between `available` and `total` rather than as spendable money (FR-6, the
632+
#562 balance work) — and **rail 13** (the spend rail) vetoes any BUY whose notional
633+
exceeds the reported available quote balance, failing closed when the balance is
634+
unreadable. The engine cannot spend what it cannot see.
635+
- **What the operator must respect**: cadence choices that would re-spend unsettled
636+
proceeds. On the profile's **daily** cadence the constraint is met by construction — the
637+
next entry attempt is at least a day after the previous buy, by which time it has settled
638+
(a weekend makes it longer, never shorter), and **exits are never T+1-blocked**: a SELL
639+
produces cash rather than spending it. The one documented cash-crunch case is an operator
640+
manually redeploying same-day sale proceeds *outside* the engine — an operator act, not an
641+
engine one. The same `interval_sec` that makes this true also scales the feed-staleness
642+
window (B1), so any future tighter equities cadence must re-answer settlement and
643+
staleness together; a sub-daily cadence is not merely a staleness question.
644+
645+
The paper profile's synthetic cash does not model settlement at all; that honesty is on
646+
record for any future live consideration.
634647

635648
### Account posture: cash only — never margin — and the PDT rule
636649

637-
The equities profile runs against a **cash account, never a margin account**. Margin
650+
The equities profile runs against a **cash-equivalent posture, never borrowed funds**. Margin
638651
borrowing is a loan that charges interest — *riba* — so the posture is categorical, not a
639-
preference; a cash account also sidesteps the pattern day trader (PDT) rule's $25,000
640-
minimum-equity requirement, which applies to **margin** accounts only. The PDT rule: FINRA
641-
flags a **margin** account as a pattern day trader when it executes four or more day trades
642-
within five business days, and such an account then needs $25,000 of equity to keep day
643-
trading. A cash account running keel's daily cadence is not that pattern — the rule does not
644-
bind cash accounts, and in any case keel evaluates a session's bar once and holds overnight
645-
by construction, so it does not day-trade in the first place; settled-cash funding is what
646-
makes entries wait for settlement, and that interplay is the T+1 section above (not repeated
647-
here).
648-
649-
Enforcement in code — the config refusing a margin posture where the venue reports one — is
650-
issue **#372**'s scope; this runbook carries the operator-facing half now: **create and keep
651-
the Alpaca account a cash account** (the paper account is one by default), and treat any
652-
offer to "upgrade" to margin as a posture violation to decline, not a capability to use.
652+
preference; **no-borrowing is the posture's whole claim, and it sidesteps nothing on PDT.**
653+
The PDT rule: FINRA flags a **margin** account as a pattern day trader when it executes four
654+
or more day trades within five business days, and such an account then needs $25,000 of
655+
equity to keep day trading — the rule binds margin accounts, and a true cash account would be
656+
exempt, but **Alpaca offers no true cash accounts**: per Alpaca staff on the cash-account
657+
option (forum.alpaca.markets/t/dan-wheres-the-cash-only-account-option/18353), "currently all
658+
Alpaca accounts are margin accounts" — at `max_margin_multiplier=1` "the account remains a
659+
margin account" and "pattern day trading rules apply". What actually keeps keel clear of PDT
660+
is the **cadence**: keel evaluates a session's bar once and holds overnight by construction,
661+
so it does not day-trade in the first place; settled-cash funding is what makes entries wait
662+
for settlement, and that interplay is the T+1 settlement section above (not repeated here).
663+
664+
**Enforced in code since #372, not just documented.** Alpaca has no `account_type` field;
665+
`/v2/account`'s `multiplier` is the venue's account margin classification, and it is as
666+
cash as the venue gets: Alpaca opens every account as margin and offers no true cash
667+
designation — multiplier **1** is the cash-equivalent posture (buying power equals cash,
668+
shorts refused), **2** reg T margin, **4** PDT day-trading margin. At broker build — every
669+
command that constructs one: the agent cycle, `fetch`, `monitor`, the order paths —
670+
`AlpacaAdapter.verify_cash_account` reads that classification and **refuses** a margin
671+
postured account (`CashAccountRequired`, naming riba, the honest PDT note — the posture
672+
buys no exemption; the cadence is the PDT safety — and the fix), and refuses fail-closed
673+
when the classification cannot be read: silence is not
674+
consent to borrow. The engine never sees a broker on a margin account.
675+
676+
**The operator's half — set the multiplier, and expect to.** The venue's *default* for any
677+
account with $2,000 or more equity is reg T margin (multiplier 2) — and a fresh Alpaca
678+
paper account carries $100,000 of paper equity, so the default classification is MARGIN:
679+
the first cycle against an untouched paper account refuses until the setting is changed.
680+
Set the account's **max margin multiplier to 1** (the dashboard's trading-configuration
681+
setting; at 1 the venue refuses orders beyond available cash and blocks shorts), keep it
682+
there, and treat any offer to "upgrade" to margin as a posture violation to decline, not a
683+
capability to use. **Confirm the classification took** before moving on: re-run
684+
`keel assets holdings` — a cash classification builds the broker and lists balances, a
685+
margin one still refuses — or read `GET /v2/account`'s `multiplier` directly, which must
686+
answer `1`. If the refusal persists with the setting saved, that is a question for Alpaca
687+
support, not something to work around: the setting you changed lives on the venue's
688+
account-configurations object (`max_margin_multiplier`, `PATCH /v2/account/configurations`)
689+
while the classification keel reads lives on the account object itself, and the venue
690+
implies but does not document the linkage between the two.
691+
692+
The posture is one system with the rest of the engine's constraints — where each piece
693+
lives:
694+
695+
| Constraint | Where it lives | Why there |
696+
| --- | --- | --- |
697+
| No margin borrowing (riba) | **Enforced in code**: `verify_cash_account` at broker build refuses any multiplier ≠ 1, fail-closed on unreadable | The venue reports the classification; refusing at build covers every path before one sees a broker |
698+
| Cash-only declared | `BrokerCapabilities.cash_only` — every adapter declares it, `keel brokers list` shows "cash only" | The port's declaration seam: vocabulary for the day an adapter announces a borrowing path |
699+
| Long-only, no shorting | **Enforced in code**, pre-existing: `Setup.direction` is `Literal["long"]` (a short entry cannot be constructed), exits only close held longs, rails 18/19 refuse non-spot shapes in every mode | Not new in #372 — cited so the posture reads as one system, not three |
700+
| T+1 settlement churn | **Documented** (the section above): settlement is venue-side; keel surfaces the spendable figure and rail 13 spends only that | Nothing keel can enforce about when the venue settles |
701+
| PDT $25k threshold | **Documented** (this section): keel's CADENCE — one evaluation per session bar, holds overnight by construction — is the PDT safety; the posture claims no exemption (at multiplier 1 the account remains a margin account under PDT rules) | The safety is the cadence, not the posture; there is no separate rule to enforce |
702+
| Stock lending / cash sweep OFF | **Operator-verified** (the section below) | No order is placed; no rail can see account settings |
653703

654704
### Operator-verified opt-outs (Alpaca account level)
655705

‎keel/commands/_common.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,14 @@ def _build_broker(config: Config, *, timeout: int | None = None) -> Any:
174174
the real (network-free at construction) Alpaca and Coinbase classes by
175175
`tests/test_paper_equities_profile.py`.
176176
177+
**The alpaca branch verifies the account posture before returning** (#372): one
178+
`verify_cash_account()` read of the venue's own account classification, refusing a
179+
margin-postured account (and failing closed on an unreadable one) so no engine path
180+
ever sees a broker on a venue posture keel does not trade -- cash only, no margin
181+
borrowing (riba), which is the posture's whole claim: it sidesteps nothing on PDT
182+
(keel's PDT safety is the cadence, not the posture). The refusal names the posture
183+
and the fix; the runbook's "Account posture" section is the operator-facing half.
184+
177185
`timeout` (seconds) is optional and defaults to `None` -- the SDK's own default (no
178186
timeout), matching every existing caller (the agent/executor broker path) exactly.
179187
Callers that cannot tolerate a hung network call pass an explicit bound.
@@ -233,6 +241,17 @@ def _build_broker(config: Config, *, timeout: int | None = None) -> Any:
233241
)
234242
# `adapter_cls` IS the class the entry point registered -- constructed through discovery
235243
# (not a direct import) so the installed adapter, not a hard dependency, is what runs.
236-
return adapter_cls(
244+
broker = adapter_cls(
237245
transport, endpoint=config.broker.endpoint, data_feed=config.broker.data_feed
238246
)
247+
# Cash-account posture (#372, PRD §5): the one venue-state check this seam makes. The
248+
# adapter reads the venue's own account classification and refuses a margin-postured
249+
# account (`CashAccountRequired`, a RuntimeError like this function's other refusals),
250+
# fail-closed on a classification it cannot read. Refusing HERE -- rather than at order
251+
# time -- is what makes it a posture and not a rail: guards are broker-less by design,
252+
# and a per-order raise could fire on an exit path, where a refusal can trap a
253+
# position. Every command that builds a broker (agent cycle, fetch, monitor, the order
254+
# paths) inherits it; one extra `/v2/account` read per build sits far inside FR-11's
255+
# rate budget at the daily equities cadence.
256+
broker.verify_cash_account()
257+
return broker

‎keel/commands/brokers.py‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ class BrokerInfo:
8080
#: Whether the venue CLOSES (`capabilities().session_bound`): session-bound
8181
#: venues render with a market clock; the rest are 24/7.
8282
session_bound: bool
83+
#: Whether the adapter spends settled cash only (`capabilities().cash_only`, #372)
84+
#: -- the declared funding posture. Every first-party adapter declares True; a False
85+
#: row would be an adapter announcing a borrowing path the engine can refuse at load.
86+
cash_only: bool
8387
quote_currencies: tuple[str, ...]
8488
asset_classes: tuple[str, ...]
8589
supported_orders: tuple[str, ...]
@@ -173,6 +177,7 @@ def list_installed_brokers() -> list[BrokerInfo]:
173177
venue=capabilities.venue,
174178
deployment=WIRED if name in WIRED_FOR_DEPLOYMENT else OPTIONAL,
175179
session_bound=bool(capabilities.session_bound),
180+
cash_only=bool(capabilities.cash_only),
176181
quote_currencies=tuple(sorted(capabilities.quote_currencies)),
177182
asset_classes=tuple(sorted(capabilities.asset_classes)),
178183
supported_orders=tuple(sorted(capabilities.supported_orders)),
@@ -193,6 +198,7 @@ def list_installed_brokers() -> list[BrokerInfo]:
193198
venue="",
194199
deployment=WIRED if name in WIRED_FOR_DEPLOYMENT else OPTIONAL,
195200
session_bound=False,
201+
cash_only=False,
196202
quote_currencies=(),
197203
asset_classes=(),
198204
supported_orders=(),
@@ -218,12 +224,18 @@ def list_installed_brokers() -> list[BrokerInfo]:
218224

219225

220226
def capability_facts(info: BrokerInfo) -> str:
221-
"""The row's capability facts as one " · "-joined phrase -- the SHARED wording the CLI
222-
prints and the Venues browser wraps, so the two front-ends cannot drift. PURE."""
227+
"""The row's capability facts as one " · "-joined phrase -- the wording
228+
`render_brokers_lines` prints for `keel brokers list`. (The console Venues browser
229+
that once wrapped this same phrase is gone -- #541 deleted the console layer -- so
230+
"the shared wording two front-ends render" describes nothing today; the phrase stays
231+
a single PURE function so the next front-end inherits it rather than re-deriving
232+
it.)"""
223233
hours = "session-bound (opens and closes)" if info.session_bound else "24/7"
234+
funding = "cash only" if info.cash_only else "MARGIN-CAPABLE"
224235
facts = [
225236
info.deployment,
226237
hours,
238+
funding,
227239
f"quotes {'/'.join(info.quote_currencies)}",
228240
f"asset classes {'/'.join(info.asset_classes)}",
229241
f"preview {info.preview}",
@@ -238,9 +250,9 @@ def capability_facts(info: BrokerInfo) -> str:
238250

239251
def adapter_error_block(info: BrokerInfo) -> list[str]:
240252
"""The honest block a raising adapter's row renders as: its name and installed
241-
version, then the construction error wrapped to the 78-column budget -- the SAME
242-
wording both front-ends render (the one-phrase rule `capability_facts` keeps), so a
243-
broken adapter reads identically everywhere. PURE, and deliberately states NO
253+
version, then the construction error wrapped to the 78-column budget -- spelled
254+
once here so a broken adapter reads the same everywhere (the one-phrase discipline
255+
`capability_facts` keeps). PURE, and deliberately states NO
244256
capability fact: an adapter that could not be constructed has declared nothing."""
245257
return [
246258
f"{info.name} ({info.package_version or 'unknown version'}) -- unavailable",

‎keel/web/payload.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1663,6 +1663,11 @@ def _venue_payload(info: Any) -> dict[str, Any]:
16631663
"package_version": info.package_version or "",
16641664
"preview": info.preview,
16651665
"session_bound": flag(info.session_bound, on="session-bound", off="stateless"),
1666+
# The #372 funding posture, beside the session one: "cash only" is the declaration
1667+
# every first-party adapter makes, and the off word is the loud one for the day a
1668+
# row ever carries it -- the venueCard's funding cell mirrors `capability_facts`'s
1669+
# "MARGIN-CAPABLE" declaration without inheriting its shout.
1670+
"cash_only": flag(info.cash_only, on="cash only", off="margin-capable"),
16661671
"supports_fee_summary": flag(
16671672
info.supports_fee_summary, on="fee summary", off="no fee summary"
16681673
),

‎keel/web/static/js/render.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1700,6 +1700,7 @@ function venueCard(info) {
17001700
gridCard([
17011701
kv("preview", plain(info.preview)),
17021702
kv("session", info.session_bound),
1703+
kv("funding", info.cash_only),
17031704
kv("fee summary", info.supports_fee_summary),
17041705
kv("package version", plain(info.package_version) || "—"),
17051706
]),

‎packages/keel-broker-alpaca/README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,23 @@ construction path to a trading host (no constructor parameter accepts a host URL
1414
there is no configuration path from a paper credential to
1515
`https://api.alpaca.markets`, by construction.
1616

17+
## Cash-account posture (#372)
18+
19+
The cash half of the scope is **enforced, not just declared**. Alpaca has no
20+
`account_type` field; `/v2/account`'s `multiplier` is the venue's account margin
21+
classification (`1` cash-equivalent — buying power equals cash, shorts refused; `2` reg T
22+
margin; `4` PDT day-trading margin), and the venue's *default* for any account over
23+
$2,000 of equity — a $100k paper account included — is margin. Every broker build calls
24+
`AlpacaAdapter.verify_cash_account()`: multiplier 1 passes, anything else raises
25+
`CashAccountRequired` (margin borrowing is riba — the posture's whole claim: it sidesteps
26+
nothing on PDT, where the cadence, not the posture, is the safety; the refusal says so and
27+
names the fix — set the account's max margin multiplier to 1), and a classification that
28+
cannot be read at all raises the same
29+
fail-closed refusal, cause chained and folded into the message. The declared half is
30+
`BrokerCapabilities.cash_only=True`, rendered as "cash only" in `keel brokers list`. The
31+
operator-facing walk (including the T+1 settlement and opt-out obligations) is the
32+
runbook's "Account posture" and "T+1 settlement" sections.
33+
1734
## What works
1835

1936
| Capability | Detail |
@@ -25,6 +42,7 @@ there is no configuration path from a paper credential to
2542
| Order status | `get_order` maps Alpaca's status enum to the port's vocabulary; unknown statuses stay `PENDING`. |
2643
| Cancel | 204 from `DELETE /v2/orders/{id}` is the venue confirmation; 404/422 and any transport failure answer `False`. |
2744
| Session | `is_market_open()` reads the venue's clock (`/v2/clock`) — no local calendar. |
45+
| Posture | `verify_cash_account()` at broker build: `/v2/account`'s `multiplier` must be the cash classification (1); margin (2/4) and unreadable answers refuse, fail-closed (#372). |
2846
| Rate limits | 429 retried with `Retry-After` when sent, exponential backoff otherwise, bounded attempt budget (FR-11). |
2947

3048
## Fees (FR-7)

‎packages/keel-broker-alpaca/keel_broker_alpaca/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@
55
Market Data APIs -- no Alpaca SDK, no third-party adapter code.
66
"""
77

8-
from keel_broker_alpaca.adapter import AlpacaAdapter
8+
from keel_broker_alpaca.adapter import AlpacaAdapter, CashAccountRequired
99

10-
__all__ = ["AlpacaAdapter"]
10+
__all__ = ["AlpacaAdapter", "CashAccountRequired"]

0 commit comments

Comments
 (0)