URIEL
← All research
UR-RSCH-01·Quant·October 4, 2026

Many coins, one factor

On October 10, BTC lost 7.28% and SUI lost 23.68%, with all seven perps hitting their worst four-hour bar together. A public-data PCA separates the shared move from the gap.

00 · AbstractWhy seven charts became one matrix

On October 10, 2025, every coin in this set sold off, but BTC's 7.28% loss and SUI's 23.68% loss are very different outcomes for what was plainly a shared event. Seven tickers can look like seven separate positions until their worst bar of the day arrives together, and we wanted to know how much risk those positions actually shared and what was left to account for a gap that large.

We took four-hour returns for BTC, ETH, SOL, BNB, HYPE, XRP, and SUI from Hyperliquid's public API, giving us 318 returns per coin from September 1 through October 24. If the seven returns mostly share one movement, there should be one direction through the data that carries much more variation than the others. That is what the calculation finds: one direction carries 76.6% of the sample's standardised return variance, meaning variation after each coin is put on its own volatility scale.

Every coin loads positively on that direction, and when we project the October 10 returns onto it, the worst four-hour bar stands out immediately. The common direction captures much of the selloff, but it does not give BTC and SUI the same loss, so the differences between their returns remain part of the question throughout.

We start with the seven losses a reader would have seen that day, then use the matrix to ask how much of the scene was one market move and what the remaining differences might mean. The equations show where the numbers come from, while the solver and code are at the end for anyone who wants to take the calculation apart.

01 · DataSeven coins, public tape

The universe. BTC, ETH, SOL, BNB, HYPE, XRP, and SUI perpetuals on Hyperliquid give us two exchange tokens and a mix of larger and more volatile names, with enough coins to see co-movement and few enough to keep the whole matrix on one screen.

The tape. Four-hour candles, 2025-09-01 00:00 UTC to 2025-10-24 00:00 UTC: 319 candles per coin and 318 log returns from Hyperliquid's public API. You can fetch these without a subscription or an API key.

The public candle history is depth-limited, so the one-hour series does not reach this event window; four-hour closes do, giving 318 observations for a first pass at seven assets. The last week of September has mean pairwise correlation around 0.610.61, while October is closer to 0.770.77; we wanted both periods in the sample, though their difference means the fitted matrix averages over two regimes.

The event. October 10, 2025 was a mass-liquidation day. The daily (candle-open to candle-close) moves in this sample:

Coin 2025-10-10
BTC −7.28%-7.28\%
ETH −12.52%-12.52\%
SOL −15.15%-15.15\%
BNB −12.13%-12.13\%
HYPE −15.03%-15.03\%
XRP −15.67%-15.67\%
SUI −23.68%-23.68\%

The 20:00 UTC bar is the worst of the day for every coin, which makes the shared move easy to see; the gap between BTC and SUI is what keeps this from being just a story about seven coins falling together. Before looking at that bar in isolation, we want a baseline for how these coins moved together across the rest of the window.

02 · CorrelationPutting the returns on the same scale

First, turn closes into log returns, which measure the change from one four-hour close to the next on a scale that can be added across bars. For coin ii at bar tt:

rt,i=ln⁡ct(i)ct−1(i)r_{t,i} = \ln\frac{c_t^{(i)}}{c_{t-1}^{(i)}}

That gives us a 318×7318 \times 7 return matrix. Correlation then asks whether two coins tend to be above or below their own average on the same bars, with +1+1 meaning they move together perfectly, 00 meaning no consistent linear relationship, and −1-1 meaning they move in opposite directions:

ρij=∑t=1m(rt,i−rˉi)(rt,j−rˉj)∑t(rt,i−rˉi)2∑t(rt,j−rˉj)2\rho_{ij} = \frac{\sum_{t=1}^{m}(r_{t,i} - \bar r_i)(r_{t,j} - \bar r_j)}{\sqrt{\sum_t (r_{t,i} - \bar r_i)^2}\sqrt{\sum_t (r_{t,j} - \bar r_j)^2}}

with m=318m = 318. The 7×77 \times 7 result, with stronger positive pairs shaded more brightly:

BTC ETH SOL BNB HYPE XRP SUI
BTC 1.000 0.832 0.779 0.642 0.623 0.755 0.715
ETH 0.832 1.000 0.835 0.648 0.668 0.764 0.745
SOL 0.779 0.835 1.000 0.645 0.731 0.798 0.803
BNB 0.642 0.648 0.645 1.000 0.599 0.667 0.664
HYPE 0.623 0.668 0.731 0.599 1.000 0.693 0.734
XRP 0.755 0.764 0.798 0.667 0.693 1.000 0.869
SUI 0.715 0.745 0.803 0.664 0.734 0.869 1.000

