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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions keel/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -763,13 +763,29 @@ def assets_holdings(ctx: click.Context, min_balance: str, run_screen: bool) -> N
@assets_group.command("discover")
@click.option("--quote", default=None, help="Settlement currency (default: config.quote_currency).")
@click.option(
"--min-volume-24h", default="1000000", show_default=True,
"--min-volume-24h", default="100000", show_default=True,
help="Cheap pre-filter on the venue's reported 24h quote volume. Bounds the request count; it "
"is NOT a liquidity criterion -- a 24h snapshot is a different statistic from the median the "
"gate applies, so use --probe-liquidity for that. Set equal to the admission floor so "
"discovery cannot be stricter than the criterion it screens for.",
"gate applies, so use --probe-liquidity for that. Deliberately set well BELOW the admission "
"floor, not equal to it: a 24h snapshot and the gate's all-cached-history median are "
"different statistics, so an equal number does not make discovery non-stricter than the gate "
"it feeds -- a quiet trading day can push the snapshot under a floor the asset's own median "
"clears many times over.",
)
@click.option(
# Raised from 25 to 100 on 2026-08-15: lowering --min-volume-24h's floor (see that option's
# own help text) grew a typical sweep from ~35 candidates to ~130, sorted by descending 24h
# volume, so the assets that floor change exists to surface can land well past rank 25 and
# get cut off by this option before an operator ever sees them. 100 shows nearly all of a
# typical sweep at no extra cost: with NEITHER probe flag, `discover` makes exactly ONE venue
# request regardless of --limit -- filtering and sorting are local. --probe-history and
# --probe-liquidity are the ones with a per-row cost, called out below.
"--limit", default=100, show_default=True,
help="Show at most this many candidates. With neither --probe-history nor --probe-liquidity, "
"raising this costs nothing extra -- discovery still makes exactly one venue request. Each "
"probe flag adds one venue request PER CANDIDATE SHOWN (two requests per row if both are "
"given), so a large --limit combined with a probe flag multiplies the request count.",
)
@click.option("--limit", default=25, show_default=True, help="Show at most this many candidates.")
@click.option(
"--probe-history",
is_flag=True,
Expand Down Expand Up @@ -812,15 +828,27 @@ def assets_discover(
quote_currency=quote or config.quote_currency,
min_quote_24h_volume=Decimal(min_volume_24h),
)
candidates = screen_mod.discover_candidates(
result = screen_mod.discover_candidates(
products, policy, exclude_assets=frozenset(config.allowlist)
)

click.echo(
f"{len(products)} venue products -> {len(candidates)} candidates "
f"{len(products)} venue products -> {len(result.candidates)} candidates "
f"(quote={policy.quote_currency}, 24h volume >= {policy.min_quote_24h_volume:,.0f}, "
f"excluding the current allowlist)\n"
f"excluding the current allowlist)"
)
click.echo(result.excluded.summary_line())
# Never truncate silently: `result.candidates` above is the FULL survivor list (only the
# table loop below is cut to `limit`), so if there are more survivors than `limit` allows,
# say so explicitly -- how many exist, how many are about to be shown, and that --limit is
# the knob. A silent cap here would be exactly the defect class this command's own fix
# (the --min-volume-24h floor) exists to eliminate, just moved one step later in the pipeline.
if len(result.candidates) > limit:
click.echo(
f"showing {limit} of {len(result.candidates)} candidates -- raise --limit to see "
"the rest."
)
click.echo("")
screen_policy = screen_mod.ScreenPolicy()
header = f"{'#':>3} {'product':<14} {'asset':<8} {'24h quote volume':>18}"
if probe_history:
Expand All @@ -832,7 +860,7 @@ def assets_discover(
now_ts = int(time.time())
four_years_ago = now_ts - 4 * _DAYS_PER_YEAR * 86400
liquidity_window_start = now_ts - _LIQUIDITY_PROBE_DAYS * 86400
for index, candidate in enumerate(candidates[:limit], start=1):
for index, candidate in enumerate(result.candidates[:limit], start=1):
line = (
f"{index:>3} {candidate.product_id:<14} {candidate.asset:<8} "
f"{candidate.quote_24h_volume:>18,.0f}"
Expand Down
98 changes: 84 additions & 14 deletions keel/commands/admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from keel.commands._products import _default_sim_products
from keel.compliance.screen import (
Candidate,
DiscoveryExclusions,
DiscoveryPolicy,
MarketFacts,
ScreenResult,
Expand Down Expand Up @@ -66,18 +67,56 @@
#: create the cycle `cli -> tui -> admission -> cli`. `test_build_discover_report_applies_
#: default_volume_floor_matching_assets_discover` reads the CLI option's own default and asserts
#: it equals this constant, so the two cannot silently drift apart.
#: Discovery's 24h-volume pre-filter, pinned EQUAL to `ScreenPolicy.min_median_daily_volume` so a
#: sweep can never be stricter than the gate it feeds. It bounds how many products get probed; it
#: is not a liquidity verdict (that is `--probe-liquidity`, which computes the gate's own median).
#:
#: Discovery's 24h-volume pre-filter. It bounds how many products get probed for history; it is
#: not a liquidity verdict (that is `--probe-liquidity`, which computes the gate's own median).
#:
#: Was 5,000,000 until 2026-08-08. At that floor the sweep returned 9 candidates and exactly one
#: unsettled survivor; at a lower floor, seven more cleared BOTH mechanical gates -- FET among them
#: at $2.94M/24h, i.e. invisible to the sweep while measuring 4.8x the admission floor. The floor,
#: not the market, was the binding constraint on the candidate pipeline.
#:
#: `tests/commands/test_admission.py` pins this to the CLI option, to `DiscoveryPolicy`'s default
#: and to the admission floor; all four move together or the suite fails.
DEFAULT_MIN_QUOTE_24H_VOLUME = Decimal("1000000")
#: The fix that followed pinned this EQUAL to `ScreenPolicy.min_median_daily_volume`
#: (1,000,000), reasoning that a sweep pinned to the gate's own floor could never be stricter
#: than the gate it feeds. That reasoning got the INTENT right and the MECHANISM wrong: the two
#: floors measure DIFFERENT statistics -- this one is a single 24-hour venue snapshot, the
#: admission floor is the median of volume x close over ALL cached history -- so an equal number
#: does nothing to stop a quiet trading day from pushing the snapshot below a floor the asset's
#: own median clears many times over. Measured 2026-08-15: five assets (ATOM, AAVE, BCH, CRV,
#: ALGO) were silently dropped by the equal-floor sweep despite each measuring 3.1x-6.3x the
#: admission floor on the gate's own statistic; four of the five sat in an 852,133-979,000 24h
#: snapshot cluster on that single quiet day.
#:
#: The floor is now strictly BELOW the admission floor, by an order of magnitude, so a quiet-day
#: snapshot has real room before it can hide an asset the gate would admit. See
#: `keel.compliance.screen.DiscoveryPolicy.min_quote_24h_volume`, which carries the identical
#: reasoning next to the number it actually applies.
#:
#: `tests/commands/test_admission.py` pins this to the CLI option, to `DiscoveryPolicy`'s default,
#: and to being strictly less than the admission floor; all of those move together or the suite
#: fails.
DEFAULT_MIN_QUOTE_24H_VOLUME = Decimal("100000")

#: Mirrors `keel assets discover --limit`'s own default (`keel/cli.py::assets_discover`), for the
#: same "literal here, not an import from `keel.cli`" reason `DEFAULT_MIN_QUOTE_24H_VOLUME` gives
#: above. `test_build_discover_report_applies_default_limit_matching_assets_discover` pins the two
#: together.
#:
#: Was 25, set back when `--min-volume-24h`'s floor was 1,000,000 and a sweep returned ~35
#: candidates -- 25 showed nearly all of them. Lowering that floor to 100,000 (see
#: `DEFAULT_MIN_QUOTE_24H_VOLUME` above) grew a typical sweep to ~130 candidates, all sorted by
#: descending 24h volume, so the five assets that floor change exists to surface (ATOM, AAVE,
#: BCH, CRV, ALGO -- see that constant's docstring) landed at ranks 33-59: below the ~35 still
#: above the OLD floor, and past a limit of 25. The floor fix was real but invisible at the
#: operator's own default view.
#:
#: 100 costs nothing extra on its own: with neither `--probe-history` nor `--probe-liquidity`,
#: `assets discover` makes exactly ONE venue request (`list_products`) regardless of `--limit` --
#: the candidate list is filtered and sorted locally. The two probe flags are the ones with a
#: per-row cost (one venue request EACH per candidate SHOWN, so two together), which is why that
#: trade-off is called out in `--limit`'s own `help=` text rather than left for an operator to
#: discover by combining the flags and watching the request count climb.
DEFAULT_DISCOVER_LIMIT = 100


# -- 2a. shortlist location (offline) ------------------------------------------------------------
Expand Down Expand Up @@ -380,13 +419,18 @@ class DiscoverReport:
venue_product_count: int
candidates: list[Candidate]
min_quote_24h_volume: Decimal
#: Per-reason exclusion counts over the WHOLE sweep -- every product in `venue_product_count`
#: that did not become a candidate, not just the ones cut by `limit` below. See
#: `build_discover_report`'s docstring for why that distinction matters when reading this
#: field alongside `candidates`.
excluded: DiscoveryExclusions


def build_discover_report(
products: list[dict],
config: Config,
*,
limit: int = 25,
limit: int = DEFAULT_DISCOVER_LIMIT,
min_quote_24h_volume: Decimal | None = None,
) -> DiscoverReport:
"""PURE over `products` -- the caller's already-fetched venue metadata. Builds no broker and
Expand All @@ -396,22 +440,34 @@ def build_discover_report(
Uses `screen.discover_candidates` with a `DiscoveryPolicy` built from `config.quote_currency`
and the volume floor, excluding `config.allowlist` -- mirroring `assets_discover` in
`keel/cli.py`. `min_quote_24h_volume` defaults to `DEFAULT_MIN_QUOTE_24H_VOLUME`, matching
`assets discover`'s own CLI default (`5000000`).
`assets discover`'s own CLI default (`100000`); `limit` defaults to `DEFAULT_DISCOVER_LIMIT`,
matching its `--limit` default (`100`).

`DiscoverReport.candidates` is truncated to `limit`, but `DiscoverReport.excluded` is NOT --
it is the per-reason count over the WHOLE sweep, every product in `venue_product_count` that
did not become a candidate at all. Read the two together with that in mind: `excluded.total`
plus `len(candidates)` before truncation equals `venue_product_count`, not
`len(report.candidates)` plus `excluded.total` -- the shown rows are a further cut of the
survivors, not of the population `excluded` is counted against. `render_discover_report`
relies on exactly that identity (`venue_product_count - excluded.total` recovers the
pre-truncation survivor count) to report when `limit` has cut the table, without this
dataclass needing a redundant field to carry the same number twice.
"""
floor = (
min_quote_24h_volume
if min_quote_24h_volume is not None
else DEFAULT_MIN_QUOTE_24H_VOLUME
)
policy = DiscoveryPolicy(quote_currency=config.quote_currency, min_quote_24h_volume=floor)
candidates = discover_candidates(
result = discover_candidates(
products, policy, exclude_assets=frozenset(a.upper() for a in config.allowlist)
)
return DiscoverReport(
quote=policy.quote_currency,
venue_product_count=len(products),
candidates=candidates[:limit],
candidates=result.candidates[:limit],
min_quote_24h_volume=floor,
excluded=result.excluded,
)


Expand All @@ -420,14 +476,28 @@ def render_discover_report(report: DiscoverReport) -> list[str]:
with the SAME loud warning `keel assets discover` prints -- these are PROPOSALS, not
admissions. Deliberately omits `--probe-history`'s per-candidate marker column: that is an
extra network request per candidate, out of scope for this offline module (the caller already
made the one network call this workflow needs, to fetch `products`)."""
made the one network call this workflow needs, to fetch `products`).

Also never truncates SILENTLY: `report.candidates` is already cut to whatever `limit`
`build_discover_report` was called with, and `venue_product_count - excluded.total` recovers
how many survivors there were before that cut (see `build_discover_report`'s docstring for
the identity this leans on). When that is more than `len(report.candidates)`, a line says so
-- how many candidates exist, how many are shown, and that `--limit`/`limit=` controls it --
so a truncated table can never read as the whole candidate set."""
survivor_count = report.venue_product_count - report.excluded.total
lines = [
f"{report.venue_product_count} venue products -> {len(report.candidates)} candidates "
f"{report.venue_product_count} venue products -> {survivor_count} candidates "
f"(quote={report.quote}, 24h volume >= {report.min_quote_24h_volume:,.0f}, excluding "
"the current allowlist)",
"",
f"{'#':>3} {'product':<14} {'asset':<8} {'24h quote volume':>18} name",
report.excluded.summary_line(),
]
if survivor_count > len(report.candidates):
lines.append(
f"showing {len(report.candidates)} of {survivor_count} candidates -- raise "
"--limit to see the rest."
)
lines.append("")
lines.append(f"{'#':>3} {'product':<14} {'asset':<8} {'24h quote volume':>18} name")
for index, candidate in enumerate(report.candidates, start=1):
lines.append(
f"{index:>3} {candidate.product_id:<14} {candidate.asset:<8} "
Expand Down
Loading
Loading