Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions analytics/eval/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,20 @@ def payload_of(name: str) -> Mapping[str, object]:
monotonicity_spearman=_payload_number(
returns, "monotonicity_spearman", float("nan")
),
# design §6 v0.8: the GATED monotonicity. Absent (a pre-v0.8 IR) -> NaN ->
# the verdict falls back to the pooled field and DISCLOSES it.
monotonicity_spearman_by_date=_payload_number(
returns, "monotonicity_spearman_by_date", float("nan")
),
# design §6 v0.9: the per-date monotonicity's N_eff-based CI, which is what
# the DIRECTION gate reads. Absent (a pre-v0.9 IR) -> NaN -> the verdict
# reverts to the bare point (v0.8 behaviour) and DISCLOSES that too.
monotonicity_spearman_by_date_ci_low=_payload_number(
returns, "monotonicity_spearman_by_date_ci_low", float("nan")
),
monotonicity_spearman_by_date_ci_high=_payload_number(
returns, "monotonicity_spearman_by_date_ci_high", float("nan")
),
# Incremental axis facts (design §6, v0.5). A Skipped purity section (no
# book) leaves the payload empty -> known_factors_supplied defaults False
# -> the axis is NOT_ASSESSED. A supplied-but-unmeasurable orthogonalized
Expand All @@ -141,6 +155,12 @@ def payload_of(name: str) -> Mapping[str, object]:
purity, "incremental_ic_ir_ci_high", float("nan")
),
net_long_short_by_cost=by_cost,
# design §6 v0.8: needed to align a spread by hypothesis without adding the
# cost back (cost = gross - net). Absent -> NaN -> a sign=-1 aligned spread
# reads UNKNOWN rather than silently mis-signed.
gross_long_short_mean=_payload_number(
returns, "gross_long_short_mean", float("nan")
),
oos_available=bool(_payload_flag(oos, "oos_available", False)),
oos_sign_consistent=bool(_payload_flag(oos, "sign_consistent", False)),
oos_sign_flipped=bool(_payload_flag(oos, "sign_flipped", False)),
Expand Down
26 changes: 24 additions & 2 deletions analytics/eval/sections.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,31 @@
"ic_win_rate",
"ic_nw_t",
),
# monotonicity_spearman: RAW Spearman(bucket index, bucket mean return).
# monotonicity_spearman: RAW Spearman(bucket index, bucket mean return) over
# the POOLED (date-equal-weighted) bucket means — unbounded and magnitude
# sensitive, so (v0.8) it is REPORTED but no longer gates.
# monotonicity_spearman_by_date: (v0.8) the gated one — Spearman per date
# (bounded [-1, 1] before averaging, structurally parallel to the rank IC),
# then averaged. The verdict falls back to the pooled field when it is
# absent and SAYS SO in its reasons.
# monotonicity_spearman_by_date_ci_low/high: (v0.9) the N_eff-based 95% CI of
# that per-date series — what the DIRECTION gate now reads. Three-valued:
# aligned CI above the bar = direction holds; entirely below 0 = contradicted
# (FAIL); straddling 0 = UNKNOWN, which neither convicts nor acquits. The bare
# point is only the fallback, disclosed as such.
# net_long_short_by_cost: {cost multiplier: net (top - bottom) return}, RAW.
"return_risk": ("monotonicity_spearman", "net_long_short_by_cost"),
# gross_long_short_mean: (v0.8) the GROSS (pre-cost) leg difference — the
# verdict needs it to align a net spread, since aligning is
# `sign * gross - cost` and cost = gross - net cannot be recovered from the
# net alone.
"return_risk": (
"monotonicity_spearman",
"monotonicity_spearman_by_date",
"monotonicity_spearman_by_date_ci_low",
"monotonicity_spearman_by_date_ci_high",
"net_long_short_by_cost",
"gross_long_short_mean",
),
# The Incremental axis (design §6, v0.5). known_factors_supplied = was a
# known-factor BOOK supplied at all (False -> the axis is NOT_ASSESSED);
# incremental_ic_ir / incremental_ic_mean = mean(orthIC)/std(orthIC) and
Expand Down
70 changes: 65 additions & 5 deletions analytics/eval/standard.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,12 @@
half_life,
hypothesis_win_rate,
information_ratio_ci,
mean_by_date_spearman,
mean_ci,
newey_west_t,
sortino,
spearman,
spearman_series_by_date,
)
from analytics.factor import ic_summary
from analytics.performance import performance_summary
Expand Down Expand Up @@ -314,6 +316,11 @@ def return_risk(self, ir: StandardEvalIR) -> Section | Skipped:
top, bottom = ir.cfg.long_short
buckets = list(quantiles.columns)

