Skip to content

is_round_number fires unconditionally on 2dp-quoted assets — BTC/ETH/PAXG get a free CTS point on every bar #225

Description

@eaitbrahim

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingfixBug fix (groups under Fixes)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions