← Engineering notes

A per-trade Sharpe treats your trades as independent samples. Ten open positions are not.

Our Sharpe scales mean-over-stdev by the square root of the trade count, which is the right scaling only if the trades are independent draws — here is the effective-sample-size arithmetic for ten concurrent positions, and the fix we have documented but not shipped.

· 9 min read

Schematic drawing: ten horizontal bars that overlap heavily in time, stacked like a Gantt chart, with a bracket showing they cover the same span — many samples that are really only a few independent ones

We found three places where our own backtest metrics overstated: an annualisation that assumed the strategy was never out of the market, a total return that compounded trades which had run side by side, and a drawdown measured in trade order instead of in time. All three are fixed, and the arithmetic for each is written up in Three ways our own backtester was flattering us. This post is not about those.

It is about the fourth, which is still in the code. It sits in the module docstring of konis_core/backtest/metrics.py, in capitals, because the alternative was to leave it for someone else to find:

WHAT IS STILL APPROXIMATE, stated rather than hidden: a per-trade Sharpe over concurrent positions treats each trade as an independent sample, and trades opened in the same hour on correlated pairs are not independent. That inflates Sharpe for a strategy whose signals cluster. Fixing it properly means a time-bucketed return series, which needs the equity curve resampled to a fixed interval -- worth doing, not done here.

Below is the arithmetic behind that paragraph, how large it gets, and the specific reason it is not fixed yet.

Where independence enters the formula

This is the whole of _sharpe, minus the guard clauses:

mean = sum(returns) / len(returns)
variance = sum((r - mean) ** 2 for r in returns) / (len(returns) - 1)
stdev = math.sqrt(variance)
trades_per_year = len(returns) * MINUTES_PER_YEAR / span_minutes
return (mean / stdev) * math.sqrt(trades_per_year)

returns is one number per completed round trip. span_minutes is the run's real calendar length, first entry to last exit — that divisor is one of the three fixes, and it is the part that is now right.

The part that is not right is math.sqrt. Scaling a per-period ratio by the square root of the number of periods rests on exactly one fact: the variance of a sum of n independent variables is n times the variance of one. Write it out. Let each trade return have mean μ and variance σ². Over a year containing n trades:

E[sum]   = n·μ
Var[sum] = n·σ²                       (independent)
Sharpe   = n·μ / (σ·√n) = (μ/σ)·√n

That last line is the code. Now drop independence. The variance of a sum picks up every covariance term:

Var[sum] = n·σ² + 2·Σ_{i<j} cov(r_i, r_j)
         = n·σ²·(1 + (n−1)·ρ̄)

where ρ̄ is the average pairwise correlation among the n returns. The mean is untouched — correlation does nothing to expected return. Only the denominator moves:

Sharpe = n·μ / (σ·√(n·(1 + (n−1)·ρ̄)))
       = (μ/σ)·√( n / (1 + (n−1)·ρ̄) )
       = (μ/σ)·√(n_eff)

So the correction is entirely contained in the effective sample size:

n_eff = n / (1 + (n−1)·ρ̄)

The code multiplies by √n. The sample supports √n_eff. The ratio between them is the overstatement, and for any ρ̄ > 0 it is an overstatement rather than an understatement.

The ρ̄ that matters is not a whole-year average

Average pairwise correlation across every trade in a year is a strange object. A trade in March and a trade in September have no particular reason to move together; forcing a single ρ̄ across all of them understates the clustering and overstates the isolation at the same time.

The structure that actually occurs is blocks. A regime change fires several pairs within an hour, those positions run together and are strongly related; the next cluster arrives weeks later and has nothing to do with the first. Model that: n trades arranged in n/k independent blocks of k concurrent trades, correlation ρ within a block, zero between blocks.

Var[sum] = (n/k) · [ k·σ²·(1 + (k−1)·ρ) ]
         = n·σ²·(1 + (k−1)·ρ)

Sharpe   = (μ/σ)·√n / √(1 + (k−1)·ρ)

Same expression, with k in place of n. Which means the overstatement does not depend on how many trades the run took at all. It depends on how many positions are open together, and on how alike they are.

Our block size is a configured constant. From konis_core/backtest/portfolio.py:

def backtest(
    strategy: Strategy,
    candles_by_pair: Mapping[str, pd.DataFrame],
    params: BacktestParams | None = None,
    max_open_trades: int = 10,

Ten slots, shared across every pair in the run — the docstring above it calls max_open_trades "a portfolio constraint, not a per-pair one". So k = 10 is the worst case our own default permits.

Ten concurrent positions, tabulated

k = 10, so 1 + (k−1)·ρ = 1 + 9ρ. n_eff is per block of ten. The fourth column is √(n_eff/n) = 1/√(1+9ρ), the factor the reported figure should be multiplied by; the fifth is its reciprocal, the factor by which the reported figure exceeds what the sample supports.

ρ within the block 1 + 9ρ n_eff per 10 trades √(n_eff/n) reported ÷ supported
0.0 1.0 10.00 1.000 1.00×
0.1 1.9 5.26 0.725 1.38×
0.3 3.7 2.70 0.520 1.92×
0.5 5.5 1.82 0.426 2.35×
0.7 7.3 1.37 0.370 2.70×

These ρ values are illustrative. We do not currently estimate the realised correlation between concurrent trade returns — that needs the same machinery described in the last section — so read the table as a sensitivity, not as a measurement of anything we have run.

Two things worth pulling out of it:

At ρ = 0.3 — a mild figure for two crypto majors in the same direction on the same day — ten concurrent positions carry the information of about 2.7 independent ones, and a per-trade Sharpe computed over them is close to double what the sample supports. The correlation does not have to be dramatic for the correction to be large; the (k−1) multiplier does the work.

And the block has a ceiling. As k grows, n_eff per block tends to 1/ρ. At ρ = 0.5 that is 2: ten simultaneous positions carry the information of two, and so do a hundred. Adding slots buys breadth only to the extent the occupants disagree.

This is not a uniform haircut

A strategy that holds one position at a time has k = 1, so 1 + (k−1)·ρ = 1 for any ρ, and its per-trade Sharpe needs no correction at all. A strategy whose entry conditions fire across ten pairs on one regime change sits at the bottom row of the table.

So the bias is largest exactly where the number is most flattering, and it cannot be corrected by scaling a whole leaderboard. It reorders one. Any comparison between a one-at-a-time strategy and a fire-everything-at-once strategy on per-trade Sharpe is comparing two numbers computed under assumptions that hold for one of them.

The engine already sees the clustering, twice, and uses it for other purposes:

  • portfolio.py drops overflow signals earliest first, ties broken by pair name, and reports the count as rejected_for_capacity. A run with a high rejection count is telling you its signals arrive together. That is not ρ, but it is the observable we already emit.
  • _exposure in metrics.py merges overlapping intervals rather than summing them, on the explicit grounds that "two pairs held at once for an hour is one hour of exposure, not two".

_sharpe is the one function in the module that has the overlap information available and does not use it.

The fix we have not done

The standard answer is to stop measuring per trade. Resample the equity curve onto a fixed interval — daily buckets, say — compute mean and standard deviation over the bucket returns, and annualise by √365. Ten correlated positions moving together inside one day then land inside one bucket, their co-movement shows up as a larger bucket return, and the variance is right by construction. No ρ has to be estimated, which is the main attraction.

Two things stand between the current code and that.

The curve is not on a grid. _equity_curve emits one point per trade close:

ordered = sorted(trades, key=lambda t: (t.closed_at, t.pair))

Its x-axis is trade ordinal. The timestamps are carried, so bucketing realised P&L into days is genuinely a small change.

But the grid worth having is mark-to-market, and the engine does not produce one. A Trade carries opened_at, closed_at, entry_price, exit_price — and nothing in between. Resampling realised P&L alone would move a multi-day position's entire result into the day it closed, producing flat days punctuated by lumps. That is a different wrong answer, not a smaller one, and we cannot sign the direction of its error in advance, which makes it worse than the per-trade version we can at least bound.

A true mark-to-market series needs the value of every open position on every bar, and the engine is fast precisely because it never visits every bar. From engine.py: "the walk skips straight from one entry candidate to the next rather than visiting every bar, so a year of 5m data costs roughly the number of trades, not the number of bars." Producing a bar-by-bar equity series means either giving that up or adding a second pass that reprices the open position ledger against the candle frames it already holds.

That is a real piece of work with a real cost, which is why the docstring says "worth doing, not done here" instead of quietly shipping the half version.

What we say until then

  • span_minutes and exposure are fields on Metrics and ship beside the Sharpe, so the annualisation can be recomputed rather than trusted.
  • rejected_for_capacity ships, and it is the clustering indicator we currently have.
  • The per-trade Sharpe we report is an upper bound for any strategy that holds more than one position at a time and whose concurrent positions move together. Negative ρ̄ would push the other way; across crypto majors under a shared regime, that is the exception rather than the case to plan for.

The correction, if you want to apply one to a number of ours: divide by √(1 + (k−1)·ρ), with k your typical concurrency and ρ whatever within-cluster correlation you are willing to defend. The table above is that formula at k = 10.

KONIS is quantitative trading infrastructure. Backtest declarative strategies against years of exchange data, run what survives on a managed runtime, and read the market through analytics while it runs.

Build and backtest free — no card

Technical software and market analytics, not financial, investment or trading advice. Nothing here is a recommendation to buy or sell anything. Backtested results are historical and do not indicate future results. Trading carries substantial risk, including total loss of capital.