Source code for fynance.research.experiment
#!/usr/bin/env python
# -*- coding: utf-8 -*-
""" The :class:`Experiment` record.
A serializable description of one strategy experiment — enough to reproduce it
and to render a report — with **no run logic** (that lives in
:mod:`fynance.research.runner`). This is the portable artifact a downstream
(private) research repo stores; ``fynance`` itself never persists results except
to a caller-provided ``output_dir``.
"""
# Built-in
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
__all__ = ['Experiment']
def _utc_now() -> str:
""" Current UTC timestamp as an ISO-8601 string. """
return datetime.now(timezone.utc).isoformat()
def _fynance_version() -> str:
""" Resolve the installed fynance version lazily (avoids import cycles). """
from fynance import __version__
return __version__
[docs]
@dataclass
class Experiment:
""" Serializable record of a single strategy experiment.
Parameters
----------
name : str
Short slug identifying the experiment (also the output sub-directory).
spec : dict, optional
Everything needed to reproduce the run: strategy description, params,
walk-forward config, cost config and the data spec (e.g.
``{"kind": "synthetic-gbm", "seed": 7, "n": 1000}``).
code : str, optional
The generated strategy source, stored verbatim for audit/reproduction.
seed : int
Master seed driving every stochastic step.
metrics : dict of str to float, optional
Summary metrics (``sharpe``/``sortino``/…); empty until a run fills it.
series : dict of str to list of float, optional
JSON-friendly curves (e.g. ``equity``/``returns``) for the report.
created_at : str
ISO-8601 UTC creation time (set automatically).
fynance_version : str
Version of fynance that produced the experiment (set automatically).
Examples
--------
>>> from fynance.research import Experiment
>>> e = Experiment(name="demo", seed=7, metrics={"sharpe": 1.5})
>>> e2 = Experiment.from_dict(e.to_dict())
>>> e2.name, e2.seed, e2.metrics["sharpe"]
('demo', 7, 1.5)
"""
name: str
spec: dict[str, Any] = field(default_factory=dict)
code: str | None = None
seed: int = 0
metrics: dict[str, float] = field(default_factory=dict)
series: dict[str, list[float]] | None = None
created_at: str = field(default_factory=_utc_now)
fynance_version: str = field(default_factory=_fynance_version)
[docs]
def to_dict(self) -> dict[str, Any]:
""" Return a JSON-serializable dict of the experiment. """
return asdict(self)
[docs]
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Experiment:
""" Rebuild an :class:`Experiment` from a :meth:`to_dict` mapping. """
return cls(**data)
[docs]
def save(self, output_dir: str | Path, *, name: str | None = None) -> Path:
""" Write ``<output_dir>/<name>/experiment.json`` and return its path.
Always writes under the caller-provided ``output_dir`` — never inside
the package. Parent directories are created as needed.
Parameters
----------
output_dir : str or pathlib.Path
Base directory the artifact is written under.
name : str, optional
Sub-directory name; defaults to :attr:`name`.
Returns
-------
pathlib.Path
Path to the written ``experiment.json``.
"""
sub = name if name is not None else self.name
target = Path(output_dir) / sub
target.mkdir(parents=True, exist_ok=True)
path = target / "experiment.json"
path.write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
return path
[docs]
@classmethod
def load(cls, path: str | Path) -> Experiment:
""" Load an :class:`Experiment` from an ``experiment.json`` file. """
data = json.loads(Path(path).read_text(encoding="utf-8"))
return cls.from_dict(data)