Source code for fynance.research.ledger
#!/usr/bin/env python
# -*- coding: utf-8 -*-
""" Persistent experiment ledger.
A :class:`Ledger` turns one-off runs into **cumulative** research: it persists
experiments under a caller-provided ``root``, reloads them, ranks them into a
leaderboard, and tracks the **number of trials** — which it can feed into the
deflated Sharpe ratio so a selected strategy is judged against the multiple
testing it came from. The store lives entirely under ``root`` (the caller's
private repo) — never inside fynance.
"""
# Built-in
from __future__ import annotations
from pathlib import Path
# Third-party
import numpy as np
# Local
from fynance.research.compare import leaderboard
from fynance.research.experiment import Experiment
from fynance.research.guards import deflated_sharpe_ratio
__all__ = ['Ledger']
[docs]
class Ledger:
""" A persistent, append-only store of experiments under ``root``.
Parameters
----------
root : str or pathlib.Path
Directory the experiments live under (created on demand). Each experiment
is stored at ``<root>/<name>/experiment.json``.
Examples
--------
>>> import tempfile
>>> from fynance.research import Experiment, Ledger
>>> d = tempfile.mkdtemp()
>>> led = Ledger(d)
>>> _ = led.append(Experiment(name="a", metrics={"sharpe": 1.0}))
>>> _ = led.append(Experiment(name="b", metrics={"sharpe": 2.0}))
>>> led.n_trials
2
>>> [r["name"] for r in led.leaderboard()]
['b', 'a']
"""
def __init__(self, root: str | Path):
self.root = Path(root)
[docs]
def append(self, experiment: Experiment) -> Path:
""" Persist ``experiment`` under the ledger root; return its json path. """
self.root.mkdir(parents=True, exist_ok=True)
return experiment.save(self.root)
[docs]
def load(self) -> list[Experiment]:
""" Load every stored experiment (sorted by name for determinism). """
if not self.root.exists():
return []
return [Experiment.load(p)
for p in sorted(self.root.glob("*/experiment.json"))]
@property
def n_trials(self) -> int:
""" Number of experiments in the ledger (the multiple-testing count). """
if not self.root.exists():
return 0
return sum(1 for _ in self.root.glob("*/experiment.json"))
[docs]
def leaderboard(self, *, sort_by: str = "sharpe",
descending: bool = True) -> list[dict[str, float | str]]:
""" Rank the stored experiments (see :func:`fynance.research.leaderboard`). """
return leaderboard(self.load(), sort_by=sort_by, descending=descending)
[docs]
def deflated_sharpe(self, experiment: Experiment,
metric: str = "sharpe") -> float:
""" Deflated Sharpe of ``experiment`` against the ledger's trial count.
Uses the ledger's :attr:`n_trials` as the number of trials and the
dispersion of the stored Sharpe metrics as the trial variance, so a
selected strategy is judged against the multiple testing it came from.
"""
sharpes = [float(e.metrics[metric]) for e in self.load()
if metric in e.metrics]
sr_variance = float(np.var(sharpes)) if len(sharpes) > 1 else 1.0
n_obs = len(experiment.series["returns"]) if (
experiment.series and experiment.series.get("returns")) else 0
return deflated_sharpe_ratio(
float(experiment.metrics[metric]), n_obs, max(self.n_trials, 1),
sr_variance=sr_variance,
)