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
27 changes: 27 additions & 0 deletions docs/experiments/2026-09-02-equities-cost-fidelity.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,33 @@ the fee question it was filed beside. Two consequences worth separating:
closed, so it is safe — but safe for the wrong reason, and it will misinform any equities
universe decision. Filed as **#696**; nothing in this document changes it.

> **Amendment (2026-09-03), per #696.** Records are appended to, never rewritten, so nothing
> above has been altered — but the framing of this finding was sharper than the evidence, and
> the resolution improved on it.
>
> Two claims here were asserted rather than measured: that the cached figure is "roughly 2% …
> approximately IEX's share of US equity volume", and that the understatement is "~50×". IEX
> publishes its own overall share as roughly 3.8% for Q2 2026, so the 2% was simply wrong, and
> neither figure was derived from anything in the tables above. **No number in this document
> depends on either**; the finding is that the statistic is structurally unable to answer the
> question asked of it, which needs no percentage at all.
>
> What shipped is better than what this section proposed. The fix is not a fail-closed refusal
> of every partial-feed series — that would have made a data-vendor pricing tier a prerequisite
> for running the engine, and would have banned MSFT and AAPL for thinness they do not have.
> It is an **asymmetric lower-bound gate**: venue volume is a lower bound on consolidated
> volume, so at or above the admission floor a partial feed is CONCLUSIVE, while below it the
> screen refuses as `liquidity_unmeasured` rather than asserting an asset is thin. The bound
> holds for any venue share below 100%, so no percentage is encoded anywhere in the code —
> `keel/data/feed_scope.py` carries the argument, and a test greps its body to keep a market
> share from ever being multiplied into a volume statistic.
>
> Provenance is now recorded per series at fetch time (`candle_series_feed`, schema v17), so
> the feed is no longer inferred from whatever config is loaded when someone reads the series,
> and `doctor`'s `data.feed_scope` reports which cached series carry a bound rather than a
> measurement. Series cached before v17 read as *unrecorded* — deliberately distinct from
> *partial*, since one should be re-fetched and the other may already be consolidated.

## Finding 3 — the crypto model is validated, in passing

332.27bp modelled against 306.31bp measured, conservative by 8%, using an estimator with no
Expand Down
7 changes: 7 additions & 0 deletions keel/commands/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
discover_candidates,
)
from keel.config import Config
from keel.data.feed_scope import reports_consolidated_volume
from keel.data.repository import Repository
from keel.types import Granularity

Expand Down Expand Up @@ -152,6 +153,7 @@ def market_facts(repo: Repository, product: str, quote: str) -> MarketFacts:
"""Everything the screen can compute for itself from data we already hold."""
asset = product.split("-")[0]
candles = repo.get_candles(product, Granularity.ONE_DAY)
feeds = repo.get_series_feeds(product, Granularity.ONE_DAY)
return MarketFacts(
asset=asset,
daily_bars=len(candles),
Expand All @@ -169,6 +171,11 @@ def market_facts(repo: Repository, product: str, quote: str) -> MarketFacts:
product_id=product,
# The other half of the key the instrument statement is recorded under. See `VENUE`.
venue=VENUE,
# WHICH FEED the median above was computed from (#696). Read for ONE_DAY specifically,
# because that is the granularity the statistic uses -- an hourly series fetched under a
# different feed says nothing about this number.
volume_feed=", ".join(feeds) or None,
volume_feed_is_consolidated=reports_consolidated_volume(feeds),
)


Expand Down
121 changes: 112 additions & 9 deletions keel/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from keel_core.telemetry import current_venue
from keel_core.trade_scope import READ_ONLY, TRADING, TradeScopeState, VenueTradeScope

from keel.data.feed_scope import reports_consolidated_volume
from keel.data.freshness import Freshness
from keel.execution import sizing
from keel.types import Granularity
Expand Down Expand Up @@ -819,6 +820,90 @@ def balance_drift_findings(records: dict[str, Any]) -> list[Finding]:
]


def feed_scope_findings(series_feeds: dict[tuple[str, str], tuple[str, ...]]) -> list[Finding]:
"""Cached series whose volume statistic is a LOWER BOUND rather than a measurement (#696).

`median_daily_quote_volume` is read by the admission floor, by
`slippage_for_quote_volume`, and by the asset scout's discovery probe. On a feed that reports
one venue's own executions it is a bound on the market, not the market -- the cost-fidelity
run measured MSFT cached at $186M/day and priced it as thinner than the model's reference
liquidity.

`screen_asset` already refuses honestly at screen time. This exists because that only fires
for a candidate somebody is actively screening, while the slippage model prices off the same
number on EVERY cycle and says nothing. The bound is invisible unless something reports it.

WARN, never FAIL. A single-venue feed is a legitimate configuration -- it is the free tier,
and the asymmetric bound admits the liquid names from it honestly (volume at or above the
floor on one venue proves the floor is cleared). What is not legitimate is not knowing.

UNRECORDED series are reported SEPARATELY from partial ones, because they are different
facts: a partial series should be re-fetched under a consolidated feed, an unrecorded one may
already BE consolidated and simply predates the provenance table. Telling an operator to
re-fetch the second would be advice based on the absence of evidence.
"""
partial: list[tuple[str, str, str]] = []
unrecorded: list[str] = []
for (product, granularity), feeds in sorted(series_feeds.items()):
if not feeds:
unrecorded.append(f"{product} {granularity}")
elif reports_consolidated_volume(feeds) is False:
partial.append((product, granularity, ", ".join(feeds)))