The smallest off-diagonal entry is 0.5990.599 and the mean is 0.7240.724, so these books move together quite a lot on four-hour bars, and none of the 21 distinct pairs is negatively correlated in this sample. That makes a shared October 10 move less surprising, but pairwise correlations do not tell us whether there is one direction running through all seven returns or several overlapping groups, which is the distinction we need before we can read the event chart.

Why correlation instead of covariance? SUI's raw volatility is roughly twice BTC's in this window, so a covariance matrix would pick up that difference in scale before telling us much about the structure. Standardising the return series puts ones on the diagonal and makes the trace 77, which lets us read an eigenvalue divided by 77 as its share of total standardised variance. It also means that a coin's share of variance on a factor is a different question from how far its raw price fell on October 10, a distinction that will matter when we get back to SUI. For portfolio sizing, we would put the volatilities back in.

03 · EVDFinding the shared direction

To find out whether the co-movement in that matrix has one dominant direction, we use principal component analysis (PCA). It rotates the seven return columns into new directions whose scores are uncorrelated in this sample, so the largest direction tells us how much of their variation can be described by one shared move. The correlation matrix CC is symmetric, so we can write

C=VΛV⊤,Λ=diag(λ1≥λ2≥⋯≥λ7)C = V\Lambda V^\top, \qquad \Lambda = \mathrm{diag}(\lambda_1 \ge \lambda_2 \ge \dots \ge \lambda_7)

The columns of VV give the new directions, and each λk\lambda_k, or eigenvalue, measures how much standardised variance its direction carries. We used a small cyclic Jacobi solver and checked its result against the original matrix; the rotations and checks are in the code appendix. The eigenvalues are:

k λₖ explained %
1 5.3593 76.56%
2 0.4357 6.22%
3 0.4286 6.12%
4 0.3155 4.51%
5 0.1948 2.78%
6 0.1426 2.04%
7 0.1236 1.77%

That 5.35935.3593 is a remarkably lopsided answer to the question the correlation table left open, but we cannot call it a market direction until we know which way the eigenvector points: the same large eigenvalue could describe one group of coins moving against another.

04 · FactorsOne direction does most of the work

The first eigenvector settles that point. Its loadings are

v(1)=(0.378, 0.389, 0.396, 0.340, 0.355, 0.393, 0.391)⊤v^{(1)} = (0.378,\ 0.389,\ 0.396,\ 0.340,\ 0.355,\ 0.393,\ 0.391)^\top

in (BTC, ETH, SOL, BNB, HYPE, XRP, SUI) order. Every entry is positive and near 1/7≈0.3781/\sqrt{7}\approx0.378. An equal-weight move in all seven standardised returns looks almost exactly like this, so we will call F1 the market mode. Nothing here points to a coin that reliably moves against the rest; the difference between coins is in how much each move follows this direction and what happens outside it.

The smaller eigenvalues are where we have to be more careful:

λ2=0.44,λ3=0.43,λ4=0.32,λ5≤0.19\lambda_2 = 0.44, \quad \lambda_3 = 0.43, \quad \lambda_4 = 0.32, \quad \lambda_5 \le 0.19

F2 and F3 carry nearly the same variance, which means their individual axes can rotate when the sample changes even if the plane they span stays roughly where it was. That is why we think of this as one factor and a half: the first direction is clear, while the soft pair is worth studying without trusting its exact coordinates too much.

In a quieter window we would expect several overlapping groups of books, while a large shared move can pull their returns toward one diagonal. That is the geometry behind this first direction: most of the variation lies along it, and after we take it out, F2 and F3 are the near-tied directions left to inspect. A shared move in a selloff is the part we expected; the more useful question for October 10 is which differences between coins survived it.

One direction carries most of the sample varianceEigen-decomposition of the 7×7 sample correlation, 318 four-hour returns, 2025-09-01 → 2025-10-24
These bars show variance explained by PCA directions, not categories of coins. F1 is the shared market move and takes 76.6%; in this fit F2 puts BNB and HYPE together against BTC and ETH, while F3 separates BNB from HYPE. F2 and F3 are nearly tied, so their loading patterns should not be read as stable economic factors.

If October 10 was largely a market-wide move, its worst bar should land far out on this first direction; whatever keeps the coins from falling by the same amount should show up outside it. The event projection is where that interpretation meets the actual returns.

05 · EventWhere October 10 lands

Now we can ask whether the direction that dominates the whole window is also where the October 10 selloff sits. For the event chart, we project each bar's raw return vector onto the correlation eigenvectors, which gives a score for how far that bar moved along each direction:

st,k=∑i=17vi,k,rt,is_{t,k} = \sum_{i=1}^{7} v_{i,k},r_{t,i}

This leaves the vertical axis in log-return units; the eigenvalue shares, by comparison, come from standardised returns. The event day is shaded:

