fix(analysis): scale is_round_number to the price's own magnitude - #227
Merged
Conversation
`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>
Merged
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
is_round_numberhad no sense of scale. Fixing it, with the before/after #225 requires.The defect
stepis an absolute half-cent. Coinbase quotes BTC/ETH/PAXG to two decimals and0.01 = 2 × 0.005, so every quotable price is an exact multiple ofstep,remainderis exactly zero, and the function returnsTrueunconditionally. Thedistance <= step * 0.1test 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.
Computed from
Decimal.adjusted()— exact integer exponent arithmetic, no floatlog10, which would misplace the grid a few ulps under a power of ten.Three decisions, each argued in the write-up:
toleranceis 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) identically2 × toleranceregardless 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.quote_increment, deliberately.assemble_cts_contextis 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 ananalysis.*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.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 thanrsi_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:
This also independently confirms no other CTS factor moved — the
other factorscolumn would have broken otherwise.CTS score distribution shift
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
ctsacrosskeel/,packages/andscripts/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_promoteruns off backtestedn_trades/expectancy/ realized R:R /win_rate, and the PBO gate offpbo/degradation_slope.16.2% of bars change technique, every move one rung down (removing a point cannot raise a total).
aggressivefalls 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 inkeel/,packages/orscripts/outsideindicators_cts.py's own definition and docstring. Nothing branches on the technique; nothing sizes, stops, or picks an order type from it. Inagent.pyit reaches exactly one place: a field on theagent.enter_evaluatedlog line (agent.py:1166).cts_scorehas the same shape — written tosignals, 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
64975.78TrueTrueFalseFalse0.10is 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 at0.10the BTC band is ±100, so 64,975.78 (24.22 from the 65,000 handle) scores present. That pins tolerance below 0.0242;0.02is 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.10is a one-character change and this table is the evidence.Signature
step→tolerance, not kept. The meaning inverted from an absolute price step to a relative fraction of the grid, so a caller passingstep=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_handleis the mandated regression and fails onmain. 8 of the 9 newlevelstests 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_candle→confirm_3bar, a real crossing oflow=5caused 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
Baseline on
mainis 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_chainreturns no errors; M 75 → 76, N 30 → 31.Follow-ups (not done here)
indicators_cts.py's module table promisesconfirm_3barmeans "smaller size, wider stop" andaggressivemeans "larger size toward the cap, tighter stop" (spec §9/§17.1). None of it is wired to anything —entry_techniqueis 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 retuninglow/high— which is moot until something consumes their output, and was deliberately not touched here.tolerance=0.10as an open question, per the table above.pullback_continuation.buffer_ticks = Decimal("0.02")is the same defect class — an absolute price offset,entry = signal_candle.high + 0.02andstop = 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 dominatesrisk = entry - stop. Not urgent: the live book (keel-live.db) runs onlyturtle_breakout(ATR-scaled, correct) anddca, so no live rule is affected today — but anypullback_continuationadded on a cheap asset would inherit it.sim/report.py'sDEFAULT_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