if not partial and not unrecorded:
total = len(series_feeds)
return [
Finding(
"data.feed_scope",
OK,
"every series' volume is consolidated" if total else "no cached series to judge",
f"{total} series carry a recorded, consolidated feed -- the liquidity statistic "
"measures the market rather than bounding it"
if total
else "nothing cached yet",
"-",
)
]

# BOTH groups, in ONE finding. They coexist in the ordinary case -- equities on a
# single-venue feed alongside crypto series cached before provenance existed -- and an
# early return on `partial` dropped the unrecorded group silently in exactly that
# configuration. One finding rather than two keeps `data.feed_scope` a single name in the
# report, which the name-coverage pin depends on.
headline_parts: list[str] = []
detail_parts: list[str] = []
fix_parts: list[str] = []
if partial:
headline_parts.append(f"{len(partial)} series bounded")
detail_parts.append(
", ".join(f"{p} {g} ({feeds})" for p, g, feeds in partial)
+ " -- these feeds report one venue's own executions, so median daily volume is a "
"LOWER BOUND on consolidated volume. Above the admission floor that is conclusive; "
"below it, nothing is established"
)
fix_parts.append(
"re-fetch a below-floor bounded series under a CONSOLIDATED feed: "
"`keel fetch --refresh`"
)
if unrecorded:
headline_parts.append(f"{len(unrecorded)} series unrecorded")
detail_parts.append(
f"{', '.join(unrecorded)} -- feed provenance UNRECORDED, so whether this volume "
"measures the market or bounds it is unknown -- not the same as knowing it is partial"
)
fix_parts.append("re-fetch an unrecorded series to stamp provenance: `keel fetch`")

return [
Finding(
"data.feed_scope",
WARN,
", ".join(headline_parts),
"; ".join(detail_parts),
" | ".join(fix_parts),
)
]


