Use next-open returns for research eval
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
# Tutorial: Testing a 5-Day Reversal Alpha
|
||||
|
||||
This document is a teaching walkthrough for someone who is new to this
|
||||
research framework and only lightly familiar with quant research. We will use
|
||||
one concrete experiment, a 5-day reversal alpha on the full downloaded Chinese
|
||||
Generated: 2026-06-12T18:30:56
|
||||
|
||||
This document is a teaching walkthrough for someone who is new to this research
|
||||
framework and only lightly familiar with quant research. We will use one
|
||||
concrete experiment, a 5-day reversal alpha on the full downloaded Chinese
|
||||
A-share universe, to learn how the framework defines an alpha, stores it, tests
|
||||
it, turns it into a portfolio, and explains the gap between a research result
|
||||
and simulated trading PnL.
|
||||
|
||||
The original experiment was generated on 2026-06-11. The important point is not
|
||||
the timestamp; it is the research method.
|
||||
The important point is not the timestamp; it is the research method.
|
||||
|
||||
## The Research Question
|
||||
|
||||
@@ -29,10 +30,13 @@ The central research question is:
|
||||
|
||||
The answer from this run is nuanced:
|
||||
|
||||
- The naive built-in version loses badly on the full universe because raw
|
||||
z-score weighting is too sensitive to A-share outliers.
|
||||
- The naive built-in version is positive under the tradable
|
||||
next-open-to-next-open research convention (**41.40%**),
|
||||
but its stored weights still show that raw z-score weighting is too sensitive
|
||||
to A-share outliers.
|
||||
- A rank-weighted version on a liquid, non-ST, tradable universe has a positive
|
||||
costless research result.
|
||||
costless research result: **209.58%**
|
||||
at Sharpe **1.44**.
|
||||
- The daily-traded implementation is still not tradable after costs because
|
||||
turnover is too high.
|
||||
|
||||
@@ -90,11 +94,11 @@ For this experiment, the important phases are:
|
||||
| --- | --- | --- |
|
||||
| Data | `cli.py data download` | What market data is available. |
|
||||
| Alpha compute | `cli.py alpha compute` | How a raw research idea becomes stored weights. |
|
||||
| Alpha eval | `cli.py alpha eval` | How those weights perform in a clean costless research view. |
|
||||
| Alpha eval | `cli.py alpha eval` | How close-formed weights perform over the tradable next-open-to-next-open interval. |
|
||||
| Combo | `cli.py combo combine` | How one or more alphas become one combined book. |
|
||||
| Portfolio build | `cli.py portfolio build` | How weights become target values and integer shares. |
|
||||
| Portfolio simulate | `cli.py portfolio simulate` | How the integer book trades at next open with constraints and costs. |
|
||||
| Portfolio eval | `cli.py portfolio eval` | How the continuous target portfolio behaves as a research portfolio. |
|
||||
| Portfolio eval | `cli.py portfolio eval` | How the continuous target portfolio behaves over the same costless open-to-open research interval. |
|
||||
|
||||
In a real research workflow, you should learn to pause after every phase and
|
||||
inspect the parquet output. Most mistakes are easier to find at the interface
|
||||
@@ -123,25 +127,9 @@ So:
|
||||
|
||||
Notice the timing. The signal uses prices through date `t`. It must not use the
|
||||
return from `t` to `t+1`, because that is the future. The costless alpha
|
||||
evaluator tests the weight formed on date `t` against the next close-to-close
|
||||
return; the later execution simulator is the separate layer that trades the
|
||||
constructed integer book at the next open.
|
||||
|
||||
The code lives in `pipeline/alpha/library/reversal.py`:
|
||||
|
||||
```python
|
||||
class ReversalAlpha(BaseAlpha):
|
||||
name = "reversal"
|
||||
|
||||
def __init__(self, lookback: int = 5):
|
||||
self.lookback = lookback
|
||||
|
||||
def signal(self, close: pd.DataFrame) -> pd.DataFrame:
|
||||
return -close.pct_change(self.lookback, fill_method=None)
|
||||
```
|
||||
|
||||
The alpha class only defines the raw signal. The base class then turns that
|
||||
signal into weights.
|
||||
evaluator tests the weight formed on date `t` over the tradable interval from
|
||||
`open[t+1]` to `open[t+2]`; the later execution simulator is the separate layer
|
||||
that trades the constructed integer book at the next open.
|
||||
|
||||
## Step 2: Turn A Signal Into Cross-Sectional Weights
|
||||
|
||||
@@ -161,17 +149,19 @@ returns because they are newly listed, suspended, illiquid, or limit-constrained
|
||||
z-scoring can put a very large amount of relative exposure into exactly those
|
||||
names.
|
||||
|
||||
That is what happened in the naive full-universe run. Stored weights reached
|
||||
about `-52` standard deviations. The research result collapsed:
|
||||
That is visible in the naive full-universe run. Stored weights reached about
|
||||
`-52` standard deviations. The
|
||||
result is positive under the open-to-open convention, but it is much weaker and
|
||||
less robust than the rank-weighted versions:
|
||||
|
||||
| run | weighting | research cumulative return | research Sharpe | research turnover/year |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| naive z-score, full universe | z-score | -87.45% | -2.4515 | 160x |
|
||||
| naive z-score, full universe | z-score | 41.40% | 0.4514 | 160x |
|
||||
|
||||
The lesson is not "reversal is bad." The lesson is:
|
||||
The lesson is not "reversal is solved." The lesson is:
|
||||
|
||||
> The same raw signal can become a bad portfolio if the weighting method reacts
|
||||
> badly to outliers.
|
||||
> The same raw signal can become a fragile portfolio if the weighting method
|
||||
> reacts badly to outliers.
|
||||
|
||||
## Step 3: Make The Weighting More Robust
|
||||
|
||||
@@ -186,15 +176,15 @@ weights = ranks.subtract(ranks.mean(axis=1), axis=0)
|
||||
|
||||
Rank weighting keeps the ordering of stocks but removes the importance of the
|
||||
exact outlier magnitude. A stock can be "the worst recent loser" or "the best
|
||||
recent winner," but it cannot become 52 standard deviations important just
|
||||
because its raw percentage move is unusual.
|
||||
recent winner," but it cannot become dozens of standard deviations important
|
||||
just because its raw percentage move is unusual.
|
||||
|
||||
The full-universe rank version was much less pathological, but still not a
|
||||
clean signal:
|
||||
|
||||
| run | weighting | research cumulative return | research Sharpe | research turnover/year |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| rank, full universe | rank | -3.48% | -0.0198 | 143x |
|
||||
| rank, full universe | rank | 73.44% | 0.8860 | 143x |
|
||||
|
||||
That tells us the weighting fix helped, but the universe still contains many
|
||||
names that are poor candidates for a daily reversal strategy.
|
||||
@@ -218,7 +208,7 @@ The liquid rank result is the cleanest research result:
|
||||
|
||||
| run | weighting | universe | research cumulative return | research Sharpe | hit rate |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| rank, liquid subset | rank | top 1000 liquid, tradable, non-ST | 72.24% | 0.7310 | 54.31% |
|
||||
| rank, liquid subset | rank | top 1000 liquid, tradable, non-ST | 209.58% | 1.4422 | 55.68% |
|
||||
|
||||
This is the first point where a researcher can say:
|
||||
|
||||
@@ -229,13 +219,6 @@ That last phrase, **before trading costs**, is essential.
|
||||
|
||||

|
||||
|
||||
When reading this chart, focus on the shape and relative behavior:
|
||||
|
||||
- The naive z-score line shows the outlier problem.
|
||||
- The rank full-universe line shows that robust weighting helps but does not
|
||||
fully solve the universe problem.
|
||||
- The liquid rank line shows the signal-level edge before execution costs.
|
||||
|
||||
## Step 5: Check That The Alpha File Is Sane
|
||||
|
||||
Before trusting any metric, inspect the stored alpha artifact. The run checked:
|
||||
@@ -247,7 +230,7 @@ Before trusting any metric, inspect the stored alpha artifact. The run checked:
|
||||
- The daily cross-sectional mean is approximately zero.
|
||||
- A one-alpha combo is an exact identity transform.
|
||||
|
||||
| run | schema ok | null weights | non-finite weights | duplicate keys | max abs daily mean | weight range | combo identity diff |
|
||||
| run | schema ok | null w | non-finite w | dup keys | max \|daily mean\| | weight range | combo identity Δ |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| naive z-score (full) | True | 0 | 0 | 0 | 3.32e-16 | [-52.2, 19.2] | 0.00e+00 |
|
||||
| rank (full) | True | 0 | 0 | 0 | 0.00e+00 | [-2603.0, 2603.0] | 0.00e+00 |
|
||||
@@ -261,51 +244,42 @@ raw size of an abnormal stock move.
|
||||
|
||||

|
||||
|
||||
This is a good habit: when a backtest looks strange, plot the weights before
|
||||
debugging the PnL. A broken or concentrated weight distribution often explains
|
||||
the result.
|
||||
|
||||
## Step 6: Understand The Alpha Evaluation Formula
|
||||
|
||||
The costless alpha evaluator asks:
|
||||
The costless alpha evaluator now asks:
|
||||
|
||||
> If we held the alpha weights from date `t`, what close-to-close return would
|
||||
> we earn from `t` to `t+1`?
|
||||
> If we compute alpha weights after close on date `t`, trade them at `open[t+1]`,
|
||||
> and hold them until `open[t+2]`, what return would we earn before costs?
|
||||
|
||||
This is intentionally a **research-layer approximation**, not the trading
|
||||
simulator. At this stage the framework has only an alpha weight file. It has not
|
||||
yet rounded shares, checked limits, clipped trades, or paid costs. The purpose
|
||||
is to answer a clean signal question: "Do these close-formed weights line up
|
||||
with the next day's returns?"
|
||||
|
||||
The actual trading layer comes later. `portfolio simulate` takes the integer
|
||||
`position_shares` from the portfolio builder, executes the target from signal
|
||||
date `t` at `open[t+1]`, then marks PnL as overnight movement on the old book
|
||||
plus intraday movement on the newly filled book, minus trading cost.
|
||||
This is still a **research-layer approximation**, not the trading simulator. At
|
||||
this stage the framework has only an alpha weight file. It has not yet rounded
|
||||
shares, checked limits, clipped trades, or paid costs. The purpose is to answer
|
||||
a clean signal question: "Do these close-formed weights line up with returns
|
||||
over the interval we could actually own after next-open execution?"
|
||||
|
||||
The daily research return is:
|
||||
|
||||
```text
|
||||
R[t] = sum_i(weight[i, t] * return[i, t+1]) / sum_i(abs(weight[i, t]))
|
||||
R[t] = sum_i(weight[i, t] * (open[i, t+2] / open[i, t+1] - 1)) / sum_i(abs(weight[i, t]))
|
||||
```
|
||||
|
||||
This has three important consequences:
|
||||
|
||||
- The alpha is normalized by its gross exposure, so the scale of raw weights
|
||||
does not by itself create a higher return.
|
||||
- The next day's return is used, so the test avoids look-ahead.
|
||||
- The last signal date is dropped from performance metrics because there is no
|
||||
next return for it.
|
||||
- The new signal does not receive credit for the overnight gap from `close[t]`
|
||||
to `open[t+1]`, because it cannot be traded until `open[t+1]`.
|
||||
- The final two signal dates are dropped from performance metrics because they
|
||||
do not have a complete next-open-to-next-open holding interval.
|
||||
|
||||
Turnover is also measured from the weights:
|
||||
Turnover is still measured from the weights:
|
||||
|
||||
```text
|
||||
turnover[t] = sum_i(abs(weight[i, t] - weight[i, t-1])) / sum_i(abs(weight[i, t-1]))
|
||||
```
|
||||
|
||||
The annualized turnover numbers around 143x to 160x are a warning. Even a
|
||||
positive signal can be hard to monetize if it asks the portfolio to trade too
|
||||
much every day.
|
||||
The annualized turnover numbers are a warning. Even a positive signal can be
|
||||
hard to monetize if it asks the portfolio to trade too much every day.
|
||||
|
||||
## Step 7: Build A Portfolio From The Alpha
|
||||
|
||||
@@ -325,24 +299,14 @@ logic. This is where a research portfolio starts to become a tradable portfolio.
|
||||
|
||||
The continuous target portfolio matched the stored alpha almost exactly:
|
||||
|
||||
| run | target value identity max abs diff | alpha to target max abs diff | research correlation alpha vs portfolio | mean integer gross | mean L1 tracking |
|
||||
| run | target_value identity max\|Δ\| | alpha→target max\|Δ\| | research corr(alpha,portfolio) | mean integer gross | mean L1 tracking |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| naive z-score (full) | 0.0000 | 0.00e+00 | 1.000000 | 9,138,331 | 2,542,655 |
|
||||
| rank (full) | 0.0000 | 0.00e+00 | 1.000000 | 8,984,098 | 2,678,278 |
|
||||
| rank (liquid subset) | 0.0000 | 0.00e+00 | 1.000000 | 9,810,256 | 862,303 |
|
||||
|
||||
The integer book is not exact because small target positions can be rounded
|
||||
away. The liquid subset has lower tracking error because it spreads the book
|
||||
over fewer and more tradable names.
|
||||
|
||||

