Skip to content

research: are the 11 CTS factors collinear? — momentum cluster refuted, trend cluster real and small - #224

Merged
eaitbrahim merged 1 commit into
mainfrom
research/cts-factor-collinearity
Aug 11, 2026
Merged

research: are the 11 CTS factors collinear? — momentum cluster refuted, trend cluster real and small#224
eaitbrahim merged 1 commit into
mainfrom
research/cts-factor-collinearity

Conversation

@eaitbrahim

@eaitbrahim eaitbrahim commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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

the question #208 asked answer number
momentum cluster {rsi_extreme, rsi_divergence, deceleration} is collinear? no — refuted mean within φ −0.018 vs +0.025 background
trend cluster {condition_aligned, ema_fan_aligned} is collinear? yes, mildly mean within φ +0.190 vs +0.018 background
does collinearity inflate the score? barely Var ratio 1.161 → ≈8.6 effective independent factors of 10
largest collinearity anywhere not a pre-declared cluster deceleration × candlestick_pattern φ +0.254

rsi_extreme and rsi_divergence co-occur ZERO times in 6,822 bars (Jaccard 0.000, lift 0.00). They read the same indicators.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 signals table — holds one row in keel.db and zero in keel-live.db. The sample had to be reconstructed, which is possible because engine.assemble_cts_context is a pure function of (setup, candles) and keel.db.candles holds 611,176 bars back to 2021.

arm population granularity window N
1 (headline) every bar ONE_DAY expanding 6,822
2 (replication) every bar ONE_HOUR rolling 500 186,725
3 (window sensitivity) every 20th bar, BTC ONE_HOUR 250/500/1000 2,207 each
4 (contrast only) fired, gate-cleared signals ONE_DAY expanding 77

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_oncerepo.get_candles with no bounds → engine.evaluate), and find_levels/detect_phase read 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; seasonality is weighted 0 and hardcoded False), 36 in arm 2 (sr_touches is 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 own hypotheses_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")) treats step as an absolute half-cent. Coinbase quotes BTC/ETH/PAXG to two decimals, so price % 0.005 == 0 always and the check can never fail. Verified directly:

BTC ETH PAXG ADA XLM
quote decimals 2 2 2 5 6
P(round_number_proximity) 1.0000 1.0000 1.0000 0.2195 0.1901

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_contextassemble_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 in tests/strategy/test_engine.py + tests/sim passing, 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 private engine._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_pearsonpearson (sanctioned), with the reason in its docstring: it is the oracle test_phi_agrees_with_shipped_pearson checks 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.py is unmodified and passes.

Placement

  • keel/research/cts_factors.py — the reusable, importable, unit-tested library, next to independence.py/cscv.py. It ships in the wheel because tests/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 as 2026-08-08-between-family-independence.py (driver) against research/independence.py (library), and 2026-08-05-coinbase-asset-class-probe.py.

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
2401 passed, 1 skipped in 35.95s
SKIPPED [1] packages/keel-broker-api/keel_broker_api/conformance/suite.py:254:
           adapter serves no granularity the suite could exercise

25 new tests in tests/research/test_cts_factors.py, including the φ-vs-pearson oracle 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_only rows, session cts-factor-collinearity-2026-08-09, one per arm, each carrying its own hypotheses_tested (45 / 36 / 45). verify_chain returns no errors.

Recommendations (follow-ups, not code here)

  1. Do not collapse the momentum cluster. Close that part of Research: are the 11 CTS factors collinear? (confluence vs consensus) #208 as refuted.
  2. Open an issue on levels.is_round_number — absolute vs. fractional step.
  3. Record 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.
  4. Note for whoever reads CTS totals: mutually-exclusive pairs mean the nominal 14-point ceiling is unreachable, so the entry_technique thresholds (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.

⚠️ Independence is not edge. Under §73.5 this is a necessary and never sufficient condition. Whether any of these 11 factors predicts anything is a separate, unasked and unanswered question, and nothing here validates CTS.

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_share 0.286). The document uses 14 throughout.

🤖 Generated with Claude Code

@eaitbrahim eaitbrahim added research Measurement, validation or KB work (Research & validation) experiment A pre-declared experiment and its result (Research & validation) labels Aug 11, 2026
… 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
eaitbrahim force-pushed the research/cts-factor-collinearity branch from 7204c0d to d700f3b Compare August 11, 2026 11:12
@eaitbrahim
eaitbrahim merged commit f7318fd into main Aug 11, 2026
1 check passed
@eaitbrahim
eaitbrahim deleted the research/cts-factor-collinearity branch August 11, 2026 11:22
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

experiment A pre-declared experiment and its result (Research & validation) research Measurement, validation or KB work (Research & validation)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Research: are the 11 CTS factors collinear? (confluence vs consensus)

1 participant