Real value-anomaly replication
Source notebook: examples/value_anomaly_replication.ipynb in the repository examples directory.
Run a public, real-market value diagnostic end to end: download the Kenneth R. French Data Library's 25 portfolios formed on Size and Book-to-Market, turn their published sort labels into predeclared factor scores, evaluate monthly information coefficients (ICs), control the two-factor search with Benjamini-Hochberg-Yekutieli (BHY), and chart the per-period IC paths. The fixed July 1963 through December 1990 window matches the study period reported by Fama and French (1992).
This is a portfolio-level reproduction of the monotone value and size relations, not an independent security-level reconstruction of Fama and French (1992). The source already contains returns for portfolios formed by the data provider; it does not expose the underlying firm observations. That limitation is part of the conclusion, not hidden preprocessing.
All code cells carry the Jupyter tag illustrative. They remain executable in the notebook, while documentation CI syntax-compiles rather than executes them because the workflow needs network access and the final chart needs caller-installed Plotly.
1. Download and fingerprint the source snapshot¶
The Data Library links the CSV archive; its construction notes say the 25 portfolios intersect five size groups with five book-to-market groups. The library also warns that it reconstructs full histories when updated, so historical returns can change after CRSP revisions.
The recipe therefore fixes the research window but does not pretend the remote bytes are immutable. It prints the archive SHA-256 and the source's creation line on every run. Save those two values with the result artifact if exact reruns matter. No copyrighted source rows are committed to this repository.
from __future__ import annotations
import csv
import hashlib
import io
import urllib.request
import zipfile
import factrix as fx
import polars as pl
from factrix.metrics import ic
from factrix.metrics.ic import compute_ic
DATA_URL = (
"https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/"
"ftp/25_Portfolios_5x5_CSV.zip"
)
SOURCE_MEMBER = "25_Portfolios_5x5.csv"
START_MONTH = 196307
END_MONTH = 199012
with urllib.request.urlopen(DATA_URL, timeout=30) as response:
payload = response.read()
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
members = [name for name in archive.namelist() if not name.endswith("/")]
if members != [SOURCE_MEMBER]:
raise RuntimeError(f"Unexpected archive members: {members!r}")
source_text = archive.read(SOURCE_MEMBER).decode("utf-8-sig")
print("source_note:", source_text.splitlines()[0])
print("archive_sha256:", hashlib.sha256(payload).hexdigest())
2. Convert the published return matrix into a factor panel¶
Use only the first table, Average Value Weighted Returns -- Monthly. The file is percentage returns in a wide 5 × 5 grid ordered by size rank first and book-to-market rank second. The parser validates that shape before assigning scores; a changed upstream layout fails loudly instead of silently relabelling hypotheses.
value_score rises from low to high book-to-market. small_size_score reverses the size rank so its positive direction means smaller portfolios, matching the sign of the classic size claim. The date is the first day of the realised return month. Portfolio membership was fixed at the preceding June formation and is therefore known before each included monthly return.
lines = source_text.splitlines()
section_label = "Average Value Weighted Returns -- Monthly"
try:
section_start = next(
index for index, line in enumerate(lines) if line.strip() == section_label
)
except StopIteration as exc:
raise RuntimeError(f"Missing source section: {section_label}") from exc
header = next(csv.reader([lines[section_start + 1]]))
portfolio_names = [name.strip() for name in header[1:]]
expected_names = [
"SMALL LoBM",
"ME1 BM2",
"ME1 BM3",
"ME1 BM4",
"SMALL HiBM",
*[f"ME{size} BM{value}" for size in range(2, 5) for value in range(1, 6)],
"BIG LoBM",
"ME5 BM2",
"ME5 BM3",
"ME5 BM4",
"BIG HiBM",
]
if header[0].strip() or portfolio_names != expected_names:
raise RuntimeError(f"Unexpected 5 x 5 portfolio header: {header!r}")
records: list[list[int | float]] = []
for line in lines[section_start + 2 :]:
fields = next(csv.reader([line]))
month_text = fields[0].strip() if fields else ""
if not month_text:
break
if len(month_text) != 6 or not month_text.isdecimal():
raise RuntimeError(f"Unexpected monthly row: {line!r}")
if len(fields) != 26:
raise RuntimeError(f"Expected 26 monthly fields, got {len(fields)}")
records.append([int(month_text), *(float(value.strip()) for value in fields[1:])])
raw_wide = pl.DataFrame(
records, schema=["month", *portfolio_names], orient="row", strict=False
).with_columns(pl.col("month").cast(pl.Int32))
portfolio_grid = pl.DataFrame(
{
"asset_id": portfolio_names,
"size_rank": [index // 5 + 1 for index in range(25)],
"bm_rank": [index % 5 + 1 for index in range(25)],
}
)
panel = (
raw_wide.filter(pl.col("month").is_between(START_MONTH, END_MONTH, closed="both"))
.unpivot(
on=portfolio_names,
index="month",
variable_name="asset_id",
value_name="return_pct",
)
.join(portfolio_grid, on="asset_id", how="left", validate="m:1")
.filter(pl.col("return_pct").is_finite(), pl.col("return_pct") > -90.0)
.with_columns(
pl.concat_str(pl.col("month").cast(pl.Utf8), pl.lit("01"))
.str.strptime(pl.Date, "%Y%m%d", strict=True)
.alias("date"),
(pl.col("return_pct") / 100.0).alias("forward_return"),
pl.col("bm_rank").cast(pl.Float64).alias("value_score"),
(6 - pl.col("size_rank")).cast(pl.Float64).alias("small_size_score"),
)
.select(
"date",
"asset_id",
"value_score",
"small_size_score",
"forward_return",
)
.sort("date", "asset_id")
)
counts = panel.group_by("date").len()
if counts["len"].min() != 25 or counts["len"].max() != 25:
raise RuntimeError("Expected all 25 portfolios in every selected month")
print(
panel.select(
pl.min("date").alias("first_date"),
pl.max("date").alias("last_date"),
pl.len().alias("rows"),
)
)
3. Evaluate the two predeclared hypotheses¶
The source already supplies one-month holding-period returns, so this panel does not call compute_forward_return. Passing forward_periods=1 declares that self-attached horizon; overlap_periods=1 declares non-overlapping monthly outcomes. Both metrics and factor_cols are keyword-only.
Five repeated sort scores across 25 portfolios make heavy ties intentional. Spearman IC handles ties, but its attainable magnitude differs from a continuous characteristic, so the recipe declares high_tie_ratio as expected and keeps it in the audit trail.
factor_cols = ["value_score", "small_size_score"]
results = fx.evaluate(
panel,
metrics={"ic": ic(inference=fx.inference.NEWEY_WEST)},
factor_cols=factor_cols,
forward_periods=1,
overlap_periods=1,
expected_warnings=("high_tie_ratio",),
)
if list(results) != factor_cols:
raise RuntimeError("evaluate() did not preserve factor_cols order")
summary = pl.DataFrame(
[
{
"factor": factor,
"forward_periods": result.forward_periods,
"mean_ic": result.metrics["ic"].value,
"p_value": result.metrics["ic"].p_value,
"n_obs": result.metrics["ic"].n_obs,
"n_obs_axis": result.metrics["ic"].n_obs_axis,
}
for factor, result in results.items()
]
)
print(summary)
for factor, result in results.items():
print(f"{factor} unexpected warnings: {result.unexpected_warnings}")
4. Control the planned search with BHY¶
The family contains exactly the two axes chosen before looking at their p-values. q=0.05 is the nominal false discovery rate target supplied to BHY; it is not a measured result. adj_p_all (and the adj_p column from to_frame) contains the adjusted values, including candidates that did not survive.
screen = fx.multi_factor.bhy(list(results.values()), metrics=["ic"], q=0.05)["ic"]
audit = summary.join(screen.to_frame(), on=["factor", "forward_periods"], how="left")
print(audit)
print("survivors:", [result.factor for result in screen.survivors])
print("survivor adj_p:", screen.adj_p)
print("all adj_p:", screen.adj_p_all)
5. Chart the per-period IC paths¶
Use the producer frame from the IC plotting recipe. The chart is diagnostic: an IC is a cross-sectional rank correlation, so do not compound or relabel its cumulative sum as a strategy return. Plotly 6+ is caller-installed and not a factrix dependency.
import plotly.express as px
ic_by_factor = compute_ic(panel, factor_cols=tuple(factor_cols))
ic_path = pl.concat(
[
frame.select("date", "ic").with_columns(pl.lit(factor).alias("factor"))
for factor, frame in ic_by_factor.items()
]
)
fig = px.line(
ic_path,
x="date",
y="ic",
color="factor",
title="Monthly IC on 25 Size x Book-to-Market portfolios",
)
fig.add_hline(y=0.0, line_dash="dot")
fig.show()
6. Interpret the result against the claim¶
Fama and French (1992) document positive book-to-market and negative size relations in the cross-section of expected returns. This recipe orients both scores so the literature-compatible direction is a positive mean IC. Read the value row in audit using this predeclared rule:
| Observed value row | Conclusion for this sample |
|---|---|
mean_ic > 0 and survived == True |
The portfolio-level sample is directionally compatible with the value claim after controlling this two-hypothesis family. |
mean_ic > 0 and survived == False |
Direction is compatible, but the value candidate does not clear the planned FDR screen. |
mean_ic <= 0 |
This sample does not reproduce the claimed direction, regardless of p-value. |
Do not promote the first row to an independent firm-level replication or a causal explanation. These are provider-constructed portfolio returns, the same five discrete scores repeat every month, and the remote history can be revised. The per-period path, unexpected_warnings, source note, and archive digest belong beside the scalar conclusion. See the Fama and French (1992) bibliography entry for the literature claim.
Reproducibility checklist¶
- Record
DATA_URL,source_note,archive_sha256,START_MONTH, andEND_MONTH. - Keep the declared factor family and score directions unchanged after inspecting results.
- Compare digests before comparing exact numbers across reruns; a changed digest means a changed source snapshot.
- Retain all warnings.
high_tie_ratiois expected by design; any other warning still needs interpretation. - For an independent anomaly replication, replace the published portfolio matrix with licensed security-level returns and point-in-time characteristics, then keep the same
evaluate-> BHY -> IC-path readout.