From 8f7c94dc711147782885f64fb75a47ba7906ee3c Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 3 Sep 2026 15:16:30 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(compliance):=20rail=2022=20=E2=80=94?= =?UTF-8?q?=20the=20cash=20posture=20no=20venue=20will=20affirm=20(#691,?= =?UTF-8?q?=20part=201=20of=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of #666. Schema v18. WHAT STAGE 1 ESTABLISHED, AND WHY IT CANNOT BE FIXED WITH ANOTHER READ. Coinbase exposes no cash-versus-margin field for spot. The 2026-09-02 probe found `margin_rate` present in every account's response schema and carrying `null` -- so a check keyed on its presence would refuse every deployment -- and portfolios `DEFAULT`/`CONSUMER` with no INTX. Every margin, borrow, leverage and liquidation field in the SDK lives in the futures or perpetuals types. The adapter check therefore REFUTES and never issues: an INTX portfolio proves derivatives are available, its absence proves nothing. That residual is the same shape as rail 17's `qabd`. Silence is not evidence of possession, and silence is not evidence of a cash account. What closes it is a human who knows their own account saying so, on the record, with the venue able to contradict them. TWO DESIGN CHOICES DIFFER FROM `VenueTradeScope`, AND BOTH FOLLOW FROM THAT: * **No `CONFIRMED` state**, and a test pins its absence. Trade scope earns one because the venue re-proves it on every accepted placement. NOTHING can ever prove a spot account is cash-only, so `CONFIRMED` would be a state nothing is entitled to write -- and an unreachable state is one a later reader eventually writes anyway. * **A TTL, where trade scope has none.** Trade scope needs no clock precisely because the venue re-confirms it continuously. This record has NO observation channel, which is `VenueSubscription`'s situation, and there a due date is the only thing between a lapsed claim and a live spend. 90 days, not rail 17's 7: an account does not silently acquire margin, and a prompt that fires weekly gets automated, which is how an attestation stops meaning anything. The due date is STORED, not derived, so changing the TTL cannot retroactively expire a claim a human made under the window in force when they made it. Rail 22 vetoes new ENTRIES and nothing else -- exits, stop rolls, cancels and DCA exits are untouched, because a rail that blocked an exit over a fact about the ACCOUNT would strand a position that wanted out. It is in `LIVE_STATE_RAILS`, so paper skips it AND reports the skip. FOUR distinct veto messages, because there is no venue read to fall back on and the message IS the interface: never-attested, expired, attested-margin (which calls for a change to the ACCOUNT, not a re-run of the command), and a DIFFERENT credential -- never described as "never attested", which is #624's exact mistake. `keel posture attest --spot-cash` is TTY-gated and declared in `keel/capabilities.py`; `--margin-enabled` is ungated, because it only ever reduces capability -- `keel autonomy`'s asymmetry. Recording margin is better than refusing to write: absent reads as "nobody has attested", which is a less useful fact and invites the same person to be asked again next week. `refute_posture` is deliberately not symmetric: a human issues a claim, only the venue withdraws one. With no record it writes nothing rather than inventing a history, and `refuted_ts` marks the FIRST contradiction of a claim rather than advancing every build. Tests written first, red before green: 42 across four files (16 core, 10 storage, 9 rail, 11 CLI, 6 refutation). Mutation-verified, 9 mutants, all killed: the entries-only gate removed; the paper skip removed; a missing record failing open; the TTL ignored; a margin attestation permitting; due-at off by one; refutation inventing a record; attest not stamping a due date; a refuted record permitting. A tenth was written and discarded as ineffective -- `del is_buy` after the branch changes nothing, and counting it would have inflated the run. The rail-count pin did its job and exposed two bugs IN ITSELF along the way: it read "the 20% rail" (rail 11's drawdown threshold) as a count claim, and `\btwenty\b` matched inside "twenty-one" because a hyphen is a word boundary, so every line it had just corrected still read as stale. Both are fixed with the reasoning inline. Refs #691 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NzuKAe2RVrPt9acVAWjRyL --- README.md | 2 +- docs/fiqh-basis.md | 2 +- docs/launch.md | 2 +- keel/capabilities.py | 12 + keel/cli.py | 2 + keel/commands/posture.py | 222 +++++++++++++++++++ keel/commands/setup.py | 2 +- keel/data/db.py | 40 +++- keel/data/repository.py | 73 ++++++ keel/execution/executor.py | 6 +- keel/execution/guards.py | 91 +++++++- keel/web/__init__.py | 2 +- keel/web/security.py | 2 +- keel/web/server.py | 4 +- keel/web/static/js/api.js | 2 +- keel/web/static/js/render.js | 2 +- packages/keel-core/keel_core/cash_posture.py | 169 ++++++++++++++ tests/commands/test_posture_cli.py | 173 +++++++++++++++ tests/commands/test_posture_refutation.py | 113 ++++++++++ tests/conftest.py | 49 ++++ tests/core/test_cash_posture.py | 184 +++++++++++++++ tests/data/test_cash_posture_store.py | 131 +++++++++++ tests/data/test_db.py | 4 +- tests/data/test_migrations.py | 8 +- tests/data/test_trade_outcomes.py | 4 +- tests/execution/test_cash_posture_rail.py | 160 +++++++++++++ tests/execution/test_executor.py | 7 +- tests/execution/test_guards.py | 12 +- tests/execution/test_rail_count.py | 11 +- tests/sim/test_account.py | 5 +- tests/test_agent.py | 10 +- tests/test_hostile_rule_rails.py | 4 +- 32 files changed, 1478 insertions(+), 32 deletions(-) create mode 100644 keel/commands/posture.py create mode 100644 packages/keel-core/keel_core/cash_posture.py create mode 100644 tests/commands/test_posture_cli.py create mode 100644 tests/commands/test_posture_refutation.py create mode 100644 tests/core/test_cash_posture.py create mode 100644 tests/data/test_cash_posture_store.py create mode 100644 tests/execution/test_cash_posture_rail.py diff --git a/README.md b/README.md index 9d2fd769..e739d9b3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/fiqh-basis.md b/docs/fiqh-basis.md index c0eefa6e..dfa5b62b 100644 --- a/docs/fiqh-basis.md +++ b/docs/fiqh-basis.md @@ -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: diff --git a/docs/launch.md b/docs/launch.md index 7a2c9d3c..283853fa 100644 --- a/docs/launch.md +++ b/docs/launch.md @@ -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. > diff --git a/keel/capabilities.py b/keel/capabilities.py index 5c46442a..19822a8c 100644 --- a/keel/capabilities.py +++ b/keel/capabilities.py @@ -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", diff --git a/keel/cli.py b/keel/cli.py index 39e2288f..5894c034 100644 --- a/keel/cli.py +++ b/keel/cli.py @@ -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 @@ -1336,6 +1337,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) diff --git a/keel/commands/posture.py b/keel/commands/posture.py new file mode 100644 index 00000000..9d438490 --- /dev/null +++ b/keel/commands/posture.py @@ -0,0 +1,222 @@ +"""`keel posture` -- the operator's statement about an account no venue will describe. + +Stage 2 of #666, issue #691. Stage 1 established that Coinbase exposes NO cash-versus-margin +field for spot: `margin_rate` sits in every account's response schema carrying `null`, and every +margin/borrow/leverage/liquidation field in the SDK lives in the futures or perpetuals types. The +adapter's check therefore REFUTES and never issues -- an INTX portfolio proves derivatives are +available, its absence proves nothing. + +What closes that residual is a human who knows their own account saying so, on the record, with +the venue able to contradict them. This module is that record's front door, and rail 22 reads it. + +**The vocabulary is `keel scope`'s, deliberately.** `attest` writes the claim, `show` reports it, +the venue resolves the same way, and re-attesting over a refutation is allowed because that is +how an operator reports "I closed the derivative portfolio". A new vocabulary for the same shape +would be a second thing to learn and a second thing to get wrong. + +**The one real difference is the clock.** Trade scope carries no TTL because the venue re-confirms +it on every accepted placement. Nothing re-confirms this, ever, so the due date is the only thing +between a lapsed claim and a live entry -- `VenueSubscription`'s situation and `VenueSubscription`'s +remedy. The due date is STORED rather than derived at read time, so changing `ATTESTATION_TTL_SEC` +cannot retroactively expire a claim a human made under the window in force when they made it, nor +silently revive one that had already lapsed. +""" + +from __future__ import annotations + +import time +from datetime import UTC, datetime + +import click +from keel_core.cash_posture import ( + ATTESTATION_TTL_SEC, + MARGIN_ENABLED, + SPOT_CASH, + CashPostureState, + VenueCashPosture, +) +from keel_core.credential_identity import current_credential_fingerprint + +from keel.commands._common import ( + _bound_venue_or_default, + _open_repo, + _require_interactive_confirmation, + with_disclaimer, +) +from keel.data.repository import Repository + + +def _utc_date(ts: int) -> str: + return datetime.fromtimestamp(ts, tz=UTC).strftime("%Y-%m-%d") + + +def apply_posture_attest( + repo: Repository, *, venue: str | None, spot_cash: bool, now_ts: int +) -> str: + """`posture attest`'s write: resolve the venue, upsert the record, return the confirmation. + + **`spot_cash=False` records `MARGIN_ENABLED` rather than refusing to write.** An operator + whose account really does have margin must be able to say so; leaving the record absent would + read as "nobody has attested" in every report, which is a less useful fact and invites the + same person to be asked again next week. The record then vetoes entries, which is correct -- + on a margin-enabled account a sell can fill as a short, which is *bay' ma la yamlik*. + + **Re-attesting over a refuted record is allowed, by design** -- it is how an operator reports + "I closed the derivative portfolio". `refuted_ts`/`refuted_reason` are carried FORWARD, not + cleared: they are history about this venue, not a property of the claim being made now, and + `doctor`/`posture show` must still be able to say the venue once contradicted a claim here. + Only `state`, `attested_posture`, `attested_ts` and `attest_due_ts` are the fresh facts. + + `credential_fingerprint` (#633) is stamped with the CURRENT credential rather than carried + forward: the operator is attesting about the account the credential in place right now + reaches, and carrying an old fingerprint would bind a fresh claim to a possibly + already-rotated-away credential. + """ + resolved_venue = _bound_venue_or_default(venue) + existing = repo.get_venue_cash_posture(resolved_venue) + posture = SPOT_CASH if spot_cash else MARGIN_ENABLED + + repo.upsert_venue_cash_posture( + VenueCashPosture( + venue=resolved_venue, + state=CashPostureState.ATTESTED, + attested_posture=posture, + attested_ts=now_ts, + attest_due_ts=now_ts + ATTESTATION_TTL_SEC, + refuted_ts=existing.refuted_ts if existing is not None else None, + refuted_reason=existing.refuted_reason if existing is not None else None, + credential_fingerprint=current_credential_fingerprint(resolved_venue), + ) + ) + due = _utc_date(now_ts + ATTESTATION_TTL_SEC) + line = f"attested {resolved_venue}: posture={posture}, expires {due}" + if spot_cash: + line += " -- rail 22 may now place live ENTRIES against this account" + else: + line += " -- rail 22 will veto live ENTRIES against this account" + if existing is not None and existing.refuted_ts is not None: + line += ( + " (venue evidence contradicted a claim on this venue on " + f"{_utc_date(existing.refuted_ts)})" + ) + return line + + +def refute_posture(repo: Repository, *, venue: str, reason: str, now_ts: int) -> bool: + """Record that venue evidence CONTRADICTS `venue`'s standing cash-posture claim. + + Returns whether anything was written. **Deliberately NOT symmetric with attestation:** a + human may issue a claim, and only the venue may withdraw one. With no record to refute this + writes nothing and returns `False` -- creating a `REFUTED` row from nothing would invent a + history keel never had, and "no claim" is already a veto, so there is nothing to improve by + fabricating one. + + `refuted_ts` is set only on the FIRST contradiction of a given claim. Advancing it on every + build would report the most recent cycle instead of the discovery, and the discovery is the + moment the claim stopped being true as far as keel can tell. A re-attestation resets the + cycle: the next contradiction is a new discovery about a new claim, and its timestamp moves. + + The attestation columns are preserved. `doctor` and `posture show` must be able to say WHAT + was claimed and when, and an operator asked to re-attest deserves to be told what they said + last time. + """ + existing = repo.get_venue_cash_posture(venue) + if existing is None: + return False + already_refuted = existing.state is CashPostureState.REFUTED and existing.refuted_ts is not None + repo.upsert_venue_cash_posture( + VenueCashPosture( + venue=existing.venue, + state=CashPostureState.REFUTED, + attested_posture=existing.attested_posture, + attested_ts=existing.attested_ts, + attest_due_ts=existing.attest_due_ts, + refuted_ts=existing.refuted_ts if already_refuted else now_ts, + refuted_reason=existing.refuted_reason if already_refuted else reason, + credential_fingerprint=existing.credential_fingerprint, + ) + ) + return True + + +def posture_show_lines(repo: Repository, *, now_ts: int) -> list[str]: + """`posture show`'s exact lines, as a function of the repo -- `doctor` renders the same facts. + + Reports EXPIRY as its own column, not folded into a pass/fail. "Expired" and "attested + margin" both stop a live entry, but one calls for a re-attestation and the other for a change + to the account, and an operator who cannot tell them apart will do the wrong one. + """ + records = repo.list_venue_cash_postures() + if not records: + return [ + "no venue has an attested cash posture.", + " No venue exposes a cash-versus-margin field for spot, so nothing can supply this " + "but you -- rail 22 vetoes live ENTRIES until it is attested.", + " Check the account, then: `keel posture attest --spot-cash`", + ] + lines = ["cash posture, by venue:"] + for record in records: + state = record.state.value.upper() + if record.state is CashPostureState.ATTESTED and not record.is_current(now_ts): + state = "EXPIRED" + lines.append( + f" {record.venue}: {state} posture={record.attested_posture} " + f"attested={_utc_date(record.attested_ts) if record.attested_ts else '-'} " + f"expires={_utc_date(record.attest_due_ts) if record.attest_due_ts else '-'}" + ) + if record.refuted_ts is not None: + reason = f": {record.refuted_reason}" if record.refuted_reason else "" + lines.append( + f" venue evidence contradicted a claim here on " + f"{_utc_date(record.refuted_ts)}{reason}" + ) + return lines + + +@click.group("posture") +def posture_group() -> None: + """What you have established about a venue account's cash-versus-margin posture.""" + + +@posture_group.command("attest") +@click.option("--venue", "venue", default=None, help="Venue to attest (default: the bound venue).") +@click.option( + "--spot-cash/--margin-enabled", + "spot_cash", + required=True, + help="Whether this account is cash-only spot, or can borrow/short.", +) +@click.pass_context +@with_disclaimer +def posture_attest(ctx: click.Context, venue: str | None, spot_cash: bool) -> None: + """Record what you have established about this venue account's posture. + + GATED at a terminal when attesting `--spot-cash`, and deliberately ungated for + `--margin-enabled`: the first RELEASES rail 22 and is the only statement in keel that can + permit a live entry on the strength of a human's word alone, so it demands a typed `yes` from + a person. The second only ever reduces capability, so it must work from a script -- the same + asymmetry `keel autonomy` draws. + """ + repo = _open_repo(ctx) + resolved = _bound_venue_or_default(venue) + if spot_cash: + _require_interactive_confirmation( + f"attest that {resolved} is a CASH-ONLY spot account", + "No venue exposes this field for spot, so nothing can check you. Rail 22 will " + "permit live ENTRIES on the strength of this statement alone, and a margin-enabled " + "account can fill a sell as a SHORT. Confirm you have checked the account itself.", + ) + click.echo( + apply_posture_attest( + repo, venue=venue, spot_cash=spot_cash, now_ts=int(time.time()) + ) + ) + + +@posture_group.command("show") +@click.pass_context +@with_disclaimer +def posture_show(ctx: click.Context) -> None: + """Print every venue's cash-posture record.""" + for line in posture_show_lines(_open_repo(ctx), now_ts=int(time.time())): + click.echo(line) diff --git a/keel/commands/setup.py b/keel/commands/setup.py index 042414bc..dcd273e9 100644 --- a/keel/commands/setup.py +++ b/keel/commands/setup.py @@ -941,7 +941,7 @@ def _state_as_json(state: DeploymentState) -> dict[str, Any]: # outcome that changed nothing -- never an overwrite, and there is deliberately no `force` # parameter for any web caller to pass. # -# 4. Nothing here increases what keel can DO. Not one of the eight capability-increasing actions +# 4. Nothing here increases what keel can DO. Not one of the nine capability-increasing actions # in `keel/capabilities.py` is reachable from this module, and a test asserts the two sets are # disjoint. Creating a config, a schema and a library of CANDIDATE rules leaves an engine that # still places nothing: candidates trade nothing until a human promotes them, and promotion is diff --git a/keel/data/db.py b/keel/data/db.py index 8ed2ef74..eab962d2 100644 --- a/keel/data/db.py +++ b/keel/data/db.py @@ -20,7 +20,7 @@ from pathlib import Path from typing import Any -SCHEMA_VERSION = 17 +SCHEMA_VERSION = 18 # 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. @@ -265,6 +265,28 @@ ) """, """ + CREATE TABLE IF NOT EXISTS venue_cash_postures ( + venue TEXT PRIMARY KEY, + state TEXT NOT NULL, + -- `spot_cash` | `margin_enabled` | NULL. NULL only alongside state `unverified`. + attested_posture TEXT, + attested_ts INTEGER, + -- When the attestation expires. Deliberately stored rather than derived, so changing + -- `ATTESTATION_TTL_SEC` cannot retroactively expire (or extend) a claim a human made + -- under the window that was in force when they made it. + attest_due_ts INTEGER, + -- Set when venue evidence CONTRADICTS the claim -- an INTX portfolio is the only such + -- evidence Stage 1 can produce. The attestation columns are preserved alongside it: a + -- report has to be able to say what was claimed and when, not just that it was refuted. + refuted_ts INTEGER, + refuted_reason TEXT, + -- Non-reversible fingerprint of the credential IDENTIFIER the claim was made under + -- (#633). NULL means "recorded without fingerprinting" and reads as MATCHING, never as a + -- mismatch -- the same fail-safe direction as `venue_trade_scopes`. + credential_fingerprint TEXT + ) + """, + """ CREATE TABLE IF NOT EXISTS trade_outcomes ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id TEXT NOT NULL, @@ -779,6 +801,21 @@ def _migrate_v17_candle_series_feed(conn: sqlite3.Connection) -> None: """ +def _migrate_v18_venue_cash_postures(conn: sqlite3.Connection) -> None: + """v18 adds `venue_cash_postures`. Table creation is handled by `_SCHEMA_STATEMENTS`; there is + deliberately NO backfill, and here that is close to the entire point of the feature. + + A row asserts that a HUMAN examined their own venue account and stated its cash-versus-margin + posture. Nothing else can produce that claim -- Coinbase exposes no such field for spot + (#666), which is why the attestation exists at all. Seeding a row for the live deployment + because its portfolios happen to look cash-only today would fabricate the one thing the record + is for, and would do it in the table a rail reads before placing a live entry. + + An empty table means every venue is UNVERIFIED, which is true, and unverified vetoes new + entries and nothing else. + """ + + _MIGRATIONS: dict[int, Callable[[sqlite3.Connection], None]] = { 2: _migrate_v2_broker_subscriptions, 3: _migrate_v3_trade_outcomes, @@ -796,6 +833,7 @@ def _migrate_v17_candle_series_feed(conn: sqlite3.Connection) -> None: 15: _migrate_v15_trade_scope_credential_fingerprint, 16: _migrate_v16_orders_submit_book, 17: _migrate_v17_candle_series_feed, + 18: _migrate_v18_venue_cash_postures, } diff --git a/keel/data/repository.py b/keel/data/repository.py index f708f415..63ba591c 100644 --- a/keel/data/repository.py +++ b/keel/data/repository.py @@ -14,6 +14,7 @@ from decimal import Decimal from typing import Any +from keel_core.cash_posture import CashPostureState, VenueCashPosture from keel_core.subscription import BrokerSubscription, SubscriptionStatus from keel_core.trade_scope import TradeScopeState, VenueTradeScope @@ -133,6 +134,24 @@ def _trade_scope_from_row(row: sqlite3.Row) -> VenueTradeScope: ) + +def _cash_posture_from_row(row: Any) -> VenueCashPosture: + """Map a `venue_cash_postures` row to the domain record. + + `state` is rebuilt as the enum, not left a string, so a caller comparing with `is` cannot + silently always be false -- the mistake a `str`-valued Enum makes easy. + """ + return VenueCashPosture( + venue=row["venue"], + state=CashPostureState(row["state"]), + attested_posture=row["attested_posture"], + attested_ts=row["attested_ts"], + attest_due_ts=row["attest_due_ts"], + refuted_ts=row["refuted_ts"], + refuted_reason=row["refuted_reason"], + credential_fingerprint=row["credential_fingerprint"], + ) + def _json_default(obj: Any) -> Any: if isinstance(obj, Decimal): return {"__decimal__": str(obj)} @@ -623,6 +642,60 @@ def list_venue_trade_scopes(self) -> list[VenueTradeScope]: rows = self._conn.execute("SELECT * FROM venue_trade_scopes ORDER BY venue").fetchall() return [_trade_scope_from_row(row) for row in rows] + # -- venue cash posture (#691) ------------------------------------------ + + def get_venue_cash_posture(self, venue: str) -> VenueCashPosture | None: + """Return `venue`'s cash-posture record, or `None` if it has never been recorded. + + `None` is meaningful, not an error: no row means no human has stated this account's + posture, and since no venue read can supply one either, the caller must treat it as + unknown and therefore closed for new entries. + """ + row = self._conn.execute( + "SELECT * FROM venue_cash_postures WHERE venue = ?", (venue,) + ).fetchone() + return None if row is None else _cash_posture_from_row(row) + + def upsert_venue_cash_posture(self, record: VenueCashPosture) -> None: + """Insert or replace `record`, keyed on venue. One posture record per venue. + + Writes `credential_fingerprint` exactly as given, `None` included -- `None` is the value + meaning "recorded without fingerprinting", not "leave whatever was there". A writer that + could not clear it would let a stale fingerprint outlive the record it described. + """ + self._conn.execute( + """ + INSERT INTO venue_cash_postures ( + venue, state, attested_posture, attested_ts, attest_due_ts, refuted_ts, + refuted_reason, credential_fingerprint + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(venue) DO UPDATE SET + state = excluded.state, + attested_posture = excluded.attested_posture, + attested_ts = excluded.attested_ts, + attest_due_ts = excluded.attest_due_ts, + refuted_ts = excluded.refuted_ts, + refuted_reason = excluded.refuted_reason, + credential_fingerprint = excluded.credential_fingerprint + """, + ( + record.venue, + record.state.value, + record.attested_posture, + record.attested_ts, + record.attest_due_ts, + record.refuted_ts, + record.refuted_reason, + record.credential_fingerprint, + ), + ) + self._conn.commit() + + def list_venue_cash_postures(self) -> list[VenueCashPosture]: + """Every recorded cash posture, ordered by venue.""" + rows = self._conn.execute("SELECT * FROM venue_cash_postures ORDER BY venue").fetchall() + return [_cash_posture_from_row(row) for row in rows] + # -- trade outcomes (closed round-trips; rails 11 and 16) --------------- def insert_trade_outcome(self, outcome: dict[str, Any]) -> int: diff --git a/keel/execution/executor.py b/keel/execution/executor.py index 09119578..4900ec1b 100644 --- a/keel/execution/executor.py +++ b/keel/execution/executor.py @@ -1,7 +1,7 @@ """The order executor (P3 Task 4) -- turns a `Signal` into a guarded live order. `execute()` is the only path from a strategy `Signal` to a real order: it sizes the candidate -(`execution.sizing`), runs the twenty un-overridable §14 hard rails (`execution.guards.check`) +(`execution.sizing`), runs the twenty-one un-overridable §14 hard rails (`execution.guards.check`) **before** anything reaches the broker, previews the order, honors the confirm/autonomous mode gate, places it, and writes a full audit trail to the `orders` table both before and after the broker call (so a crash mid-placement, or a broker-side rejection, still leaves a record). No path in @@ -90,7 +90,7 @@ `(best_ask - best_bid) / mid` at or beyond `execution.max_entry_spread_pct` (default 0.005, 50bp -- until #523 numerically #334's slippage cap; now an independent threshold that #523 deliberately left where it was) refuses the order, and a preview with no readable -bid/ask fails closed with a distinct reason. It sits BESIDE the twenty rails, not among +bid/ask fails closed with a distinct reason. It sits BESIDE the twenty-one rails, not among them: `guards.check` is broker-less by design, and the book exists only in the preview this module just fetched -- the same preview #332's warning reads (`_preview_book`: one helper, two consumers). BUY-only (exits must execute, like rail 17 halting entries not exits) and @@ -1555,7 +1555,7 @@ def _entry_spread_gate( **Where it sits, and why.** AFTER `guards.check` and AFTER the preview: guards are broker-less by design (this module's docstring), so the book -- which only `broker.preview_order` returns -- cannot reach a `guards.check` rail. The gate is a - routing-time check BESIDE the twenty rails, not a numbered rail, and it consumes the + routing-time check BESIDE the twenty-one rails, not a numbered rail, and it consumes the SAME preview #332's `_warn_if_market_routing_overrides_entry` reads (one helper, `_preview_book`, two consumers). It runs after that warning so the warning's position -- pinned by #332's tests -- is unchanged; on a wide book both facts are true at routing diff --git a/keel/execution/guards.py b/keel/execution/guards.py index bd8bdda3..56970a08 100644 --- a/keel/execution/guards.py +++ b/keel/execution/guards.py @@ -4,7 +4,7 @@ un-overridable safety-critical rails: 13/14 added by Issue #59 (USDC-funding + monthly-allowance), 16, the consecutive-loss circuit breaker (Task 4), 17, the withdrawal/`qabd` rail, 18, the settlement-currency rail, 19, the spot-instrument rail, and 20, the trade-scope rail (#233) — -twenty in all, since there is no rail 15. They run before any order is placed, in every +twenty-one in all, since there is no rail 15. They run before any order is placed, in every `auto_trade` mode (confirm *and* autonomous) and for both rule-trading and DCA order classes. It never short-circuits: every violated rail is collected and reported so an operator (or the executor, Task 4) sees the full picture, not just the first trip-wire. @@ -134,6 +134,7 @@ from decimal import Decimal from typing import Any +from keel_core.cash_posture import MARGIN_ENABLED, CashPostureState from keel_core.credential_identity import current_credential_fingerprint from keel_core.products import parse_spot_product_id, quote_currency_of from keel_core.subscription import SubscriptionStatus @@ -259,12 +260,13 @@ class OrderIntent: "withdrawal_capability", "trade_scope", "base_balance", + "cash_posture", ) @dataclass(frozen=True) class GuardResult: - """The outcome of running all twenty rails: `ok` iff `violations` is empty.""" + """The outcome of running all twenty-one rails: `ok` iff `violations` is empty.""" ok: bool violations: list[str] @@ -468,7 +470,7 @@ def check( now_ts: int, offline: bool = False, ) -> GuardResult: - """Run all twenty §14 (+ Issue #59, Task 4, #233, #667) hard rails against `intent`. Never + """Run all twenty-one §14 (+ Issue #59, Task 4, #233, #667) hard rails against `intent`. Never short-circuits. Called before every order in every `auto_trade` mode (confirm *and* autonomous) -- @@ -1014,6 +1016,89 @@ def check( "transfer, or an exit that already executed) before trading this product." ) + # 22. Cash posture (#691, Stage 2 of #666) — a live BUY may not be placed against an account + # nobody has stated is cash-only. + # + # STAGE 1 ESTABLISHED WHY THIS CANNOT BE A VENUE READ. Coinbase exposes no + # cash-versus-margin field for spot: `margin_rate` is in every account's response schema + # and carries `null`, so a check keyed on its presence would refuse every deployment, and + # every margin/borrow/leverage/liquidation field in the SDK lives in the futures or + # perpetuals types. The adapter's `verify_cash_account` therefore REFUTES and never + # issues -- an INTX portfolio proves derivatives are available, its absence proves + # nothing. + # + # That residual is the same shape as rail 17's `qabd`: silence is not evidence of + # possession, and silence is not evidence of a cash account. So the fact comes from a + # human who knows their own account, on the record, with the venue able to contradict + # them -- and this rail asks that record. + # + # The policy lives on the record (`VenueCashPosture.may_place_live_entry`, + # `keel_core/cash_posture.py`), not here. This rail does not re-derive the state machine; + # it calls the one method that owns it and then explains the answer, so there is exactly + # one place to get the decision wrong and one place to get the wording wrong. + # + # Venue-keyed like rails 14 and 20, ENTRIES ONLY like rails 11/16/17/20, and fails CLOSED + # on a missing record like rails 12/13/17/20. The entries-only part is load-bearing: a + # rail that blocked an exit over a fact about the ACCOUNT would strand a position that + # wanted out. + # + # FOUR distinct veto messages, because there is no venue read for an operator to fall + # back on -- the message IS the interface. "Never attested" and "expired" call for the + # same command but say different things about the operator's own diligence; "attested + # margin" calls for a change to the ACCOUNT, not a re-run of the command; and a DIFFERENT + # credential must never be described as "never attested", which is #624's exact mistake. + if is_buy and not offline: + venue = current_venue() or DEFAULT_VENUE + posture = repo.get_venue_cash_posture(venue) + current_fingerprint = current_credential_fingerprint(venue) + attest = f"`keel posture attest --spot-cash --venue {venue}`" + unaffected = "Exits, stop rolls, cancels and DCA exits are unaffected." + if posture is None: + violations.append( + f"cash_posture: nobody has attested {venue}'s cash-versus-margin posture, and " + "the venue exposes no field that could -- unknown is not evidence of a cash " + f"account, so new ENTRIES are vetoed. {unaffected} Run {attest} once you have " + "checked the account yourself." + ) + elif ( + posture.credential_evidence(current_fingerprint) + is CredentialEvidence.DIFFERENT_CREDENTIAL + ): + violations.append( + f"cash_posture: {venue}'s posture was attested under a DIFFERENT credential than " + "the one in place now -- a claim about one account is not a claim about another, " + f"so new ENTRIES are vetoed until it is re-attested. {unaffected} Run {attest} " + "for the current credential." + ) + elif not posture.may_place_live_entry(now_ts, current_fingerprint): + if posture.state is CashPostureState.REFUTED: + reason = f" ({posture.refuted_reason})" if posture.refuted_reason else "" + violations.append( + f"cash_posture: venue evidence CONTRADICTS the attested cash posture for " + f"{venue}{reason} -- evidence outranks the claim, so new ENTRIES are vetoed. " + f"{unaffected} Close the derivative portfolio, then re-attest with {attest}." + ) + elif posture.attested_posture == MARGIN_ENABLED: + violations.append( + f"cash_posture: {venue} is attested as MARGIN-ENABLED -- on a margin account " + "a sell can fill as a short, which is *bay' ma la yamlik*, so new ENTRIES " + f"are vetoed. {unaffected} This needs a change to the ACCOUNT, not a " + "re-attestation: disable margin at the venue, then re-attest." + ) + elif not posture.is_current(now_ts): + violations.append( + f"cash_posture: {venue}'s cash-posture attestation has EXPIRED " + f"(due {posture.attest_due_ts}) -- a claim nobody has re-confirmed is the " + "same class of unknown as no claim, so new ENTRIES are vetoed. " + f"{unaffected} Re-attest with {attest} after re-checking the account." + ) + else: + violations.append( + f"cash_posture: {venue}'s cash-posture record does not permit a live entry " + f"(state {posture.state.value}, posture {posture.attested_posture!r}) -- new " + f"ENTRIES are vetoed. {unaffected} Run {attest} once the account is checked." + ) + for violation in violations: log_event( logger, diff --git a/keel/web/__init__.py b/keel/web/__init__.py index 8a7fc699..427fae6f 100644 --- a/keel/web/__init__.py +++ b/keel/web/__init__.py @@ -18,7 +18,7 @@ non-destructive steps -- so a first-run user on a machine with no terminal can create a deployment. "No POST at all" was a clean property that was also satisfied by a server which could not set anything up. -* **Not one of the eight capability-increasing actions in `keel/capabilities.py` is reachable +* **Not one of the nine capability-increasing actions in `keel/capabilities.py` is reachable from this package**, asserted by a test that scans this source rather than by inspection. The server cannot arm, release or spend. Attesting, promoting, releasing a halt and arming autonomy remain CLI-only, behind the TTY gate; D3 (#436) is where a browser gate for those would go. diff --git a/keel/web/security.py b/keel/web/security.py index 1a395a76..cf6c1b1c 100644 --- a/keel/web/security.py +++ b/keel/web/security.py @@ -30,7 +30,7 @@ 4. **A closed set of setup actions** is the whole write surface. `POST` exists now (#437 -- a first-run user on a machine with no terminal has to be able to create a deployment somehow), but it routes ONLY through `keel.commands.setup.ACTIONS`, every member of which is a step - declared `MECHANICAL`, is idempotent and is never destructive. Not one of the eight + declared `MECHANICAL`, is idempotent and is never destructive. Not one of the nine capability-increasing actions in `keel/capabilities.py` is reachable, and a test asserts the two sets are disjoint. That is a narrower guarantee than "no POST at all" was, and a more useful one: "no POST" would have been satisfied by a server that could not set anything up, diff --git a/keel/web/server.py b/keel/web/server.py index 6559b4ee..2ab79680 100644 --- a/keel/web/server.py +++ b/keel/web/server.py @@ -24,7 +24,7 @@ So `do_POST` exists, and it routes ONLY through `keel.commands.setup.ACTIONS`: three idempotent, non-destructive steps, every one of them declared `MECHANICAL` in the same module's step list. -The guarantee is now that **not one of the eight capability-increasing actions in +The guarantee is now that **not one of the nine capability-increasing actions in `keel/capabilities.py` is reachable from this package**, asserted by a test that scans this source rather than by inspection. "No POST" said the server could not write; this says it cannot arm, release or spend -- which is the property anyone actually cares about. @@ -202,7 +202,7 @@ def ensure_schema(db_path: str) -> None: #: #: The action SET is unchanged by all of this, and that is the invariant that matters more than the #: path: `keel.commands.setup.ACTIONS` still contains only idempotent, non-destructive, -#: `MECHANICAL` steps, and a test still asserts it is disjoint from the eight capability- +#: `MECHANICAL` steps, and a test still asserts it is disjoint from the nine capability- #: increasing actions in `keel/capabilities.py`. A browser can set a deployment up. It still cannot #: arm a rule, attest an asset or enable autonomy. API_SETUP_PREFIX = "/api/setup/" diff --git a/keel/web/static/js/api.js b/keel/web/static/js/api.js index e60d6af8..a122573d 100644 --- a/keel/web/static/js/api.js +++ b/keel/web/static/js/api.js @@ -264,7 +264,7 @@ function isField(value) { * ── WHAT MAKES THIS SAFE IS NOT IN THIS FILE ──────────────────────────────────────────────── * `key` reaches the server as a path segment and is looked up in `keel.commands.setup.ACTIONS`, * a closed registry of idempotent, non-destructive, `MECHANICAL` steps that a test asserts is - * disjoint from the eight capability-increasing actions. Nothing this function is called with + * disjoint from the nine capability-increasing actions. Nothing this function is called with * can widen that: an unknown key is a 404, and a known one runs the same step `keel setup` runs. * The client cannot arm a rule, attest an asset or enable autonomy, and it is the SERVER that * makes that true. diff --git a/keel/web/static/js/render.js b/keel/web/static/js/render.js index 0d7bb985..4223dedd 100644 --- a/keel/web/static/js/render.js +++ b/keel/web/static/js/render.js @@ -961,7 +961,7 @@ function stepCard(step, actions, notAutomated) { * * **The gate did not move with the button, and that is the property to keep in view.** What may be * performed here is `keel.commands.setup.ACTIONS` and nothing else: idempotent, non-destructive, - * `MECHANICAL` steps, asserted disjoint from the eight capability-increasing actions. A client + * `MECHANICAL` steps, asserted disjoint from the nine capability-increasing actions. A client * that hides a button is not a gate, so nothing is hidden -- and nothing needed to be, because the * server refuses what is not in that set regardless of what this file draws. * diff --git a/packages/keel-core/keel_core/cash_posture.py b/packages/keel-core/keel_core/cash_posture.py new file mode 100644 index 00000000..9eb0bf24 --- /dev/null +++ b/packages/keel-core/keel_core/cash_posture.py @@ -0,0 +1,169 @@ +"""The per-venue cash-posture record: what a human attested about an account no venue will describe. + +**The fact this module exists around.** Coinbase exposes NO cash-versus-margin field for spot. +A read of the live account on 2026-09-02 (#666, Stage 1) found `margin_rate` present in the +response schema and `null` in the value -- so its presence signals nothing, and a check keyed on +presence would refuse every deployment -- and portfolios `DEFAULT`/`CONSUMER` with no INTX. Every +margin, borrow, leverage and liquidation field in the SDK lives in `futures_types`, +`perpetuals_types`, or the derivative order fields. + +So the venue check **refutes and never issues**: an INTX portfolio proves derivatives are +available on the account, and its absence proves nothing at all. That residual is not an +engineering problem to solve with another read. It is the same shape as rail 17's `qabd` (§65.4) +and #233's trade scope: silence is not evidence of possession, and silence is not evidence of a +cash account. What closes it is a human who knows their own account saying so, on the record, +with the venue able to contradict them. + +TWO THINGS DIFFER FROM `VenueTradeScope`, AND BOTH FOLLOW FROM THAT. + +**There is no `CONFIRMED` state.** Trade scope earns one because the venue re-proves it on every +accepted placement, moving the record forward by itself. Nothing -- no placement, no read, no +field -- can ever prove a spot account is cash-only. A `CONFIRMED` value would therefore be a +state nothing is entitled to write, and an unreachable state is one a later reader eventually +writes anyway. Its absence is the design, and a test pins it. + +**There IS a TTL**, where trade scope has none. Trade scope needs no clock precisely because the +venue re-confirms it continuously, so there is no silent drift for a clock to catch. This record +has NO observation channel, which is exactly `VenueSubscription`'s situation -- and there a due +date is the only thing standing between a lapsed claim and a live spend. Same argument, same +remedy, same boundary semantics (due-at is expired, not one tick still-good). + +WHAT THIS RECORD MAY AND MAY NOT GATE. A missing, expired, refuted or margin-attested posture +vetoes new ENTRIES and nothing else. Exits, stop rolls and cancels are unaffected, because a rail +that blocked an exit over a fact about the account would strand a position that wanted out -- the +rule rails 11/16/17/20 already follow. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from keel_core.trade_scope import CredentialEvidence + +#: The two postures an operator may attest. `SPOT_CASH` is the only one that permits a live +#: entry; `MARGIN_ENABLED` is an HONEST answer that refuses one, and recording it is better than +#: leaving the record absent -- absent reads as "nobody has attested" in every report, which is a +#: different and less useful fact than "the operator says this account can borrow". +SPOT_CASH = "spot_cash" +MARGIN_ENABLED = "margin_enabled" + +#: How long an attestation stands: 90 days. +#: +#: NOT rail 17's 7 days. That window guards a withdrawal freeze, which a venue can impose +#: overnight without telling anyone, so it has to be re-checked at roughly the cadence a freeze +#: could appear. An account does not silently acquire margin -- enabling it is a deliberate act by +#: the account holder -- so a weekly re-attestation would be ceremony, and ceremony that fires +#: often enough gets automated, which is how an attestation stops meaning anything. +#: +#: NOT "never", either: a posture attested about an account eighteen months ago is not evidence +#: about that account today, and the operator may not even be the same person. 90 days is roughly +#: quarterly -- often enough that a real change surfaces within a quarter, rare enough that the +#: prompt is still read rather than dismissed. +ATTESTATION_TTL_SEC = 90 * 86400 + + +class CashPostureState(str, Enum): + """What is known about a venue account's cash-versus-margin posture. + + THREE states, and the missing fourth is the point -- see the module docstring. There is no + `CONFIRMED`, because no venue read can affirm this and a state nothing may write is a trap. + """ + + UNVERIFIED = "unverified" + ATTESTED = "attested" + REFUTED = "refuted" + + +@dataclass(frozen=True) +class VenueCashPosture: + """One venue's cash-posture record: what the operator claimed, and what the venue has refuted. + + `attested_posture` is `SPOT_CASH`, `MARGIN_ENABLED`, or `None` when nobody has attested. + + `attest_due_ts` deliberately has no default and is checked for `None`: a record with no due + date is a claim that never expires, which this record does not permit, and inventing one at + read time would let a writer forget to set one and have the reader quietly cover for it. + + `refuted_reason` is free text from the venue evidence that refuted the claim -- "INTX + portfolio present" is the one Stage 1 can produce. It is for the operator surface only and is + never read by policy. + + `credential_fingerprint` (#633) is a non-reversible fingerprint of the credential IDENTIFIER + the claim was made under, or `None`. No default, for the same reason as `VenueTradeScope`: a + default lets a future writer silently forget and degrade detection back to a venue-only key. + """ + + venue: str + state: CashPostureState + attested_posture: str | None + attested_ts: int | None + attest_due_ts: int | None + refuted_ts: int | None + refuted_reason: str | None + credential_fingerprint: str | None + + def credential_evidence(self, current_fingerprint: str | None) -> CredentialEvidence: + """What this record's fingerprint says about the CURRENT credential. + + Imported from `trade_scope` rather than redefined: the four states and the reasoning for + why there are four rather than three are general to "evidence collected under some + credential", and a second copy would drift from the first. + """ + if self.credential_fingerprint is None: + return CredentialEvidence.UNFINGERPRINTED + if current_fingerprint is None: + return CredentialEvidence.CREDENTIAL_UNREADABLE + if self.credential_fingerprint == current_fingerprint: + return CredentialEvidence.MATCHES + return CredentialEvidence.DIFFERENT_CREDENTIAL + + def is_current(self, now_ts: int) -> bool: + """Whether the attestation is still inside its window -- the CLOCK question, alone. + + Asked separately from `may_place_live_entry` so a report can say "expired" rather than + only "refused": those call for different actions from an operator, one a re-attestation + and the other a change to the account. + """ + return self.attest_due_ts is not None and self.attest_due_ts > now_ts + + def may_place_live_entry(self, now_ts: int, current_fingerprint: str | None) -> bool: + """Whether a live ENTRY may be placed against this venue's account. + + `current_fingerprint` is REQUIRED (no default) so mypy names every call site rather than + letting one silently opt out of the #633 check. `None` means "current credential unknown" + and never withdraws permission -- the safe value to pass, not merely the lazy one. + + `DIFFERENT_CREDENTIAL` withdraws permission unconditionally: a posture attested under one + credential is not a claim about the account another credential reaches. `UNFINGERPRINTED` + and `CREDENTIAL_UNREADABLE` fall through untouched, both fail-safe for the reasons + `VenueTradeScope` sets out. + + Then, driven by `state` and the clock -- never by `refuted_ts`, which is history. An + operator who closes an INTX portfolio and re-attests must be able to trade again while + `doctor` can still report the old refusal; vetoing on `refuted_ts is not None` would mean + the one path that RECOVERS never unblocks. + + - `ATTESTED`, posture `SPOT_CASH`, inside its window: True. + - `ATTESTED` but expired: False. An unre-confirmed claim is the same class of unknown as + no claim. + - `ATTESTED` with any other posture (`MARGIN_ENABLED` included): False. + - `REFUTED`: False. Venue evidence outranks the claim. + - `UNVERIFIED`: False. + + Fails closed on anything not listed. + """ + if self.credential_evidence(current_fingerprint) is CredentialEvidence.DIFFERENT_CREDENTIAL: + return False + if self.state is not CashPostureState.ATTESTED: + return False + return self.attested_posture == SPOT_CASH and self.is_current(now_ts) + + +__all__ = [ + "ATTESTATION_TTL_SEC", + "MARGIN_ENABLED", + "SPOT_CASH", + "CashPostureState", + "VenueCashPosture", +] diff --git a/tests/commands/test_posture_cli.py b/tests/commands/test_posture_cli.py new file mode 100644 index 00000000..07f486b1 --- /dev/null +++ b/tests/commands/test_posture_cli.py @@ -0,0 +1,173 @@ +"""`keel posture` -- the operator surface for the cash posture no venue will affirm (#691). + +Mirrors `keel scope`'s vocabulary deliberately: `attest` writes the claim, `show` reports it, the +venue resolves the same way, and re-attesting over a refutation is allowed because that is how an +operator reports "I closed the derivative portfolio". A new vocabulary for the same shape would be +a second thing to learn and a second thing to get wrong. + +The two that carry it: + +* `test_attesting_stamps_a_due_date` -- this record's whole difference from trade scope is that it + EXPIRES, because nothing re-confirms it. An attestation written without a due date would never + expire and the TTL would be decorative. +* `test_the_due_date_is_stored_not_derived` -- so that changing `ATTESTATION_TTL_SEC` later cannot + retroactively expire (or silently extend) a claim a human made under the window in force when + they made it. +""" + +from __future__ import annotations + +import pytest +from keel_core.cash_posture import ( + ATTESTATION_TTL_SEC, + MARGIN_ENABLED, + SPOT_CASH, + CashPostureState, + VenueCashPosture, +) + +from keel.commands.posture import apply_posture_attest, posture_show_lines +from keel.data.db import connect, migrate +from keel.data.repository import Repository + +NOW = 1_800_000_000 + + +@pytest.fixture() +def repo() -> Repository: + conn = connect(":memory:") + migrate(conn) + return Repository(conn) + + +def test_attesting_spot_cash_records_an_attested_record(repo: Repository) -> None: + line = apply_posture_attest(repo, venue="coinbase", spot_cash=True, now_ts=NOW) + record = repo.get_venue_cash_posture("coinbase") + assert record.state is CashPostureState.ATTESTED + assert record.attested_posture == SPOT_CASH + assert record.attested_ts == NOW + assert "coinbase" in line and "spot" in line.lower() + + +def test_attesting_margin_records_it_rather_than_refusing_to_write(repo: Repository) -> None: + """An operator whose account HAS margin must be able to say so. Refusing to record it would + leave the record absent, which reads as "nobody has attested" -- a less useful fact, and one + that invites the same person to be asked again next week.""" + line = apply_posture_attest(repo, venue="coinbase", spot_cash=False, now_ts=NOW) + record = repo.get_venue_cash_posture("coinbase") + assert record.state is CashPostureState.ATTESTED + assert record.attested_posture == MARGIN_ENABLED + assert "margin" in line.lower() + assert "veto" in line.lower() + + +def test_attesting_stamps_a_due_date(repo: Repository) -> None: + apply_posture_attest(repo, venue="coinbase", spot_cash=True, now_ts=NOW) + assert repo.get_venue_cash_posture("coinbase").attest_due_ts == NOW + ATTESTATION_TTL_SEC + + +def test_the_due_date_is_stored_not_derived(repo: Repository) -> None: + """Stored, so shortening the TTL later cannot retroactively expire a claim a human made under + a longer window -- or lengthening it silently revive one that had already lapsed.""" + apply_posture_attest(repo, venue="coinbase", spot_cash=True, now_ts=NOW) + due = repo.get_venue_cash_posture("coinbase").attest_due_ts + assert due is not None + assert repo.get_venue_cash_posture("coinbase").is_current(due - 1) + assert not repo.get_venue_cash_posture("coinbase").is_current(due) + + +def test_the_confirmation_line_says_which_way_the_rail_will_go(repo: Repository) -> None: + cash = apply_posture_attest(repo, venue="coinbase", spot_cash=True, now_ts=NOW) + margin = apply_posture_attest(repo, venue="alpaca", spot_cash=False, now_ts=NOW) + assert "may now place live ENTRIES" in cash + assert "veto" in margin.lower() + + +def test_re_attesting_over_a_refutation_recovers_and_reports_the_history( + repo: Repository, +) -> None: + """The recovery path. `refuted_ts`/`refuted_reason` are carried FORWARD, not cleared: they + are history about this venue, and `doctor` must still be able to say the venue once + contradicted a claim here.""" + repo.upsert_venue_cash_posture( + VenueCashPosture( + venue="coinbase", + state=CashPostureState.REFUTED, + attested_posture=SPOT_CASH, + attested_ts=NOW - 1000, + attest_due_ts=NOW + 1000, + refuted_ts=NOW - 500, + refuted_reason="INTX portfolio present", + credential_fingerprint=None, + ) + ) + line = apply_posture_attest(repo, venue="coinbase", spot_cash=True, now_ts=NOW) + record = repo.get_venue_cash_posture("coinbase") + assert record.state is CashPostureState.ATTESTED + assert record.refuted_ts == NOW - 500 + assert record.refuted_reason == "INTX portfolio present" + assert "refuted" in line.lower() or "contradicted" in line.lower() + + +def test_the_fingerprint_is_stamped_fresh_not_carried_forward( + repo: Repository, monkeypatch +) -> None: + """#633. The operator is attesting about the credential in place NOW; carrying an old + fingerprint forward would bind a fresh claim to a possibly-rotated-away credential.""" + repo.upsert_venue_cash_posture( + VenueCashPosture( + venue="coinbase", + state=CashPostureState.ATTESTED, + attested_posture=SPOT_CASH, + attested_ts=NOW - 10, + attest_due_ts=NOW + 10, + refuted_ts=None, + refuted_reason=None, + credential_fingerprint="fp-old", + ) + ) + monkeypatch.setattr( + "keel.commands.posture.current_credential_fingerprint", lambda _v: "fp-new" + ) + apply_posture_attest(repo, venue="coinbase", spot_cash=True, now_ts=NOW) + assert repo.get_venue_cash_posture("coinbase").credential_fingerprint == "fp-new" + + +# --- show ------------------------------------------------------------------------------------- + + +def test_show_says_nothing_is_attested_when_nothing_is(repo: Repository) -> None: + lines = posture_show_lines(repo, now_ts=NOW) + assert any("no venue" in line.lower() or "never" in line.lower() for line in lines) + + +def test_show_reports_the_posture_and_its_expiry(repo: Repository) -> None: + apply_posture_attest(repo, venue="coinbase", spot_cash=True, now_ts=NOW) + text = "\n".join(posture_show_lines(repo, now_ts=NOW)) + assert "coinbase" in text + assert "spot_cash" in text + assert "expires" in text.lower() + + +def test_show_marks_an_expired_attestation_as_expired(repo: Repository) -> None: + """The distinction an operator needs: re-attest, versus fix the account.""" + apply_posture_attest(repo, venue="coinbase", spot_cash=True, now_ts=NOW) + text = "\n".join(posture_show_lines(repo, now_ts=NOW + ATTESTATION_TTL_SEC + 1)) + assert "EXPIRED" in text + + +def test_show_names_the_venue_evidence_that_refuted_a_claim(repo: Repository) -> None: + repo.upsert_venue_cash_posture( + VenueCashPosture( + venue="coinbase", + state=CashPostureState.REFUTED, + attested_posture=SPOT_CASH, + attested_ts=NOW - 10, + attest_due_ts=NOW + 10, + refuted_ts=NOW, + refuted_reason="INTX portfolio present", + credential_fingerprint=None, + ) + ) + text = "\n".join(posture_show_lines(repo, now_ts=NOW)) + assert "INTX portfolio present" in text diff --git a/tests/commands/test_posture_refutation.py b/tests/commands/test_posture_refutation.py new file mode 100644 index 00000000..75318159 --- /dev/null +++ b/tests/commands/test_posture_refutation.py @@ -0,0 +1,113 @@ +"""Venue evidence must REFUTE a standing attestation, not coexist with it -- #691. + +The acceptance criterion: "an INTX portfolio found at build refutes a standing attestation rather +than silently coexisting with it". Without this, an operator's honest-at-the-time claim outlives +the fact that contradicted it -- the record still reads ATTESTED, `doctor` still shows a green +posture, and the only thing standing between that and a live entry is an exception nobody wrote +down. + +Refutation is deliberately NOT symmetric with attestation: a human may issue a claim, and only +the venue may withdraw one. `refute_posture` cannot create a record from nothing -- refuting +something never claimed would invent a history, and the correct state for "no claim, venue looks +wrong" is still "no claim", which rail 22 already vetoes. +""" + +from __future__ import annotations + +import pytest +from keel_core.cash_posture import SPOT_CASH, CashPostureState, VenueCashPosture + +from keel.commands.posture import refute_posture +from keel.data.db import connect, migrate +from keel.data.repository import Repository + +NOW = 1_800_000_000 + + +@pytest.fixture() +def repo() -> Repository: + conn = connect(":memory:") + migrate(conn) + return Repository(conn) + + +def _attested(repo: Repository) -> None: + repo.upsert_venue_cash_posture( + VenueCashPosture( + venue="coinbase", + state=CashPostureState.ATTESTED, + attested_posture=SPOT_CASH, + attested_ts=NOW - 100, + attest_due_ts=NOW + 100, + refuted_ts=None, + refuted_reason=None, + credential_fingerprint="fp-1", + ) + ) + + +def test_venue_evidence_moves_a_standing_claim_to_refuted(repo: Repository) -> None: + _attested(repo) + assert refute_posture(repo, venue="coinbase", reason="INTX portfolio present", now_ts=NOW) + record = repo.get_venue_cash_posture("coinbase") + assert record.state is CashPostureState.REFUTED + assert record.refuted_ts == NOW + assert record.refuted_reason == "INTX portfolio present" + + +def test_the_claim_that_was_refuted_is_preserved(repo: Repository) -> None: + """`doctor` has to be able to say WHAT was claimed and when the venue contradicted it. An + operator asked to re-attest deserves to know what they said last time.""" + _attested(repo) + refute_posture(repo, venue="coinbase", reason="INTX portfolio present", now_ts=NOW) + record = repo.get_venue_cash_posture("coinbase") + assert record.attested_posture == SPOT_CASH + assert record.attested_ts == NOW - 100 + assert record.credential_fingerprint == "fp-1" + + +def test_refuting_what_was_never_claimed_writes_nothing(repo: Repository) -> None: + """Not symmetric with attestation, on purpose. A human issues a claim; only the venue + withdraws one. Creating a REFUTED record from no record would invent a history, and "no + claim" is already a veto -- there is nothing to improve by fabricating one.""" + assert not refute_posture(repo, venue="coinbase", reason="INTX", now_ts=NOW) + assert repo.get_venue_cash_posture("coinbase") is None + + +def test_refuting_twice_keeps_the_first_refutation_time(repo: Repository) -> None: + """The first contradiction is when the claim stopped being true as far as keel can tell. + Advancing it on every build would report the most recent cycle rather than the discovery.""" + _attested(repo) + refute_posture(repo, venue="coinbase", reason="INTX portfolio present", now_ts=NOW) + refute_posture(repo, venue="coinbase", reason="INTX portfolio present", now_ts=NOW + 5000) + assert repo.get_venue_cash_posture("coinbase").refuted_ts == NOW + + +def test_a_re_attestation_after_a_refutation_can_be_refuted_again(repo: Repository) -> None: + """The full loop: attest, refuted, re-attest, refuted again. The second refutation is a NEW + discovery about a NEW claim, so its timestamp does move.""" + _attested(repo) + refute_posture(repo, venue="coinbase", reason="INTX", now_ts=NOW) + repo.upsert_venue_cash_posture( + VenueCashPosture( + venue="coinbase", + state=CashPostureState.ATTESTED, + attested_posture=SPOT_CASH, + attested_ts=NOW + 1000, + attest_due_ts=NOW + 9000, + refuted_ts=NOW, + refuted_reason="INTX", + credential_fingerprint="fp-1", + ) + ) + refute_posture(repo, venue="coinbase", reason="INTX again", now_ts=NOW + 2000) + record = repo.get_venue_cash_posture("coinbase") + assert record.state is CashPostureState.REFUTED + assert record.refuted_ts == NOW + 2000 + assert record.refuted_reason == "INTX again" + + +def test_only_the_named_venue_is_refuted(repo: Repository) -> None: + _attested(repo) + refute_posture(repo, venue="alpaca", reason="multiplier 4", now_ts=NOW) + assert repo.get_venue_cash_posture("coinbase").state is CashPostureState.ATTESTED diff --git a/tests/conftest.py b/tests/conftest.py index a1894e1a..34a40733 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,12 @@ import pytest from keel_core import telemetry +from keel_core.cash_posture import ( + ATTESTATION_TTL_SEC, + SPOT_CASH, + CashPostureState, + VenueCashPosture, +) from keel_core.subscription import BrokerSubscription, SubscriptionStatus from keel_core.trade_scope import TradeScopeState, VenueTradeScope @@ -55,6 +61,49 @@ def attest_subscription( ) +def attest_cash_posture( + repo: Repository, + *, + now_ts: int, + state: CashPostureState = CashPostureState.ATTESTED, + attested_posture: str | None = SPOT_CASH, + venue: str = "coinbase", + attested_ts: int | None = None, + attest_due_ts: int | None = None, + refuted_ts: int | None = None, + refuted_reason: str | None = None, + credential_fingerprint: str | None = None, +) -> None: + """Write a venue cash-posture record -- rail 22's baseline (#691), the `attest_trade_scope` + counterpart. + + Defaults to an in-force `ATTESTED` / `spot_cash` record: the shape that lets rail 22 admit a + live entry, so a test not ABOUT rail 22 is not incidentally vetoed. Same reason this file + already defaults `attest_trade_scope` to `CONFIRMED` and `attest_subscription` to a roomy + allowance. + + `attest_due_ts` defaults to `now_ts + ATTESTATION_TTL_SEC`, i.e. freshly attested. A test + about EXPIRY passes its own, in the past. + + `credential_fingerprint` defaults to `None` -- the "recorded without fingerprinting" value, + which reads as MATCHING, so a test that does not care about #633 is not vetoed by it. + """ + repo.upsert_venue_cash_posture( + VenueCashPosture( + venue=venue, + state=state, + attested_posture=attested_posture, + attested_ts=now_ts if attested_ts is None else attested_ts, + attest_due_ts=( + now_ts + ATTESTATION_TTL_SEC if attest_due_ts is None else attest_due_ts + ), + refuted_ts=refuted_ts, + refuted_reason=refuted_reason, + credential_fingerprint=credential_fingerprint, + ) + ) + + def attest_trade_scope( repo: Repository, *, diff --git a/tests/core/test_cash_posture.py b/tests/core/test_cash_posture.py new file mode 100644 index 00000000..dae63891 --- /dev/null +++ b/tests/core/test_cash_posture.py @@ -0,0 +1,184 @@ +"""The per-venue cash-posture record -- issue #691, Stage 2 of #666. + +Stage 1 established the fact that shapes all of this: **Coinbase exposes no cash-versus-margin +field for spot.** A probe of the live account on 2026-09-02 found `margin_rate` present in the +response schema and `null` in the value, and portfolios `DEFAULT`/`CONSUMER` with no INTX. Every +margin, borrow, leverage and liquidation field in the SDK lives in the futures or perpetuals +types. So the venue check REFUTES and never issues: an INTX portfolio proves derivatives are +available, its absence proves nothing. + +That residual is not an engineering gap, it is the same shape as rail 17's `qabd` -- silence is +not evidence of possession, and here silence is not evidence of a cash account. What closes it is +a human who knows their own account saying so on the record, with the venue able to contradict +them. + +TWO DESIGN CHOICES DIFFER FROM `VenueTradeScope`, AND BOTH ARE DELIBERATE: + +* **There is no `CONFIRMED` state**, and `test_there_is_no_confirmed_state` pins its absence. + Trade scope earns `CONFIRMED` because the venue re-proves it on every accepted placement. No + placement, read, or field can ever prove a spot account is cash-only, so a `CONFIRMED` value + would be a state nothing could legally write -- and a state nothing can write is one a future + reader will eventually write anyway. +* **There IS a TTL**, where trade scope has none. Trade scope needs no clock because the venue + re-confirms it continuously; this record has NO observation channel at all, which is exactly + `VenueSubscription`'s situation, and there a due date is the only thing between a lapsed claim + and a live spend. Same argument, same remedy. +""" + +from __future__ import annotations + +from keel_core.cash_posture import ( + ATTESTATION_TTL_SEC, + MARGIN_ENABLED, + SPOT_CASH, + CashPostureState, + VenueCashPosture, +) +from keel_core.trade_scope import CredentialEvidence + +NOW = 1_800_000_000 +FP = "fp-current" + + +def _posture( + state: CashPostureState = CashPostureState.ATTESTED, + posture: str | None = SPOT_CASH, + attested_ts: int | None = NOW - 86400, + due_ts: int | None = NOW + 86400, + refuted_ts: int | None = None, + fingerprint: str | None = FP, +) -> VenueCashPosture: + return VenueCashPosture( + venue="coinbase", + state=state, + attested_posture=posture, + attested_ts=attested_ts, + attest_due_ts=due_ts, + refuted_ts=refuted_ts, + refuted_reason="INTX portfolio present" if refuted_ts else None, + credential_fingerprint=fingerprint, + ) + + +# --- the state machine's shape ---------------------------------------------------------------- + + +def test_there_is_no_confirmed_state() -> None: + """THE load-bearing pin. No venue read can affirm a spot cash posture, so a `CONFIRMED` + value would be one nothing is entitled to write -- and an unreachable state is one a later + reader eventually reaches for. The absence is the design.""" + assert {s.value for s in CashPostureState} == {"unverified", "attested", "refuted"} + assert not hasattr(CashPostureState, "CONFIRMED") + + +def test_the_ttl_is_longer_than_rail_17s_window_and_shorter_than_forever() -> None: + """A posture changes far less often than a withdrawal freeze -- an account does not silently + acquire margin -- so rail 17's 7 days would be ceremony. But "never expires" would let a + posture attested about a different account years ago authorise a live entry today.""" + assert ATTESTATION_TTL_SEC > 7 * 86400 + assert ATTESTATION_TTL_SEC <= 366 * 86400 + + +# --- may_place_live_entry -------------------------------------------------------------------- + + +def test_an_unverified_record_refuses() -> None: + """Fails closed on absent, like rails 12/13/17/20.""" + assert not _posture(state=CashPostureState.UNVERIFIED, posture=None).may_place_live_entry( + NOW, FP + ) + + +def test_an_in_force_spot_cash_attestation_permits() -> None: + assert _posture().may_place_live_entry(NOW, FP) + + +def test_an_operator_who_attests_margin_is_refused_not_ignored() -> None: + """Attesting `margin_enabled` is an HONEST answer, and the right response is a refusal that + records it -- not silently treating the record as absent, which would read as "nobody has + attested" in every report.""" + assert not _posture(posture=MARGIN_ENABLED).may_place_live_entry(NOW, FP) + + +def test_an_unrecognised_posture_refuses() -> None: + assert not _posture(posture="probably fine").may_place_live_entry(NOW, FP) + assert not _posture(posture=None).may_place_live_entry(NOW, FP) + + +def test_an_expired_attestation_refuses() -> None: + assert not _posture(due_ts=NOW - 1).may_place_live_entry(NOW, FP) + + +def test_the_due_moment_is_already_expired() -> None: + """Due-at is when it expires, not one tick still-good -- `VenueSubscription`'s boundary, + copied so the two records cannot disagree about what a due date means.""" + assert not _posture(due_ts=NOW).may_place_live_entry(NOW, FP) + assert _posture(due_ts=NOW + 1).may_place_live_entry(NOW, FP) + + +def test_a_missing_due_date_refuses() -> None: + """An attestation with no due date is a claim that never expires, which this record does not + permit. Rather than inventing one at read time, it refuses -- the writer owes a due date.""" + assert not _posture(due_ts=None).may_place_live_entry(NOW, FP) + + +def test_a_refuted_record_refuses_even_inside_its_ttl() -> None: + """The venue found an INTX portfolio. That is evidence, and it outranks the claim.""" + assert not _posture( + state=CashPostureState.REFUTED, refuted_ts=NOW - 10 + ).may_place_live_entry(NOW, FP) + + +def test_re_attesting_over_a_refutation_recovers_and_keeps_the_history() -> None: + """The recovery path, and the reason the predicate reads `state` rather than `refuted_ts`: + an operator who closes the INTX portfolio and re-attests must be able to trade again, while + `doctor` can still say "you re-attested a venue that refuted on ". Vetoing on + `refuted_ts is not None` would make the one path that recovers never actually unblock -- + exactly the argument `VenueTradeScope.may_place_live_entry` sets out.""" + record = _posture(state=CashPostureState.ATTESTED, refuted_ts=NOW - 1000) + assert record.may_place_live_entry(NOW, FP) + assert record.refuted_ts == NOW - 1000 + + +# --- #633: the credential the claim was made about -------------------------------------------- + + +def test_a_different_credential_withdraws_permission_unconditionally() -> None: + """A posture attested under one credential is not a claim about the account another + credential reaches. Same rule as trade scope, and it overrides an in-force ATTESTED.""" + assert not _posture().may_place_live_entry(NOW, "fp-other") + + +def test_an_unfingerprinted_record_still_permits() -> None: + """Fail-SAFE, matching trade scope: a record predating fingerprinting was never going to + have one, and withholding permission would veto the next entry on a healthy deployment.""" + assert _posture(fingerprint=None).may_place_live_entry(NOW, FP) + + +def test_an_unreadable_current_credential_still_permits() -> None: + """"I could not resolve this" is not "this changed" -- a locked keychain is a fact about the + observer, not the credential.""" + assert _posture().may_place_live_entry(NOW, None) + + +def test_the_credential_evidence_vocabulary_is_shared_with_trade_scope() -> None: + """One vocabulary, not two. A second copy of these four states would drift, and #624's whole + lesson was that conflating two kinds of "unknown" fails differently.""" + assert _posture().credential_evidence(FP) is CredentialEvidence.MATCHES + assert _posture().credential_evidence("fp-other") is CredentialEvidence.DIFFERENT_CREDENTIAL + assert _posture(fingerprint=None).credential_evidence(FP) is CredentialEvidence.UNFINGERPRINTED + assert _posture().credential_evidence(None) is CredentialEvidence.CREDENTIAL_UNREADABLE + + +# --- is_current, reported separately from permission ------------------------------------------ + + +def test_is_current_answers_the_clock_question_alone() -> None: + """Separate from `may_place_live_entry` so a report can say "expired" rather than only + "refused" -- an operator needs to know whether to re-attest or to fix the account.""" + assert _posture().is_current(NOW) + assert not _posture(due_ts=NOW - 1).is_current(NOW) + assert not _posture(due_ts=None).is_current(NOW) + # A refuted record can still be inside its window; the clock and the evidence are + # different questions. + assert _posture(state=CashPostureState.REFUTED, refuted_ts=NOW).is_current(NOW) diff --git a/tests/data/test_cash_posture_store.py b/tests/data/test_cash_posture_store.py new file mode 100644 index 00000000..495edb5e --- /dev/null +++ b/tests/data/test_cash_posture_store.py @@ -0,0 +1,131 @@ +"""Storage for the per-venue cash-posture record -- issue #691. + +Mirrors `venue_trade_scopes` deliberately: one row per venue, `None` from the read meaning +"never recorded" rather than an error, and `credential_fingerprint` written exactly as given +including `None`. A second storage idiom for a record that behaves the same way would be a +second thing to get wrong. + +The one that matters is `test_a_refutation_preserves_the_attestation_history`: the record has to +keep `attested_ts` and the posture claimed when the venue refutes it, or `doctor` cannot say WHAT +was claimed and when -- and an operator asked to re-attest deserves to be told what they said +last time. +""" + +from __future__ import annotations + +import pytest +from keel_core.cash_posture import SPOT_CASH, CashPostureState, VenueCashPosture + +from keel.data.db import SCHEMA_VERSION, connect, migrate +from keel.data.repository import Repository + +NOW = 1_800_000_000 + + +@pytest.fixture() +def repo() -> Repository: + conn = connect(":memory:") + migrate(conn) + return Repository(conn) + + +def _record( + venue: str = "coinbase", + state: CashPostureState = CashPostureState.ATTESTED, + posture: str | None = SPOT_CASH, + fingerprint: str | None = "fp-1", + refuted_ts: int | None = None, +) -> VenueCashPosture: + return VenueCashPosture( + venue=venue, + state=state, + attested_posture=posture, + attested_ts=NOW, + attest_due_ts=NOW + 90 * 86400, + refuted_ts=refuted_ts, + refuted_reason="INTX portfolio present" if refuted_ts else None, + credential_fingerprint=fingerprint, + ) + + +def test_the_schema_carries_the_table() -> None: + conn = connect(":memory:") + migrate(conn) + names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + assert "venue_cash_postures" in names + + +def test_an_existing_database_gains_the_table_on_migration() -> None: + conn = connect(":memory:") + migrate(conn) + conn.execute(f"UPDATE schema_version SET version = {SCHEMA_VERSION - 1}") + conn.execute("DROP TABLE venue_cash_postures") + conn.commit() + migrate(conn) + names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + assert "venue_cash_postures" in names + + +def test_an_unrecorded_venue_reads_as_none(repo: Repository) -> None: + """`None` is meaningful: nobody has attested, so callers treat it as unknown and therefore + closed. Same convention as `get_venue_trade_scope`.""" + assert repo.get_venue_cash_posture("coinbase") is None + + +def test_a_record_round_trips(repo: Repository) -> None: + repo.upsert_venue_cash_posture(_record()) + got = repo.get_venue_cash_posture("coinbase") + assert got == _record() + + +def test_the_record_is_keyed_on_venue_alone(repo: Repository) -> None: + repo.upsert_venue_cash_posture(_record(venue="coinbase")) + repo.upsert_venue_cash_posture(_record(venue="alpaca", posture=None, + state=CashPostureState.UNVERIFIED)) + assert repo.get_venue_cash_posture("coinbase").attested_posture == SPOT_CASH + assert repo.get_venue_cash_posture("alpaca").attested_posture is None + + +def test_re_attesting_replaces_in_place(repo: Repository) -> None: + repo.upsert_venue_cash_posture(_record()) + repo.upsert_venue_cash_posture(_record(fingerprint="fp-2")) + assert len(repo.list_venue_cash_postures()) == 1 + assert repo.get_venue_cash_posture("coinbase").credential_fingerprint == "fp-2" + + +def test_a_none_fingerprint_is_written_not_ignored(repo: Repository) -> None: + """`None` means "recorded without fingerprinting", which is a value, not "leave what was + there". A writer that could not clear it would let a stale fingerprint outlive its record.""" + repo.upsert_venue_cash_posture(_record(fingerprint="fp-1")) + repo.upsert_venue_cash_posture(_record(fingerprint=None)) + assert repo.get_venue_cash_posture("coinbase").credential_fingerprint is None + + +def test_a_refutation_preserves_the_attestation_history(repo: Repository) -> None: + """`doctor` has to be able to say what was claimed, when, and when the venue contradicted + it. An operator asked to re-attest deserves to know what they said last time.""" + repo.upsert_venue_cash_posture(_record()) + repo.upsert_venue_cash_posture( + _record(state=CashPostureState.REFUTED, refuted_ts=NOW + 100) + ) + got = repo.get_venue_cash_posture("coinbase") + assert got.state is CashPostureState.REFUTED + assert got.attested_ts == NOW + assert got.attested_posture == SPOT_CASH + assert got.refuted_ts == NOW + 100 + assert got.refuted_reason == "INTX portfolio present" + + +def test_listing_is_ordered_by_venue(repo: Repository) -> None: + for venue in ("robinhood", "alpaca", "coinbase"): + repo.upsert_venue_cash_posture(_record(venue=venue)) + assert [r.venue for r in repo.list_venue_cash_postures()] == [ + "alpaca", + "coinbase", + "robinhood", + ] + + +def test_the_state_is_read_back_as_an_enum_not_a_string(repo: Repository) -> None: + repo.upsert_venue_cash_posture(_record()) + assert repo.get_venue_cash_posture("coinbase").state is CashPostureState.ATTESTED diff --git a/tests/data/test_db.py b/tests/data/test_db.py index a56abd7a..62f2467a 100644 --- a/tests/data/test_db.py +++ b/tests/data/test_db.py @@ -103,11 +103,11 @@ def test_agent_state_table_has_key_primary_key(): assert pk_columns == {"key"} -def test_schema_version_is_17(): +def test_schema_version_is_18(): """Deliberate tripwire: bump this literal consciously on every schema change.""" from keel.data.db import SCHEMA_VERSION - assert SCHEMA_VERSION == 17 + assert SCHEMA_VERSION == 18 def test_a_v6_database_migrates_up_and_gains_the_profile_table(tmp_path): diff --git a/tests/data/test_migrations.py b/tests/data/test_migrations.py index 9405f885..b6ee8425 100644 --- a/tests/data/test_migrations.py +++ b/tests/data/test_migrations.py @@ -49,7 +49,7 @@ def test_fresh_database_is_stamped_at_the_current_version() -> None: conn = db.connect(":memory:") db.migrate(conn) version = conn.execute("SELECT version FROM schema_version").fetchone()["version"] - assert version == db.SCHEMA_VERSION == 17 + assert version == db.SCHEMA_VERSION == 18 def test_fresh_database_gets_no_subscription_row() -> None: @@ -612,7 +612,7 @@ def test_v14_migration_bumps_the_stored_version() -> None: conn = _v12_database() db.migrate(conn) stamped = conn.execute("SELECT version FROM schema_version").fetchone()["version"] - assert stamped == db.SCHEMA_VERSION == 17 + assert stamped == db.SCHEMA_VERSION == 18 def test_v14_migration_step_is_not_blocked_by_another_venues_existing_row() -> None: @@ -773,7 +773,7 @@ def test_v15_migration_bumps_the_stored_version() -> None: conn = _v12_database() db.migrate(conn) stamped = conn.execute("SELECT version FROM schema_version").fetchone()["version"] - assert stamped == db.SCHEMA_VERSION == 17 + assert stamped == db.SCHEMA_VERSION == 18 def test_v15_the_12_to_15_chain_creates_the_table_with_the_column_already_present() -> None: @@ -875,7 +875,7 @@ def test_an_existing_orders_table_gains_the_submit_book_by_ALTER() -> None: assert row["submit_best_bid"] is None assert row["submit_best_ask"] is None stamped = conn.execute("SELECT version FROM schema_version").fetchone()["version"] - assert stamped == db.SCHEMA_VERSION == 17 + assert stamped == db.SCHEMA_VERSION == 18 def test_v16_is_idempotent_per_column() -> None: diff --git a/tests/data/test_trade_outcomes.py b/tests/data/test_trade_outcomes.py index 1b3a4b52..3c1945d5 100644 --- a/tests/data/test_trade_outcomes.py +++ b/tests/data/test_trade_outcomes.py @@ -35,11 +35,11 @@ def _outcome(**overrides: object) -> dict: return base -def test_schema_is_at_version_17() -> None: +def test_schema_is_at_version_18() -> None: conn = db.connect(":memory:") db.migrate(conn) version = conn.execute("SELECT version FROM schema_version").fetchone()["version"] - assert version == db.SCHEMA_VERSION == 17 + assert version == db.SCHEMA_VERSION == 18 def test_fresh_database_has_no_outcomes() -> None: diff --git a/tests/execution/test_cash_posture_rail.py b/tests/execution/test_cash_posture_rail.py new file mode 100644 index 00000000..78b03633 --- /dev/null +++ b/tests/execution/test_cash_posture_rail.py @@ -0,0 +1,160 @@ +"""Rail 22: the cash posture no venue will affirm -- issue #691, Stage 2 of #666. + +Coinbase exposes no cash-versus-margin field for spot, so the venue check refutes and never +issues (Stage 1). This rail asks the only source that CAN answer -- the operator's record -- and +fails closed when there is none, exactly as rails 12/13/17/20 do. + +TWO PROPERTIES CARRY THE RAIL, and both are about what it must NOT do: + +* `test_every_non_entry_is_untouched_by_a_missing_posture` -- exits, stop rolls, cancels and DCA + exits pass with no record at all. A rail that blocked an exit over a fact about the ACCOUNT + would strand a position that wanted out, which is rails 11/16/17/20's own rule and the reason + this one is `is_buy`-gated. +* `test_paper_mode_skips_it_and_says_so` -- paper has no live account to attest, so the rail is + skipped AND reported as skipped. Silently omitting it would make a paper track record claim a + guarantee it never ran. + +The veto messages are pinned individually because they are the whole operator interface here: +there is no venue read to consult, so a message that fails to say what to run leaves a person +with a halted engine and no next step. +""" + +from __future__ import annotations + +import pytest +from keel_core.cash_posture import MARGIN_ENABLED, CashPostureState + +from keel.execution.guards import LIVE_STATE_RAILS, check +from tests.conftest import attest_cash_posture +from tests.execution.test_guards import ( + NOW_TS, + _config, + _intent, + repo, # noqa: F401 -- the seeded guards fixture +) + + +def _violations(repository, *, is_buy: bool = True, offline: bool = False) -> list[str]: + result = check( + _intent(side="BUY" if is_buy else "SELL"), + repository, + _config(), + now_ts=NOW_TS, + offline=offline, + ) + return list(result.violations) + + +def _cash(violations: list[str]) -> list[str]: + return [v for v in violations if v.startswith("cash_posture")] + + +def test_a_missing_posture_vetoes_a_live_entry(fresh_repo_without_posture) -> None: + """Fails closed on absent. No human has stated the account's posture, and no venue read can + supply one, so unknown is not evidence of a cash account.""" + lines = _cash(_violations(fresh_repo_without_posture)) + assert lines, "a missing cash posture did not veto a live entry" + assert "coinbase" in lines[0] + assert "keel posture attest" in lines[0] + + +def test_an_in_force_spot_cash_attestation_admits(fresh_repo_without_posture) -> None: + attest_cash_posture(fresh_repo_without_posture, now_ts=NOW_TS) + assert not _cash(_violations(fresh_repo_without_posture)) + + +def test_an_expired_attestation_vetoes_and_says_it_expired(fresh_repo_without_posture) -> None: + """"Expired" and "never attested" call for the same command but tell an operator different + things about their own diligence, so the messages differ.""" + attest_cash_posture( + fresh_repo_without_posture, now_ts=NOW_TS, attest_due_ts=NOW_TS - 1 + ) + (line,) = _cash(_violations(fresh_repo_without_posture)) + assert "expired" in line.lower() + assert "keel posture attest" in line + + +def test_a_margin_attestation_vetoes_and_names_what_was_attested( + fresh_repo_without_posture, +) -> None: + """An operator who attests margin gave an honest answer. The veto has to reflect that rather + than reading as "you forgot to attest" -- the remedy is a change to the ACCOUNT, not a + re-run of the command.""" + attest_cash_posture( + fresh_repo_without_posture, now_ts=NOW_TS, attested_posture=MARGIN_ENABLED + ) + (line,) = _cash(_violations(fresh_repo_without_posture)) + assert "margin" in line.lower() + + +def test_a_refuted_posture_vetoes_and_names_the_venue_evidence( + fresh_repo_without_posture, +) -> None: + attest_cash_posture( + fresh_repo_without_posture, + now_ts=NOW_TS, + state=CashPostureState.REFUTED, + refuted_ts=NOW_TS, + refuted_reason="INTX portfolio present", + ) + (line,) = _cash(_violations(fresh_repo_without_posture)) + assert "INTX portfolio present" in line + + +def test_a_different_credential_vetoes_with_its_own_message( + fresh_repo_without_posture, monkeypatch +) -> None: + """#633. Something WAS attested here, but not for the credential in place now -- and saying + "never attested" about that would repeat #624.""" + attest_cash_posture( + fresh_repo_without_posture, now_ts=NOW_TS, credential_fingerprint="fp-old" + ) + monkeypatch.setattr( + "keel.execution.guards.current_credential_fingerprint", lambda _venue: "fp-new" + ) + (line,) = _cash(_violations(fresh_repo_without_posture)) + assert "DIFFERENT" in line + assert "never" not in line.lower() + + +# --- what the rail must NOT do ---------------------------------------------------------------- + + +@pytest.mark.parametrize("side", ["SELL"]) +def test_every_non_entry_is_untouched_by_a_missing_posture( + fresh_repo_without_posture, side +) -> None: + """THE property that keeps this rail safe. A position that wants out must always be able to + leave, whatever is or is not recorded about the account.""" + assert not _cash(_violations(fresh_repo_without_posture, is_buy=False)) + + +def test_paper_mode_skips_it_and_says_so(fresh_repo_without_posture) -> None: + """Skipped because paper has no live account -- and RECORDED as skipped, so a paper track + record is honest about the rail it never ran.""" + result = check( + _intent(side="BUY"), fresh_repo_without_posture, _config(), now_ts=NOW_TS, offline=True + ) + assert not [v for v in result.violations if v.startswith("cash_posture")] + assert "cash_posture" in result.skipped_rails + + +def test_the_rail_is_declared_a_live_state_rail() -> None: + """`LIVE_STATE_RAILS` is what makes the paper skip reportable rather than invisible.""" + assert "cash_posture" in LIVE_STATE_RAILS + + +@pytest.fixture() +def fresh_repo_without_posture(request): + """The standard guards `repo` fixture, with the cash-posture record REMOVED. + + That fixture seeds one so the other twenty-one rails' tests are not incidentally vetoed by + rail 22 -- which means a test ABOUT rail 22 has to take it back out, or it would be asserting + against the fixture's own attestation instead of the state it means to. + """ + # `getfixturevalue` rather than a parameter named `repo`: the module-level re-export above + # is what registers the fixture, and a parameter of the same name would shadow it. + repository = request.getfixturevalue("repo") + repository._conn.execute("DELETE FROM venue_cash_postures") # noqa: SLF001 + repository._conn.commit() # noqa: SLF001 + return repository diff --git a/tests/execution/test_executor.py b/tests/execution/test_executor.py index c0e7d5ea..748ec9c9 100644 --- a/tests/execution/test_executor.py +++ b/tests/execution/test_executor.py @@ -52,7 +52,7 @@ from keel.execution.guards import OrderIntent from keel.strategy.rules.base import Action, Setup, Signal from keel.types import Side -from tests.conftest import attest_subscription, attest_trade_scope +from tests.conftest import attest_cash_posture, attest_subscription, attest_trade_scope NOW_TS = 1_700_000_000 @@ -226,6 +226,8 @@ def repo() -> Repository: # rail 20 gets the CONFIRMED shape the v14 backfill produces for an already-live venue -- # same reason this fixture seeds withdrawals and the subscription above. attest_trade_scope(r, now_ts=NOW_TS) + # Rail 22 (#691) fails closed without a cash-posture record, same as rail 20. + attest_cash_posture(r, now_ts=NOW_TS) return r @@ -3866,6 +3868,9 @@ def test_the_record_is_written_against_the_BOUND_venue_not_a_frozen_default(repo attested_scope=TRADING, attested_ts=NOW_TS, ) + # Rail 22 (#691) is venue-keyed for the same reason: an alpaca-bound cycle needs + # alpaca's posture row, or it is vetoed before it reaches the venue. + attest_cash_posture(repo, now_ts=NOW_TS, venue="alpaca") # Rail 14 is venue-keyed too, so an alpaca-bound cycle needs alpaca's subscription row or it # is vetoed before it ever reaches the venue -- which would pass this test for the wrong # reason (no placement, hence no refusal, hence no write). diff --git a/tests/execution/test_guards.py b/tests/execution/test_guards.py index 1a34040b..3a43507e 100644 --- a/tests/execution/test_guards.py +++ b/tests/execution/test_guards.py @@ -34,7 +34,7 @@ from keel.execution import guards from keel.execution.guards import LIVE_STATE_RAILS, GuardResult, OrderIntent, check from keel.types import Side -from tests.conftest import attest_subscription, attest_trade_scope +from tests.conftest import attest_cash_posture, attest_subscription, attest_trade_scope NOW_TS = 1_700_000_000 # 2023-11-14T22:13:20Z -- well inside its UTC day for boundary tests @@ -60,6 +60,8 @@ def repo() -> Repository: # rail 20 gets the CONFIRMED shape the v14 backfill produces for an already-live venue -- # same reason this fixture seeds the kill-switch, feed timestamp and subscription. attest_trade_scope(r, now_ts=NOW_TS) + # Rail 22 (#691) likewise fails closed without a cash-posture record. + attest_cash_posture(r, now_ts=NOW_TS) return r @@ -763,6 +765,7 @@ def test_rail12_missing_feed_timestamp_treated_as_stale(repo): _attest(fresh_repo, free_volume_usd=_LARGE_ALLOWANCE) # confirmed so only rail 12 (not rail 20's missing-record fallback) trips attest_trade_scope(fresh_repo, now_ts=NOW_TS) + attest_cash_posture(fresh_repo, now_ts=NOW_TS) result = check(_intent(), fresh_repo, _config(), NOW_TS) @@ -1001,6 +1004,8 @@ def _unattested_repo() -> Repository: # This helper is about rail 14 (no subscription), not rail 20 -- seed a CONFIRMED trade # scope so these tests aren't incidentally vetoed by the rail this module isn't testing. attest_trade_scope(r, now_ts=NOW_TS) + # Rail 22 (#691) is not what any caller of this helper is testing. + attest_cash_posture(r, now_ts=NOW_TS) return r @@ -1169,6 +1174,7 @@ def test_rail14_reads_the_bound_venues_record_not_the_default_constants() -> Non # `_unattested_repo()` only seeds coinbase's trade scope (rail 20 is not what this test is # about) -- attest alpaca's too, or rail 20 vetoes the bound venue for an unrelated reason. attest_trade_scope(repo, now_ts=NOW_TS, venue="alpaca") + attest_cash_posture(repo, now_ts=NOW_TS, venue="alpaca") token = bind_venue("alpaca") try: result = guards.check(_intent(notional=Decimal("50")), repo, _roomy_config(), NOW_TS) @@ -1626,6 +1632,8 @@ def _repo_no_trade_scope() -> Repository: r.set_state("kill_switch", False) r.set_state("last_feed_ts", NOW_TS) attest_subscription(r, now_ts=NOW_TS, free_volume_usd=_LARGE_ALLOWANCE) + # Rail 22 (#691) is not what any caller of this helper is testing. + attest_cash_posture(r, now_ts=NOW_TS) return r @@ -1646,6 +1654,8 @@ def test_rail20_does_NOT_veto_the_v13_backfilled_CONFIRMED_coinbase_record() -> live the whole time.""" repo = _repo_no_trade_scope() attest_trade_scope(repo, now_ts=NOW_TS) # CONFIRMED, attested_scope=None -- the backfill shape + # Rail 22 (#691) is a separate precondition; this test is about rail 20 alone. + attest_cash_posture(repo, now_ts=NOW_TS) result = check(_intent(), repo, _config(), NOW_TS) diff --git a/tests/execution/test_rail_count.py b/tests/execution/test_rail_count.py index 34755465..6ab35500 100644 --- a/tests/execution/test_rail_count.py +++ b/tests/execution/test_rail_count.py @@ -103,8 +103,15 @@ def test_every_rail_count_claim_in_the_repository_is_current() -> None: lowered = line.lower() for number, word in wrong.items(): # ` rails` and `the rails` -- the two shapes this repository writes. - if re.search(rf"\b{word}\b[^.]{{0,40}}\brails?\b", lowered) or re.search( - rf"\bthe {number}\b[^.]{{0,20}}\brails?\b", lowered + # `(?!\s*%)` keeps a THRESHOLD from reading as a count: "total drawdown at or + # past the 20% rail" is rail 11's percentage, not a claim that there are twenty + # rails, and it matched until #691 added the twenty-SECOND rail and surfaced it. + # `(?!-)` stops a SHORTER word matching inside a longer one: a hyphen is a word + # boundary, so `\btwenty\b` matches inside "twenty-one" and the whole sweep + # reported every freshly-corrected line as still stale. #691 surfaced this the + # first time the count crossed twenty. + if re.search(rf"\b{word}\b(?!-)[^.]{{0,40}}\brails?\b", lowered) or re.search( + rf"\bthe {number}\b(?!\s*%)[^.]{{0,20}}\brails?\b", lowered ): rel = path.relative_to(_ROOT).as_posix() stale.append(f"{rel}:{line_no}: {line.strip()[:88]}") diff --git a/tests/sim/test_account.py b/tests/sim/test_account.py index 49ddf376..e8904644 100644 --- a/tests/sim/test_account.py +++ b/tests/sim/test_account.py @@ -29,7 +29,7 @@ from keel.execution.guards import OrderIntent from keel.sim.account import OpenIntent, OpenPosition, SimAccount from keel.types import Side -from tests.conftest import attest_subscription, attest_trade_scope +from tests.conftest import attest_cash_posture, attest_subscription, attest_trade_scope def _ts(year: int, month: int, day: int, hour: int = 12) -> int: @@ -362,6 +362,7 @@ def test_sim_diverges_from_guards_on_monthly_allowance_once_a_sell_has_occurred( # Rail 20 (#233) fails closed without a trade-scope record; this comparison is about the # monthly allowance divergence, not rail 20, so confirm coinbase here. attest_trade_scope(repo, now_ts=now_ts) + attest_cash_posture(repo, now_ts=now_ts) # Subscription is attested below, once `config` (and its assumed_free_volume_usd) exists -- # guards.check derives its cap from the attested record, so it must match what `config` # gives the sim side for this to be a genuine guards-vs-sim comparison. @@ -849,6 +850,7 @@ def _parity_scenario(now_ts: int) -> tuple[Repository, SimAccount]: # Rail 20 (#233) fails closed without a trade-scope record; these parity tests are about # the spend-cap rails, not rail 20, so confirm coinbase here. attest_trade_scope(repo, now_ts=now_ts) + attest_cash_posture(repo, now_ts=now_ts) # No subscription attestation here -- each caller attests a record matching its own # `config.subscription` (see `_attest`), since guards.check now derives its cap from the # attested record while SimAccount reads `config.subscription` directly; parity requires @@ -1019,6 +1021,7 @@ def test_parity_with_guards_check_streak_halt_active(): # Rail 20 (#233) fails closed without a trade-scope record; this comparison is about rail # 16, not rail 20, so confirm coinbase here. attest_trade_scope(repo, now_ts=now_ts) + attest_cash_posture(repo, now_ts=now_ts) config = _config() # roomy on every spend cap ($500 monthly allowance is the only real cap) _attest( diff --git a/tests/test_agent.py b/tests/test_agent.py index 51f2291a..34e03968 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -50,7 +50,7 @@ from keel.strategy.rules.pullback_continuation import PullbackContinuation from keel.strategy.rules.turtle_breakout import TurtleBreakout from keel.types import Candle, Granularity, Side -from tests.conftest import attest_subscription, attest_trade_scope +from tests.conftest import attest_cash_posture, attest_subscription, attest_trade_scope PRODUCT = "BTC-USD" @@ -195,6 +195,8 @@ def repo() -> Repository: # Rail 20 (#233) fails closed without a trade-scope record; confirm coinbase here for the # same reason -- these tests are not ABOUT rail 20. attest_trade_scope(r, now_ts=0) + # Rail 22 (#691) fails closed without a cash-posture record, same as rail 20. + attest_cash_posture(r, now_ts=0) return r @@ -4394,6 +4396,9 @@ def test_a_cycle_with_notifications_disabled_makes_zero_transport_calls(repo, mo # rail 17 at 5 of its 7 days (2 remain): the WARN state an opted-in deployment would be # told about -- doctor's `attest.withdrawals` WARN, not the never-attested FAIL. repo.set_state("withdrawals_attested_at", _NOTIFY_ATTESTED_AT) + # The module fixture attests the cash posture at ts 0, which is already expired by + # `_NOTIFY_NOW`. Rail 22 is not what this test is about, so re-attest on its clock (#691). + attest_cash_posture(repo, now_ts=_NOTIFY_NOW) _notify_re_attest_subscription(repo) repo.insert_rule("dca", {"product_id": PRODUCT}, status="live") broker = FakeBroker( @@ -4417,6 +4422,9 @@ def test_a_warn_state_cycle_emits_exactly_the_opted_in_events(repo, monkeypatch) # attested 5 days before NOW: 2 of the 7 TTL days remain -> doctor WARNs (not the # never-attested FAIL a negative epoch would take). repo.set_state("withdrawals_attested_at", _NOTIFY_ATTESTED_AT) + # The module fixture attests the cash posture at ts 0, which is already expired by + # `_NOTIFY_NOW`. Rail 22 is not what this test is about, so re-attest on its clock (#691). + attest_cash_posture(repo, now_ts=_NOTIFY_NOW) _notify_re_attest_subscription(repo) repo.insert_rule("dca", {"product_id": PRODUCT}, status="live") broker = FakeBroker( diff --git a/tests/test_hostile_rule_rails.py b/tests/test_hostile_rule_rails.py index 2cac4eeb..0eaac27b 100644 --- a/tests/test_hostile_rule_rails.py +++ b/tests/test_hostile_rule_rails.py @@ -40,7 +40,7 @@ from keel.data.repository import Repository from keel.strategy.rules.base import Rule, Setup from keel.types import Candle, Granularity, Side -from tests.conftest import attest_subscription, attest_trade_scope +from tests.conftest import attest_cash_posture, attest_subscription, attest_trade_scope from tests.test_agent import ( PRODUCT, FakeBroker, @@ -67,6 +67,8 @@ def _fresh_repo() -> Repository: r.set_autonomous(True, now_ts=0) attest_subscription(r, now_ts=0, free_volume_usd=Decimal("10000000")) attest_trade_scope(r, now_ts=0) + # Rail 22 (#691) fails closed without a cash-posture record, same as rail 20. + attest_cash_posture(r, now_ts=0) return r From ba42de06fcf7bb69ac736f68c79b3c2771a6ce9d Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 3 Sep 2026 16:47:26 -0400 Subject: [PATCH 2/2] fix(compliance): review findings on rail 22 (#691) Four findings from the review of #712. Two were self-inconsistencies in the work's own stated reasoning; one was an operational cliff. 1. THE ENUM CONTAINED THE TRAP ITS OWN DOCSTRING ARGUES AGAINST. `CashPostureState` had an `UNVERIFIED` member that nothing writes: `posture attest` writes `ATTESTED`, `refute_posture` writes `REFUTED`, and "nobody has attested" is NO ROW, returned as `None`. It was exactly as unreachable as the `CONFIRMED` I had argued at length for omitting, sitting one line below that argument. Absence-as-`None` and absence-as-a-row are one fact, and two spellings invite a caller to check one and miss the other. Removed; rail 22's generic `else` branch existed only to catch it, and EXPIRED is now the only remaining case -- a property of the state machine rather than luck, which the comment says. 2. NOTHING WARNED BEFORE A 90-DAY ATTESTATION EXPIRED. The rail vetoes on lapse, correctly and silently, and the live profile runs unattended daily: on day 90 entries would simply stop. 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. `doctor` gains `attest.cash_posture` (pulled forward from part 2, because a silent trading halt is not a documentation gap), and it joins `_ATTESTATION_FINDINGS` so it reaches the `attestation.expiring` notification. The warning window is PROPORTIONAL to the TTL -- a sixth, 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 gets no notice at all. The event's message no longer hardcodes "rail 17"; it reads a label per finding. A MARGIN attestation FAILs with a fix naming 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. 3. `refute_posture` HAD NO CALLER, so the "INTX refutes a standing attestation" criterion was unmet and the `REFUTED` state was reachable only from a test. `record_cash_posture_refutation` now wraps the venue check: on refusal it refutes, then RE-RAISES. Recording is in addition to failing closed, never instead -- swallowing the exception would turn a hard stop into a database row. Refute-only is preserved: a clean check touches nothing, because "no INTX portfolio" is the absence of contradicting evidence, not proof of a cash posture. `_build_broker` takes an OPTIONAL `repo`, passed on the two paths that go on to trade. The read-only inspection commands have no repo and will not act on the fact; threading one through all six call sites would be churn. 26 test doubles stubbed `_build_broker` as `lambda config:` and now tolerate the kwarg. 4. `SCHEMA_VERSION - 1` in the migration test made it pass at any version. Pinned to 17, like every other version pin in tests/data/ -- literals there are the deliberate speed bump that makes a bump acknowledged. Tests written first, red before green: 15 new (9 doctor, 6 refutation seam), plus the enum pin rewritten to assert BOTH absences and say why. Mutation-verified, 6 mutants, all killed: `UNVERIFIED` restored; the warning window collapsed to two days; the WARN downgraded to OK; the finding dropped from the notification set; the refutation swallowing the refusal; a clean check refuting. Refs #691 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NzuKAe2RVrPt9acVAWjRyL --- keel/cli.py | 8 +- keel/commands/_common.py | 58 +++++++- keel/commands/doctor.py | 113 +++++++++++++++ keel/execution/guards.py | 12 +- keel/notifications.py | 10 +- packages/keel-core/keel_core/cash_posture.py | 19 ++- .../test_build_broker_refutes_the_posture.py | 103 +++++++++++++ tests/commands/test_doctor.py | 1 + tests/commands/test_doctor_cash_posture.py | 136 ++++++++++++++++++ tests/commands/test_service_parity.py | 12 +- tests/core/test_cash_posture.py | 28 ++-- tests/data/test_cash_posture_store.py | 21 ++- tests/test_agent.py | 2 +- tests/test_cli.py | 38 ++--- 14 files changed, 499 insertions(+), 62 deletions(-) create mode 100644 tests/commands/test_build_broker_refutes_the_posture.py create mode 100644 tests/commands/test_doctor_cash_posture.py diff --git a/keel/cli.py b/keel/cli.py index 5894c034..0524e9d2 100644 --- a/keel/cli.py +++ b/keel/cli.py @@ -1025,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 @@ -1084,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 diff --git a/keel/commands/_common.py b/keel/commands/_common.py index c6589fe1..59cb5860 100644 --- a/keel/commands/_common.py +++ b/keel/commands/_common.py @@ -26,6 +26,7 @@ import functools import sqlite3 import sys +import time from dataclasses import replace from pathlib import Path from typing import Any @@ -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 @@ -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": @@ -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 diff --git a/keel/commands/doctor.py b/keel/commands/doctor.py index 3191403f..1350ec93 100644 --- a/keel/commands/doctor.py +++ b/keel/commands/doctor.py @@ -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 @@ -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 @@ -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), diff --git a/keel/execution/guards.py b/keel/execution/guards.py index 56970a08..561509c9 100644 --- a/keel/execution/guards.py +++ b/keel/execution/guards.py @@ -1085,19 +1085,17 @@ def check( f"are vetoed. {unaffected} This needs a change to the ACCOUNT, not a " "re-attestation: disable margin at the venue, then re-attest." ) - elif not posture.is_current(now_ts): + else: + # EXPIRED is the only remaining case, and that is a property of the state + # machine rather than luck: `REFUTED` and `MARGIN_ENABLED` are handled above, + # "no record" never reaches here, and there is no `UNVERIFIED` state to fall + # through to -- see `CashPostureState` for why both absences were removed. violations.append( f"cash_posture: {venue}'s cash-posture attestation has EXPIRED " f"(due {posture.attest_due_ts}) -- a claim nobody has re-confirmed is the " "same class of unknown as no claim, so new ENTRIES are vetoed. " f"{unaffected} Re-attest with {attest} after re-checking the account." ) - else: - violations.append( - f"cash_posture: {venue}'s cash-posture record does not permit a live entry " - f"(state {posture.state.value}, posture {posture.attested_posture!r}) -- new " - f"ENTRIES are vetoed. {unaffected} Run {attest} once the account is checked." - ) for violation in violations: log_event( diff --git a/keel/notifications.py b/keel/notifications.py index 60320235..52820513 100644 --- a/keel/notifications.py +++ b/keel/notifications.py @@ -59,7 +59,13 @@ #: absent: a lapsed (or never-attested) subscription surfaces through the ALLOWANCE event #: instead -- the unsubscribed allowance (0 by default) with month-to-date spend IS that #: event's zero-runway case, so a rail-14 finding here would double-notify the same fact. -_ATTESTATION_FINDINGS = frozenset({"attest.withdrawals"}) +#: `attest.cash_posture` (#691) joins rail 17 here because it has the same failure shape and +#: a longer fuse: nothing re-confirms it, so it lapses on a clock, and the live profile runs +#: unattended. A doctor finding nobody is told about is only marginally better than the veto. +_ATTESTATION_FINDINGS = frozenset({"attest.withdrawals", "attest.cash_posture"}) + +#: Which rail each attestation finding belongs to, for the event message. +_RAIL_LABEL = {"attest.withdrawals": "rail 17", "attest.cash_posture": "rail 22"} #: The doctor findings the rail-armed event reads. `rail.kill_switch` is deliberately absent: #: the kill switch is engaged by an operator at a TTY (doctor renders it "a correct state, @@ -107,7 +113,7 @@ def events_from_state( events.append( notification_event( "attestation.expiring", - f"rail 17: {finding.headline} -- {finding.detail}", + f"{_RAIL_LABEL[finding.name]}: {finding.headline} -- {finding.detail}", finding=finding.name, status=finding.status, detail=finding.detail, diff --git a/packages/keel-core/keel_core/cash_posture.py b/packages/keel-core/keel_core/cash_posture.py index 9eb0bf24..0f8390dd 100644 --- a/packages/keel-core/keel_core/cash_posture.py +++ b/packages/keel-core/keel_core/cash_posture.py @@ -66,11 +66,19 @@ class CashPostureState(str, Enum): """What is known about a venue account's cash-versus-margin posture. - THREE states, and the missing fourth is the point -- see the module docstring. There is no - `CONFIRMED`, because no venue read can affirm this and a state nothing may write is a trap. + TWO states, and both absences are the point. + + There is no `CONFIRMED`: no venue read can affirm this, so it would be a state nothing is + entitled to write, and an unreachable state is one a later reader eventually reaches for. + + There is no `UNVERIFIED` either, and the first version of this enum HAD one -- sitting a line + below a docstring arguing against exactly that. Nothing writes it: `posture attest` writes + `ATTESTED`, `refute_posture` writes `REFUTED`, and "nobody has attested" is represented by NO + ROW, which `Repository.get_venue_cash_posture` returns as `None`. Absence-as-`None` and + absence-as-a-row are the same fact, and two spellings for one fact invite a caller to check + one and miss the other. """ - UNVERIFIED = "unverified" ATTESTED = "attested" REFUTED = "refuted" @@ -149,9 +157,10 @@ def may_place_live_entry(self, now_ts: int, current_fingerprint: str | None) -> no claim. - `ATTESTED` with any other posture (`MARGIN_ENABLED` included): False. - `REFUTED`: False. Venue evidence outranks the claim. - - `UNVERIFIED`: False. - Fails closed on anything not listed. + "Nobody has attested" never reaches here -- it is the absence of a record, and the caller + (rail 22) handles `None` before constructing anything. Fails closed on anything not + listed. """ if self.credential_evidence(current_fingerprint) is CredentialEvidence.DIFFERENT_CREDENTIAL: return False diff --git a/tests/commands/test_build_broker_refutes_the_posture.py b/tests/commands/test_build_broker_refutes_the_posture.py new file mode 100644 index 00000000..7fc378ab --- /dev/null +++ b/tests/commands/test_build_broker_refutes_the_posture.py @@ -0,0 +1,103 @@ +"""Venue evidence found at broker-build must refute the standing attestation -- #691. + +Without this the acceptance criterion is unmet and `refute_posture` has no caller: an operator's +honest-at-the-time claim outlives the fact that contradicted it, the record still reads ATTESTED, +`doctor` still shows it green, and the only thing between that and a live entry is an exception +nobody wrote down. + +`repo` is OPTIONAL on `_build_broker` on purpose. Most call sites are read-only inspection +(`keel balances`, `keel brokers list`) with no repository in hand, and threading one through every +one of them to record a fact none of them will act on would be churn. The paths that matter are +the ones that go on to trade. + +**The refusal still propagates.** Recording it is in addition to failing closed, never instead -- +`test_the_refusal_still_raises` pins that, because a refutation that swallowed the exception would +turn a hard stop into a database row. +""" + +from __future__ import annotations + +import pytest +from keel_core.cash_posture import SPOT_CASH, CashPostureState, VenueCashPosture + +from keel.commands._common import record_cash_posture_refutation +from keel.data.db import connect, migrate +from keel.data.repository import Repository + +NOW = 1_800_000_000 + + +@pytest.fixture() +def repo() -> Repository: + conn = connect(":memory:") + migrate(conn) + repo = Repository(conn) + repo.upsert_venue_cash_posture( + VenueCashPosture( + venue="coinbase", + state=CashPostureState.ATTESTED, + attested_posture=SPOT_CASH, + attested_ts=NOW, + attest_due_ts=NOW + 1000, + refuted_ts=None, + refuted_reason=None, + credential_fingerprint=None, + ) + ) + return repo + + +class _Refusing: + """A broker whose posture check refuses, as `CoinbaseAdapter` does on an INTX portfolio.""" + + def verify_cash_account(self) -> None: + raise RuntimeError("this account holds an INTX (perpetuals) portfolio") + + +class _Clean: + def verify_cash_account(self) -> None: + return None + + +def test_a_refusal_marks_the_standing_attestation_refuted(repo: Repository) -> None: + with pytest.raises(RuntimeError): + record_cash_posture_refutation( + _Refusing(), repo=repo, venue="coinbase", now_ts=NOW + 10 + ) + record = repo.get_venue_cash_posture("coinbase") + assert record.state is CashPostureState.REFUTED + assert "INTX" in record.refuted_reason + + +def test_the_refusal_still_raises(repo: Repository) -> None: + """Recording is IN ADDITION to failing closed. Swallowing the exception would turn a hard + stop into a database row -- the account would still be wrong and the build would proceed.""" + with pytest.raises(RuntimeError, match="INTX"): + record_cash_posture_refutation(_Refusing(), repo=repo, venue="coinbase", now_ts=NOW) + + +def test_a_clean_check_leaves_the_attestation_alone(repo: Repository) -> None: + """REFUTE-ONLY: a check that finds no contradiction is not evidence of a cash posture, so it + must not touch the record -- and certainly must not promote it.""" + record_cash_posture_refutation(_Clean(), repo=repo, venue="coinbase", now_ts=NOW) + assert repo.get_venue_cash_posture("coinbase").state is CashPostureState.ATTESTED + + +def test_no_repo_still_runs_the_check_and_still_raises() -> None: + """`repo=None` is the read-only call sites. The posture check must still run and still + refuse -- only the RECORDING is optional.""" + with pytest.raises(RuntimeError, match="INTX"): + record_cash_posture_refutation(_Refusing(), repo=None, venue="coinbase", now_ts=NOW) + + +def test_no_repo_and_a_clean_check_is_a_no_op() -> None: + record_cash_posture_refutation(_Clean(), repo=None, venue="coinbase", now_ts=NOW) + + +def test_nothing_attested_records_nothing_and_still_raises(repo: Repository) -> None: + """`refute_posture` refuses to invent a record, and this seam must not work around that.""" + repo._conn.execute("DELETE FROM venue_cash_postures") # noqa: SLF001 + repo._conn.commit() # noqa: SLF001 + with pytest.raises(RuntimeError): + record_cash_posture_refutation(_Refusing(), repo=repo, venue="coinbase", now_ts=NOW) + assert repo.get_venue_cash_posture("coinbase") is None diff --git a/tests/commands/test_doctor.py b/tests/commands/test_doctor.py index de9715dd..8d24e0cc 100644 --- a/tests/commands/test_doctor.py +++ b/tests/commands/test_doctor.py @@ -597,6 +597,7 @@ def test_gather_findings_covers_every_check_over_a_seeded_db(tmp_path, valid_con "attest.subscription", "attest.withdrawals", "scope.trade", + "attest.cash_posture", "rail.kill_switch", "rail.streak_halt", "rail.drawdown", diff --git a/tests/commands/test_doctor_cash_posture.py b/tests/commands/test_doctor_cash_posture.py new file mode 100644 index 00000000..f91b2216 --- /dev/null +++ b/tests/commands/test_doctor_cash_posture.py @@ -0,0 +1,136 @@ +"""`doctor` must warn BEFORE a cash-posture attestation expires -- #691, review finding. + +The rail vetoes on an expired attestation, which is correct and also silent: the live profile runs +unattended daily, so on day 90 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 warning window is proportional to the TTL rather than copied from rail 17: two days' notice on +a 7-day window is ~29% of it; two days on a 90-day window is 2%, and a daily profile that misses +those two cycles for any reason gets no notice at all. + +`test_the_warning_reaches_the_notification_path` is the one that matters -- a `doctor` finding +nobody is told about is only marginally better than the veto. +""" + +from __future__ import annotations + +import pytest +from keel_core.cash_posture import ( + ATTESTATION_TTL_SEC, + MARGIN_ENABLED, + SPOT_CASH, + CashPostureState, + VenueCashPosture, +) + +from keel import notifications +from keel.commands.doctor import FAIL, OK, WARN, cash_posture_findings + +NOW = 1_800_000_000 + + +def _record( + *, + state: CashPostureState = CashPostureState.ATTESTED, + posture: str | None = SPOT_CASH, + due_ts: int | None = NOW + ATTESTATION_TTL_SEC, + refuted_ts: int | None = None, +) -> VenueCashPosture: + return VenueCashPosture( + venue="coinbase", + state=state, + attested_posture=posture, + attested_ts=NOW, + attest_due_ts=due_ts, + refuted_ts=refuted_ts, + refuted_reason="INTX portfolio present" if refuted_ts else None, + credential_fingerprint=None, + ) + + +def test_no_record_fails(m: None = None) -> None: + """Rail 22 vetoes every entry in this state, so it is a FAIL, not a warning.""" + (finding,) = cash_posture_findings(None, venue="coinbase", now_ts=NOW) + assert finding.status == FAIL + assert "keel posture attest" in finding.fix + assert "coinbase" in finding.detail + + +def test_a_fresh_attestation_is_ok() -> None: + (finding,) = cash_posture_findings(_record(), venue="coinbase", now_ts=NOW) + assert finding.status == OK + assert "spot_cash" in finding.detail + + +def test_an_expired_attestation_fails_and_says_how_long_ago() -> None: + (finding,) = cash_posture_findings( + _record(due_ts=NOW - 3 * 86400), venue="coinbase", now_ts=NOW + ) + assert finding.status == FAIL + assert "3 day" in finding.detail + + +def test_an_attestation_nearing_its_due_date_warns() -> None: + """The whole point. A daily profile gets many cycles' notice, not two.""" + (finding,) = cash_posture_findings( + _record(due_ts=NOW + 5 * 86400), venue="coinbase", now_ts=NOW + ) + assert finding.status == WARN + assert "5 day" in finding.detail + + +def test_the_warning_window_is_proportional_to_the_ttl_not_two_days() -> None: + """Two days on a 90-day window is 2% of it, and a daily profile that misses those cycles + gets no notice at all. Rail 17's two days is ~29% of ITS window; the same generosity here is + two weeks.""" + warn_at = ATTESTATION_TTL_SEC // 6 # 15 days on 90 + (early,) = cash_posture_findings( + _record(due_ts=NOW + warn_at + 86400), venue="coinbase", now_ts=NOW + ) + (late,) = cash_posture_findings( + _record(due_ts=NOW + warn_at - 86400), venue="coinbase", now_ts=NOW + ) + assert early.status == OK + assert late.status == WARN + + +def test_a_margin_attestation_fails_and_does_not_ask_for_a_re_attestation() -> None: + """The remedy is a change to the ACCOUNT. Telling an operator to re-attest would send them + to type the same true answer again.""" + (finding,) = cash_posture_findings( + _record(posture=MARGIN_ENABLED), venue="coinbase", now_ts=NOW + ) + assert finding.status == FAIL + assert "margin" in finding.detail.lower() + + +def test_a_refuted_posture_fails_and_names_the_venue_evidence() -> None: + (finding,) = cash_posture_findings( + _record(state=CashPostureState.REFUTED, refuted_ts=NOW - 10), + venue="coinbase", + now_ts=NOW, + ) + assert finding.status == FAIL + assert "INTX portfolio present" in finding.detail + + +@pytest.mark.parametrize("due_offset", [-86400, 5 * 86400]) +def test_the_warning_reaches_the_notification_path(due_offset: int) -> None: + """A doctor finding nobody is told about is only marginally better than the veto. Both the + WARN and the FAIL have to produce an `attestation.expiring` event.""" + findings = cash_posture_findings( + _record(due_ts=NOW + due_offset), venue="coinbase", now_ts=NOW + ) + assert "attest.cash_posture" in notifications._ATTESTATION_FINDINGS # noqa: SLF001 + events = notifications.events_from_state( + attestation_findings=findings, + rail_findings=[], + month_to_date_spend=None, + allowance=None, + unplaced_setups=[], + stale_products=[], + held_products=[], + ) + assert [e for e in events if e.key == "attestation.expiring"], findings diff --git a/tests/commands/test_service_parity.py b/tests/commands/test_service_parity.py index f32e31ae..88c2b582 100644 --- a/tests/commands/test_service_parity.py +++ b/tests/commands/test_service_parity.py @@ -175,7 +175,7 @@ def test_fetch_check_failing_parity(tmp_path, valid_config_path, monkeypatch, ch _repo_at(db_svc) monkeypatch.setattr(cli_module, "time", _FrozenClock(NOW_TS)) - monkeypatch.setattr(cli_module, "_build_broker", lambda config: _ExplodingBroker()) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: _ExplodingBroker()) result = CliRunner().invoke( cli, @@ -215,7 +215,7 @@ def test_fetch_check_current_parity(tmp_path, valid_config_path, monkeypatch): _seed_current_series(_repo_at(db_svc), ("BTC-USD", "ETH-USD", "PAXG-USD")) monkeypatch.setattr(cli_module, "time", _FrozenClock(NOW_TS)) - monkeypatch.setattr(cli_module, "_build_broker", lambda config: _ExplodingBroker()) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: _ExplodingBroker()) result = CliRunner().invoke( cli, ["--db", str(db_cli), "--config", str(valid_config_path), "fetch", "--check"] @@ -261,7 +261,7 @@ def test_monitor_single_poll_parity(tmp_path, valid_config_path, monkeypatch): _repo_at(db_cli) _repo_at(db_svc) monkeypatch.setattr(cli_module, "time", _FrozenClock(NOW_TS)) - monkeypatch.setattr(cli_module, "_build_broker", lambda config: _FakePollBroker()) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: _FakePollBroker()) result = CliRunner().invoke( cli, ["--db", str(db_cli), "--config", str(valid_config_path), "monitor"] @@ -334,7 +334,7 @@ def test_simulate_report_parity(tmp_path, valid_config_path, monkeypatch): monkeypatch.setattr( cli_module, "_build_broker", - lambda config: (_ for _ in ()).throw(AssertionError("no network under --no-fetch")), + lambda config, **_kw: (_ for _ in ()).throw(AssertionError("no network under --no-fetch")), ) result = CliRunner().invoke( @@ -482,7 +482,7 @@ def test_assets_holdings_render_parity(tmp_path, valid_config_path, monkeypatch) repo_svc = _repo_at(db_svc) _attest_btc(repo_cli) _attest_btc(repo_svc) - monkeypatch.setattr(cli_module, "_build_broker", lambda config: _AccountsBroker()) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: _AccountsBroker()) result = CliRunner().invoke( cli, @@ -521,7 +521,7 @@ def list_products(self) -> list[dict[str, Any]]: def test_assets_discover_render_parity(tmp_path, valid_config_path, monkeypatch): - monkeypatch.setattr(cli_module, "_build_broker", lambda config: _DiscoverBroker()) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: _DiscoverBroker()) result = CliRunner().invoke( cli, diff --git a/tests/core/test_cash_posture.py b/tests/core/test_cash_posture.py index dae63891..dbd9b421 100644 --- a/tests/core/test_cash_posture.py +++ b/tests/core/test_cash_posture.py @@ -63,12 +63,22 @@ def _posture( # --- the state machine's shape ---------------------------------------------------------------- -def test_there_is_no_confirmed_state() -> None: - """THE load-bearing pin. No venue read can affirm a spot cash posture, so a `CONFIRMED` - value would be one nothing is entitled to write -- and an unreachable state is one a later - reader eventually reaches for. The absence is the design.""" - assert {s.value for s in CashPostureState} == {"unverified", "attested", "refuted"} +def test_only_the_two_states_something_can_actually_write_exist() -> None: + """THE load-bearing pin, and it cuts BOTH ways. + + `CONFIRMED` is absent because no venue read can ever affirm a spot cash posture, so it would + be a state nothing is entitled to write -- and an unreachable state is one a later reader + eventually reaches for. + + `UNVERIFIED` is absent for the SAME reason, which the first version of this module missed: + `attest` writes `ATTESTED`, `refute_posture` writes `REFUTED`, and "nobody has attested" is + represented by NO ROW -- `get_venue_cash_posture` returns `None`. An `UNVERIFIED` member was + therefore just as unreachable as `CONFIRMED`, sitting one line below the docstring arguing + against exactly that. Absence-as-`None` and absence-as-a-row are the same fact, and having + two spellings for it invites a caller to check one and miss the other.""" + assert {s.value for s in CashPostureState} == {"attested", "refuted"} assert not hasattr(CashPostureState, "CONFIRMED") + assert not hasattr(CashPostureState, "UNVERIFIED") def test_the_ttl_is_longer_than_rail_17s_window_and_shorter_than_forever() -> None: @@ -82,11 +92,9 @@ def test_the_ttl_is_longer_than_rail_17s_window_and_shorter_than_forever() -> No # --- may_place_live_entry -------------------------------------------------------------------- -def test_an_unverified_record_refuses() -> None: - """Fails closed on absent, like rails 12/13/17/20.""" - assert not _posture(state=CashPostureState.UNVERIFIED, posture=None).may_place_live_entry( - NOW, FP - ) +# "Nobody has attested" is the ABSENCE of a record, not a state within one, so it is tested +# where absence is representable: `tests/execution/test_cash_posture_rail.py:: +# test_a_missing_posture_vetoes_a_live_entry`. def test_an_in_force_spot_cash_attestation_permits() -> None: diff --git a/tests/data/test_cash_posture_store.py b/tests/data/test_cash_posture_store.py index 495edb5e..b81e3214 100644 --- a/tests/data/test_cash_posture_store.py +++ b/tests/data/test_cash_posture_store.py @@ -14,9 +14,14 @@ from __future__ import annotations import pytest -from keel_core.cash_posture import SPOT_CASH, CashPostureState, VenueCashPosture - -from keel.data.db import SCHEMA_VERSION, connect, migrate +from keel_core.cash_posture import ( + MARGIN_ENABLED, + SPOT_CASH, + CashPostureState, + VenueCashPosture, +) + +from keel.data.db import connect, migrate from keel.data.repository import Repository NOW = 1_800_000_000 @@ -58,7 +63,10 @@ def test_the_schema_carries_the_table() -> None: def test_an_existing_database_gains_the_table_on_migration() -> None: conn = connect(":memory:") migrate(conn) - conn.execute(f"UPDATE schema_version SET version = {SCHEMA_VERSION - 1}") + # Literal 17, not `SCHEMA_VERSION - 1`: every version pin in tests/data/ is a literal + # precisely so a bump is acknowledged rather than silently absorbed. Deriving it would + # opt this one test out of that discipline. + conn.execute("UPDATE schema_version SET version = 17") conn.execute("DROP TABLE venue_cash_postures") conn.commit() migrate(conn) @@ -80,10 +88,9 @@ def test_a_record_round_trips(repo: Repository) -> None: def test_the_record_is_keyed_on_venue_alone(repo: Repository) -> None: repo.upsert_venue_cash_posture(_record(venue="coinbase")) - repo.upsert_venue_cash_posture(_record(venue="alpaca", posture=None, - state=CashPostureState.UNVERIFIED)) + repo.upsert_venue_cash_posture(_record(venue="alpaca", posture=MARGIN_ENABLED)) assert repo.get_venue_cash_posture("coinbase").attested_posture == SPOT_CASH - assert repo.get_venue_cash_posture("alpaca").attested_posture is None + assert repo.get_venue_cash_posture("alpaca").attested_posture == MARGIN_ENABLED def test_re_attesting_replaces_in_place(repo: Repository) -> None: diff --git a/tests/test_agent.py b/tests/test_agent.py index 34e03968..4c5ef75d 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -3375,7 +3375,7 @@ def _fake_run_once(broker, repo_arg, config, now_ts, confirm_fn=None): ts=now_ts, skipped=False, skip_reason=None, mode=config.auto_trade.mode, polled=0 ) - monkeypatch.setattr(cli_module, "_build_broker", lambda config: object()) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: object()) monkeypatch.setattr(cli_module, "_open_repo", lambda ctx: repo) monkeypatch.setattr(cli_module, "_load_cfg", lambda ctx: _live_config()) monkeypatch.setattr(cli_module.agent, "run_once", _fake_run_once) diff --git a/tests/test_cli.py b/tests/test_cli.py index bfc41f5b..e1a2e3c6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -121,7 +121,7 @@ def test_disclaimer_shown_even_when_refused(tmp_path): def test_agent_confirm_mode_needs_no_passphrase(tmp_path, valid_config_path, monkeypatch): - monkeypatch.setattr(cli_module, "_build_broker", lambda config: FakeBroker()) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: FakeBroker()) db_path = tmp_path / "test.db" runner = CliRunner() @@ -138,7 +138,7 @@ def test_agent_confirm_mode_needs_no_passphrase(tmp_path, valid_config_path, mon def test_agent_loop_bounded_by_max_cycles(tmp_path, valid_config_path, monkeypatch): - monkeypatch.setattr(cli_module, "_build_broker", lambda config: FakeBroker()) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: FakeBroker()) db_path = tmp_path / "test.db" runner = CliRunner() @@ -160,7 +160,7 @@ def test_agent_prints_paper_equity_and_drawdown_line(tmp_path, write_config, mon scalars -- the observability for a paper-forward, not just a side effect buried in state.""" from tests.conftest import VALID_CONFIG_YAML - monkeypatch.setattr(cli_module, "_build_broker", lambda config: FakeBroker()) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: FakeBroker()) config_path = write_config(VALID_CONFIG_YAML + "\npaper:\n starting_equity_usd: 10000\n") db_path = tmp_path / "test.db" _repo_at(db_path).set_state("kill_switch", False) @@ -213,7 +213,7 @@ def test_agent_exits_data_not_ready_when_an_entry_is_blocked(tmp_path, write_con """ from tests.conftest import VALID_CONFIG_YAML - monkeypatch.setattr(cli_module, "_build_broker", lambda config: FakeBroker()) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: FakeBroker()) # Drop FIFTEEN_MINUTE -- the live config's finest granularity -- so the market-data-wide # STALE-FEED skip (`market_feed.is_fresh`, checked against the finest configured # granularity) doesn't need its own fixture and can't mask the entry gate this test is about. @@ -239,7 +239,7 @@ def test_agent_exits_zero_and_reports_blocked_zero_when_nothing_is_blocked( emitting the `signals=[0-9]+` token the live runner greps out of its output (see the runner's own `keel-live-run.sh`), and `blocked=0` proves the new token is additive, not a replacement. """ - monkeypatch.setattr(cli_module, "_build_broker", lambda config: FakeBroker()) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: FakeBroker()) db_path = tmp_path / "test.db" _repo_at(db_path).set_state("kill_switch", False) runner = CliRunner() @@ -265,7 +265,7 @@ def test_agent_loop_does_not_exit_the_process_when_a_cycle_is_blocked( """ from tests.conftest import VALID_CONFIG_YAML - monkeypatch.setattr(cli_module, "_build_broker", lambda config: FakeBroker()) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: FakeBroker()) config_path = write_config(VALID_CONFIG_YAML.replace(" - FIFTEEN_MINUTE\n", "")) db_path = tmp_path / "test.db" now_ts = 5 * 86_400 + 2 * 3_600 @@ -316,7 +316,7 @@ def test_agent_exits_clock_unavailable_when_the_session_clock_cannot_be_read( 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( + cli_module, "_build_broker", lambda config, **_kw: _SessionClockCLIBroker( SessionState.CLOCK_UNAVAILABLE ) ) @@ -340,7 +340,7 @@ def test_agent_still_exits_zero_on_a_market_closed_skip( -- 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( + cli_module, "_build_broker", lambda config, **_kw: _SessionClockCLIBroker( SessionState.CLOSED ) ) @@ -443,7 +443,7 @@ def test_fetch_and_monitor_help_state_distinct_jobs(): def test_monitor_single_poll_needs_no_passphrase(tmp_path, valid_config_path, monkeypatch): - monkeypatch.setattr(cli_module, "_build_broker", lambda config: FakeBroker()) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: FakeBroker()) db_path = tmp_path / "test.db" runner = CliRunner() @@ -461,7 +461,7 @@ def test_monitor_single_poll_needs_no_passphrase(tmp_path, valid_config_path, mo def test_monitor_loop_bounded_by_max_cycles(tmp_path, valid_config_path, monkeypatch): - monkeypatch.setattr(cli_module, "_build_broker", lambda config: FakeBroker()) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: FakeBroker()) db_path = tmp_path / "test.db" runner = CliRunner() @@ -510,7 +510,7 @@ def test_monitor_skips_polling_while_the_venue_reports_closed( tmp_path, valid_config_path, monkeypatch ): broker = _SessionClockFakeBroker([SessionState.CLOSED]) - monkeypatch.setattr(cli_module, "_build_broker", lambda config: broker) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: broker) db_path = tmp_path / "test.db" runner = CliRunner() @@ -538,7 +538,7 @@ def test_monitor_resumes_polling_when_the_venue_reopens(tmp_path, valid_config_p broker = _SessionClockFakeBroker( [SessionState.CLOSED, SessionState.CLOSED, SessionState.OPEN] ) - monkeypatch.setattr(cli_module, "_build_broker", lambda config: broker) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: broker) db_path = tmp_path / "test.db" runner = CliRunner() @@ -561,7 +561,7 @@ def test_monitor_resumes_polling_when_the_venue_reopens(tmp_path, valid_config_p def test_monitor_polls_as_today_when_the_venue_is_open(tmp_path, valid_config_path, monkeypatch): broker = _SessionClockFakeBroker([SessionState.OPEN]) - monkeypatch.setattr(cli_module, "_build_broker", lambda config: broker) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: broker) db_path = tmp_path / "test.db" runner = CliRunner() @@ -1441,7 +1441,7 @@ def _record_ensure(client, repo_arg, products, granularities, years, now_ts, **k grans.append(tuple(granularities)) return {} - monkeypatch.setattr(cli_module, "_build_broker", lambda config: object()) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: object()) monkeypatch.setattr(cli_module.history_mod, "ensure_history", _record_ensure) result = CliRunner().invoke( @@ -1515,7 +1515,7 @@ def test_simulate_reports_per_product_slippage_beside_the_results(tmp_path, monk out_path = tmp_path / "report.md" repo = _repo_at(db_path) _seed_liquidity_stratified_candles(repo, int(time.time())) - monkeypatch.setattr(cli_module, "_build_broker", lambda config: None) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: None) runner = CliRunner() result = runner.invoke( @@ -1571,7 +1571,7 @@ def test_simulate_prints_floor_cap_and_anchor_beside_every_result(tmp_path, monk out_path = tmp_path / "report.md" repo = _repo_at(db_path) _seed_liquidity_stratified_candles(repo, int(time.time())) - monkeypatch.setattr(cli_module, "_build_broker", lambda config: None) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: None) result = CliRunner().invoke( cli, @@ -1605,7 +1605,7 @@ def test_simulate_no_fetch_does_not_refetch_and_reuses_cached_db(tmp_path, monke monkeypatch.setattr( cli_module, "_build_broker", - lambda config: (_ for _ in ()).throw(AssertionError("no network under --no-fetch")), + lambda config, **_kw: (_ for _ in ()).throw(AssertionError("no network under --no-fetch")), ) runner = CliRunner() @@ -1631,7 +1631,7 @@ def test_simulate_artifact_flag_writes_html_next_to_markdown(tmp_path, monkeypat out_path = tmp_path / "report.md" repo = _repo_at(db_path) _seed_candles_for_allowlist(repo, int(time.time())) - monkeypatch.setattr(cli_module, "_build_broker", lambda config: None) + monkeypatch.setattr(cli_module, "_build_broker", lambda config, **_kw: None) runner = CliRunner() result = runner.invoke( @@ -1710,7 +1710,7 @@ def test_simulate_no_fetch_default_runs_full_tier_matrix(tmp_path, monkeypatch): monkeypatch.setattr( cli_module, "_build_broker", - lambda config: (_ for _ in ()).throw(AssertionError("no network under --no-fetch")), + lambda config, **_kw: (_ for _ in ()).throw(AssertionError("no network under --no-fetch")), ) runner = CliRunner()