diff --git a/quantscenariobench/benchmark/runner/_run_benchmark.py b/quantscenariobench/benchmark/runner/_run_benchmark.py index 4c926a3..c18bd56 100644 --- a/quantscenariobench/benchmark/runner/_run_benchmark.py +++ b/quantscenariobench/benchmark/runner/_run_benchmark.py @@ -43,16 +43,35 @@ def _utc_now() -> str: return datetime.datetime.now(datetime.timezone.utc).isoformat() +def _serialize_parameter(value: Any) -> Any: + """One strategy field → a JSON-native value (issue #99). + + Primitives and None pass through unchanged; lists/tuples/dicts are + serialized recursively. Anything else first keeps the historical + float() conversion (scalar JAX/NumPy arrays), then falls back to + repr() — so a strategy holding, say, a neural-network module as a + field serializes to an identifying string instead of raising. The + broad except is deliberate: __float__ on arbitrary objects can raise + anything, and serialization must never fail a completed run. + """ + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, (list, tuple)): + return [_serialize_parameter(item) for item in value] + if isinstance(value, dict): + return {str(key): _serialize_parameter(item) for key, item in value.items()} + try: + return float(value) + except Exception: + return repr(value) + + def _strategy_parameters(strategy: Any) -> dict: """JSON-native snapshot of a strategy's own recorded fields (AD-15, AD-24).""" - params: dict = {} - for field in dataclasses.fields(strategy): - value = getattr(strategy, field.name) - if isinstance(value, (bool, int, float, str)): - params[field.name] = value - else: - params[field.name] = float(value) - return params + return { + field.name: _serialize_parameter(getattr(strategy, field.name)) + for field in dataclasses.fields(strategy) + } def _run_rebalancing_loop( diff --git a/tests/test_benchmark_result_schema.py b/tests/test_benchmark_result_schema.py index 9fabfa4..f63befe 100644 --- a/tests/test_benchmark_result_schema.py +++ b/tests/test_benchmark_result_schema.py @@ -218,3 +218,77 @@ def test_concentration_metrics_survive_evaluation_result_json_round_trip(): restored_metrics = {m.name: m.value for m in restored.metrics} assert restored_metrics == result.metrics + + +# --------------------------------------------------------------------------- +# Issue #99: strategy_parameters must serialize non-primitive strategy +# fields (e.g. a neural-network module held as a field) instead of +# raising on float() — primitives and None pass through, collections +# recurse, scalar arrays keep the historical float() conversion, and +# anything else falls back to repr(). +# --------------------------------------------------------------------------- + +class _FakeNeuralNet: + """Stands in for a torch.nn.Module / Flax module: not float()-able.""" + + def __repr__(self): + return "FakeNeuralNet(layers=2)" + + +def test_strategy_parameters_serialize_non_primitive_fields(): + from quantscenariobench.benchmark.interface import ( + PolicyStrategy, + PortfolioWeights, + RebalanceSchedule, + ) + + class NeuralPolicy(PolicyStrategy): + model: object + learning_rate: float + window: object # None — optional field left unset + hidden_sizes: tuple # collection of primitives, recursed + decay: object # scalar JAX array — historical float() path + + def allocate_sequence(self, observed_returns): + n = observed_returns.shape[1] + return PortfolioWeights(jnp.full((n,), 1.0 / n)) + + strategy = NeuralPolicy( + model=_FakeNeuralNet(), + learning_rate=1e-3, + window=None, + hidden_sizes=(64, 32), + decay=jnp.asarray(0.9), + ) + result = _run(strategy, rebalance_schedule=RebalanceSchedule(k=5)) + + params = result.strategy_parameters + assert params["model"] == "FakeNeuralNet(layers=2)" + assert params["learning_rate"] == 1e-3 + assert params["window"] is None + assert params["hidden_sizes"] == [64, 32] + assert params["decay"] == pytest.approx(0.9) + + +def test_strategy_parameters_with_non_primitive_fields_json_round_trip(): + from quantscenariobench.benchmark.interface import ( + PolicyStrategy, + PortfolioWeights, + RebalanceSchedule, + ) + + class ModelBackedPolicy(PolicyStrategy): + model: object + + def allocate_sequence(self, observed_returns): + n = observed_returns.shape[1] + return PortfolioWeights(jnp.full((n,), 1.0 / n)) + + result = _run( + ModelBackedPolicy(model=_FakeNeuralNet()), + rebalance_schedule=RebalanceSchedule(k=5), + ) + + payload = json.dumps(dataclasses.asdict(result)) + restored = json.loads(payload) + assert restored["strategy_parameters"]["model"] == "FakeNeuralNet(layers=2)"