# The PER-DATE monotonicity series, built ONCE (design §6, v0.9): the
# payload's point figure and its CI are then two reductions of the SAME
# observations, and the panel is walked once instead of twice.
mono_by_date = spearman_series_by_date(quantiles)

bucket_mean = {int(q): as_float(quantiles[q].mean()) for q in buckets}
bucket_nav = {
int(q): as_float((1.0 + quantiles[q].dropna()).prod()) for q in buckets
Expand All @@ -329,14 +336,17 @@ def return_risk(self, ir: StandardEvalIR) -> Section | Skipped:
net_by_cost: dict[float, float] = {}
cumulative_by_cost: dict[float, float] = {}
base_net = None
base_cost = None
for multiplier in ir.cfg.cost_scenarios:
net = gross - ir.ctx.fee_rate * multiplier * leg_turnover
cost = ir.ctx.fee_rate * multiplier * leg_turnover
net = gross - cost
net_by_cost[float(multiplier)] = as_float(net.mean())
cumulative_by_cost[float(multiplier)] = as_float(
(1.0 + net.dropna()).prod() - 1.0
)
if abs(multiplier - 1.0) < 1e-9:
base_net = net
base_cost = cost

payload: dict[str, object] = {
"long_short_legs": f"Q{top} - Q{bottom}",
Expand All @@ -345,21 +355,71 @@ def return_risk(self, ir: StandardEvalIR) -> Section | Skipped:
"quantile_periods_with_return": bucket_periods,
# RAW Spearman of bucket index vs bucket mean return; the verdict is
# the ONE place that multiplies by expected_ic_sign.
#
# POOLED (v0.1..v0.7 semantics, DELIBERATELY UNCHANGED): correlates
# against cross-date arithmetic means, so it is unbounded and carries
# MAGNITUDE structure. Kept as a reported field — historical reports
# stay comparable — but as of v0.8 it is NOT what the Predictive axis
# gates on, because a rank-based axis must not be decided by a
# magnitude-sensitive statistic.
"monotonicity_spearman": spearman(
[float(q) for q in buckets], [bucket_mean[int(q)] for q in buckets]
),
# PER-DATE version (v0.8, the GATED one): each date's Spearman over the
# buckets is capped in [-1, 1] BEFORE averaging across dates, exactly
# like the rank IC. Robust to a few extreme-return days concentrating
# in one bucket. RAW; the verdict applies expected_ic_sign.
#
# (v0.9) The POINT is kept — reported, cross-run comparable — but it is
# no longer what decides the direction on its own; the CI attached below
# is. Identical value to v0.8 by construction (same reduction, same
# series).
"monotonicity_spearman_by_date": mean_by_date_spearman(mono_by_date),
"gross_long_short_mean": as_float(gross.mean()),
"net_long_short_by_cost": net_by_cost,
"net_long_short_cumulative_by_cost": cumulative_by_cost,
"fee_rate_base": ir.ctx.fee_rate,
"periods_per_year": ir.periods_per_year,
}
# N_eff-based CI of the PER-DATE monotonicity (design §6, v0.9) — the same
# mean_ci the ICIR uses, on the per-date rank series instead of the IC
# series. THIS is what the Predictive axis' direction gate reads: the bare
# point carries no dispersion estimate at all, and this project's real runs
# put a perfect quantile ladder at only ~0.05-0.11 on it, so "point > 0.0"
# was a coin flip dressed as a criterion. The CI turns the gate three-valued
# (holds / contradicted / UNKNOWN) — see verdict._monotonicity_direction.
mono_ci = mean_ci(mono_by_date, confidence=DEFAULT_CONFIDENCE)
payload["monotonicity_spearman_by_date_se"] = mono_ci["se"]
payload["monotonicity_spearman_by_date_ci_low"] = mono_ci["ci_low"]
payload["monotonicity_spearman_by_date_ci_high"] = mono_ci["ci_high"]
payload["monotonicity_spearman_by_date_n_eff"] = mono_ci["n_eff"]
payload["monotonicity_spearman_by_date_n_dates"] = int(mono_by_date.size)
payload["monotonicity_spearman_by_date_ci_note"] = (
"N_eff-based 95% CI of the per-date monotonicity (design §6, v0.9). The "
"Predictive axis gates the monotonicity DIRECTION on this interval, not "
"on the point: aligned CI entirely above the bar = direction holds; "
"entirely below 0 = contradicted (FAIL); straddling 0 = UNKNOWN, which "
"neither convicts nor acquits. n_dates counts the dates that qualified "
"(< 3 finite buckets or all-tied dates are skipped, not zero-filled)."
)

# N_eff-based CI of the base-cost QN-Q1 spread (design §6, v0.6). REPORTED
# for the reader (b); the Tradable axis still gates on the POINT base spread
# > 0 (the CI on this leg difference is point-only for now — c names only
# ICIR / incremental ICIR as the lower-CI magnitude criteria).
if base_net is not None:
base_ci = mean_ci(sign * base_net, confidence=DEFAULT_CONFIDENCE)
# HYPOTHESIS-ALIGNED base-cost spread (v0.8 fix). The hypothesis decides
# WHICH LEG IS LONG; cost is a drag in BOTH directions. So flip the legs
# first, THEN subtract the cost:
# aligned_net = sign * gross - cost
# The pre-v0.8 form `sign * net` expanded, at sign=-1, to `-gross + cost`
# — it ADDED the trading cost back as if shorting earned it. At sign=+1
# this is bit-identical to the old expression (1 * gross - cost == net).
aligned_base = None
if base_net is not None and base_cost is not None:
aligned_base = sign * gross - base_cost

if aligned_base is not None:
base_ci = mean_ci(aligned_base, confidence=DEFAULT_CONFIDENCE)
payload["net_long_short_base_se"] = base_ci["se"]
payload["net_long_short_base_ci_low"] = base_ci["ci_low"]
payload["net_long_short_base_ci_high"] = base_ci["ci_high"]
Expand All @@ -374,8 +434,8 @@ def return_risk(self, ir: StandardEvalIR) -> Section | Skipped:
f"annualization below is a fallback, not a derived fact."
)

