Skip to content

fix(analysis): scale is_round_number to the price's own magnitude - #227

Merged
eaitbrahim merged 1 commit into
mainfrom
fix/round-number-scale
Aug 11, 2026
Merged

fix(analysis): scale is_round_number to the price's own magnitude#227
eaitbrahim merged 1 commit into
mainfrom
fix/round-number-scale

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

is_round_number had no sense of scale. Fixing it, with the before/after #225 requires.

The defect

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 an absolute half-cent. Coinbase quotes BTC/ETH/PAXG to two decimals and 0.01 = 2 × 0.005, so every quotable price is an exact multiple of step, remainder is exactly zero, and the function returns True unconditionally. The distance <= step * 0.1 test never got a chance to be false.

As weight 1 of DEFAULT_WEIGHTS' 14, three of the five live allowlist assets carried an unconditional +1 on every CTS score. A constant is worse than a redundant factor — a redundant factor at least varies — and because it applied to three assets and not the other two, the CTS total was not comparable across the allowlist.

What "round" now means, and why

A round handle is a price with few significant figures — 65,000, 3,400, 0.38. That is a property of the price relative to its own magnitude, which is exactly what an absolute constant cannot express.

spacing  = 10 ** (floor(log10(price)) - 1)     # the two-significant-figure grid
present  ⟺ dist_to_nearest_multiple <= spacing * tolerance     # tolerance default 0.02

Computed from Decimal.adjusted() — exact integer exponent arithmetic, no float log10, which would misplace the grid a few ulps under a power of ten.

Three decisions, each argued in the write-up:

  • Two significant figures, not three. Two is what the words mean (65,100 and 0.381 are not handles). It is also what survives measurement: a three-figure grid pushes the spacing onto ADA/XLM's quote increment, and tick quantization alone then drives the base rate — ADA reaches 0.2996 against BTC's 0.1861 at the same tolerance, a 1.61× spread manufactured by quote precision. Same class of artifact this fix removes.
  • tolerance is a fraction of the handle SPACING, not of price. Both scale with the instrument, so both fix the reported bug; they differ in what they hold constant. A fraction of price makes the presence rate depend on where in the decade the price sits — the spacing is 10% of price just above a power of ten and 1% just below, so the same rule would fire ten times as often on BTC at 99,000 as at 10,500, and the factor would silently change meaning as an asset trended through a decade. BTC's daily history spans 15,760–124,720, two decade crossings. A fraction of spacing makes P(present) identically 2 × tolerance regardless of price, decade position and quote precision — is_round_number fires unconditionally on 2dp-quoted assets — BTC/ETH/PAXG get a free CTS point on every bar #225's acceptance criterion restated as an invariant.
  • No quote_increment, deliberately. assemble_cts_context is a pure function of (setup, candles) and research: are the 11 CTS factors collinear? — momentum cluster refuted, trend cluster real and small #224's offline replay — the instrument measuring this very change — depends on that. Threading venue state into an analysis.* primitive to fix a factor that doesn't need it would make the scoring path unreplayable. Tick size is a venue's quoting rule; a psychological handle is a property of the number.

Before / after — P(present), same 6,827 daily bars

Unconditional sample, ONE_DAY, expanding window (which reproduces the live path exactly). #224's harness, keel/research/cts_factors.py, reused unchanged — no second instrument was written.

asset N before after Δ
BTC-USD 1,648 1.0000 0.0358 −0.9642
ETH-USD 1,648 1.0000 0.0334 −0.9666
PAXG-USD 259 1.0000 0.0463 −0.9537
ADA-USD 1,636 0.2170 0.0465 −0.1705
XLM-USD 1,636 0.1901 0.0440 −0.1461
pooled 6,827 0.6183 0.0401 −0.5781

Cross-asset spread (max/min): 5.26× → 1.39×. That number is the acceptance test. The residual 1.39× is the sampling spread of a ~4% Bernoulli rate over 259–1,648 bars; PAXG (N=259) is the widest cell.

The repaired factor now sits inside the panel rather than dominating it — rarer than candlestick_pattern (0.2026), commoner than rsi_extreme (0.0230).

The before-arm is verified, not asserted

The before-arm is reconstructed arithmetically (only this factor moves, and it is a pure function of the entry price). That is an argument, so arm E re-runs the full replay on all five assets with the pre-#225 predicate monkeypatched back in and compares bar for bar:

asset           N   factor vec   CTS totals   other factors
BTC-USD      1648        MATCH        MATCH           MATCH
ETH-USD      1648        MATCH        MATCH           MATCH
PAXG-USD      259        MATCH        MATCH           MATCH
ADA-USD      1636        MATCH        MATCH           MATCH
XLM-USD      1636        MATCH        MATCH           MATCH
  reconstruction is EXACT

This also independently confirms no other CTS factor moved — the other factors column would have broken otherwise.

CTS score distribution shift

asset arm mean median sd min max Δmean
BTC-USD before 5.771 6.0 2.008 1 11
after 4.806 5.0 2.016 0 10 −0.964
ETH-USD before 5.620 6.0 1.906 1 11
after 4.653 5.0 1.916 0 10 −0.967
PAXG-USD before 6.367 6.0 1.647 3 10
after 5.413 5.0 1.643 2 9 −0.954
ADA-USD before 4.652 4.0 1.861 0 10
after 4.481 4.0 1.818 0 10 −0.171
XLM-USD before 4.335 4.0 1.945 0 10
after 4.189 4.0 1.921 0 10 −0.146
pooled before 5.145 5.0 2.028 0 11
after 4.566 4.0 1.930 0 10 −0.578

Exactly as predicted: the three 2dp assets lose ≈0.96 (the constant, minus the ~4% of bars where the factor legitimately fires); the other two lose ≈0.16. The BTC-vs-XLM mean gap narrows from 1.44 points to 0.62 — 57% of the spread between the highest- and lowest-scoring allowlist assets was this bug, not the market.

Threshold impact — the part that matters

Grepping cts across keel/, packages/ and scripts/ finds exactly one threshold a CTS total is compared against: indicators_cts.entry_technique(total, low=5, high=8), called from one site (engine.py:144) with no override. There is no config key (min_cts / cts_min / min_score — none exist), and the promotion gate does not read CTS at all: promotion.can_promote runs off backtested n_trades / expectancy / realized R:R / win_rate, and the PBO gate off pbo / degradation_slope.

asset arm confirm_3bar signal_candle aggressive moved
BTC-USD before 460 847 341
after 751 733 164 468 (28.4%)
ETH-USD before 489 891 268
after 762 751 135 406 (24.6%)
PAXG-USD before 37 157 65
after 80 145 34 74 (28.6%)
ADA-USD before 826 688 122
after 889 650 97 88 (5.4%)
XLM-USD before 894 645 97
after 934 622 80 67 (4.1%)
pooled before 2,706 3,228 893
after 3,416 2,901 510 1,103 (16.2%)

16.2% of bars change technique, every move one rung down (removing a point cannot raise a total). aggressive falls 43% pooled and roughly halves on the three 2dp assets.

And none of it changes a single order. entry_technique's three return values — "confirm_3bar", "signal_candle", "aggressive" — appear nowhere in keel/, packages/ or scripts/ outside indicators_cts.py's own definition and docstring. Nothing branches on the technique; nothing sizes, stops, or picks an order type from it. In agent.py it reaches exactly one place: a field on the agent.enter_evaluated log line (agent.py:1166). cts_score has the same shape — written to signals, logged, carried on sim records, read by no gate.

So the specific danger #225 raised — "may push scores below promotion/entry thresholds that were tuned with the constant in place"cannot occur. There is no CTS floor. No setup that previously qualified is rejected, no position changes size, no stop moves. What changes is the label in the audit trail and in signals.cts_score, on 16.2% of bars. No threshold was retuned.

The one free parameter, and an honest flag

tolerance BTC ETH PAXG ADA XLM pooled spread 64975.78
0.10 0.1953 0.1953 0.1961 0.2146 0.2097 0.2033 1.10× True
0.05 0.0925 0.0871 0.1089 0.1117 0.1057 0.0998 1.28× True
0.02 0.0368 0.0357 0.0414 0.0436 0.0425 0.0397 1.22× False
0.01 0.0162 0.0195 0.0218 0.0267 0.0212 0.0210 1.64× False

⚠️ 0.10 is arguably the better number and it was not chosen. It preserves the original docstring's stated intent ("within 10% of the step size") with only the step corrected, gives the tightest spread in the table (1.10×), and lands the base rate at 0.195–0.215 — almost exactly where ADA and XLM already sat, making the fix minimally disruptive on the two assets that were never broken.

It was rejected on one ground: #225 names Decimal("64975.78") as a price that must score absent, and at 0.10 the BTC band is ±100, so 64,975.78 (24.22 from the 65,000 handle) scores present. That pins tolerance below 0.0242; 0.02 is the round number under that ceiling. Whether 24.22 dollars from 65,000 — 3.7 basis points, a fraction of a daily range — is really "far from any round handle" is a judgement I did not feel entitled to overturn on a change to live scoring. If the looser band was the intent, tolerance=0.10 is a one-character change and this table is the evidence.

Signature

steptolerance, not kept. The meaning inverted from an absolute price step to a relative fraction of the grid, so a caller passing step=Decimal("0.005") under the old name would silently get new behaviour; renaming makes it fail loudly. The one caller in the package (engine.assemble_cts_context, engine.py:288) uses the default. There are none outside the tests.

Tests (TDD — red first)

test_is_round_number_false_on_two_decimal_price_away_from_a_handle is the mandated regression and fails on main. 8 of the 9 new levels tests failed before the fix. Coverage: 2dp scale, 5–6dp scale, genuine handles at each scale, exact band boundary (inclusive) ±1 ulp, scale-invariance across 10^±8, Decimal("0"), -0, negatives, NaN/Infinity, and 10^±30 — no division or modulo by zero at either end.

One existing test changed, and it is the bug in miniature: the engine fixture enters at 128.02, which is 2.02 from the nearest handle (130, on a 10-wide grid) — it was never near a magnet level and scored present only because 128.02 is a multiple of half a cent. Its CTS drops 5 → 4 and its technique signal_candleconfirm_3bar, a real crossing of low=5 caused entirely by removing a point that was never earned. A companion test asserts the point returns when the entry is nudged onto the 130 handle.

Gates

$ uv run ruff check keel tests packages scripts
All checks passed!

$ uv run mypy
Success: no issues found in 219 source files

$ uv run pytest -q
2444 passed, 1 skipped in 33.93s
SKIPPED [1] packages/keel-broker-api/keel_broker_api/conformance/suite.py:254:
            adapter serves no granularity the suite could exercise

Baseline on main is 2437 passed / 1 skipped; +7 net tests, same single expected skip.

Ledger

One row, round-number-scale-2026-08-11 (kind: ablation, provenance: fitted, decision: selected — charged to N, since the tolerance was selected against measured data and this ships to live scoring). verify_chain returns no errors; M 75 → 76, N 30 → 31.

Follow-ups (not done here)

  1. The graded entry ladder is inert. indicators_cts.py's module table promises confirm_3bar means "smaller size, wider stop" and aggressive means "larger size toward the cap, tighter stop" (spec §9/§17.1). None of it is wired to anything — entry_technique is computed, persisted and read by nothing. Either wire it or delete the claim; the current state, where the audit trail records a posture execution does not implement, is the worst of both. This is also the precondition for retuning low/high — which is moot until something consumes their output, and was deliberately not touched here.
  2. tolerance=0.10 as an open question, per the table above.
  3. pullback_continuation.buffer_ticks = Decimal("0.02") is the same defect class — an absolute price offset, entry = signal_candle.high + 0.02 and stop = low - 0.02, not scaled to the instrument. At BTC's 65,000 it is a rounding error; at XLM's ~0.07 it would be a ~29% buffer that dominates risk = entry - stop. Not urgent: the live book (keel-live.db) runs only turtle_breakout (ATR-scaled, correct) and dca, so no live rule is affected today — but any pullback_continuation added on a cheap asset would inherit it.
  4. sim/report.py's DEFAULT_MAE_MFE_THRESHOLD = Decimal("50") compares a pooled cross-asset absolute dollar excursion against one flat threshold. Already named as a known limitation in that module's docstring; degrades a diagnostic report only, no order path.

Closes #225

`is_round_number(price, step=Decimal("0.005"))` compared price against an
ABSOLUTE half-cent. Coinbase quotes BTC/ETH/PAXG to two decimals and
`0.01 = 2 * 0.005`, so every quotable price was an exact multiple of `step`,
`remainder` was always zero, and the function could never return False.
Measured over the daily candle cache: P(present) = 1.0000 on BTC-USD,
ETH-USD and PAXG-USD against 0.2170 on ADA-USD and 0.1901 on XLM-USD. As
weight 1 of DEFAULT_WEIGHTS' 14, that handed three of the five live
allowlist assets an unconditional +1 on every CTS score -- a constant, which
is worse than a redundant factor, and one that applied to three assets and
not the other two, so the total was not comparable across the allowlist.

A round handle is a price with few significant figures, which is a property
of the price relative to its own scale. The grid is now the two-significant-
figure lattice derived from `Decimal.adjusted()` (exact integer exponent
arithmetic, no float log10), and `tolerance` is a fraction of the HANDLE
SPACING rather than of price. That denominator is the load-bearing choice:
a fraction of price would make the presence rate depend on where in the
decade the price sits (the spacing is 10% of price just above a power of ten
and 1% just below), so the factor would silently change meaning as an asset
trended through a decade -- BTC's history crosses two. A fraction of spacing
makes P(present) identically 2*tolerance regardless of price, decade
position or quote precision, which is #225's acceptance criterion stated as
an invariant.

Measured before/after on the same 6,827 daily bars, using #224's replay
harness unchanged:

  P(present)  BTC 1.0000 -> 0.0358, ETH 1.0000 -> 0.0334,
              PAXG 1.0000 -> 0.0463, ADA 0.2170 -> 0.0465,
              XLM 0.1901 -> 0.0440.  Cross-asset spread 5.26x -> 1.39x.
  CTS total   pooled mean 5.145 -> 4.566, median 5 -> 4. The BTC-vs-XLM
              mean gap falls 1.44 -> 0.62, so 57% of it was this bug.
  Thresholds  1,103 of 6,827 bars (16.2%) change entry_technique, every
              one a rung down; `aggressive` falls 43% pooled.

No order changes. `entry_technique(total, low=5, high=8)` at engine.py:144
is the only threshold a CTS total is compared against anywhere in the
package; its three return values appear nowhere outside indicators_cts.py,
and no config key, entry gate or promotion gate reads a CTS score at all
(promotion.can_promote runs off backtested trade statistics). So the danger
#225 raised -- pushing qualifying setups below a tuned threshold -- cannot
occur. No threshold was retuned.

`step` was renamed to `tolerance` rather than kept: the meaning inverted
from an absolute price step to a relative fraction of the grid, and a caller
passing `step=Decimal("0.005")` under the old name would silently get new
behaviour. The one caller in the package (engine.assemble_cts_context) uses
the default. The function stays pure -- no venue lookup, no quote_increment
-- because #224's offline replay depends on that.

tests/strategy/test_engine.py's fixture enters at 128.02, which is 2.02 from
the nearest handle and was never near a magnet level; it scored present only
because 128.02 is a multiple of half a cent. Its CTS drops 5 -> 4 and its
technique signal_candle -> confirm_3bar, which is the bug's consequence in
miniature. A companion test asserts the point returns on a real handle.

Closes #225

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eaitbrahim eaitbrahim added bug Something isn't working fix Bug fix (groups under Fixes) labels Aug 11, 2026
@eaitbrahim
eaitbrahim merged commit 266e942 into main Aug 11, 2026
1 check passed
@eaitbrahim
eaitbrahim deleted the fix/round-number-scale branch August 11, 2026 11:58
@eaitbrahim eaitbrahim mentioned this pull request Aug 11, 2026
eaitbrahim added a commit that referenced this pull request Aug 11, 2026
…the CTS scoring fix (#241)

A minor bump, not a patch, for three reasons that each require operator action or
change behaviour the deployment is currently relying on.

SCHEMA. `SCHEMA_VERSION` goes 9 -> 10 (#223). Both deployed databases are at 9 and
must be migrated before this build can use them.

BEHAVIOUR REQUIRING OPERATOR ACTION. #223 adds a second attested claim -- what
CONTRACT a venue listing is, not only what the underlying asset is. It fails closed
with no backfill, deliberately, so after this lands `keel assets screen` REJECTS
every product with `instrument_wrapper: UNATTESTED` until `keel assets
attest-instrument` is run once per product. Live trading is unaffected: rail 1 gates
buys on `config.allowlist`, not on the screen.

LIVE SCORING CHANGED. #227 fixed `is_round_number`, which returned True for every
2dp-quoted price and so handed BTC/ETH/PAXG a free CTS point on every bar. Scores on
those three assets are genuinely lower under this build than under 0.5.7.

Also ships: the Robinhood crypto adapter behind the broker port (#216/#218/#222/#229,
not wired to the live path), the TUI activity feed (#235/#237), the CTS factor
collinearity study (#224), `Preview.synthetic` at the confirm gate (#221), rail 9
seeing a bracket's own stop (#212), and CI gating merges on the `test` check
(#234/#238).

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

bug Something isn't working fix Bug fix (groups under Fixes)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant