Coverage for src/tinycta/hyper/_study.py: 100%
60 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-30 04:16 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-30 04:16 +0000
1"""Frozen Study result and Optuna-based hyperparameter optimisation."""
3from __future__ import annotations
5from collections.abc import Callable
6from dataclasses import dataclass, field
7from pathlib import Path
8from typing import Any
10import optuna
11from jquantstats import Portfolio
12from loguru import logger
15@dataclass(frozen=True)
16class Study:
17 """Frozen wrapper around a completed Optuna study."""
19 best_params: dict[str, Any]
20 best_value: float
21 n_completed: int
22 n_trials: int
23 optuna_study: optuna.Study = field(repr=False)
25 def __str__(self) -> str:
26 """Return a human-readable summary of the best trial."""
27 if self.n_completed == 0:
28 return "No completed trials — all returned NaN Sharpe."
29 lines = ["=== Best parameters ==="]
30 for k, v in self.best_params.items():
31 lines.append(f" {k:<12} = {v}")
32 lines.append(f" {'Sharpe':<12} = {self.best_value:.4f}")
33 lines.append(f" {'Completed':<12} = {self.n_completed} / {self.n_trials} trials")
34 return "\n".join(lines)
36 @classmethod
37 def from_optuna(cls, s: optuna.Study) -> Study:
38 """Wrap a completed optuna.Study in a frozen Study."""
39 n_completed = sum(1 for t in s.trials if t.state == optuna.trial.TrialState.COMPLETE)
40 if n_completed == 0:
41 best_params, best_value = {}, float("nan")
42 else:
43 best_params, best_value = s.best_params, s.best_value
44 return cls(
45 best_params=best_params,
46 best_value=best_value,
47 n_completed=n_completed,
48 n_trials=len(s.trials),
49 optuna_study=s,
50 )
52 def plot(self, output_dir: Path) -> None:
53 """Write Optuna visualisation plots to output_dir (HTML, PNG if kaleido available)."""
54 output_dir.mkdir(parents=True, exist_ok=True)
55 figures = {
56 "optuna_history": optuna.visualization.plot_optimization_history(self.optuna_study),
57 "optuna_importance": optuna.visualization.plot_param_importances(self.optuna_study),
58 "optuna_parallel": optuna.visualization.plot_parallel_coordinate(self.optuna_study),
59 "optuna_contour": optuna.visualization.plot_contour(self.optuna_study),
60 }
61 for name, fig in figures.items():
62 fig.write_html(str(output_dir / f"{name}.html"))
63 try:
64 fig.write_image(str(output_dir / f"{name}.png"), scale=2)
65 except (ValueError, ImportError) as exc:
66 # PNG export needs the optional `kaleido` backend; skip if unavailable.
67 logger.debug(f"Skipping PNG export for {name}: {exc}")
70def _sharpe(portfolio: Portfolio) -> float:
71 """Compute Sharpe ratio, raising TrialPruned if the result is NaN or None."""
72 result = portfolio.stats.sharpe()
73 sharpe = result["returns"] if isinstance(result, dict) else float(result)
74 if sharpe is None or sharpe != sharpe:
75 raise optuna.exceptions.TrialPruned()
76 return sharpe
79def _run_study(
80 objective: Callable[[optuna.Trial], float],
81 *,
82 n_trials: int = 100,
83 seed: int = 42,
84 name: str | None = None,
85) -> optuna.Study:
86 """Create and run an Optuna study, returning the optuna.Study."""
87 optuna.logging.set_verbosity(optuna.logging.WARNING)
88 s = optuna.create_study(direction="maximize", sampler=optuna.samplers.TPESampler(seed=seed), study_name=name)
89 s.optimize(objective, n_trials=n_trials, show_progress_bar=False)
90 return s
93def _build_objective(
94 suggest_portfolio_fn: Callable[[optuna.Trial], Portfolio],
95) -> Callable[[optuna.Trial], float]:
96 """Objective factory: wraps a portfolio-returning function with Sharpe scoring."""
98 def objective(trial: optuna.Trial) -> float:
99 """Call suggest_portfolio_fn and return the Sharpe ratio."""
100 return _sharpe(suggest_portfolio_fn(trial))
102 return objective
105def optimize(
106 suggest_portfolio_fn: Callable[[optuna.Trial], Portfolio],
107 n_trials: int = 100,
108 seed: int = 42,
109) -> Study:
110 """Build objective, run study, log the summary and return a frozen Study."""
111 s = _run_study(_build_objective(suggest_portfolio_fn), n_trials=n_trials, seed=seed)
112 study = Study.from_optuna(s)
113 logger.info(str(study))
114 return study