def orphan_bracket_findings(records: dict[str, Any]) -> list[Finding]:
"""Resting SELLs the orphan sweep cancelled because the account no longer held them (#668).

Expand Down Expand Up @@ -1221,6 +1306,32 @@ def gather_findings(repo: Any, config: Any, log_lines: Iterable[str], now_ts: in
market_closed = agent.recorded_market_closed(repo, config, now_ts)
start_ts = now_ts - DOCTOR_WINDOW_SEC

# Computed ONCE and read twice: the health report needs every row, and the feed-scope
# report needs to know which series actually HAVE bars.
health = fetch.assess_products(
repo,
products,
granularities,
now_ts,
start_ts,
freshness_mod.DEFAULT_TOLERANCE_BARS,
market_closed,
)

# #696. Only series that carry candles. A never-fetched series has no provenance because it
# has no BARS, not because it predates the provenance table -- `data.missing` already reports
# an empty series, and on a fresh deployment this would otherwise say "predates feed
# provenance" about every one of them.
findings += feed_scope_findings(
{
(row.product, row.granularity.value): repo.get_series_feeds(
row.product, row.granularity
)
for row, _unexplained in health
if row.n_candles > 0
}
)

findings += data_health_findings(
[
SeriesHealth(
Expand All @@ -1229,15 +1340,7 @@ def gather_findings(repo: Any, config: Any, log_lines: Iterable[str], now_ts: in
freshness=row,
unexplained_gaps=unexplained,
)
for row, unexplained in fetch.assess_products(
repo,
products,
granularities,
now_ts,
start_ts,
freshness_mod.DEFAULT_TOLERANCE_BARS,
market_closed,
)
for row, unexplained in health
]
)
findings += admissibility_findings(
Expand Down
52 changes: 49 additions & 3 deletions keel/compliance/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
#: callers import it (or, better, call `split_failures`/`missing_history_lines` below rather than
#: reimplementing the split) so a tag rename here cannot silently disable the suppression
#: elsewhere.
DATA_DERIVED_FAILURES = frozenset({"history", "liquidity"})
DATA_DERIVED_FAILURES = frozenset({"history", "liquidity", "liquidity_unmeasured"})


@dataclass(frozen=True)
Expand Down Expand Up @@ -155,6 +155,17 @@ class MarketFacts:
#: silently inherit "Coinbase, therefore spot" -- the fail-OPEN answer to the one question
#: this field was added to ask. A construction site that forgets it must fail at the call.
venue: str
#: Which data feed the cached candles came from (`candle_series_feed`, #696), or `None` when
#: unrecorded -- every series cached before that table existed.
volume_feed: str | None = None
#: Whether that feed reports CONSOLIDATED market volume. `True` = the statistic measures the
#: market; `False` = it is a LOWER BOUND on it; `None` = unrecorded, which is the absence of
#: evidence and NOT the same as either.
#:
#: Defaults are safe here, unlike `product_id`'s: a caller that forgets these gets `None`,
#: which preserves the pre-#696 verdict exactly rather than silently admitting or refusing
#: anything new.
volume_feed_is_consolidated: bool | None = None


@dataclass(frozen=True)
Expand Down Expand Up @@ -233,10 +244,34 @@ def screen_asset(
f"history: {facts.daily_bars} daily bars < {policy.min_daily_bars} required "
"(a rule cannot be validated on a series shorter than its evidence needs)"
)
if facts.median_daily_volume < policy.min_median_daily_volume:
if facts.median_daily_volume >= policy.min_median_daily_volume:
# CONCLUSIVE, on any feed. Venue volume is a LOWER BOUND on consolidated volume, so a
# name that traded this much on one venue alone necessarily traded at least this much in
# total (#696). Refusing here would ban the most liquid securities in the world for
# thinness they do not have.
pass
elif facts.volume_feed_is_consolidated is False:
# NOT a verdict. Below the floor on a single-venue feed is equally consistent with a thin
# asset and with a liquid one that barely trades HERE, and the gate has measured neither.
# Saying "illiquid" would assert the half it cannot see.
# `:,` on a Decimal keeps whatever exponent the arithmetic produced -- `Decimal("5E+5")`
# formats as `5E+5`, not `500,000`, and `volume * close` can land in that form. An
# operator comparing `5E+5` against `1,000,000` is being asked to do the wrong work.
observed = _dollars(facts.median_daily_volume)
floor = _dollars(policy.min_median_daily_volume)
failures.append(
f"liquidity_unmeasured: {facts.volume_feed} reports one venue's own executions, and "
f"its median daily volume {observed} is below the {floor} floor -- so CONSOLIDATED "
"volume is UNMEASURED, not low. Re-fetch this series under a consolidated feed to "
"decide it"
)
else:
scope = (
"" if facts.volume_feed_is_consolidated else " (feed scope unrecorded for this series)"
)
failures.append(
f"liquidity: median daily volume {facts.median_daily_volume} < "
f"{policy.min_median_daily_volume} required"
f"{policy.min_median_daily_volume} required{scope}"
)
if policy.require_settlement_quote and not facts.quotable_in_settlement_currency:
failures.append(
Expand Down Expand Up @@ -492,6 +527,17 @@ class DiscoveryPolicy:
min_quote_24h_volume: Decimal = Decimal("100000")


def _dollars(amount: Decimal) -> str:
"""`$1,234,567` -- thousands-separated, exponent form normalised away.

`f"{Decimal('5E+5'):,}"` is `5E+5`: Decimal preserves the exponent its arithmetic produced,
and `format` does not normalise it. Every figure this renders comes from `volume * close`,
so the form is not hypothetical, and these strings are read by an operator deciding whether
a series is worth re-fetching.
"""
return f"${amount.quantize(Decimal(1)):,}"


def median_daily_quote_volume(candles: Sequence[Any]) -> Decimal:
"""Median of `volume * close` over `candles` -- the liquidity statistic, defined ONCE.

Expand Down
17 changes: 17 additions & 0 deletions tests/commands/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from keel_core.trade_scope import READ_ONLY, TRADING, TradeScopeState, VenueTradeScope

from keel.commands.doctor import (
OK,
AdmissibilityRow,
Finding,
SeriesHealth,
Expand Down Expand Up @@ -569,6 +570,21 @@ def _seeded_repo(db_path: Path):
return Repository(conn)


def test_feed_scope_ignores_series_with_no_bars(tmp_path, valid_config_path) -> None:
"""A product that was never fetched has no provenance because it has no CANDLES, not because
it predates the provenance table (#696). `data.missing` already reports an empty series; the
feed-scope report saying "predates feed provenance" about it is advice from the absence of
bars, and on a fresh deployment it would say that about everything.
"""
repo = _seeded_repo(tmp_path / "keel.db")
config = load_config(valid_config_path)
findings = gather_findings(repo, config, [], NOW)
(scope,) = [f for f in findings if f.name == "data.feed_scope"]
assert scope.status == OK, scope.detail
for product in config.allowlist:
assert product not in scope.detail


def test_gather_findings_covers_every_check_over_a_seeded_db(tmp_path, valid_config_path) -> None:
repo = _seeded_repo(tmp_path / "keel.db")
config = load_config(valid_config_path)
Expand All @@ -594,6 +610,7 @@ def test_gather_findings_covers_every_check_over_a_seeded_db(tmp_path, valid_con
"data.missing",
"data.stale",
"data.gaps",
"data.feed_scope",
"sizing.admissible",
}
# an unattested, empty deployment fails the run, exactly as the command does
Expand Down
Loading
Loading