research: are the 11 CTS factors collinear? — momentum cluster refuted, trend cluster real and small - #224
Merged
Conversation
… is not there Issue #208 (QuantCrawler teardown §1.5) asks whether keel's 11 additive CTS confluence factors are collinear -- specifically whether {rsi_extreme, rsi_divergence, deceleration} are three reads of one momentum axis and {condition_aligned, ema_fan_aligned} two reads of trend, together 8 of the 14 raw points. Additivity presumes independence; nobody had measured it. MEASUREMENT ONLY. DEFAULT_WEIGHTS, every factor, every gate and every threshold are untouched. No scoring behaviour changes in this PR. The `signals` table -- the issue's proposed data source -- holds ONE row in keel.db and zero in keel-live.db, so the sample had to be reconstructed. `engine.assemble_cts_context` is a pure function of (setup, candles), so the real scoring path replays offline over the 611k cached candles. N = 6,822 daily bar-observations across the five live-allowlist assets (unconditional, expanding window = exactly what agent.run_once passes), plus a 186,725-observation hourly replication and a 77-signal conditional arm. momentum cluster mean within phi -0.018 vs +0.025 background -- REFUTED. rsi_extreme and rsi_divergence co-occur ZERO times in 6,822 bars (Jaccard 0.000): same RSI array, disjoint states. trend cluster +0.190 vs +0.018 background -- confirmed, and small (3.6% shared variance), stable across all five assets. strongest pair deceleration x candlestick_pattern +0.254 -- not one #208 predicted, and the only plausible collapse candidate. score impact Var(total) ratio 1.161 => ~8.6 effective independent factors of the 10 that vary. Additive CTS survives the critique. 45 pairwise tests, Holm-Bonferroni at alpha=0.05 -- and the write-up states plainly that at these sample sizes significance is uninformative and no conclusion rests on a p-value. Incidental but verified: `levels.is_round_number` takes step=0.005 as an ABSOLUTE half-cent, so on any 2dp-quoted product the check can never fail -- P(present) = 1.0000 on BTC/ETH/PAXG, 0.22/0.19 on ADA/XLM. Three of five live assets get an unconditional +1 on every CTS score. Recommended as a follow-up issue, deliberately not fixed here. Two deliberate promotions, both behaviour-preserving renames: - `engine._assemble_cts_context` -> `assemble_cts_context`. sim/portfolio_sim already imported it through the underscore; research/cts_factors needs it too. Rename + docstring only (see the diff); the 191 engine/sim tests are unchanged and pass. - `independence._pearson` -> `pearson`, because it is the ORACLE the new phi implementation is tested against, and an oracle behind a private name is one refactor from vanishing. compare()'s behaviour is untouched. Three `diagnostic_only` trials-ledger rows, one per arm, each carrying its own `hypotheses_tested` so the multiple-testing budget is charged per arm. Closes #208 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim
force-pushed
the
research/cts-factor-collinearity
branch
from
August 11, 2026 11:12
7204c0d to
d700f3b
Compare
This was referenced Aug 11, 2026
eaitbrahim
added a commit
that referenced
this pull request
Aug 11, 2026
`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
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.
Closes #208.
Research only.
DEFAULT_WEIGHTS, every factor, every gate and every threshold are untouched — no scoring behaviour changes in this PR. The issue is explicit that this is "not a drive-by tweak to the scoring weights", and it isn't one.The answer
{rsi_extreme, rsi_divergence, deceleration}is collinear?{condition_aligned, ema_fan_aligned}is collinear?deceleration×candlestick_patternφ +0.254rsi_extremeandrsi_divergenceco-occur ZERO times in 6,822 bars (Jaccard 0.000, lift 0.00). They read the sameindicators.rsi(closes)array and are wired to fire in disjoint states — a bullish divergence needs RSI making a higher low, which is by construction off the oversold extreme. Sharing an input is not sharing an answer. Collapsing that cluster, the remedy #208 proposes, would destroy information rather than remove duplication.The trend pair is real (10–22× background, stable on all five assets, reproduced at +0.201 on a 27× larger sample) and small: 3.6% shared variance over 4 of 14 raw points.
This is a negative result on the headline hypothesis and it is reported as one. #208's structural argument is a correct description of the wiring and a wrong prediction about the output.
N, and how it was obtained
The issue's proposed data source — the
signalstable — holds one row inkeel.dband zero inkeel-live.db. The sample had to be reconstructed, which is possible becauseengine.assemble_cts_contextis a pure function of(setup, candles)andkeel.db.candlesholds 611,176 bars back to 2021.N = 6,822 = every cached daily bar after a 200-bar warm-up on the five live-allowlist assets (1,647 BTC + 1,647 ETH + 258 PAXG + 1,635 ADA + 1,635 XLM). Expanding window because that is literally what the live path passes (
agent.run_once→repo.get_candleswith no bounds →engine.evaluate), andfind_levels/detect_phaseread the whole list they are handed.Both samples are reported and the unconditional one carries the conclusion. Restricting to fired signals conditions on
Rule.detect()and the choppy/higher-TF/kill-zone gates, which are functions of the same regime state several factors read — a collider. Arm 4 demonstrates it rather than assuming it: P(condition_aligned) goes 0.275 → 0.818, P(ema_fan_aligned) 0.301 → 0.909, and the variance ratio falls below 1 (0.663) because conditioning truncates rather than decorrelates.Multiple testing
45 pairwise tests in arm 1 (10 of 11 factors vary;
seasonalityis weighted 0 and hardcodedFalse), 36 in arm 2 (sr_touchesis constant under a 500-bar hourly window). Holm–Bonferroni at α = 0.05 over exactly the pairs tested, not over 11·10/2. Each ledger row carries its ownhypotheses_tested.Then the write-up says plainly, rather than dressing it up: at N = 6,822, φ = 0.041 clears Holm; at N = 186,725, φ = 0.054 gives p = 1.9×10⁻¹¹⁷. 23 of 45 pairs are "significant". Significance answers "is this exactly zero", which nobody asked. No conclusion in the document rests on a p-value, and the caveats note that serial dependence between bars makes every p-value optimistic anyway.
The finding worth acting on is not collinearity
levels.is_round_number(price, step=Decimal("0.005"))treatsstepas an absolute half-cent. Coinbase quotes BTC/ETH/PAXG to two decimals, soprice % 0.005 == 0always and the check can never fail. Verified directly:round_number_proximity)Three of five live assets receive an unconditional +1 on every CTS score, and the factor means something entirely different on the other two. That is a correctness bug in a shipped analysis primitive, not a weighting question — recommended as a follow-up issue and deliberately not fixed here, per the issue's own scope.
On the source
The teardown's "3-of-4 agreement" ratio is unvalidated SEO-adjacent content and is used in no computation: no threshold here is theirs, no result is compared against it. Only the mechanism — additivity presumes independence — was taken seriously. The document says this in its own words.
Three modified files, all justified
keel/strategy/engine.py—_assemble_cts_context→assemble_cts_context, plus a docstring paragraph. Rename and docstring only; the diff is 4 lines of code. No control flow, no logic, no scoring change. Proven by the 191 unchanged tests intests/strategy/test_engine.py+tests/simpassing, and by the full 2,401-test suite.keel/sim/portfolio_sim.py— call-site update forced by that rename. It was already importing the privateengine._assemble_cts_context(line 486 and a docstring reference); promoting the name is what removes that wart rather than adding a second one. Two lines, one of them a docstring.keel/research/independence.py—_pearson→pearson(sanctioned), with the reason in its docstring: it is the oracletest_phi_agrees_with_shipped_pearsonchecks the new contingency-table φ against, and an oracle behind a private name is one refactor from vanishing.compare()'s behaviour is untouched — same call, same result;tests/research/test_independence.pyis unmodified and passes.Placement
keel/research/cts_factors.py— the reusable, importable, unit-tested library, next toindependence.py/cscv.py. It ships in the wheel becausetests/research/imports it;scripts/is release/operator tooling excluded from the wheel and would be the wrong home.docs/experiments/2026-08-09-cts-factor-collinearity.py— the pre-declared configuration and report only. Same split as2026-08-08-between-family-independence.py(driver) againstresearch/independence.py(library), and2026-08-05-coinbase-asset-class-probe.py.Gates
25 new tests in
tests/research/test_cts_factors.py, including the φ-vs-pearsonoracle check on five seeds, a proof that Holm is step-down and monotone, a proof that the variance ratio is 1.0 on a crossed design and >1.9 on duplicated factors, and a proof that cluster means are signed so mutual exclusion cannot be laundered into "tight coupling".Ledger
Three
diagnostic_onlyrows, sessioncts-factor-collinearity-2026-08-09, one per arm, each carrying its ownhypotheses_tested(45 / 36 / 45).verify_chainreturns no errors.Recommendations (follow-ups, not code here)
levels.is_round_number— absolute vs. fractionalstep.deceleration×candlestick_pattern(φ 0.254) as the only plausible collapse candidate, and do not act on it: 6.5% shared variance over 2 of 14 points, and arm 4 shows both fire on 1 of 77 real signals.entry_techniquethresholds (low=5,high=8) sit at a different place on the real distribution than on the nominal one. Observed mean total 5.14, σ 2.03.One correction to the issue's framing
#208 and its scouting comment both describe the clusters as "4 of 13 raw points".
sum(DEFAULT_WEIGHTS.values())is 14, not 13 — 2+1+2+1+1+2+1+2+1+1+0. The cluster shares are 4/14 = 28.6% each, which is what the harness computes and prints (weight_share0.286). The document uses 14 throughout.🤖 Generated with Claude Code