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
117 changes: 114 additions & 3 deletions keel/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,24 @@ def assets_group() -> None:
_LIQUIDITY_PROBE_DAYS = 180


#: The venue every product screened here is listed on.
#:
#: ⚠️ A CONSTANT because it is currently a fact, not a configuration. The live path constructs
#: `keel/data/cb_client.py`'s `CoinbaseClient` directly, so there is exactly one venue these
#: product ids can mean, and an `InstrumentAttestation` is keyed on `(venue, product_id)` --
#: which means the screen needs a venue id to look one up, and inventing a per-call parameter
#: for a value with one possible answer would be a knob whose only safe setting is its default.
#:
#: The broker-port migration replaces this with the adapter's own `BrokerCapabilities.venue`
#: (`packages/keel-broker-api/keel_broker_api/capabilities.py`), at which point the wrapper
#: statement recorded for `BTC-USD` on Coinbase correctly stops applying to `BTC-USD` somewhere
#: else -- which is issue #202's entire point and the reason the key is a pair. Until an adapter
#: handle actually reaches this function, reading a venue id off one would be reading it off
#: nothing: the same dead-gate pattern `capabilities.py` warns about, where a lookup that cannot
#: fail reads as a defence.
_VENUE = "coinbase"


def _market_facts(repo: Repository, product: str, quote: str) -> screen_mod.MarketFacts:
"""Everything the screen can compute for itself from data we already hold."""
asset = product.split("-")[0]
Expand All @@ -535,6 +553,8 @@ def _market_facts(repo: Repository, product: str, quote: str) -> screen_mod.Mark
# Carried, not reduced: `screen_asset` applies rail 19's grammar to it, so the screen's
# shape verdict and the rail's cannot disagree, and the verdict can name the id.
product_id=product,
# The other half of the key the instrument statement is recorded under. See `_VENUE`.
venue=_VENUE,
)


Expand All @@ -547,6 +567,11 @@ def _screen_product(
all route through here, so none of them can drift onto a laxer path -- which is what makes
"the same vetting process" a property of the code rather than an intention. Returns the facts
alongside the verdict so a caller can explain WHY without recomputing them.

The instrument statement is looked up HERE, next to the asset attestation, rather than being
threaded in by each caller -- that is what makes the wrapper criterion inherit the same
single-decision-point property as everything else on this path. Three callers get the new
check with no per-caller wiring, and none of them can be the one that forgot it.
"""
asset = product.split("-")[0]
facts = _market_facts(repo, product, quote)
Expand All @@ -564,8 +589,23 @@ def _screen_product(
if raw is not None
else None
)
raw_instrument = repo.get_instrument_attestation(_VENUE, product)
instrument = (
screen_mod.InstrumentAttestation(
venue=raw_instrument["venue"],
product_id=raw_instrument["product_id"],
wrapper=raw_instrument["wrapper"],
source=raw_instrument["source"],
attested_by=raw_instrument["attested_by"],
attested_at=raw_instrument["attested_at"],
)
if raw_instrument is not None
else None
)
waived = repo.get_screen_exceptions(asset)
return facts, screen_mod.screen_asset(facts, attestation, waived=waived)
return facts, screen_mod.screen_asset(
facts, attestation, waived=waived, instrument=instrument
)


# Never candidates: you cannot trade the currency you settle in, and fiat is funding rather than
Expand Down Expand Up @@ -975,6 +1015,65 @@ def assets_attest(
click.echo(f"attested {asset}: sector={sector} backing={backing} pays_yield={pays_yield}")


@assets_group.command("attest-instrument")
@click.option("--venue", default=_VENUE, show_default=True, help="Venue the product is listed on.")
@click.option("--product", required=True, help="Venue product id, e.g. BTC-USD.")
@click.option(
"--wrapper",
required=True,
type=click.Choice(sorted(screen_mod.KNOWN_WRAPPERS)),
help="What CONTRACT this listing is. Only 'spot' admits; every other value is a refusal.",
)
@click.option(
"--source",
required=True,
help="Where this was established: the venue's contract spec, its API docs, a filing.",
)
@click.option("--attested-by", required=True, help="Who established it.")
@click.pass_context
@with_disclaimer
def assets_attest_instrument(
ctx: click.Context,
venue: str,
product: str,
wrapper: str,
source: str,
attested_by: str,
) -> None:
"""Record what CONTRACT a venue listing is. A claim about the PRODUCT, not the asset.

`keel assets attest` says what the underlying is -- sector, backing, yield. This says what
you actually get when you buy this listing, and the two are genuinely independent: the honest
asset attestation for the underlying of a BTC CFD is BTC's existing, already-admitted one, so
nothing recorded there can ever surface the leverage, swap financing or counterparty exposure
that the CFD adds (issue #202).

Keyed per `(venue, product)` rather than per asset because one venue lists several contracts
on the same base leg -- Coinbase quotes both `BTC-USD` and `BTC-PERP-USD` -- so a per-asset
wrapper claim would be wrong on the venue keel already uses, not merely imprecise later.

This is ATTESTED and cannot be derived. The id's shape does not answer it (a CFD broker
spells its contract `BTC-USD`, identical to spot), and the venue's own `product_type` field
is its self-report about its own product -- excellent evidence to cite in `--source`, and not
a substitute for a human making the claim.

Not passphrase-gated, for the same reason `keel assets attest` is not: an attestation cannot
itself place an order or raise a cap, and the screen it feeds only ever ADMITS to a list that
`guards.py` rail 1 still enforces per-trade.
"""
product = product.upper() # matches the uppercase ids `_screen_product` looks up by
repo = _open_repo(ctx)
repo.upsert_instrument_attestation(
venue=venue,
product_id=product,
wrapper=wrapper,
source=source,
attested_by=attested_by,
attested_at=int(time.time()),
)
click.echo(f"attested {product} on {venue}: wrapper={wrapper}")


@assets_group.command("exempt")
@click.option("--asset", required=True, help="Asset code, e.g. PAXG.")
@click.option(
Expand Down Expand Up @@ -1052,18 +1151,30 @@ def assets_unexempt(ctx: click.Context, asset: str, criterion: str) -> None:
@assets_group.command("list")
@click.pass_context
def assets_list(ctx: click.Context) -> None:
"""List recorded attestations and any documented screen exceptions."""
"""List recorded attestations and any documented screen exceptions.

Both KINDS of attestation are shown, because admission now requires both and an operator
reading only the asset list would see a fully-attested allowlist that still screens REJECT.
"""
repo = _open_repo(ctx)
rows = repo.get_asset_attestations()
instruments = repo.get_instrument_attestations()
exceptions = repo.list_screen_exceptions()
if not rows and not exceptions:
if not rows and not instruments and not exceptions:
click.echo("no attestations recorded")
return
for row in rows:
click.echo(
f"{row['asset']:<8} sector={row['sector']:<16} backing={row['backing']:<8} "
f"pays_yield={bool(row['pays_yield'])!s:<5} by={row['attested_by']}"
)
if instruments:
click.echo("\ninstruments:")
for row in instruments:
click.echo(
f"{row['product_id']:<14} venue={row['venue']:<10} "
f"wrapper={row['wrapper']:<16} by={row['attested_by']}"
)
if exceptions:
click.echo("\nexceptions:")
for row in exceptions:
Expand Down
119 changes: 114 additions & 5 deletions keel/compliance/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@
BACKING_NATIVE = "native" # a base-layer coin, neither a claim nor a warehouse receipt
KNOWN_BACKINGS = frozenset({BACKING_AYN, BACKING_DAYN, BACKING_NATIVE})

#: §71.4a: the allowlist is not juristically homogeneous, so admission has to name the CONTRACT,
#: not just the underlying. `spot` is the only wrapper this policy admits; every other name here
#: exists so that refusing it is an explicit, recorded classification rather than a shrug.
WRAPPER_SPOT = "spot"
KNOWN_WRAPPERS = frozenset(
{WRAPPER_SPOT, "cfd", "future", "perpetual", "option", "leveraged_token"}
)

#: Criteria a documented, human-recorded exception (`keel assets exempt`) may EVER waive. Only
#: DATA/market criteria belong here -- history depth, liquidity, that kind of thing -- because
#: those are facts about our own cache, not about the asset's shariah status. The shariah
Expand Down Expand Up @@ -86,6 +94,39 @@ class AssetAttestation:
attested_at: int


@dataclass(frozen=True)
class InstrumentAttestation:
"""What CONTRACT a venue listing actually is. A separate claim from `AssetAttestation`.

The two are complementary, and keeping them apart is the point rather than an accident of
layout. Sector, backing and yield are facts about the UNDERLYING -- they are true of BTC
wherever BTC is quoted. "What is this listing" is a fact about a VENUE'S PRODUCT, and the
honest asset attestation for the underlying of a BTC CFD is character-for-character BTC's
existing spot one: `sector=payments, backing=native, pays_yield=False`. That is issue #202 in
one sentence -- leverage, swap financing and counterparty exposure are properties of the
contract, so no amount of care taken over the asset claim can ever surface them.

**Keyed on `product_id`, not on `(venue, asset)`.** Coinbase -- the one venue keel already
uses -- lists both `BTC-USD` and `BTC-PERP-USD` against the same base leg, so a per-asset
wrapper claim would be factually wrong today, not merely imprecise once a second venue lands.
The key has to be the thing being traded.

**Attested, not computed, and that is the whole reason the type exists.** The id's shape
cannot answer it: a cTrader CFD spells itself `BTC-USD`, which is exactly the gap --
`parse_spot_product_id` reads that as a well-formed spot id and is right to, because the
grammar is all it has. Nor is the venue's own metadata a substitute: `product_type` is the
venue's self-report about its own product, which makes it excellent INPUT to the human's
`source` and unacceptable as the claim itself. Fail closed, like every other attestation here.
"""

venue: str
product_id: str
wrapper: str # one of KNOWN_WRAPPERS; only WRAPPER_SPOT admits
source: str # where this was established -- venue docs, a contract spec, a regulator filing
attested_by: str
attested_at: int


@dataclass(frozen=True)
class MarketFacts:
"""Everything the screen can compute for itself."""
Expand All @@ -104,6 +145,16 @@ class MarketFacts:
#: so a construction site that forgets it must fail loudly at the call, not quietly at the
#: verdict.
product_id: str
#: The venue the product is listed on. Half of the key an `InstrumentAttestation` is recorded
#: under, and carried here so `screen_asset` can check that the statement on file is about
#: THIS listing rather than a same-named one elsewhere -- `BTC-USD` on Coinbase is spot and
#: `BTC-USD` on a CFD broker is not, and the id alone cannot tell them apart.
#:
#: NO default, for `product_id`'s reason exactly. A defaulted venue would have to name some
#: venue, and naming the venue keel currently trades on would make every forgotten call site
#: silently inherit "Coinbase, therefore spot" -- the fail-OPEN answer to the one question
#: this field was added to ask. A construction site that forgets it must fail at the call.
venue: str


@dataclass(frozen=True)
Expand Down Expand Up @@ -137,16 +188,23 @@ def screen_asset(
attestation: AssetAttestation | None,
policy: ScreenPolicy | None = None,
waived: Mapping[str, str] | None = None,
instrument: InstrumentAttestation | None = None,
) -> ScreenResult:
"""Deterministic admission decision. `attestation=None` fails closed.
"""Deterministic admission decision. `attestation=None` and `instrument=None` both fail closed.

TWO attestations are required, and they answer different questions. `attestation` says what
the UNDERLYING is; `instrument` says what the LISTING is. Either one missing is a rejection,
and both missing produce both failures in a single run rather than one at a time -- an
operator should learn every action they owe from one `keel assets screen`, not discover the
second only after satisfying the first.

`waived` is `{criterion: rationale}` from a documented human exception (`keel assets
exempt` / `repository.get_screen_exceptions`). It is consulted ONLY when a check would
otherwise FAIL, and ONLY for criteria in `WAIVABLE_CRITERIA` -- a waiver for anything else
(a stray `screen_exceptions` row for, say, `attestation`) is silently ignored and that
criterion still fails closed. A waiver never affects any criterion other than its own, and a
blank/whitespace rationale is treated as no waiver at all (fail closed -- see the `.strip()`
check below, mirroring the unsourced-attestation guard further down).
(a stray `screen_exceptions` row for, say, `attestation` or `instrument_wrapper`) is silently
ignored and that criterion still fails closed. A waiver never affects any criterion other than
its own, and a blank/whitespace rationale is treated as no waiver at all (fail closed -- see
the `.strip()` check below, mirroring the unsourced-attestation guard further down).
"""
policy = policy or ScreenPolicy()
# Filtered ONCE, up front, rather than inline per-branch: this is the actual defense-in-depth
Expand Down Expand Up @@ -211,6 +269,57 @@ def screen_asset(
"order for it"
)

# The ATTESTED half of the same question, and the reason `spot_instrument` above is not
# enough on its own. That check reads the id's GRAMMAR, which is all an id can offer and is
# exactly why it cannot close this gap: a cTrader CFD is spelled `BTC-USD`, parses clean, and
# is not spot. The two criteria are complementary, deliberately not merged, and both fire for
# a derivative-shaped id attested as spot -- a venue whose ids lie about the contract and a
# human who mis-states it are different failures, and collapsing them would let either hide
# behind the other.
#
# Not in `DATA_DERIVED_FAILURES`, for `settlement`'s reason: this consults an attestation and
# never touches candles, so it stays a real, assessable verdict at zero bars. A candidate we
# have never fetched is still one we can say "nobody has told us what contract this is" about.
#
# Not in `WAIVABLE_CRITERIA` either, and issue #202 says so explicitly. A waiver here would be
# a documented exception permitting a derivative, which is the charter, not a threshold.
if instrument is None or (instrument.venue, instrument.product_id) != (
facts.venue,
facts.product_id,
):
# A mismatch is treated as ABSENCE, not as a mismatch worth reporting in its own right.
# `_screen_product` looks the row up BY this pair, so the two can only diverge via a
# direct caller passing a statement about some other listing -- and a claim about a
# different product is not weaker evidence about this one, it is no evidence at all.
failures.append(
f"instrument_wrapper: UNATTESTED for {facts.product_id!r} on {facts.venue!r}. Which "
"CONTRACT a venue lists cannot be read off the id -- a CFD can spell itself exactly "
"like spot -- so an unattested listing is unknown, and unknown is a rejection (fail "
"closed). Record one with `keel assets attest-instrument`."
)
else:
wrapper = instrument.wrapper.strip().lower()
if wrapper not in KNOWN_WRAPPERS:
failures.append(
f"instrument_wrapper: {wrapper!r} is not one of {sorted(KNOWN_WRAPPERS)} -- "
"classify it explicitly rather than leaving it open (§71.4a)"
)
elif wrapper != WRAPPER_SPOT:
failures.append(
f"instrument_wrapper: {wrapper!r} -- keel is spot-only, and this listing is a "
"derivative on the underlying rather than the underlying itself. Leverage, swap "
"financing and counterparty exposure are properties of the CONTRACT, so they "
"survive any attestation about the asset: the base leg being admissible says "
"nothing about this wrapper (§65.6/§65.11, §71.4a)"
)
if not instrument.source.strip():
# Mirrors the unsourced-attestation guard below. Reported ALONGSIDE any wrapper
# verdict above rather than instead of it, because "spot, but nobody said where that
# came from" is precisely the unsourced claim that must not admit.
failures.append(
"instrument_wrapper: no source recorded -- an unsourced claim is not evidence"
)

# -- attested shariah classification ---------------------------------------
if attestation is None:
failures.append(
Expand Down
34 changes: 33 additions & 1 deletion keel/data/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from pathlib import Path
from typing import Any

SCHEMA_VERSION = 9
SCHEMA_VERSION = 10

# Creation order matters for readability (and for backends that validate FK targets eagerly);
# SQLite itself only checks FK targets at DML time, but we still declare referenced tables first.
Expand Down Expand Up @@ -239,6 +239,17 @@
)
""",
"""
CREATE TABLE IF NOT EXISTS instrument_attestations (
venue TEXT NOT NULL,
product_id TEXT NOT NULL,
wrapper TEXT NOT NULL,
source TEXT NOT NULL,
attested_by TEXT NOT NULL,
attested_at INTEGER NOT NULL,
PRIMARY KEY (venue, product_id)
)
""",
"""
CREATE TABLE IF NOT EXISTS screen_exceptions (
asset TEXT NOT NULL,
criterion TEXT NOT NULL,
Expand Down Expand Up @@ -420,6 +431,26 @@ def _migrate_v9_screen_exceptions(conn: sqlite3.Connection) -> None:
"""


def _migrate_v10_instrument_attestations(conn: sqlite3.Connection) -> None:
"""v10 adds `instrument_attestations`. Table creation is handled by `_SCHEMA_STATEMENTS`;
there is deliberately NO backfill.

A row asserts a human established what CONTRACT a given venue listing actually is (spot,
CFD, perpetual, ...) against a named source. Seeding `spot` rows for the currently-allowlisted
products would fabricate exactly the claim this gap exists to demand -- and would do it for
the products the project is most likely to stop questioning.

Like v9, this is a genuine no-op migration: `migrate()` runs every `_SCHEMA_STATEMENTS`
statement (all `IF NOT EXISTS`) before the version loop below, so a database already stamped
at v9 picks the table up from that pass alone. This step only exists to advance the stamp.

An empty table correctly says nothing has been attested yet -- which, because the screen
fails closed on a missing instrument attestation, means every product reports REJECT until
the operator runs `keel assets attest-instrument`. That is the intended fail-closed default,
not a regression.
"""


_MIGRATIONS: dict[int, Callable[[sqlite3.Connection], None]] = {
2: _migrate_v2_broker_subscriptions,
3: _migrate_v3_trade_outcomes,
Expand All @@ -429,6 +460,7 @@ def _migrate_v9_screen_exceptions(conn: sqlite3.Connection) -> None:
7: _migrate_v7_profile,
8: _migrate_v8_autonomy_expiry,
9: _migrate_v9_screen_exceptions,
10: _migrate_v10_instrument_attestations,
}


Expand Down
Loading
Loading