Found by the #208 collinearity study (PR #224) as a side effect. This is a correctness bug in shipped scoring code, not a research finding.
The defect
keel/analysis/levels.py:137
def is_round_number(price: Decimal, step: Decimal = Decimal("0.005")) -> bool:
remainder = price % step
distance = min(remainder, step - remainder)
return distance <= step * Decimal("0.1")
step is treated as an absolute value (half a cent), not a fraction of price. Coinbase quotes BTC, ETH and PAXG to 2 decimal places — and every 2dp value is an exact multiple of 0.005, because 0.01 = 2 × 0.005. So remainder is always 0, distance is always 0, and the function always returns True.
Verified directly:
64975.78 -> True
103412.99 -> True
3421.07 -> True
0.01 -> True
random 2dp sample: 10000/10000 return True
random 5dp sample: 1953/10000 return True (ADA/XLM, 5-6dp)
The study measured the same thing over real history: P(present) = 1.0000 / 1.0000 / 1.0000 on BTC/ETH/PAXG, 0.2195 on ADA, 0.1901 on XLM.
Impact
round_number_proximity carries weight 1 of a 14-point DEFAULT_WEIGHTS total (keel/strategy/indicators_cts.py:51-63). So on three of the five live allowlist assets (config.live-sandbox.yaml:2-6 — BTC, ETH, PAXG, ADA, XLM) every CTS score carries an unconditional +1 that encodes nothing about the market.
This is not merely a wasted factor. It shifts the score distribution for those three assets relative to ADA and XLM, so any threshold calibrated across assets is calibrated against a constant offset that applies to some of them and not others.
The intended behaviour is presumably proximity to a round handle relative to the instrument's own scale (e.g. BTC near 65,000; ADA near 0.40), which requires step to be derived from price magnitude or from the venue's quote_increment — not a fixed half-cent.
Fix direction (not a decision)
- Derive
step from the price's own magnitude (a percentage band), or from the product's quote_increment/tick size, so "round" means the same thing at 65,000 and at 0.38.
- Whatever replaces it, the acceptance test is that P(present) is materially below 1.0 on 2dp-quoted assets and broadly comparable across the allowlist.
Care required — this changes live scoring
Unlike #208, this is not diagnostic-only. Changing is_round_number changes CTS scores on the live path, so it needs the normal gate: a trials-ledger entry and a before/after on the affected assets, not a drive-by patch. Note also that fixing it will lower CTS on BTC/ETH/PAXG by one point wherever the factor legitimately does not apply, which may push scores below promotion/entry thresholds that were tuned with the constant in place.
Reference
docs/experiments/2026-08-09-cts-factor-collinearity.py and PR #224, where this was measured. The study deliberately did not fix it, per #208's "not a drive-by tweak to the scoring weights" constraint.
Found by the #208 collinearity study (PR #224) as a side effect. This is a correctness bug in shipped scoring code, not a research finding.
The defect
keel/analysis/levels.py:137stepis treated as an absolute value (half a cent), not a fraction of price. Coinbase quotes BTC, ETH and PAXG to 2 decimal places — and every 2dp value is an exact multiple of0.005, because0.01 = 2 × 0.005. Soremainderis always0,distanceis always0, and the function always returnsTrue.Verified directly:
The study measured the same thing over real history: P(present) = 1.0000 / 1.0000 / 1.0000 on BTC/ETH/PAXG, 0.2195 on ADA, 0.1901 on XLM.
Impact
round_number_proximitycarries weight 1 of a 14-pointDEFAULT_WEIGHTStotal (keel/strategy/indicators_cts.py:51-63). So on three of the five live allowlist assets (config.live-sandbox.yaml:2-6— BTC, ETH, PAXG, ADA, XLM) every CTS score carries an unconditional +1 that encodes nothing about the market.This is not merely a wasted factor. It shifts the score distribution for those three assets relative to ADA and XLM, so any threshold calibrated across assets is calibrated against a constant offset that applies to some of them and not others.
The intended behaviour is presumably proximity to a round handle relative to the instrument's own scale (e.g. BTC near 65,000; ADA near 0.40), which requires
stepto be derived from price magnitude or from the venue'squote_increment— not a fixed half-cent.Fix direction (not a decision)
stepfrom the price's own magnitude (a percentage band), or from the product'squote_increment/tick size, so "round" means the same thing at 65,000 and at 0.38.Care required — this changes live scoring
Unlike #208, this is not diagnostic-only. Changing
is_round_numberchanges CTS scores on the live path, so it needs the normal gate: a trials-ledger entry and a before/after on the affected assets, not a drive-by patch. Note also that fixing it will lower CTS on BTC/ETH/PAXG by one point wherever the factor legitimately does not apply, which may push scores below promotion/entry thresholds that were tuned with the constant in place.Reference
docs/experiments/2026-08-09-cts-factor-collinearity.pyand PR #224, where this was measured. The study deliberately did not fix it, per #208's "not a drive-by tweak to the scoring weights" constraint.