|
||||
|
||||
When you research a new alpha, ask two separate questions:
|
||||
|
||||
- Does the continuous target portfolio match the alpha? It should.
|
||||
- Does the integer tradable portfolio still resemble the target? It may not,
|
||||
especially for small books or very broad universes.
|
||||
|
||||
## Step 8: Simulate Execution And Costs
|
||||
|
||||
Research returns are not the same as tradable PnL. The simulator executes the
|
||||
@@ -366,17 +330,19 @@ The execution results explain the final research conclusion:
|
||||
|
||||
| run | corr(alpha, exec net) | PnL before cost | total cost | net PnL | mean daily turnover |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| naive z-score (full) | 0.9675 | 1,838,974 | 13,032,720 | -11,193,746 | 0.5711 |
|
||||
| rank (full) | 0.9613 | 5,052,067 | 11,713,451 | -6,661,383 | 0.5133 |
|
||||
| rank (liquid subset) | 0.9762 | 11,017,842 | 12,733,803 | -1,715,960 | 0.5715 |
|
||||
| naive z-score (full) | 0.8956 | 1,838,974 | 13,032,720 | -11,193,746 | 0.5711 |
|
||||
| rank (full) | 0.9126 | 5,052,067 | 11,713,451 | -6,661,383 | 0.5133 |
|
||||
| rank (liquid subset) | 0.8884 | 11,017,842 | 12,733,803 | -1,715,960 | 0.5715 |
|
||||
|
||||
The liquid rank run made about 11.0 million before cost, but paid about 12.7
|
||||
million in cost. That is why the final net PnL is negative.
|
||||
For the liquid rank run, simulated PnL before cost is about
|
||||
11,017,842, but total
|
||||
cost is about 12,733,803. That is why
|
||||
the final net PnL is weak or negative.
|
||||
|
||||
This is not a contradiction. It is exactly what a research pipeline should show:
|
||||
|
||||
> The signal exists in the costless layer, but the daily implementation trades
|
||||
> too much to keep the edge.
|
||||
> The signal can exist in the costless layer, but the daily implementation can
|
||||
> still trade too much to keep the edge.
|
||||
|
||||

|
||||
|
||||
@@ -384,22 +350,27 @@ This is not a contradiction. It is exactly what a research pipeline should show:
|
||||
|
||||
The complete summary is:
|
||||
|
||||
| run | weighting | research cumulative return | research Sharpe | research turnover/year | exec before cost | exec net | exec net Sharpe |
|
||||
| run | weighting | research cum | research Sharpe | research turn/yr | exec before cost | exec net | exec net Sharpe |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| naive z-score (full) | z-score | -87.45% | -2.4515 | 160x | 18.39% | -111.94% | -1.4508 |
|
||||
| rank (full) | rank | -3.48% | -0.0198 | 143x | 50.52% | -66.61% | -1.1839 |
|
||||
| rank (liquid subset) | rank | 72.24% | 0.7310 | 148x | 110.18% | -17.16% | -0.2226 |
|
||||
| naive z-score (full) | z-score | 41.40% | 0.4514 | 160× | 18.39% | -111.94% | -1.4508 |
|
||||
| rank (full) | rank | 73.44% | 0.8860 | 143× | 50.52% | -66.61% | -1.1839 |
|
||||
| rank (liquid subset) | rank | 209.58% | 1.4422 | 148× | 110.18% | -17.16% | -0.2226 |
|
||||
|
||||
*Research = costless, no-look-ahead weights over the next-open-to-next-open
|
||||
holding interval. Execution = next-open fills on the discretized integer book
|
||||
under suspension / price-limit / volume-cap constraints, 5 bps commission + 5
|
||||
bps slippage.*
|
||||
|
||||
Here is the interpretation:
|
||||
|
||||
- **Naive z-score full universe**: not a useful test of the reversal idea,
|
||||
because the weighting scheme lets outliers dominate the book.
|
||||
- **Naive z-score full universe**: positive under open-to-open research, but a
|
||||
less reliable test of the reversal idea because the weighting scheme lets
|
||||
outliers dominate parts of the book.
|
||||
- **Rank full universe**: a better test of the same idea, but still noisy
|
||||
because the universe includes too many problematic names.
|
||||
- **Rank liquid subset**: the best signal-level test; it finds a positive
|
||||
- **Rank liquid subset**: the best signal-level test; it finds the cleanest
|
||||
costless reversal effect.
|
||||
- **Execution net**: all variants lose after cost at daily rebalance frequency,
|
||||
so the implementation is not yet tradable.
|
||||
- **Execution net**: daily rebalancing remains heavily constrained by cost.
|
||||
|
||||
A beginner might look only at the final net PnL and say "the alpha failed." A
|
||||
researcher should be more precise:
|
||||
@@ -407,9 +378,25 @@ researcher should be more precise:
|
||||
> The raw 5-day reversal idea has signal value in a liquid universe, but the
|
||||
> current daily trading rule has too much turnover for the assumed cost model.
|
||||
|
||||
That distinction tells you what to try next.
|
||||
## Step 10: Time Consumption By Phase
|
||||
|
||||
## Step 10: Reproduce The Experiment
|
||||
| phase | rank full (s) | rank liquid (s) |
|
||||
| --- | --- | --- |
|
||||
| alpha compute | 94.1 | 107.8 |
|
||||
| alpha eval | 93.3 | 96.9 |
|
||||
| combo combine | 21.6 | 21.7 |
|
||||
| portfolio build | 537.6 | 236.7 |
|
||||
| portfolio eval | 95.1 | 88.3 |
|
||||
| portfolio simulate | 139.6 | 139.1 |
|
||||
| total | 981.3 | 690.5 |
|
||||
|
||||

|
||||
|
||||
`portfolio build` usually dominates because it iterates per signal date and
|
||||
repairs a multi-thousand-name integer book under lot rules. The liquid run is
|
||||
faster because it carries fewer non-zero names per date.
|
||||
|
||||
## Step 11: Reproduce The Experiment
|
||||
|
||||
These commands reproduce the important artifacts, assuming the full daily-bar
|
||||
dataset already exists at `data/daily_bars/all`.
|
||||
@@ -423,9 +410,7 @@ uv run python cli.py alpha compute --data-path data/daily_bars/all \
|
||||
# Rank-weighted full and liquid runs.
|
||||
bash scripts/run_reversal_rank_e2e.sh
|
||||
|
||||
# Regenerate figures, diagnostics, and the older auto-generated report.
|
||||
# This command rewrites this markdown file, so run it only when you want
|
||||
# generated output to replace the tutorial.
|
||||
# Regenerate figures, diagnostics, and this tutorial report.
|
||||
uv run python scripts/generate_reversal_5d_report.py
|
||||
```
|
||||
|
||||
@@ -452,8 +437,9 @@ Use this checklist for a new idea.
|
||||
illiquid names.
|
||||
|
||||
5. Evaluate the alpha as a portfolio, not as a prediction.
|
||||
Check cumulative return, Sharpe, drawdown, hit rate, and turnover. Do not add
|
||||
IC/IR unless the framework's alpha convention changes.
|
||||
Check cumulative return, Sharpe, drawdown, hit rate, and turnover over the
|
||||
next-open-to-next-open holding interval. Do not add IC/IR unless the
|
||||
framework's alpha convention changes.
|
||||
|
||||
6. Build the portfolio and inspect tracking.
|
||||
Confirm that target weights match the alpha, then check whether integer
|
||||
@@ -468,7 +454,7 @@ Use this checklist for a new idea.
|
||||
universe, construction, execution constraints, turnover, or cost.
|
||||
|
||||
For this 5-day reversal study, the diagnosis is clear: **the signal-level result
|
||||
is promising only after robust weighting and a liquid universe filter, but the
|
||||
is most promising after robust weighting and a liquid universe filter, but the
|
||||
current implementation needs turnover control before it can be considered
|
||||
tradable.**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user