October 10, on the common factorRaw-return projections s(t) = R(t)·v_k, four-hour log returns; shaded band = 2025-10-10 UTC
The deepest dip in the shaded October 10 band is the 20:00 UTC bar, roughly eighteen times the quiet-bar reference. F2 puts BNB and HYPE together against BTC and ETH in this fit, while F3 separates BNB from HYPE; their near-tied eigenvalues make both labels provisional (see §06). The event is also inside the fit, so this is a description rather than a forecast.

A fairly quiet four-hour bar in this chart has ∣s1∣≈0.016|s_1|\approx0.016. On October 10:

  • At 12:00 UTC, s1=−0.112s_1=-0.112.
  • At 20:00 UTC, s1=−0.289s_1=-0.289, the largest absolute common-direction reading in the 318-bar window. That is about eighteen times the quiet reference magnitude.
  • The second direction also moved, with s2=−0.111s_2=-0.111 at 20:00. The selloff was shared, but it wasn't a perfectly equal-weight one.
  • On October 12, s1=+0.134s_1=+0.134, the largest positive reading in the window.

This answers the first half of what bothered us about the daily table: the worst losses arrived together and the 20:00 bar is an extreme move along the direction that the whole sample shares. It also leaves the second half intact, because a single common score cannot by itself turn a −7%-7\% BTC day into a −24%-24\% SUI day.

We fitted the eigenvectors on the whole window, including October 10, so that day's returns helped define the axis on which we are measuring them. The chart shows where the event sits in the fitted space; it says nothing about what an earlier fit would have predicted. We can now ask which coins usually stay close to that common direction and which have more movement outside it, while keeping in mind that a full-window variance share is not a breakdown of this one day's loss.

06 · AttributionWhere each coin differs

F1 carries 76.6% of variance across the seven standardised return series, but it does not carry the same share for each coin. That difference will not tell us what triggered a particular liquidation, but it will show whether the unequal October 10 losses happened among coins that usually follow the common direction or among coins that usually move more independently.

The table below answers a question about the whole sample, not a question about how to divide up a single crash bar. Here we switch to standardised returns, zt,i=(rt,i−rˉi)/σiz_{t,i}=(r_{t,i}-\bar r_i)/\sigma_i, so the PCA decomposition and its truncated version are

zt=VV⊤zt=Bft+εt,B=[v(1) … v(K)],ft=B⊤ztz_t = V V^\top z_t = B f_t + \varepsilon_t, \qquad B = [v^{(1)}\ \dots\ v^{(K)}],\quad f_t=B^\top z_t

The retained directions sit in BB; εt\varepsilon_t is the projection onto the remaining directions. Their sample covariances are zero, so for coin ii the variance accounting is exact:

1=Var(zi)=∑k=1Kvi,k2λk+∑k=K+17vi,k2λk1 = \mathrm{Var}(z_i) = \sum_{k=1}^{K} v_{i,k}^{2}\lambda_k + \sum_{k=K+1}^{7} v_{i,k}^{2}\lambda_k

Each term is a share of that coin's standardised variance, with the second sum carrying everything left out of the truncated PCA, so there is no extra residual to add. The top four directions give:

Coin F1 (market) F2 F3 F4 Total
BTC 76.6% 10.3% 2.6% 1.0% 90.4%
ETH 81.0% 7.6% 0.5% 2.0% 91.0%
SOL 84.0% 1.5% 0.9% 0.3% 86.7%
BNB 62.1% 13.9% 23.7% 0.1% 99.8%
HYPE 67.6% 9.3% 12.2% 9.7% 98.7%
XRP 82.6% 0.0% 0.6% 10.2% 93.4%
SUI 82.1% 1.0% 2.5% 8.2% 93.7%

SOL, XRP, and SUI each put more than 80% of their standardised variance on F1, whereas BNB puts 62.1% there and HYPE puts 67.6%. SUI is the useful check on what this table means: it had the largest raw loss on October 10 and still has an 82.1% market share across the window, because these percentages describe variation relative to each coin's own volatility, which for SUI is roughly twice BTC's. A high share on F1 can therefore coexist with a much larger fall in raw return units; the table gives the usual structure, while the event chart shows how far this particular bar traveled along and away from it.

BNB and HYPE are different puzzles, since more of their variation sits outside F1 even before we single out October 10, so we looked at their F2 and F3 loadings:

F2 F3
BTC −0.487-0.487 +0.244+0.244
ETH −0.417-0.417 +0.107+0.107
SOL −0.187-0.187 −0.146-0.146
BNB +0.565+0.565 +0.743+0.743
HYPE +0.461+0.461 −0.533-0.533
XRP +0.010+0.010 −0.117-0.117
SUI +0.153+0.153 −0.240-0.240

Both axes involve BNB and HYPE, but in different ways: in the full-window fit F2 puts them together on one side against BTC and ETH, whereas F3 puts BNB and HYPE on opposite sides. Their shared F2 loading makes an "exchange-token factor" a tempting name, but λ2\lambda_2 and λ3\lambda_3 are almost tied, and the exact split between their axes depends on the fit. Before treating either axis as an explanation of why coins diverged during the selloff, we want to know whether it survives when we change the fitting window.

