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
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Série du cœur actions pour la matrice de corrélation de la ligue (#20365, #19821 brique 4).

Une stratégie n'entre au panier que si elle se décorrèle du cœur : la matrice de
`league_correlation.py` a donc besoin d'une colonne pour le cœur actions. Ce cœur est la
règle de volatilité inverse du harnais paper (#19072, `inverse_vol_currency`) ; il n'a pas
de trace QC, sa série se calcule ici, hors QC, par le point d'entrée de l'ombre
(`shadow_inverse_vol.replay`), sans recopier la règle.

Protocole, amendé dans #20365 avant la mesure :

- convention `U` (volatilité des clôtures en USD, la règle du harnais), cible de
volatilité `--target` (0,025 dans le verdict de #19072) ;
- **compte en USD** (`--account usd`) : mêmes poids, rendements des clôtures en USD,
liquidité en USD. Les autres membres de la matrice sont des comptes QC en USD : un cœur
compté en EUR porterait le change EUR/USD sur sa liquidité, et la corrélation mesurerait
en partie le change. `--account eur` garde la lecture d'un investisseur en euros, comme
sensibilité ;
- clôtures quotidiennes Yahoo (ajustées des dividendes) gardées dans `--closes-dir`, pour
qu'une seconde lecture rejoue les mêmes cours : leurs empreintes vont dans
`provenance.json`.

Sortie dans `--out` : `series.csv` (colonnes `date`, `net_return`, `turnover`, format de
`shadow_replay.py --series-dir`), lu par `league_correlation.py` pour un membre
`"kind": "local"`, et `provenance.json`.

python league_core_series.py --out <traces>/20365-league-core/core-equity \\
--closes-dir <traces>/20365-league-core/closes
"""

from __future__ import annotations

import argparse
import csv
import hashlib
import json
import subprocess
import sys
from pathlib import Path

import inverse_vol_currency as ivc
import shadow_inverse_vol as siv

SERIES = "series.csv"
PROVENANCE = "provenance.json"
DEFAULT_START = "2010-02-01"
DEFAULT_END = "2026-10-01"


def write_series(out: dict, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as f:
w = csv.writer(f, lineterminator="\n")
w.writerow(["date", "net_return", "turnover"])
w.writerows(zip(out["dates"], out["net_returns"], out["turnover"]))


def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()


def _commit() -> str | None:
try:
return subprocess.run(["git", "rev-parse", "HEAD"], cwd=Path(__file__).parent,
capture_output=True, text=True, check=True).stdout.strip()
except (OSError, subprocess.CalledProcessError):
return None


def core_series(start: str, end: str, closes_dir: Path, target: float = ivc.VERDICT_TARGET,
account: str = "USD") -> dict:
"""Rendements quotidiens nets du cœur ouvert le `start`, sur `[start, end)`."""
us_closes, fx = siv.fetch_closes(start, end, closes_dir)
return siv.replay(us_closes, fx, start, end, "U", target, account=account)


def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--out", type=Path, required=True, help="dossier du membre (hors dépôt)")
ap.add_argument("--closes-dir", type=Path, required=True,
help="dossier des clôtures Yahoo, réutilisées si présentes")
ap.add_argument("--start", default=DEFAULT_START)
ap.add_argument("--end", default=DEFAULT_END, help="exclu")
ap.add_argument("--target", type=float, default=ivc.VERDICT_TARGET)
ap.add_argument("--account", choices=["usd", "eur"], default="usd")
args = ap.parse_args(argv)

out = core_series(args.start, args.end, args.closes_dir, args.target, args.account.upper())
write_series(out, args.out / SERIES)
closes = sorted(p for p in args.closes_dir.glob("*.csv"))
provenance = {
"issue": 20365,
"rule": "inverse_vol_currency (#19072), convention U",
"account": args.account.upper(), "target": args.target,
"start": args.start, "end_excluded": args.end,
"first": out["dates"][0], "last": out["dates"][-1], "sessions": len(out["dates"]),
"lookback": ivc.LOOKBACK, "max_weight": ivc.MAX_WEIGHT, "band": ivc.BAND, "fee": ivc.FEE,
"commit": _commit(),
"closes": {p.name: sha256(p) for p in closes},
"series_sha256": sha256(args.out / SERIES),
}
(args.out / PROVENANCE).write_text(json.dumps(provenance, indent=1) + "\n", encoding="utf-8")
sys.stdout.write(f"{len(out['dates'])} sessions {out['dates'][0]} -> {out['dates'][-1]}"
f" ({args.account.upper()}, target {args.target:g}) -> {args.out / SERIES}\n")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
« fragile » une estimation dont l'intervalle chevauche le seuil.

Les membres sont listés dans `shadow/league_members.json`, une variante par stratégie
mesurée, et les séries restent dans les traces QC hors dépôt (`--traces-root`) :
mesurée, et les séries restent dans les traces QC hors dépôt (`--traces-root`). Un membre
`"kind": "local"` n'a pas de trace QC : sa série est un `series.csv` (colonnes `date`,
`net_return`, format de `shadow_replay.py --series-dir`) calculé hors QC, comme le cœur
actions écrit par `league_core_series.py` (#20365) :

python league_correlation.py --traces-root <dossier des traces> \\
--against spy --against btc --json matrice.json
Expand All @@ -32,6 +35,7 @@
from __future__ import annotations

import argparse
import csv
import datetime as dt
import json
import math
Expand All @@ -49,6 +53,8 @@
SEED = 0
ENTRY_MAX_CORR = 0.7
MEMBERS = Path(__file__).resolve().parents[1] / "shadow" / "league_members.json"
LOCAL_SERIES = "series.csv"
KINDS = ("qc", "local")


def week_key(date: str) -> str:
Expand Down Expand Up @@ -136,8 +142,27 @@ def active_share(returns: dict[str, float]) -> float:
return float((np.abs(v) > 1e-12).mean()) if v.size else 0.0


def local_daily_equity(path: Path) -> tuple[list[str], list[float]]:
"""Capital par séance d'une série locale : produit des `1 + net_return`, base 1."""
dates, equity, level = [], [], 1.0
with path.open(encoding="utf-8", newline="") as fh:
for row in csv.DictReader(fh):
level *= 1.0 + float(row["net_return"])
dates.append(row["date"])
equity.append(level)
if not dates:
raise ValueError(f"no row in {path}")
return dates, equity


def member_returns(root: Path, member: dict) -> dict[str, float]:
"""Rendements hebdomadaires d'un membre, lus dans `chart.json` de sa trace."""
"""Rendements hebdomadaires d'un membre, lus dans `chart.json` de sa trace QC, ou
dans son `series.csv` pour un membre `"kind": "local"`."""
kind = member.get("kind", "qc")
if kind not in KINDS:
raise ValueError(f"member {member['id']}: kind must be one of {KINDS}, got {kind!r}")
if kind == "local":
return weekly_returns(*local_daily_equity(root / member["trace"] / LOCAL_SERIES))
chart = json.loads((root / member["trace"] / "chart.json").read_text(encoding="utf-8"))
prefix = member.get("series", "e")
if prefix != "e":
Expand Down Expand Up @@ -172,7 +197,8 @@ def report(members: list[dict], returns: dict[str, dict[str, float]],
for m in members:
w = sorted(returns[m["id"]])
lines.append(f"| `{m['id']}` | {m['role']} | `{m['trace']}`"
f"{' (' + m['series'] + ')' if m.get('series', 'e') != 'e' else ''} | "
f"{' (' + m['series'] + ')' if m.get('series', 'e') != 'e' else ''}"
f"{' (local)' if m.get('kind') == 'local' else ''} | "
f"{'#' + str(m['issue']) if m.get('issue') else ''} | "
f"{'#' + str(m['pr']) if m.get('pr') else ''} | {len(w)} | "
f"{active_share(returns[m['id']]):.0%} | {w[0]} | {w[-1]} |")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@

Both conventions hold the same assets and earn the returns of a euro investor
(closes converted to EUR): only the volatility measure changes, as in H2 of
#19072.
#19072. ``replay(..., account="USD")`` holds the same weights on a USD account
(USD returns, cash in dollars): the series of the core in the league matrix
(``league_core_series.py``, #20365). The shadow candidate keeps the EUR default.

Data: Yahoo daily closes through ``panier_loader.ensure_symbol``, downloaded into
a temporary folder at each pass (``auto_adjust=True``: a dividend adjusts the past
Expand All @@ -45,6 +47,7 @@
import inverse_vol_currency as ivc

CONVENTIONS = ("U", "F")
ACCOUNTS = ("EUR", "USD")
HISTORY_DAYS = 120 # calendar days of closes before ``start``: LOOKBACK + 1 sessions, with room


Expand All @@ -60,10 +63,18 @@ def schedule_dates(index: pd.DatetimeIndex, start: str, end: str) -> pd.Datetime


def replay(us_closes: dict[str, pd.Series], fx: pd.Series, start: str, end: str,
convention: str, target: float, harness=None) -> dict:
"""Daily net returns in EUR of the rule opened on ``start``, over ``[start, end)``."""
convention: str, target: float, harness=None, account: str = "EUR") -> dict:
"""Daily net returns of the rule opened on ``start``, over ``[start, end)``.

``account`` is the currency of the account that holds the lines: ``"EUR"`` (the
shadow candidate, a euro investor) or ``"USD"`` (the same weights on a USD
account, whose cash is in dollars: the league matrix of #20365 correlates USD
accounts). The weights do not depend on it, only the returns they earn.
"""
if convention not in CONVENTIONS:
raise ValueError(f"convention must be one of {CONVENTIONS}, got {convention!r}")
if account not in ACCOUNTS:
raise ValueError(f"account must be one of {ACCOUNTS}, got {account!r}")
harness = harness or ivc.load_harness()
panel = ivc.build_panel(us_closes, fx)
keep = panel["usd"].index < pd.Timestamp(end)
Expand All @@ -74,8 +85,8 @@ def replay(us_closes: dict[str, pd.Series], fx: pd.Series, start: str, end: str,

prices = usd if convention == "U" else eur
schedule = {d: ivc.target_weights(harness, prices, d, target) for d in rebal}
eur_ret = eur.pct_change().fillna(0.0)
sim = ivc.simulate(eur_ret[eur_ret.index >= rebal[0]], schedule)
held_ret = (eur if account == "EUR" else usd).pct_change().fillna(0.0)
sim = ivc.simulate(held_ret[held_ret.index >= rebal[0]], schedule)

rets, turnover = sim["returns"], sim["turnover"]
# fee of each session in fraction of the starting equity: the equity before the fee times its rate
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Tests for league_core_series.py (#20365) on synthetic series, without network."""

from __future__ import annotations

import json
import math

import numpy as np
import pandas as pd
import pytest

import league_core_series as lcs
import league_correlation as lc
import shadow_inverse_vol as siv


def _market():
idx = pd.bdate_range("2025-06-02", "2026-12-31")
rng = np.random.default_rng(5)

def walk(vol, start=100.0):
return pd.Series(start * np.cumprod(1.0 + rng.normal(0.0, vol / math.sqrt(252), len(idx))),
index=idx)

closes = {s: walk(v) for s, v in [("SPY", 0.18), ("QQQ", 0.24), ("IEF", 0.07), ("GLD", 0.15)]}
return closes, walk(0.08, start=1.15)


@pytest.fixture
def market(monkeypatch, tmp_path):
closes, fx = _market()
calls = []

def fake_fetch(start, end, folder):
calls.append((start, end, folder))
(folder / "SPY_x.csv").write_text("Date,Close\n", encoding="utf-8")
return closes, fx

monkeypatch.setattr(siv, "fetch_closes", fake_fetch)
return closes, fx, calls


def test_series_is_the_usd_account_replay(market, tmp_path):
closes, fx, calls = market
out_dir, closes_dir = tmp_path / "core", tmp_path / "closes"
closes_dir.mkdir()
assert lcs.main(["--out", str(out_dir), "--closes-dir", str(closes_dir),
"--start", "2025-09-01", "--end", "2026-10-01"]) == 0
assert calls == [("2025-09-01", "2026-10-01", closes_dir)]
want = siv.replay(closes, fx, "2025-09-01", "2026-10-01", "U", 0.025, account="USD")
dates, equity = lc.local_daily_equity(out_dir / lcs.SERIES)
assert dates == want["dates"]
assert equity == pytest.approx(list(np.cumprod(1.0 + np.asarray(want["net_returns"]))))
prov = json.loads((out_dir / lcs.PROVENANCE).read_text(encoding="utf-8"))
assert prov["account"] == "USD" and prov["target"] == 0.025
assert prov["sessions"] == len(want["dates"]) and prov["last"] == want["dates"][-1]
# the closes read are fingerprinted, so that a second reading can check it replays them
assert set(prov["closes"]) == {"SPY_x.csv"}
assert prov["series_sha256"] == lcs.sha256(out_dir / lcs.SERIES)


def test_euro_account_is_a_sensitivity_not_the_default(market, tmp_path):
closes_dir = tmp_path / "closes"
closes_dir.mkdir()
usd = lcs.core_series("2025-09-01", "2026-10-01", closes_dir)
eur = lcs.core_series("2025-09-01", "2026-10-01", closes_dir, account="EUR")
assert usd["dates"] == eur["dates"]
assert usd["turnover"][0] == pytest.approx(eur["turnover"][0])
assert usd["net_returns"] != pytest.approx(eur["net_returns"])


def test_written_series_feeds_the_league_matrix(market, tmp_path):
root = tmp_path / "traces"
out_dir = root / "20365-league-core" / "core-equity"
closes_dir = tmp_path / "closes"
closes_dir.mkdir()
lcs.main(["--out", str(out_dir), "--closes-dir", str(closes_dir),
"--start", "2025-09-01", "--end", "2026-10-01"])
weekly = lc.member_returns(root, {"id": "core-equity", "kind": "local",
"trace": "20365-league-core/core-equity"})
assert len(weekly) > 40
assert all(lc.week_key(w) == w for w in weekly)
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,40 @@ def test_member_returns_reads_held_reference_series(tmp_path):
assert all(v > 0 for v in e.values()) and all(v < 0 for v in b.values())


def test_member_returns_reads_a_local_series(tmp_path):
# un membre local (le cœur actions, #20365) : series.csv au format de shadow_replay --series-dir
dates = _days("2026-01-05", 60)
rets = [0.01 if i % 2 else -0.004 for i in range(60)]
folder = tmp_path / "20365-league-core" / "core-equity"
folder.mkdir(parents=True)
rows = ["date,net_return,turnover"] + [f"{d},{r!r},0.0" for d, r in zip(dates, rets)]
(folder / lc.LOCAL_SERIES).write_text("\n".join(rows) + "\n", encoding="utf-8")
got = lc.member_returns(tmp_path, {"id": "core", "trace": "20365-league-core/core-equity",
"kind": "local"})
equity = list(np.cumprod(1.0 + np.asarray(rets)))
assert got == pytest.approx(lc.weekly_returns(dates, equity))
# la même courbe tracée dans un chart.json QC rend les mêmes rendements hebdomadaires
(folder / "chart.json").write_text(json.dumps(_chart(dates, equity)), encoding="utf-8")
assert got == pytest.approx(lc.member_returns(tmp_path, {"id": "q", "trace":
"20365-league-core/core-equity"}))


def test_member_returns_rejects_an_unknown_kind(tmp_path):
with pytest.raises(ValueError, match="kind"):
lc.member_returns(tmp_path, {"id": "z", "trace": "a/b", "kind": "csv"})


def test_members_file_is_well_formed():
members = lc.load_members()
ids = [m["id"] for m in members]
assert len(set(ids)) == len(ids)
for m in members:
assert m["role"] in {"candidate", "reference", "core"}
assert m["trace"].count("/") == 1 and m.get("series", "e") in {"e", "b"}
assert m.get("kind", "qc") in lc.KINDS
assert m.get("kind", "qc") == "qc" or "series" not in m
# chaque cœur du panier est dans la matrice (#20365)
assert any(m["role"] == "core" for m in members)


def test_active_share_counts_invested_weeks():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,35 @@ def test_conventions_differ_when_the_fx_moves(harness):
assert u["net_returns"] != pytest.approx(f["net_returns"])


def test_usd_account_does_not_see_the_fx(harness):
# convention U: the weights come from the USD closes, and a USD account earns USD
# returns, so two FX paths (without bad prints) give the same series (#20365)
closes, fx = _market()
other = pd.Series(1.15, index=fx.index)
a = siv.replay(closes, fx, "2026-03-02", "2026-12-01", "U", 0.025, harness, account="USD")
b = siv.replay(closes, other, "2026-03-02", "2026-12-01", "U", 0.025, harness, account="USD")
assert a["dates"] == b["dates"]
assert a["net_returns"] == pytest.approx(b["net_returns"], rel=1e-12, abs=1e-15)
assert a["turnover"] == pytest.approx(b["turnover"], rel=1e-12, abs=1e-15)


def test_usd_account_holds_the_same_weights_as_the_euro_one(harness):
closes, fx = _market()
e = siv.replay(closes, fx, "2026-03-02", "2026-12-01", "U", 0.025, harness)
u = siv.replay(closes, fx, "2026-03-02", "2026-12-01", "U", 0.025, harness, account="USD")
assert e["dates"] == u["dates"]
# same opening allocation and fee; only the returns earned afterwards differ
assert u["turnover"][0] == pytest.approx(e["turnover"][0])
assert u["net_returns"][0] == pytest.approx(e["net_returns"][0])
assert u["net_returns"] != pytest.approx(e["net_returns"])


def test_unknown_account_raises(harness):
closes, fx = _market()
with pytest.raises(ValueError, match="account"):
siv.replay(closes, fx, "2026-06-03", "2026-09-01", "U", 0.025, harness, account="GBP")


def test_not_enough_history_raises(harness):
closes, fx = _market(start="2026-06-01")
with pytest.raises(ValueError, match="closes up to"):
Expand Down
Loading
Loading