Skip to content

fix(compliance): stop discovery hiding assets the gate would admit - #270

Merged
eaitbrahim merged 2 commits into
mainfrom
fix/discover-exclusions
Aug 15, 2026
Merged

fix(compliance): stop discovery hiding assets the gate would admit#270
eaitbrahim merged 2 commits into
mainfrom
fix/discover-exclusions

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

The problem

keel assets discover was silently dropping assets that clear the admission gate comfortably, and its own output gave an operator no way to notice. Two defects, one symptom.

(a) A one-day statistic compared against a multi-year threshold

--min-volume-24h defaulted to 1,000,000, pinned EQUAL to the admission floor ScreenPolicy.min_median_daily_volume.

That pinning (2026-08-08, superseding a 5,000,000 floor that had hidden FET at $2.94M/24h while it measured 4.8x the admission floor) had the right intent: a pre-filter must never be stricter than the criterion it screens for. It had the wrong mechanism. The two numbers are not the same statistic:

statistic window
discovery --min-volume-24h venue's reported quote_24h_volume a single 24-hour snapshot
admission min_median_daily_volume median of volume x close all cached history

Equal numbers cannot make one non-stricter than the other when the two sides measure different things — one quiet trading day pushes the snapshot below a floor the asset's own median clears many times over.

Measured 2026-08-15, five assets were silently dropped whose real gate statistic sits far above the admission floor:

asset median daily quote volume vs admission floor
ATOM 3,077,474 3.08x
AAVE 6,315,463 6.32x
BCH 5,464,940 5.46x
CRV 3,329,753 3.33x
ALGO 3,780,207 3.78x

Four of the five had 24h volumes clustered between 852,133 and 979,000 on that one quiet day — comfortably under the 1,000,000 floor.

The discovery floor now sits an order of magnitude below the admission floor (100,000), leaving real room beneath that measured cluster. This supersedes the 2026-08-08 pinning without abandoning its goal: the test that asserted the two floors are equal now asserts discovery's is strictly less than the gate's, which is the property the original fix was reaching for. The admission floor itself is untouched — it is the real criterion.

(b) Nothing recorded what was excluded

discover_candidates dropped products with a bare continue and returned only survivors; the CLI printed only N venue products -> M candidates. A filter that can silently remove admissible assets must be auditable from its own output.

It now returns a DiscoveryResult carrying per-reason exclusion counts, surfaced by both keel assets discover and the TUI's discover overlay:

5 venue products -> 2 candidates (quote=USD, 24h volume >= 100,000, excluding the current allowlist)
excluded 3: wrong quote currency 1, not online 0, trading disabled 0, view only 0, already on allowlist 1, unreadable 24h volume 0, below 24h volume floor 1

Every reason is listed even at 0, so the line has a fixed shape an operator can scan rather than one that changes with the data. The new named return type — rather than a tuple — is deliberate: an un-updated caller fails loudly at the attribute access instead of silently mis-indexing. Candidate is unchanged, and discover_candidates stays pure and offline.

Changes

  • keel/compliance/screen.py — new DiscoveryExclusions / DiscoveryResult; discover_candidates counts each drop against the first criterion it fails; DiscoveryPolicy.min_quote_24h_volume -> 100,000.
  • keel/commands/admission.pyDEFAULT_MIN_QUOTE_24H_VOLUME -> 100,000; DiscoverReport.excluded; render_discover_report emits the summary.
  • keel/cli.py — option default and help text; echoes the summary.
  • The three DISCOVERY defaults still move together; only their relationship to the admission floor changed.

Tests

Written first, confirmed failing for the right reasons (19 red), then implemented:

  • Regression test with ATOM's real measured figure: a product at 852,133/24h must appear as a candidate under the default policy.
  • Per-criterion exclusion counts, one product per reason, each asserted individually.
  • Both trading_disabled / is_disabled flag variants pinned to the single trading_disabled bucket.
  • summary_line() wording pinned exactly.
  • Discovery's floor asserted strictly below the admission floor, with the FET rationale and the 2026-08-15 evidence in the docstring.
  • CLI-level test that keel assets discover prints the summary.
  • All nine pre-existing discovery tests still pass.

Gates

uv run ruff check keel tests packages   All checks passed!
uv run mypy                             Success: no issues found in 224 source files
uv run pytest -q                        2734 passed, 1 skipped in 31.48s

🤖 Generated with Claude Code

eaitbrahim and others added 2 commits August 15, 2026 15:50
`keel assets discover` was silently dropping assets that clear the
admission gate comfortably, and its own output gave an operator no way
to notice.

Two defects, one symptom.

(a) A one-day statistic compared against a multi-year threshold. The
`--min-volume-24h` default was 1,000,000, pinned EQUAL to the admission
floor `ScreenPolicy.min_median_daily_volume`. The intent of that pinning
(2026-08-08, superseding a 5,000,000 floor that had hidden FET at
$2.94M/24h while it measured 4.8x the admission floor) was right: a
pre-filter must never be stricter than the criterion it screens for. The
mechanism was wrong. Discovery's number is a 24-hour venue snapshot;
admission's is the median of volume x close over ALL cached history.
Equal NUMBERS cannot make one non-stricter than the other when the two
sides measure different statistics -- a single quiet trading day pushes
the snapshot below a floor the asset's own median clears many times over.

Measured 2026-08-15, five assets were silently dropped whose real gate
statistic sits far ABOVE the admission floor: ATOM 3,077,474 (3.08x),
AAVE 6,315,463 (6.32x), BCH 5,464,940 (5.46x), CRV 3,329,753 (3.33x),
ALGO 3,780,207 (3.78x). Four of the five had 24h volumes clustered
between 852,133 and 979,000 on that one quiet day.

