§ 01The ledger: decoding, ordering, reorgs
The raw material is EVM event logs. An exchange contract emits a fill; a conditional-token contract emits a transfer, a split, a merge or a redemption. Every log arrives with a block number, a transaction index and a log index, and those three integers are the only ordering I trust. The timestamp belongs to the block, not to the log, so two fills in the same block share a timestamp and only the log index says which came first. An indexer's created_at column is not ordering; it is when the indexer got round to it.
Decoding is mechanical once the ABI is pinned: topic 0 is the event signature hash, indexed parameters occupy the remaining topics, and the rest is ABI-encoded in data. I pin the ABI by contract address and by block range, because an upgradeable proxy can change its event set under the same address. Every decoded row keeps the raw topics and data next to the decoded columns, so a decoding bug is repaired by re-running the decoder over the store instead of re-fetching the chain. The store is columnar (Parquet files queried with DuckDB) because every question I ask is a scan over one address or one market, never a point lookup.
-- one row per decoded log; the raw payload stays for re-decoding
CREATE TABLE fills (
chain_id SMALLINT NOT NULL,
block_number BIGINT NOT NULL,
block_hash BLOB NOT NULL, -- reorg detection
block_time TIMESTAMP NOT NULL, -- from the header, not the log
tx_index INTEGER NOT NULL,
log_index INTEGER NOT NULL,
tx_hash BLOB NOT NULL,
contract BLOB NOT NULL, -- 20 bytes
maker BLOB NOT NULL,
taker BLOB NOT NULL,
asset_id DECIMAL(38,0) NOT NULL, -- outcome token id (uint256)
maker_amount DECIMAL(38,0) NOT NULL, -- raw units, never float
taker_amount DECIMAL(38,0) NOT NULL,
side TINYINT NOT NULL, -- +1 buys the outcome, -1 sells it
price_e6 BIGINT NOT NULL, -- collateral per share, scaled 1e6
topics BLOB[] NOT NULL, -- raw
data BLOB NOT NULL, -- raw
finalised BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (chain_id, block_number, tx_index, log_index)
);
-- the only order that means anything, per address
CREATE INDEX fills_by_addr ON fills (chain_id, maker, block_number, tx_index, log_index);
A reorganisation is a property of the source, not an edge case. The table has two zones: rows deeper than the finality depth are finalised and never change; rows above it are provisional. The ingester stores the block hash with every row and, on each new head, walks back until a stored hash matches the chain's hash at that height; everything above the match is deleted and re-fetched, which is cheap because the provisional zone is small. On Ethereum after the Merge, finality takes two epochs, roughly thirteen minutes; on Polygon PoS, where Polymarket settles, reorgs of more than a hundred blocks have happened1, so the provisional depth is a configured parameter with a generous default and not a constant copied from a documentation page. Features are computed on finalised rows only. A label built on a block that later disappears is a label built on nothing.
§ 02From fills to positions
A fill is not a decision; a change of position is. The second layer nets fills per address and outcome token, in ledger order, into a position path: the quantity held after each row, and the crossings through zero that define "entry" and "exit" here. Conditional tokens complicate this, because the same economic act can be written several ways: buying NO directly, or splitting collateral into YES and NO and selling the YES, produce the same exposure through different logs. So the position layer works in exposure per outcome, and split, merge and redemption events are folded in as if they were fills at prices of one and zero. Without that fold, the size and venue features would see two traders where there is one.
WITH ordered AS (
SELECT maker AS addr, asset_id, block_time,
side * taker_amount AS dq, -- signed quantity
price_e6 / 1e6 AS px,
ROW_NUMBER() OVER (PARTITION BY maker, asset_id
ORDER BY block_number, tx_index, log_index) AS k
FROM fills WHERE finalised
),
path AS (
SELECT *,
SUM(dq) OVER w AS qty_after,
SUM(dq) OVER w - dq AS qty_before
FROM ordered
WINDOW w AS (PARTITION BY addr, asset_id ORDER BY k)
)
SELECT addr, asset_id, block_time, dq, px, qty_before, qty_after,
CASE WHEN qty_before = 0 AND qty_after <> 0 THEN 'open'
WHEN qty_before <> 0 AND qty_after = 0 THEN 'close'
WHEN SIGN(qty_before) <> SIGN(qty_after) THEN 'flip'
WHEN ABS(qty_after) > ABS(qty_before) THEN 'add'
ELSE 'reduce' END AS act
FROM path
ORDER BY addr, asset_id, k;
SQL windows can tell me the crossings; they cannot carry a volume-weighted cost cleanly once a position has been partly closed, so cost basis, realised result and holding period come from a Python pass over the same ordering. Everything downstream reads the position path, never the fills.
§ 03Features per address
Over a window, each address becomes a vector, and I group the features into four families because a strategy is a claim about how someone decides and each family answers a different part of that claim. Timing: median seconds from a price move larger than a threshold in a market to this address's next fill in the same market (the reaction lag); the entropy of the hour-of-day distribution of its fills (low means one timezone and one habit, high means a machine or a team); holding period, in log-hours. Size: median position in collateral units and its dispersion; size relative to the market's trailing volume, so that a whale in a thin market and a minnow in a deep one are not confused. Venue: number of distinct markets; concentration by category as a Herfindahl index; share of volume in markets that resolve within a week. Response: the signed correlation between the address's net flow into an outcome and that outcome's return over the preceding k minutes. Positive means it buys after rises, negative after falls, and near zero means it does not look at price at all, which is a strategy in its own right.
The table shows the vector for three synthetic addresses generated to test the pipeline, on a 90-day window; nothing in it is an observed wallet. These are the columns the clustering step receives, before robust standardisation.
| Feature | A · reacts | B · holds | C · sprays |
|---|---|---|---|
| Fills in window | 412 | 38 | 1,940 |
| Distinct markets | 27 | 9 | 310 |
| Category concentration (HHI) | 0.31 | 0.72 | 0.06 |
| Median reaction lag (s) | 41 | 6,300 | 890 |
| Hour-of-day entropy (bits) | 3.1 | 2.2 | 4.5 |
| Median holding period (h) | 5.4 | 610 | 19 |
| Flow–return correlation, k = 15 min | +0.34 | −0.05 | +0.02 |
| Median size / trailing 24 h volume | 0.018 | 0.090 | 0.002 |
The three columns read as three different people, and that is exactly the trap: they are three descriptions, not three strategies. Whether B holds because it has a thesis or because it forgot the position, the vector cannot say. What it can say, later, is whether the vector stays put.
§ 04Rolling windows and regime change
One vector per address is a photograph, and the question is about a process, so the features are computed on rolling windows, thirty days stepping every seven, for any address with enough activity to fill at least six of them. Each address becomes a short multivariate series and two things are read from it. Stability: the mean distance between consecutive window vectors, standardised against the population; an address whose vectors wander is not following a repeatable process, whatever any single window says about it. Change points: a CUSUM on the standardised distance from the address's own trailing mean, with the alarm threshold calibrated on the permutation null of §06 rather than picked by eye. A crossing splits the address into two regimes, each labelled on its own, with the confidence of the older regime decaying as it recedes. A strategy that stopped a year ago is history, not behaviour.
This is also where the twin in the markets project earns the confidence it reports. On each new window the decision the current label would have taken is compared with the observed one; a divergence that widens is not absorbed by refitting, it lowers the confidence attached to the label until it falls under the reporting threshold and the address goes back to unclassified. Most addresses spend most of their lives there, and that is the honest state.
§ 05Clustering, and how far to trust the label
Standardise robustly (median and interquartile range, because the size features are heavy-tailed) and cluster with HDBSCAN rather than k-means, for two reasons: it does not force every point into a cluster, so noise is a legitimate outcome and most addresses should end up there, and it does not need k. Clusters are then named by a person reading the feature medians, "reacts within a minute to moves in a handful of markets, small size, short hold", and the name is a description of what the vector does, not a claim about intent. The confidence per address is the product of two numbers: HDBSCAN's membership probability, and how often the address lands in the same named cluster when the population is bootstrapped. Under 0.6 the address is reported as unclassified. Over 0.9 is rarer than anyone would like.
import numpy as np, hdbscan
from sklearn.preprocessing import RobustScaler
X = RobustScaler().fit_transform(F.values) # F: one row per (address, window)
base = hdbscan.HDBSCAN(min_cluster_size=40, min_samples=10,
prediction_data=True).fit(X)
labels, prob = base.labels_, base.probabilities_ # -1 is noise, and noise is fine
# stability: does the address keep its cluster when the population is resampled?
rng, B, n = np.random.default_rng(7), 200, len(X)
agree = np.zeros(n)
for _ in range(B):
idx = rng.choice(n, n, replace=True)
m = hdbscan.HDBSCAN(min_cluster_size=40, min_samples=10,
prediction_data=True).fit(X[idx])
lab_b, _ = hdbscan.approximate_predict(m, X) # every point, in-bag or not
mapping = {} # bootstrap cluster -> base cluster, by vote
for c in set(lab_b) - {-1}:
votes = labels[(lab_b == c) & (labels != -1)]
mapping[c] = np.bincount(votes).argmax() if len(votes) else -1
agree += np.array([mapping.get(l, -1) for l in lab_b]) == labels
confidence = prob * (agree / B)
confidence[labels == -1] = 0.0
F["label"] = np.where(confidence >= 0.6, labels, -1)
F["confidence"] = confidence.round(2)
The label is assigned per address-window and then aggregated over the current regime: the modal label, with the mean confidence, and a regime whose windows split their vote is unclassified by construction. The number that travels with the label is not decoration. It is the answer to the only question the twin was built to ask.
§ 06Failure modes: where correlation stops being evidence
The honesty check runs before anything is reported. The permutation test asks whether the address's response statistic, the flow–return correlation, or the reaction lag, is any better than what the same fills would show if their timing had nothing to do with price. Shuffling the fills destroys the autocorrelation of the price series and makes the null too easy, so instead I shift the price series circularly by a random offset, recompute the statistic, and repeat a thousand times; the p-value is the fraction of shifted worlds at least as extreme as the real one. Then the adjustment across the population: with ten thousand addresses, a hundred will clear p < 0.01 by luck, so I control the false discovery rate with Benjamini–Hochberg and only labels that survive it are kept. The holdout is temporal: labels are fitted on windows up to a time T and scored on whether they predict the sign of the address's flow–return relation after T better than the population base rate. A label that only describes the past is a summary, not a strategy.
import numpy as np
from statsmodels.stats.multitest import multipletests
def flow_return_corr(flow, price, k=900):
"""flow: signed net flow per second; price: mid per second.
corr(flow at t, log return over (t-k, t])."""
ret = np.zeros_like(price)
ret[k:] = np.log(price[k:] / price[:-k])
m = flow != 0 # only the seconds the address acted
return np.corrcoef(flow[m], ret[m])[0, 1]
def perm_test(flow, price, n=1000, seed=0):
rng = np.random.default_rng(seed)
obs = flow_return_corr(flow, price)
null = np.empty(n)
T = len(price)
for i in range(n):
s = rng.integers(3600, T - 3600) # circular shift keeps the autocorrelation
null[i] = flow_return_corr(flow, np.roll(price, s))
p = (np.sum(np.abs(null) >= abs(obs)) + 1) / (n + 1)
return obs, p, np.percentile(np.abs(null), 99)
# across the population: 10,000 addresses, 100 will pass p < 0.01 by luck
res = [perm_test(f, px) for f, px in per_address]
p_val = np.array([r[1] for r in res])
keep = multipletests(p_val, alpha=0.05, method="fdr_bh")[0]
What survives the test can still be wrong, in ways the test cannot see. These are the ones I have had to write rules for.
- Common cause. Hundreds of addresses respond to the same public event within the same minute; the correlation with price is real and the "strategy" is reading the news. Recomputing the statistic outside event windows separates "trades momentum" from "reacts to headlines": if it vanishes there, the label is the second.
- Look-ahead. Pricing a fill at log index 3 with the block's closing price leaks the thirty-seven fills after it. The reference price is the last one observed strictly before
(block, tx_index, log_index), and inside a block that means the previous log, not the previous block. - Machines read as people. Sub-second reaction lags across hundreds of markets, present as maker and taker alike, describe an automated market maker. It gets that label and no attempt to read intent into it.
- Address is not participant. One person runs many wallets and one custodial wallet holds many people. The clusters label addresses; merging them through funding-graph heuristics is a separate hypothesis with its own confidence, never a silent join.
- Survivorship. Addresses that lost and stopped drop out of the active set. Fitting only on the currently active inflates every "profitable" cluster; the population is everything that was active in the window, including what went quiet.
- Small samples. Thirty-eight fills give a correlation interval wide enough to hold zero comfortably. Every statistic carries its interval, and a label whose interval crosses the decision boundary is not a label.
The engine on the other side of the markets project, the one that has to act inside a hundred milliseconds, does not consume any of this. The twin is a research instrument and its output is a sentence about how far a pattern can be trusted, not a signal; most of the time the sentence is that it cannot. Where the engine's clock starts and stops is its own note.
- Ethereum finalises after two justified epochs of 32 slots at 12 s each, so about 12.8 minutes at best. Polygon PoS finality comes from checkpoints and milestones; the chain saw a reorg of over 150 blocks in 2023, and that number, not the documented average, sets the default provisional depth.