Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ the stop holds, not what you spend.
```
keel/ the agent and CLI
├── agent.py the loop and RULE_REGISTRY (where rules live)
├── execution/guards.py the 20 rails (where enforcement lives)
├── execution/guards.py the 21 rails (where enforcement lives)
├── execution/sizing.py position sizing
├── compliance/screen.py attested allowlist admission (fails closed)
└── commands/ CLI command implementations
Expand Down
2 changes: 1 addition & 1 deletion docs/fiqh-basis.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ account level is the operator's obligation, listed first in

### The remaining rails — prudential, not fiqh

Twenty rails exist (1–14, 16–21 — there is no rail 15). Of these, rails 17 and 21 encode a
Twenty-one rails exist (1–14, 16–22 — there is no rail 15). Of these, rails 17, 21 and 22 encode a
fiqh ruling, and rails 1/18/19 enforce what the screen and the charter admit. The rest are
PRUDENTIAL — risk and discipline, justified by trading evidence, carrying no religious
claim:
Expand Down
2 changes: 1 addition & 1 deletion docs/launch.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ the Arabic README.
> I built an open-source Shariah-compliance **engine** for spot crypto trading — not a
> trading bot with a halal coat of paint, but the compliance machinery: allowlist
> admission where Shariah classifications are attested with a source and never inferred
> from market data, a fails-closed screen, twenty un-overridable safety rails including
> from market data, a fails-closed screen, twenty-one un-overridable safety rails including
> §65.4 *qabd* (constructive possession) encoded as an executable check, and an
> audit trail of who attested what.
>
Expand Down
12 changes: 12 additions & 0 deletions keel/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,18 @@ def key(self) -> tuple[str, str]:
"refused that until the credential was attested to trade"
),
),
Capability(
module="keel.commands.posture",
function="posture_attest",
surface="cli",
invocation="keel posture attest --spot-cash",
increases=(
"rail 22 may place a live ENTRY order against this venue's account -- it was refused "
"that until a human attested the account is cash-only spot. No venue exposes this "
"field for spot (#666), so this statement is the ONLY thing that can release the "
"rail, and nothing can check it"
),
),
Capability(
module="keel.commands.update",
function="typed_update_gate",
Expand Down
10 changes: 8 additions & 2 deletions keel/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
from keel.commands.monitor import run_monitor
from keel.commands.orders import orders_cmd
from keel.commands.pnl import build_pnl_report, render_pnl_report
from keel.commands.posture import posture_group
from keel.commands.purification import render_purification_report
from keel.commands.research import research_group
from keel.commands.rules import rules_group, rules_seed
Expand Down Expand Up @@ -1024,7 +1025,10 @@ def monitor(
"""
repo = _open_repo(ctx)
config = _load_cfg(ctx)
broker = _build_broker(config)
# `repo=` so a venue posture check that REFUSES also refutes the standing attestation
# (#691). Passed on the paths that go on to trade; the read-only inspection commands do
# not have a repo and do not need one -- see `record_cash_posture_refutation`.
broker = _build_broker(config, repo=repo)
products = _default_sim_products(config)
granularities = list(config.market_data.granularities)
interval = interval_sec if interval_sec is not None else config.auto_trade.interval_sec
Expand Down Expand Up @@ -1083,7 +1087,8 @@ def agent_cmd(
"""
config = _load_cfg(ctx)
repo = _open_repo(ctx)
broker = _build_broker(config)
# See the `run` command: refutation is recorded on the trading paths (#691).
broker = _build_broker(config, repo=repo)

if not loop:
confirm_fn = _interactive_confirm
Expand Down Expand Up @@ -1336,6 +1341,7 @@ def simulate(
# -- scope (rail 20, per-venue trade-scope attestation, #233) -------------------------------------

# The `scope` group is defined in `keel.commands.scope`; register it here.
cli.add_command(posture_group)
cli.add_command(scope_group)


Expand Down
58 changes: 55 additions & 3 deletions keel/commands/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import functools
import sqlite3
import sys
import time
from dataclasses import replace
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -240,7 +241,56 @@ def _bound_venue_or_default(venue: str | None) -> str:
return current_venue() or DEFAULT_VENUE


def _build_broker(config: Config, *, timeout: int | None = None) -> Any:
def record_cash_posture_refutation(
broker: Any, *, repo: Any | None, venue: str, now_ts: int
) -> None:
"""Run the broker's cash-posture check and, if it REFUSES, mark the standing attestation
refuted before letting the refusal propagate (#691).

**Recording is in addition to failing closed, never instead.** The exception is re-raised
unchanged: swallowing it would turn a hard stop into a database row, leaving the account just
as wrong and the build proceeding anyway.

**REFUTE-ONLY.** A check that finds no contradiction touches nothing. Coinbase exposes no
cash-vs-margin field for spot, so "no INTX portfolio" is the absence of contradicting evidence
and not proof of a cash posture -- promoting a record on the strength of it would manufacture
exactly the affirmation #666 established cannot be had.

`repo=None` is the read-only call sites (`keel balances`, `keel brokers list`), which have no
repository in hand and will not go on to trade. The CHECK still runs and still refuses for
them; only the recording is skipped, so threading a repo through every inspection command to
record a fact none of them acts on is churn nobody needs.
"""
try:
broker.verify_cash_account()
except Exception:
if repo is not None:
# Imported here rather than at module scope: `keel.commands.posture` imports
# `_common` for the venue resolver, and a top-level import would close the cycle.
from keel.commands.posture import refute_posture

refute_posture(
repo,
venue=venue,
reason=_refutation_reason(),
now_ts=now_ts,
)
raise


def _refutation_reason() -> str:
"""The refusal's own message, for the operator surface. Read from the live exception rather
than composed here, so `doctor` and `posture show` quote what the venue check actually said
instead of a paraphrase that could drift from it."""
import sys

exc = sys.exc_info()[1]
return str(exc) if exc is not None else "venue posture check refused"


def _build_broker(
config: Config, *, timeout: int | None = None, repo: Any | None = None
) -> Any:
"""Construct the real, network-talking broker for the venue `config.broker` selects.

**Every name resolves through the registry (issue #524).** The `broker:` config section
Expand Down Expand Up @@ -305,7 +355,9 @@ def _build_broker(config: Config, *, timeout: int | None = None) -> Any:
# on an unreadable response for that reason: failing closed would refuse a compliant
# deployment on a network blip while establishing nothing. One `get_portfolios` read
# per build. See `CoinbaseAdapter.verify_cash_account` for the probe that settled it.
broker.verify_cash_account()
record_cash_posture_refutation(
broker, repo=repo, venue=venue, now_ts=int(time.time())
)
return broker

if module_root != "keel_broker_alpaca":
Expand Down Expand Up @@ -353,5 +405,5 @@ def _build_broker(config: Config, *, timeout: int | None = None) -> Any:
# position. Every command that builds a broker (agent cycle, fetch, monitor, the order
# paths) inherits it; one extra `/v2/account` read per build sits far inside FR-11's
# rate budget at the daily equities cadence.
broker.verify_cash_account()
record_cash_posture_refutation(broker, repo=repo, venue=venue, now_ts=int(time.time()))
return broker
113 changes: 113 additions & 0 deletions keel/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@
from typing import Any

import click
from keel_core.cash_posture import (
ATTESTATION_TTL_SEC,
MARGIN_ENABLED,
CashPostureState,
VenueCashPosture,
)
from keel_core.telemetry import current_venue
from keel_core.trade_scope import READ_ONLY, TRADING, TradeScopeState, VenueTradeScope

Expand Down Expand Up @@ -190,6 +196,108 @@ def _utc_date(ts: int) -> str:
return datetime.fromtimestamp(ts, tz=UTC).date().isoformat()


def cash_posture_findings(
record: VenueCashPosture | None, *, venue: str, now_ts: int
) -> list[Finding]:
"""Rail 22's standing report: has a human stated this posture, and is it still current?

**The warning window is the reason this exists rather than leaving the rail to speak.** Rail 22
vetoes an expired attestation, correctly and silently -- the live profile runs unattended
daily, so on the day it lapses entries stop and nothing has said why. Rail 17 already treats
an expiring attestation as WARN-worthy BEFORE it bites, and this TTL is thirteen times longer,
which makes the cliff more surprising rather than less: nobody remembers what they attested a
quarter ago.

The window is PROPORTIONAL to the TTL (a sixth of it, 15 days on 90), not rail 17's flat two
days. Two days is ~29% of a 7-day window and 2% of a 90-day one, and a daily profile that
misses two cycles for any reason would get no notice at all.

A MARGIN attestation is a FAIL whose fix is a change to the ACCOUNT, not a re-attestation --
telling an operator to re-attest would send them to type the same true answer again.
"""
attest = f"keel posture attest --spot-cash --venue {venue}"
if record is None:
return [
Finding(
"attest.cash_posture",
FAIL,
"cash posture never attested",
f"no venue exposes a cash-versus-margin field for spot, so nothing but you can "
f"supply this for {venue} -- rail 22 vetoes every live ENTRY until it is attested",
attest,
)
]
if record.state is CashPostureState.REFUTED:
reason = f": {record.refuted_reason}" if record.refuted_reason else ""
return [
Finding(
"attest.cash_posture",
FAIL,
"venue evidence contradicts the attested posture",
f"{venue}{reason} -- evidence outranks the claim, and rail 22 vetoes live "
"ENTRIES until the account is changed and re-attested",
attest,
)
]
if record.attested_posture == MARGIN_ENABLED:
return [
Finding(
"attest.cash_posture",
FAIL,
"account attested as MARGIN-ENABLED",
f"{venue} is attested MARGIN-ENABLED, so a sell can fill as a short -- rail "
"22 vetoes live ENTRIES. This needs a change to the ACCOUNT, not a "
"re-attestation",
"disable margin at the venue, then re-attest",
)
]
due = record.attest_due_ts
if due is None:
return [
Finding(
"attest.cash_posture",
FAIL,
"cash posture attested without a due date",
f"{venue}'s record carries no expiry, which rail 22 refuses rather than treating "
"as never expiring",
attest,
)
]
remaining = _days(due - now_ts)
if due <= now_ts:
return [
Finding(
"attest.cash_posture",
FAIL,
"cash posture attestation expired",
f"{venue} expired {_days(now_ts - due)} day(s) ago; rail 22 vetoes "
"live ENTRIES",
attest,
)
]
if due - now_ts <= ATTESTATION_TTL_SEC // 6:
return [
Finding(
"attest.cash_posture",
WARN,
"cash posture attestation due soon",
f"{remaining} day(s) remain on {venue}'s "
f"{ATTESTATION_TTL_SEC // 86_400}-day TTL -- rail 22 vetoes live ENTRIES the "
"moment it lapses",
attest,
)
]
return [
Finding(
"attest.cash_posture",
OK,
"cash posture attested and current",
f"{venue}: {record.attested_posture}, {remaining} day(s) remain",
"-",
)
]


def trade_scope_findings(record: VenueTradeScope | None, venue: str) -> list[Finding]:
"""Rail 20 (#233) -- whether `venue`'s credential has proven (or can at least claim) it may
place a live entry. Takes the record directly, like `attestation_findings` takes
Expand Down Expand Up @@ -1261,6 +1369,11 @@ def gather_findings(repo: Any, config: Any, log_lines: Iterable[str], now_ts: in
now_ts=now_ts,
)
findings += trade_scope_findings(repo.get_venue_trade_scope(venue), venue)
# #691. Venue-keyed the same way, and reported BEFORE it bites: rail 22 vetoes silently on
# a lapse, and the live profile runs unattended.
findings += cash_posture_findings(
repo.get_venue_cash_posture(venue), venue=venue, now_ts=now_ts
)
findings += rail_state_findings(
kill_switch=bool(repo.get_state("kill_switch", default=False)),
streak_halt_until=int(repo.get_state("streak_halt_until", default=0) or 0),
Expand Down
Loading
Loading