The factor-space panel

We refitted the correlation on a rolling 30-day window, or 180 four-hour bars, advancing one bar at a time, and plotted each coin's F1 and F2 loadings for every fit. That gives 138 positions per coin, with an ellipse drawn from each cloud's two-dimensional sample covariance; its half-axes are

a=χ0.95,22λmax⁡,b=χ0.95,22λmin⁡a = \sqrt{\chi^2_{0.95,2}\lambda_{\max}}, \qquad b = \sqrt{\chi^2_{0.95,2}\lambda_{\min}}

where χ0.95,22=5.991\chi^2_{0.95,2}=5.991. These Gaussian reference ellipses are calculated from the rolling loadings rather than built to enclose the points, and because the windows overlap so heavily, their 95% contours are not coverage guarantees for the observed trail.

Where each coin sits in factor spaceRolling 30-day F1/F2 loadings (180-bar windows, one step); Gaussian-reference ellipse per coin
Each trail follows a coin's loadings as the 30-day window rolls forward: the shared F1 coordinate holds fairly steady, while F2 moves more, especially for BNB and HYPE, and some of that movement may come from the nearly tied F2 and F3 axes rotating between fits.

The F1 loadings stay close to 1/71/\sqrt{7} across the rolling fits, whereas F2 wanders much more, especially for BNB and HYPE, because near-tied eigenvalues can make the solver rotate or swap the F2 and F3 axes as the window changes. The filled dots mark the last fitted window.

The common direction holds up reasonably well in this check, but the coordinates of the soft directions do not. That leaves us comfortable calling the October 10 move broadly shared, while making us reluctant to say a particular F2 loading explains a particular coin's loss; we would need a longer history and a way to align the soft plane across windows before using one of those ellipses to make a trading decision.

07 · Named factorsTrying to name the leftovers

The soft axes move around enough that their loadings alone do not give us a convincing story for the differences in the October 10 losses, so we tried asking whether observable characteristics account for any of what the market direction leaves behind. The Q-7 framework of Babayev and Aliyev (Crypto has Fundamentals) motivates that list, although this small public-tape exercise is not a replication of their seven-factor model.

We can test whether four characteristics built from exchange data add anything to each coin's four-hour return fit after F1.

What's computable from public tape

We can construct four backward-looking characteristics from the exchange tape without using a bar's future data:

Factor Definition (per coin, per 4h bar)
Size ln⁡\ln of trailing 30-day average dollar volume, cross-section centred
Funding mean of the hour-1 funding rates accrued during the bar, cross-section centred
Residual vol −-EWMA std (15-day half-life) of the coin's return residualized on the equal-weight market (market beta, then the residual's vol)
Reversal −-prior 4h log return

Value needs network fundamentals that are absent from this exchange tape, while quality and turnover need additional history, including open interest, so this exercise stops at four Q-7-inspired proxies rather than attempting the full model.

The nested decomposition

For each coin, we use the part of the sample left after a 30-day warm-up, then fit the return series in stages:

r=bf(1)+Zϕ+er = b f^{(1)} + Z\phi + e

Here rr is that coin's return vector, f(1)f^{(1)} the common-direction score, and ZZ its four-characteristic design matrix. We fit the market term first and then add ZZ, reporting each stage's incremental drop in residual sum of squares as a share of the total; the order matters, because a characteristic correlated with the market only gets credit for what it adds after the market term. This is a time-series regression on a short subsample, so the percentages tell us about these particular proxies rather than settling anything about characteristic models in general.

Four named proxies add little after the marketNested OLS on the 30-day valid subsample: market factor, then size / funding / residual-vol / reversal, per coin
For every coin, the market term takes most of the fitted share, while the four public-tape proxies add only 0.26% to 1.53% in this short subsample; this is a small test inspired by Q-7, not the full model.
Coin Market Four proxies Remaining
BTC 73.8% 0.26% 25.9%
ETH 80.1% 0.89% 19.0%
SOL 87.2% 0.63% 12.2%
BNB 66.7% 1.13% 32.1%
HYPE 80.4% 1.53% 18.1%
XRP 87.6% 0.82% 11.5%
SUI 85.9% 0.31% 13.8%

What the answer actually is

After the market term, the proxy block adds 0.26% to 1.53% of each coin's return variance, with HYPE at the top of that range, and the largest time-series correlation between a PCA score and one of these characteristics is 0.140.14. On the event bar, the largest fitted named contribution is 0.00650.0065 in log-return units (BNB, through the size proxy), beside reported market components of −0.05-0.05 to −0.16-0.16 and SUI's excess drawdown of −0.064-0.064 outside those fitted terms. The common move is large, but these four names do little to account for SUI's excess loss on that bar.

We had hoped "funding" or "size" might give one of the soft directions a name, but nothing in this short-window regression supports either label. That tells us very little about Q-7 itself, which studies return premia across many assets and much longer periods while this PCA describes co-movement every four hours. A characteristic can matter for one question and contribute very little to the other.

On these seven coins the four tape-derived proxies add little after F1, so we still have a measured difference between the coins without a defensible name for much of it; testing Q-7 itself would take the missing characteristics, a wider universe, and a longer history.

08 · ScopeWhere the evidence stops

The October 10 event is inside the fitting window, so its large projection onto F1 describes the event in a space that the event helped define; it is an in-sample result. There are also only 318 four-hour returns, seven chosen coins, and one venue, with different average correlations in September and October.

Within this sample, F1 carries 76.6% of total standardised variance and the October 10 selloff projects strongly onto it, which shows how the seven losses look like one event in the tape. It does not erase their differences: SUI's much larger raw loss is compatible with its high market share once its higher volatility is put back in, while the soft loadings and four characteristics we tested give us little basis for a firm account of the remaining event-bar gap.

Four-hour closes show when the shared move arrived and how the returns differed; they cannot tell us which positions were liquidated first or what set off the selling. They also cannot establish that stress itself compressed the market into one direction, or that a soft factor led the crash. For those questions we would want a fit made before the event, more than one stressed period, and finer data that can resolve which moves came first.

That is the public-data answer we can defend. What the same test would say about today's market, whether any useful hedge remains when all 21 pairs in this sample are positively correlated, and whether the unnamed differences survive at finer resolution are exactly the questions we would want to take up next. If you like the part of a problem where the neat first answer stops being enough, we would like to hear from you.

09 · CodeThe calculation in code

If the case for a shared October 10 move depends on a seven-by-seven matrix, the matrix should be easy to check. The first listing fetches the public candles and works through the correlation matrix, Jacobi solver, and per-coin attribution using ureq and serde_json, with no linear-algebra dependency. The second shows the rolling loading clouds, but leaves jacobi_in_place as the rotation routine from the first listing, so it is an extension sketch rather than a standalone program; the Q-7-inspired regression in §07 is not included in these snippets.

The eigensolver check

A numerical library would diagonalise this matrix in one call, but at d=7d=7 the cyclic Jacobi method is short enough to inspect. It works through every off-diagonal pair (p,q)(p,q), rotating that plane until apqa_{pq} becomes zero; the angle obeys tan⁡2ϕ=2apq/(app−aqq)\tan 2\phi = 2a_{pq}/(a_{pp}-a_{qq}), and the matrix update is A′=J⊤AJA' = J^\top A J, where JJ is the identity apart from that two-dimensional rotation. We keep sweeping until the off-diagonal entries are negligible, applying the same rotations to VV so that its columns end up holding the eigenvectors.

Before printing a result, we check both max⁡ij∣(VΛV⊤−C)ij∣\max_{ij}|(V\Lambda V^\top-C)_{ij}| and max⁡ij∣(V⊤V−I)ij∣\max_{ij}|(V^\top V-I)_{ij}|. If either check fails, the code stops; a plausible-looking spectrum from a broken rotation is worse than an obvious failure.

Core pipeline: fetch, correlation, Jacobi EVD, attribution

//! pca7: PCA + attribution on public Hyperliquid 4h candles, 7 perps.
//! Window: 2025-09-01 .. 2025-10-24 UTC (contains the 2025-10-10 mass
//! liquidation). HTTP/JSON via ureq and serde_json; EVD by cyclic Jacobi; no
//! linear-algebra crate, which is the point.

use serde_json::{Value, json};

const COINS: [&str; 7] = ["BTC", "ETH", "SOL", "BNB", "HYPE", "XRP", "SUI"];
const START_MS: i64 = 1_756_684_800_000; // 2025-09-01 00:00 UTC
const END_MS: i64 = 1_761_264_000_000; // 2025-10-24 00:00 UTC

fn fetch_candles(coin: &str) -> Vec<(i64, f64)> {
    let body = json!({
        "type": "candleSnapshot",
        "req": { "coin": coin, "interval": "4h", "startTime": START_MS, "endTime": END_MS }
    });
    let resp = ureq::post("https://api.hyperliquid.xyz/info")
        .set("Content-Type", "application/json")
        .send_string(&body.to_string())
        .unwrap();
    let resp: Value = serde_json::from_str(&resp.into_string().unwrap()).unwrap();
    resp.as_array()
        .unwrap()
        .iter()
        .map(|c| {
            let t = c["t"].as_i64().unwrap();
            let close = c["c"].as_str().unwrap().parse::<f64>().unwrap();
            (t, close)
        })
        .collect()
}

fn jacobi_in_place(a: &mut [Vec<f64>], v: &mut [Vec<f64>]) {
    let d = a.len();
    for _sweep in 0..100 {
        let mut off = 0.0;
        for p in 0..d {
            for q in (p + 1)..d {
                off += a[p][q] * a[p][q];
            }
        }
        if off < 1e-14 {
            break;
        }
        for p in 0..d {
            for q in (p + 1)..d {
                if a[p][q].abs() < 1e-14 {
                    continue;
                }
                // Givens rotation with A' = J^T A J, J = [[c,-s],[s,c]]:
                // phi = 1/2 * atan2(2*a_pq, a_pp - a_qq) zeroes a'_pq.
                let phi = 0.5 * (2.0 * a[p][q]).atan2(a[p][p] - a[q][q]);
                let (sine, cos) = (phi.sin(), phi.cos());
                let (app, aqq, apq) = (a[p][p], a[q][q], a[p][q]);
                a[p][p] = cos * cos * app + 2.0 * sine * cos * apq + sine * sine * aqq;
                a[q][q] = sine * sine * app - 2.0 * sine * cos * apq + cos * cos * aqq;
                a[p][q] = 0.0;
                a[q][p] = 0.0;
                // V' = V J rotates rows p and q too, so the V update runs
                // for every k; the A update (the diagonals are handled
                // above) only for k outside the plane.
                for k in 0..d {
                    if k != p && k != q {
                        let (apk, akq) = (a[k][p], a[k][q]);
                        let (nk, nq) = (cos * apk + sine * akq, -sine * apk + cos * akq);
                        a[k][p] = nk;
                        a[p][k] = nk;
                        a[k][q] = nq;
                        a[q][k] = nq;
                    }
                    let (vkp, vkq) = (v[k][p], v[k][q]);
                    v[k][p] = cos * vkp + sine * vkq;
                    v[k][q] = -sine * vkp + cos * vkq;
                }
            }
        }
    }
}

fn main() {
    // ---- fetch + align -------------------------------------------------
    let mut per_coin: Vec<(Vec<i64>, Vec<f64>)> = Vec::new();
    for coin in COINS {
        let data = fetch_candles(coin);
        println!("{coin}: {} candles", data.len());
        let (t, c): (Vec<i64>, Vec<f64>) = data.into_iter().unzip();
        per_coin.push((t, c));
    }
    let times: Vec<i64> = per_coin[0].0.clone();
    assert!(
        per_coin.iter().all(|(t, _)| t == &times),
        "candle grids differ"
    );
    let closes: Vec<Vec<f64>> = (0..COINS.len()).map(|i| per_coin[i].1.clone()).collect();
    let n = times.len();
    assert!(n > 200, "expected ~320 points, got {n}");

    // log returns on closes, inner-aligned (drop first candle)
    let t_ret: Vec<i64> = times[1..].to_vec();
    let r: Vec<Vec<f64>> = (0..COINS.len())
        .map(|i| {
            (1..n)
                .map(|t| (closes[i][t] / closes[i][t - 1]).ln())
                .collect()
        })
        .collect();
    let m = r[0].len();

    // ---- sample correlation -------------------------------------------
    let mut col_mean = vec![0.0_f64; COINS.len()];
    for i in 0..COINS.len() {
        for t in 0..m {
            col_mean[i] += r[i][t];
        }
        col_mean[i] /= m as f64;
    }
    let mut col_sd = vec![0.0_f64; COINS.len()];
    for i in 0..COINS.len() {
        for t in 0..m {
            let d = r[i][t] - col_mean[i];
            col_sd[i] += d * d;
        }
        col_sd[i] = (col_sd[i] / (m as f64 - 1.0)).sqrt();
    }
    let mut corr = vec![vec![0.0_f64; COINS.len()]; COINS.len()];
    for i in 0..COINS.len() {
        for j in i..COINS.len() {
            let mut s = 0.0;
            for t in 0..m {
                s += (r[i][t] - col_mean[i]) * (r[j][t] - col_mean[j]);
            }
            let c = s / ((m as f64 - 1.0) * col_sd[i] * col_sd[j]);
            corr[i][j] = c;
            corr[j][i] = c;
        }
    }

    // ---- cyclic Jacobi EVD (symmetric) ---------------------------------
    let d = COINS.len();
    let mut a = corr.clone();
    let mut v = vec![vec![0.0_f64; d]; d];
    for i in 0..d {
        v[i][i] = 1.0;
    }
    jacobi_in_place(&mut a, &mut v);
    // self-check: the decomposition must reconstruct and V must be orthonormal
    let mut voff = 0.0_f64;
    // Direct check: ||V Lambda V^T - C||_max
    let mut maxdiff = 0.0_f64;
    for i in 0..d {
        for j in 0..d {
            let mut acc = 0.0;
            for k in 0..d {
                acc += v[i][k] * a[k][k] * v[j][k];
            }
            maxdiff = maxdiff.max((acc - corr[i][j]).abs());
        }
    }
    for i in 0..d {
        for j in 0..d {
            let mut dot = 0.0;
            for k in 0..d {
                dot += v[k][i] * v[k][j];
            }
            voff = voff.max((dot - (if i == j { 1.0 } else { 0.0 })).abs());
        }
    }
    println!(
        "self-check: ||V\\Lambda V^T - A||\\infty = {:.2e}, max|V^T V - I| = {:.2e}",
        maxdiff, voff
    );
    assert!(maxdiff < 1e-9 && voff < 1e-9, "EVD self-check failed");

    let mut ev: Vec<(f64, usize)> = (0..d).map(|i| (a[i][i], i)).collect();
    ev.sort_by(|x, y| y.0.partial_cmp(&x.0).unwrap());
    let eig: Vec<(f64, Vec<f64>)> = ev
        .iter()
        .map(|&(_, i)| (a[i][i], (0..d).map(|k| v[k][i]).collect()))
        .collect();
    let trace: f64 = eig.iter().map(|(l, _)| l).sum();
    let explained: Vec<f64> = eig.iter().map(|(l, _)| l / trace * 100.0).collect();
    let kaiser: usize = eig.iter().filter(|(l, _)| *l > 1.0).count();

    // ---- factor scores (time series) ------------------------------------
    // f_tk = sum_j r_tj * v_jk  (unit eigenvector columns)
    let kf = 4usize;
    let mut scores: Vec<(i64, Vec<f64>)> = Vec::new();
    for t in 0..m {
        let mut s = vec![0.0_f64; kf];
        for k in 0..kf {
            for j in 0..d {
                s[k] += r[j][t] * eig[k].1[j];
            }
        }
        scores.push((t_ret[t], s));
    }

    // ---- per-asset explained variance (attribution) ---------------------
    // asset i: h_i^2 = sum_k v_ik^2 * lambda_k (corr matrix => total var 1)
    let mut communality = vec![0.0_f64; d];
    let mut per_factor_share: Vec<Vec<f64>> = vec![vec![0.0_f64; kf]; d];
    for i in 0..d {
        for k in 0..kf.min(d) {
            let share = eig[k].1[i] * eig[k].1[i] * eig[k].0;
            per_factor_share[i][k] = share;
            communality[i] += share;
        }
    }

    // ---- event window stats ---------------------------------------------
    let event_start = t_ret.iter().position(|t| *t >= 1_760_054_400_000); // 2025-10-10 00:00 UTC
    let event_start = event_start.unwrap();
    let event_len = 18usize; // 3 days of 4h bars
    let mut event_ret = vec![0.0_f64; d];
    for i in 0..d {
        for t in event_start..(event_start + event_len).min(m) {
            event_ret[i] += r[i][t];
        }
    }

    // ---- print ---------------------------------------------------------
    println!("m={m} returns, d={d} assets, 4h candles 2025-09-01..2025-10-24");
    for k in 0..d {
        println!(
            "F{}  lambda = {:.4}  {:>5.2}%  loadings [{}] {}",
            k + 1,
            eig[k].0,
            explained[k],
            eig[k]
                .1
                .iter()
                .map(|v| format!("{v:+.3}"))
                .collect::<Vec<_>>()
                .join(" "),
            COINS
                .iter()
                .enumerate()
                .map(|(i, c)| format!("{c}:{:+.3}", eig[k].1[i]))
                .collect::<Vec<_>>()
                .join(" ")
        );
    }
    println!("kaiser count (>1): {kaiser}");
    println!("per-asset attribution (top 4 factors, % of variance):");
    for i in 0..d {
        println!(
            "  {:<5} total {:>6.2}%  | F1 {:>6.2}% F2 {:>6.2}% F3 {:>6.2}% F4 {:>6.2}%",
            COINS[i],
            communality[i] * 100.0,
            per_factor_share[i][0] * 100.0,
            per_factor_share[i][1] * 100.0,
            per_factor_share[i][2] * 100.0,
            per_factor_share[i][3] * 100.0
        );
    }
    println!("event 2025-10-10..13 (3d) log returns:");
    for i in 0..d {
        println!("  {:<5} {:+.2}%", COINS[i], event_ret[i] * 100.0);
    }
    let (mi, _) = scores
        .iter()
        .enumerate()
        .max_by(|a, b| a.1.1[0].abs().partial_cmp(&b.1.1[0].abs()).unwrap())
        .unwrap();
    println!(
        "max |F1| score: {:.4} at {} (ms epoch {})",
        scores[mi].1[0], COINS[0], scores[mi].0
    );
}

The rolling-window re-fit and two-dimensional ellipse calculation from §06 can go below that program. Calling main_addition(&r, &eig) at the end of main prints the ellipse summaries:

Addition: rolling loadings + covariance ellipses

// ADDITION (extend the core): rolling 30-day loadings + 95% covariance ellipses.
// Needs the return matrix `r` and eigenpairs `eig` from the core listing.

const WIN: usize = 180; // 30 days of 4h bars