The discovery floor now sits an order of magnitude BELOW the admission
floor (100,000), and the test that used to assert the two are equal now
asserts discovery's is STRICTLY LESS -- preserving 2026-08-08's goal
while replacing the mechanism that could not deliver it. The admission
floor itself is unchanged: it is the real criterion.

(b) Nothing recorded what was excluded. `discover_candidates` dropped
products with a bare `continue` and returned only survivors, and the CLI
printed only `N venue products -> M candidates`. A filter that can
silently remove admissible assets must be auditable from its own output.
It now returns a `DiscoveryResult` carrying per-reason exclusion counts
(wrong quote currency, not online, trading disabled, view only, already
on allowlist, unreadable 24h volume, below the 24h floor), surfaced by
both `keel assets discover` and the TUI's discover overlay. The new
named return type -- rather than a tuple -- is deliberate: an un-updated
caller fails loudly at the attribute access instead of silently
mis-indexing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #270 lowered the discovery pre-filter so the five assets it exists to
surface (ATOM, AAVE, BCH, CRV, ALGO) become candidates again. But `keel
assets discover` sorts candidates by descending 24h volume and defaults
--limit to 25, which was sized for the ~35-candidate sweeps the OLD
1,000,000 floor produced. At the new 100,000 floor a typical sweep is
~130 candidates, so the five recovered assets land at ranks 33-59 --
below the CLI's own default view, cut off with no indication anything
was hidden. Same defect class the PR exists to fix, one step later in
the pipeline.

Two changes, both in `assets_discover` (keel/cli.py) and the offline
`build_discover_report`/`render_discover_report` path it shares with
the TUI's discover overlay (keel/commands/admission.py):

- Never truncate silently. When more candidates survive than `--limit`
  shows, say so: how many exist, how many are shown, that --limit
  controls it. `render_discover_report` derives the pre-truncation
  survivor count from `venue_product_count - excluded.total` rather
  than adding a redundant field to `DiscoverReport`.
- Raise the default --limit from 25 to 100. Verified --limit is applied
  before the probe loop in both paths, so this is free with neither
  probe flag (`assets discover` still makes exactly one venue request
  regardless of --limit). --probe-history/--probe-liquidity each cost
  one request per candidate SHOWN, so combining a probe with a large
  --limit multiplies the request count -- called out in --limit's own
  help text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eaitbrahim
eaitbrahim merged commit 043662a into main Aug 15, 2026
1 check failed
@eaitbrahim
eaitbrahim deleted the fix/discover-exclusions branch August 15, 2026 20:13
eaitbrahim added a commit that referenced this pull request Aug 15, 2026
…or, stop a NaN crash (#273)

* fix(compliance): pin the admission floor's value, not just its relationship

This PR's own test_admission.py change replaced an equality assertion on
DEFAULT_MIN_QUOTE_24H_VOLUME vs ScreenPolicy().min_median_daily_volume with a
`<` relationship check. That relationship guard is correct and stays, but it
was also the suite's only test pinning the admission floor's VALUE -- and
nothing replaced that. Verified: dropping min_median_daily_volume from
1,000,000 to 200,000 (a 5x cut to the real criterion that decides which
assets a money-moving tool may buy) left the whole suite green, 2741 passed /
1 skipped, `<` assertion included.

Add an absolute pin alongside the `<` assertion so both are guarded: the
relationship (discovery must never be stricter than the gate) and the
criterion itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(compliance): guard NaN/Infinity volume, assert the survivor invariant, fix the incident-cluster record, and make discovery's dataclasses genuinely frozen

Four independent fixes from an adversarial review of #270:

- discover_candidates parsed quote_24h_volume inside a try/except but compared
  it outside: Decimal("NaN")/"nan"/float("nan")/"sNaN" all parse cleanly and
  then crash the whole sweep on `<` with decimal.InvalidOperation.
  Decimal("Infinity") also parses cleanly, compares fine, and would silently
  become a candidate. Both are now caught and counted unreadable_volume, same
  bucket as a value that failed to parse outright -- a NaN/Infinity venue row
  is exactly as uninformative as one that failed to parse.

- render_discover_report derives survivor_count by subtraction
  (venue_product_count - excluded.total) but nothing asserted the invariant
  that subtraction relies on, and DiscoverReport is a public frozen dataclass
  constructable directly with inconsistent fields -- confirmed one such
  report renders "10 venue products -> -89 candidates". Added a test pinning
  len(candidates) + excluded.total == len(products) over a mixed product
  list, and clamped the subtraction so it can never go negative.

- The DiscoveryPolicy comment and the regression test both misnamed the
  2026-08-15 incident cluster as (ATOM, BCH, CRV, ALGO)/"all but AAVE".
  Measured against the venue: the cluster is ATOM, AAVE, BCH, CRV; ALGO was a
  separate, lower outlier at 437,712 (430,520 an hour later) -- the lowest of
  the five. The regression test pinned only the cluster's top value (852,133),
  which a future floor of 500,000 would still pass while silently re-hiding
  ALGO. Now pinned to ALGO's 437,712, the actual constraint; verified 500,000
  fails this corrected test and would not have failed the old one.

- DiscoveryResult and DiscoverReport were @DataClass(frozen=True) but held
  list[Candidate]: mutable in place, and unhashable regardless of the
  decorator. Both candidates fields are now tuple[Candidate, ...], with
  callers and tests updated accordingly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant