1. Summary
In the default backtest configuration, TopkDropoutStrategy allocates cash equally across
holdings while the reported excess return subtracts a cap-weighted index (SH000300) as a plain
difference. Over 2017-01-03..2020-07-31 that weighting mismatch alone is worth -4.13% per year
(information ratio -0.723) with no alpha involved, measured by simply holding the point-in-time
CSI300 constituents equally weighted. Because equal weighting underperformed the cap-weighted
index in this window, the offset makes reported model excess returns lower than a
weighting-consistent comparison would show, so this is a measurement-convention question rather
than a result that flatters Qlib.
2. Evidence in code
Repository commit 79633dd9506ea689e5400dea0197717b5b3d74b7 (2026-07-23).
-
Portfolio is equal-weighted by cash. qlib/contrib/strategy/signal_strategy.py:266
value = cash * self.risk_degree / len(buy) if len(buy) > 0 else 0
-
Excess return is a plain difference against the benchmark series.
qlib/workflow/record_temp.py:507-512
analysis["excess_return_without_cost"] = risk_analysis(
report_normal["return"] - report_normal["bench"], freq=_analysis_freq
)
analysis["excess_return_with_cost"] = risk_analysis(
report_normal["return"] - report_normal["bench"] - report_normal["cost"], freq=_analysis_freq
)
-
The shipped example configs set a cap-weighted index as that benchmark.
examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml:5 (benchmark: &benchmark SH000300)
and line 23 inside backtest:. The same default appears in qlib/workflow/record_temp.py:410.
-
An equal-weight benchmark is anticipated but not implemented.
qlib/backtest/profit_attribution.py:238
:param bench: The benchmark for comparing. TODO: if no benchmark is set, the equal-weighted is used.
I searched the GitHub issue tracker via the search API for existing discussion of benchmark
weighting and did not find a matching issue; apologies if I missed one.
3. Measurement
3a. Model-independent control
To isolate the weighting convention from any alpha, I held the point-in-time CSI300 constituents
equally weighted and compared them with SH000300 over the same window the example configs
backtest.
- Window: 2017-01-03 .. 2020-07-31, 871 trading days
- Universe:
D.instruments(market="csi300"), so Qlib's own membership spans apply
- Constituents with a usable return per day: median 291, max 300
- Statistics from Qlib's own
qlib.contrib.evaluate.risk_analysis
| Series |
Daily mean |
Daily std |
Annualized return |
Information ratio |
Max drawdown |
| Equal-weight CSI300 |
0.000355 |
0.012662 |
+8.44% |
0.432 |
-39.96% |
SH000300 (cap-weighted) |
0.000467 |
0.012298 |
+11.10% |
0.585 |
-37.05% |
| Equal-weight minus cap-weighted |
-0.000174 |
0.003708 |
-4.13% |
-0.723 |
-19.50% |
No model is involved in this table.
3b. Same effect on an actual model run
I then ran examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml (single seed,
seed=0) and re-measured the identical daily return series against both benchmarks. As a sanity
check, my recomputed SH000300 daily return matched the bench column Qlib produced to
max absolute difference 0.00e+00.
| Metric |
vs SH000300 (cap-weighted) |
vs equal-weight CSI300 |
Difference |
| Annualized excess, without cost |
+17.33% |
+22.51% |
+5.18 pt |
| Annualized excess, with cost |
+12.80% |
+17.96% |
+5.16 pt |
| Information ratio, without cost |
2.005 |
3.347 |
+1.342 |
| Information ratio, with cost |
1.481 |
2.670 |
+1.189 |
| Daily std of excess |
0.005605 |
0.004360 |
-22.2% |
| Max drawdown, with cost |
-9.94% |
-4.25% |
— |
Single-seed IC figures for that run: IC 0.0475, ICIR 0.3892, Rank IC 0.0506, Rank ICIR 0.4213.
examples/benchmarks/README.md:45 reports IC 0.0448±0.00, ICIR 0.3660±0.00, Rank IC 0.0469±0.00,
Rank ICIR 0.3877±0.00, annualized return 0.0901±0.00, IR 1.0164±0.00, max drawdown -0.1038±0.00
over 20 seeds. My IC-family numbers are close but not inside the quoted ±0.00 bands, and my
backtest annualized return (0.1280 with cost) differs from the README's 0.0901. I did not chase
that gap down; it may come from the seed count, the dataset snapshot, or environment differences,
and it does not affect the comparison in the table above, which uses one return series measured
two ways.
4. Implication
For any window in which equal weighting and cap weighting diverge, excess_return_with_cost and
excess_return_without_cost carry a systematic component that reflects the weighting convention
rather than the model. In this window that component is about -4.13% per year, i.e. reported
excess returns for equal-weighted strategies are understated by roughly that amount. The sign is
window-dependent: in a period where equal weighting outperforms, the same mismatch would overstate
them instead.
5. Reproduction
Dataset: v2/qlib_data_cn_1d_latest.zip, 196.5 MB
python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --region cn
import qlib
from qlib.constant import REG_CN
from qlib.data import D
from qlib.contrib.evaluate import risk_analysis
qlib.init(provider_uri="~/.qlib/qlib_data/cn_data", region=REG_CN)
START, END = "2017-01-01", "2020-08-01"
px = D.features(D.instruments("csi300"), ["$close"],
start_time=START, end_time=END, freq="day")["$close"].unstack("instrument")
# NOTE: pandas defaults pct_change to fill_method="pad", which turns a suspended name into a
# 0% day and drags the cross-sectional mean toward zero. With the default the median number
# of constituents contributing a return per day is 376; with fill_method=None it is 291.
ew = px.pct_change(fill_method=None).mean(axis=1, skipna=True)
bm = D.features(["SH000300"], ["$close"], start_time=START, end_time=END,
freq="day")["$close"].unstack("instrument")["SH000300"].pct_change(fill_method=None)
idx = ew.index.intersection(bm.index)
print(risk_analysis(ew.reindex(idx).dropna()))
print(risk_analysis(bm.reindex(idx).dropna()))
print(risk_analysis((ew.reindex(idx) - bm.reindex(idx)).dropna()))
Environment: Windows 11, Python 3.12.8 in a clean venv, pyqlib 0.9.7 from PyPI,
numpy 1.26.4, pandas 2.1.4, lightgbm 4.7.0.
Two environment notes that cost me a couple of runs and may help others, neither of which is a
Qlib defect:
- On Windows, a script that builds an
Alpha158 dataset must sit behind
if __name__ == "__main__":, otherwise the joblib workers re-import the module and re-enter
dataset construction.
- Recent MLflow releases refuse the filesystem tracking backend by default; setting
MLFLOW_ALLOW_FILE_STORE=true restores the current recorder behaviour.
6. Suggested fix
Either of these would resolve the ambiguity; the second is much cheaper:
- Implement the equal-weight benchmark already sketched at
qlib/backtest/profit_attribution.py:238, and let benchmark in the backtest config accept
something like "equal_weight" in addition to an instrument code, so the benchmark weighting
can be matched to the strategy's weighting.
- Document the convention where
excess_return_* is defined (record_temp.py, and the
report/benchmark pages in docs/), stating that the default benchmark is cap-weighted while
TopkDropoutStrategy is equal-weighted, and that the resulting excess return therefore
contains a weighting component. A one-line note next to the examples/benchmarks/README.md
results table would reach the most readers.
I am happy to open a PR for either if the direction is agreeable.
7. What I checked and found sound
I looked at these while investigating and want to record that they held up, so this issue is not
read as a broader criticism:
- Point-in-time index membership is real in the shipped data.
instruments/csi300.txt has 820 rows for 690 unique symbols, 109 symbols carry more than one
membership span, and the maximum for a single symbol is 5 (SH601991: 2005-01-01..2010-12-31,
2012-01-04..2012-12-31, 2013-07-01..2014-12-12, 2015-06-15..2016-12-09, 2017-12-11..2019-06-14).
520 of 820 rows (63.4%) end before the dataset's last calendar day (2020-09-25). Of the 441
symbols whose CSI300 membership ended early, 434 still have a directory under features/
(3,875 symbol directories in total). The spans are enforced at qlib/data/data.py:624-628, and
scripts/data_collector/index.py:204-238 reconstructs them from index change history.
Separately, scripts/data_collector/crowd_source/README.md:4 already documents that the
Yahoo-sourced data itself can miss delisted names.
- No look-ahead in the signal-to-execution path. The signal is read from the previous step at
qlib/contrib/strategy/signal_strategy.py:140-143 (get_step_time(trade_step, shift=1)), and
the Alpha158/Alpha360 label Ref($close, -2)/Ref($close, -1) - 1
(qlib/contrib/data/handler.py:90,152) spans T+1 close to T+2 close, consistent with execution
at the T+1 close. Normalizers fit only on the training window
(qlib/data/dataset/processor.py:197-205), with an explicit in-code warning that
fit_end_time must not include test information.
- Transaction costs are on by default and included in the published table. Defaults are
open_cost=0.0015, close_cost=0.0025, min_cost=5.0
(qlib/backtest/exchange.py:48-50), and examples/run_all_model.py:155-160,178-180 populates
the README table from 1day.excess_return_with_cost.*.
8. Limitations of this report
- Measured on CSI300 over 2017-01-03..2020-07-31 only. I did not test the US region, other index
universes, or other windows, and the sign and size of the offset are window-dependent.
- The model run is a single seed, and as noted in 3b it does not match the README's 20-seed
backtest figures; I did not investigate that difference.
- The equal-weight benchmark I built rebalances daily and ignores transaction costs on the
benchmark side, which is the same convention the cap-weighted index comparison uses but is
worth stating.
- I did not test whether an equal-weight benchmark would change the ranking of the models in
examples/benchmarks/README.md, only the level of a single model's reported excess return.
1. Summary
In the default backtest configuration,
TopkDropoutStrategyallocates cash equally acrossholdings while the reported excess return subtracts a cap-weighted index (
SH000300) as a plaindifference. Over 2017-01-03..2020-07-31 that weighting mismatch alone is worth -4.13% per year
(information ratio -0.723) with no alpha involved, measured by simply holding the point-in-time
CSI300 constituents equally weighted. Because equal weighting underperformed the cap-weighted
index in this window, the offset makes reported model excess returns lower than a
weighting-consistent comparison would show, so this is a measurement-convention question rather
than a result that flatters Qlib.
2. Evidence in code
Repository commit
79633dd9506ea689e5400dea0197717b5b3d74b7(2026-07-23).Portfolio is equal-weighted by cash.
qlib/contrib/strategy/signal_strategy.py:266Excess return is a plain difference against the benchmark series.
qlib/workflow/record_temp.py:507-512The shipped example configs set a cap-weighted index as that benchmark.
examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml:5(benchmark: &benchmark SH000300)and line 23 inside
backtest:. The same default appears inqlib/workflow/record_temp.py:410.An equal-weight benchmark is anticipated but not implemented.
qlib/backtest/profit_attribution.py:238I searched the GitHub issue tracker via the search API for existing discussion of benchmark
weighting and did not find a matching issue; apologies if I missed one.
3. Measurement
3a. Model-independent control
To isolate the weighting convention from any alpha, I held the point-in-time CSI300 constituents
equally weighted and compared them with
SH000300over the same window the example configsbacktest.
D.instruments(market="csi300"), so Qlib's own membership spans applyqlib.contrib.evaluate.risk_analysisSH000300(cap-weighted)No model is involved in this table.
3b. Same effect on an actual model run
I then ran
examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml(single seed,seed=0) and re-measured the identical daily return series against both benchmarks. As a sanitycheck, my recomputed
SH000300daily return matched thebenchcolumn Qlib produced tomax absolute difference 0.00e+00.
SH000300(cap-weighted)Single-seed IC figures for that run: IC 0.0475, ICIR 0.3892, Rank IC 0.0506, Rank ICIR 0.4213.
examples/benchmarks/README.md:45reports IC 0.0448±0.00, ICIR 0.3660±0.00, Rank IC 0.0469±0.00,Rank ICIR 0.3877±0.00, annualized return 0.0901±0.00, IR 1.0164±0.00, max drawdown -0.1038±0.00
over 20 seeds. My IC-family numbers are close but not inside the quoted ±0.00 bands, and my
backtest annualized return (0.1280 with cost) differs from the README's 0.0901. I did not chase
that gap down; it may come from the seed count, the dataset snapshot, or environment differences,
and it does not affect the comparison in the table above, which uses one return series measured
two ways.
4. Implication
For any window in which equal weighting and cap weighting diverge,
excess_return_with_costandexcess_return_without_costcarry a systematic component that reflects the weighting conventionrather than the model. In this window that component is about -4.13% per year, i.e. reported
excess returns for equal-weighted strategies are understated by roughly that amount. The sign is
window-dependent: in a period where equal weighting outperforms, the same mismatch would overstate
them instead.
5. Reproduction
Dataset: v2/qlib_data_cn_1d_latest.zip, 196.5 MB
Environment: Windows 11, Python 3.12.8 in a clean venv,
pyqlib0.9.7 from PyPI,numpy 1.26.4, pandas 2.1.4, lightgbm 4.7.0.
Two environment notes that cost me a couple of runs and may help others, neither of which is a
Qlib defect:
Alpha158dataset must sit behindif __name__ == "__main__":, otherwise the joblib workers re-import the module and re-enterdataset construction.
MLFLOW_ALLOW_FILE_STORE=truerestores the current recorder behaviour.6. Suggested fix
Either of these would resolve the ambiguity; the second is much cheaper:
qlib/backtest/profit_attribution.py:238, and letbenchmarkin the backtest config acceptsomething like
"equal_weight"in addition to an instrument code, so the benchmark weightingcan be matched to the strategy's weighting.
excess_return_*is defined (record_temp.py, and thereport/benchmark pages in
docs/), stating that the default benchmark is cap-weighted whileTopkDropoutStrategyis equal-weighted, and that the resulting excess return thereforecontains a weighting component. A one-line note next to the
examples/benchmarks/README.mdresults table would reach the most readers.
I am happy to open a PR for either if the direction is agreeable.
7. What I checked and found sound
I looked at these while investigating and want to record that they held up, so this issue is not
read as a broader criticism:
instruments/csi300.txthas 820 rows for 690 unique symbols, 109 symbols carry more than onemembership span, and the maximum for a single symbol is 5 (
SH601991: 2005-01-01..2010-12-31,2012-01-04..2012-12-31, 2013-07-01..2014-12-12, 2015-06-15..2016-12-09, 2017-12-11..2019-06-14).
520 of 820 rows (63.4%) end before the dataset's last calendar day (2020-09-25). Of the 441
symbols whose CSI300 membership ended early, 434 still have a directory under
features/(3,875 symbol directories in total). The spans are enforced at
qlib/data/data.py:624-628, andscripts/data_collector/index.py:204-238reconstructs them from index change history.Separately,
scripts/data_collector/crowd_source/README.md:4already documents that theYahoo-sourced data itself can miss delisted names.
qlib/contrib/strategy/signal_strategy.py:140-143(get_step_time(trade_step, shift=1)), andthe Alpha158/Alpha360 label
Ref($close, -2)/Ref($close, -1) - 1(
qlib/contrib/data/handler.py:90,152) spans T+1 close to T+2 close, consistent with executionat the T+1 close. Normalizers fit only on the training window
(
qlib/data/dataset/processor.py:197-205), with an explicit in-code warning thatfit_end_timemust not include test information.open_cost=0.0015,close_cost=0.0025,min_cost=5.0(
qlib/backtest/exchange.py:48-50), andexamples/run_all_model.py:155-160,178-180populatesthe README table from
1day.excess_return_with_cost.*.8. Limitations of this report
universes, or other windows, and the sign and size of the offset are window-dependent.
backtest figures; I did not investigate that difference.
benchmark side, which is the same convention the cap-weighted index comparison uses but is
worth stating.
examples/benchmarks/README.md, only the level of a single model's reported excess return.