// Re-fit the correlation in a rolling window; signs aligned to the
// full-window fit so the factor identities don't flip between windows.
fn rolling_evd(
    r: &[Vec<f64>],
    win_start: usize,
    win_end: usize,
    ref_v: &[[f64; 7]; 2],
) -> [[f64; 7]; 2] {
    const D: usize = 7;
    let m = win_end - win_start;
    // window means/stds
    let mut mean = [0.0f64; D];
    let mut sd = [0.0f64; D];
    for i in 0..D {
        for t in win_start..win_end {
            mean[i] += r[i][t];
        }
        mean[i] /= m as f64;
        for t in win_start..win_end {
            let dv = r[i][t] - mean[i];
            sd[i] += dv * dv;
        }
        sd[i] = (sd[i] / (m as f64 - 1.0)).sqrt();
    }
    // window correlation
    let mut c = vec![vec![0.0f64; D]; D];
    for i in 0..D {
        for j in i..D {
            let mut s = 0.0;
            for t in win_start..win_end {
                s += (r[i][t] - mean[i]) * (r[j][t] - mean[j]);
            }
            let v = s / ((m as f64 - 1.0) * sd[i] * sd[j]);
            c[i][j] = v;
            c[j][i] = v;
        }
    }
    // Jacobi (same routine as the core; omitted here for brevity)
    let mut a = c;
    let mut v = vec![vec![0.0f64; D]; D];
    for i in 0..D {
        v[i][i] = 1.0;
    }
    jacobi_in_place(&mut a, &mut v);
    // sort desc + sign-align against the full-window F1/F2
    let mut order = (0..D).collect::<Vec<_>>();
    order.sort_by(|&x, &y| a[y][y].partial_cmp(&a[x][x]).unwrap());
    let mut out = [[0.0f64; 7]; 2];
    for k in 0..2 {
        for i in 0..D {
            out[k][i] = v[i][order[k]];
        }
        let ref_dot: f64 = (0..D).map(|i| out[k][i] * ref_v[k][i]).sum();
        if ref_dot < 0.0 {
            for i in 0..D {
                out[k][i] = -out[k][i];
            }
        }
    }
    out
}

fn main_addition(r: &[Vec<f64>], eig: &[(f64, Vec<f64>)]) {
    const KAPPA: f64 = 5.991; // chi-squared 2-dof, 95% quantile
    const D: usize = 7;
    let ref_v: [[f64; 7]; 2] = std::array::from_fn(|k| std::array::from_fn(|i| eig[k].1[i]));
    let mut traj: [[Vec<f64>; 2]; D] = std::array::from_fn(|_| [vec![], vec![]]);
    for t in WIN..r[0].len() {
        let ev = rolling_evd(r, t - WIN, t, &ref_v);
        for i in 0..D {
            traj[i][0].push(ev[0][i]);
            traj[i][1].push(ev[1][i]);
        }
    }
    for i in 0..D {
        let n = traj[i][0].len() as f64;
        let (mx, my) = (
            traj[i][0].iter().sum::<f64>() / n,
            traj[i][1].iter().sum::<f64>() / n,
        );
        let (mut sxx, mut sxy, mut syy) = (0.0f64, 0.0, 0.0);
        for t in 0..traj[i][0].len() {
            let (dx, dy) = (traj[i][0][t] - mx, traj[i][1][t] - my);
            sxx += dx * dx;
            sxy += dx * dy;
            syy += dy * dy;
        }
        (sxx, sxy, syy) = (sxx / (n - 1.0), sxy / (n - 1.0), syy / (n - 1.0));
        let (tr_, det) = (sxx + syy, sxx * syy - sxy * sxy);
        let disc = (tr_ * tr_ / 4.0 - det).max(0.0).sqrt();
        let (l1, l2) = (tr_ / 2.0 + disc, (tr_ / 2.0 - disc).max(1e-18));
        let ang = 0.5 * (2.0 * sxy).atan2(sxx - syy);
        println!(
            "{:<5} ellipse a={:.3} b={:.3} ang={:+.2}  center=({:+.3},{:+.3})",
            COINS[i],
            (KAPPA * l1).sqrt(),
            (KAPPA * l2).sqrt(),
            ang,
            mx,
            my
        );
    }
}

The eigensolver asserts reconstruction and orthonormality before printing the spectrum, because if we break a rotation we want the program to stop instead of handing us a plausible wrong answer. The matrix is small enough to inspect and the inputs are public, so you can take apart the calculation behind the market direction and the coin-level differences yourself.

Reference

Cite as

BibTeX
@misc{tristan2026pca,
  author = {Tristan Britt},
  title = {{Many coins, one factor: PCA and attribution on public perp tape}},
  howpublished = {Uriel Research},
  note = {Notes from the desk, UR-RSCH-01},
  year = {2026},
  month = oct,
  url = {https://uriel.xyz/research/pca-market-structure}
}