if base_net is not None and base_net.notna().sum() >= 2:
aligned = (sign * base_net).dropna()
if aligned_base is not None and aligned_base.notna().sum() >= 2:
aligned = aligned_base.dropna()
nav = (1.0 + aligned).cumprod()
simple = performance_summary(nav, periods_per_year=ir.periods_per_year)
payload["aligned_spread_annual_return"] = simple["annual_return"]
Expand Down
92 changes: 92 additions & 0 deletions analytics/eval/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,95 @@ def spearman(x: pd.Series | list, y: pd.Series | list) -> float:
return float(pair["x"].corr(pair["y"], method="spearman"))


def spearman_series_by_date(
quantile_returns: pd.DataFrame, min_buckets: int = 3
) -> pd.Series:
"""PER-DATE Spearman(bucket index, bucket return), one value per QUALIFYING date.

The series :func:`spearman_by_date` averages, exposed in its own right (design
§6, v0.9) so the verdict can put a DISPERSION estimate on that average. A bare
cross-date mean of per-date rank correlations is heavily attenuated by daily
noise — an empirically perfect quantile ladder scores ~0.05-0.11 in this
project's real runs — so gating a direction claim on whether that point happens
to land above 0.0 reads noise as evidence. With the series in hand the caller
can hand it to :func:`mean_ci` and gate on the N_eff-based interval instead.

A date is SKIPPED — ABSENT FROM THE SERIES, never a zero and never a NaN row —
when it carries fewer than ``min_buckets`` finite bucket returns (too few points
for a meaningful rank correlation), or when its surviving buckets are all tied
(Spearman undefined there). NO date qualifying yields an EMPTY series, not a
series of NaN: "no observation" and "an unknown observation" are different
facts, and only the first is true here.

Because skipped dates are absent rather than held as holes, the index is the
SURVIVING date grid. Downstream N_eff (via :func:`mean_ci`) is therefore read
off that compacted grid — the same convention :func:`mean_ci` already applies to
the IC series, whose degenerate cross-sections it drops before measuring
autocorrelation. Noted rather than hidden (design §11).

RAW, like :func:`spearman`: the hypothesis sign is applied by the verdict.
"""
dates: list[object] = []
daily: list[float] = []
if not (quantile_returns.empty or quantile_returns.shape[1] < min_buckets):
index = [float(q) for q in quantile_returns.columns]
for date, row in quantile_returns.iterrows():
values = [float(v) for v in row.to_numpy(dtype=float)]
if sum(1 for v in values if math.isfinite(v)) < min_buckets:
continue
# spearman() drops the non-finite pairs itself, keeping each surviving
# bucket paired with its OWN index (not a renumbered 1..k).
rho = spearman(index, values)
if math.isfinite(rho):
dates.append(date)
daily.append(rho)
name = quantile_returns.index.name if isinstance(quantile_returns, pd.DataFrame) else None
return pd.Series(daily, index=pd.Index(dates, name=name), dtype=float)


def mean_by_date_spearman(daily: pd.Series) -> float:
"""The cross-date average :func:`spearman_by_date` reduces its series with.

Exposed so a caller that already built the per-date series (the standard layer
does, to attach a CI to it) gets the POINT from the very same expression rather
than paying for a second pass over the panel — the point and the interval can
then never disagree about which observations they describe.

Deliberately the sequential Python ``sum(...) / len(...)`` of the pre-v0.9
implementation, not ``Series.mean()``: the two can differ in the last bit, and
this figure is a published, cross-run-comparable report field.
"""
values = daily.tolist()
if not values:
return float("nan")
return float(sum(values) / len(values))


def spearman_by_date(
quantile_returns: pd.DataFrame, min_buckets: int = 3
) -> float:
"""Mean over dates of the PER-DATE Spearman(bucket index, bucket return).

Structurally parallel to the rank IC (design §6, v0.8): each date's statistic
is BOUNDED in [-1, 1] before the cross-date average, so a handful of extreme
return days cannot dominate it. The pooled :func:`spearman` version correlates
against cross-date ARITHMETIC MEANS, which are unbounded and magnitude
sensitive — one outlier bucket-day can flip it while the daily-capped rank IC
barely moves.

As of v0.9 this is the mean of :func:`spearman_series_by_date` (see there for
the skip rules) — a pure refactor: the returned float is BIT-IDENTICAL to the
v0.8 implementation, which is asserted against the v0.8 fixtures.

NaN when NO date qualifies: unknown, not 0.0.

RAW, like :func:`spearman`: the hypothesis sign is applied by the verdict.
"""
return mean_by_date_spearman(
spearman_series_by_date(quantile_returns, min_buckets=min_buckets)
)


def as_float(value: object) -> float:
"""Coerce to a plain float for a payload; anything unusable becomes NaN."""
try:
Expand All @@ -409,9 +498,12 @@ def as_float(value: object) -> float:
"half_life",
"hypothesis_win_rate",
"information_ratio_ci",
"mean_by_date_spearman",
"mean_ci",
"newey_west_lag",
"newey_west_t",
"sortino",
"spearman",
"spearman_by_date",
"spearman_series_by_date",
]
Loading