from __future__ import annotations import math import numpy as np import pytest from services.financial import CashFlow, net_present_value from services.simulation import ( CashFlowSpec, DistributionConfigError, DistributionSource, DistributionSpec, DistributionType, SimulationConfig, SimulationMetric, run_monte_carlo, ) def test_run_monte_carlo_deterministic_matches_financial_helpers() -> None: base_flows = [ CashFlow(amount=-1000.0, period_index=0), CashFlow(amount=600.0, period_index=1), CashFlow(amount=600.0, period_index=2), ] specs = [CashFlowSpec(cash_flow=flow) for flow in base_flows] config = SimulationConfig( iterations=10, discount_rate=0.1, percentiles=(50,), seed=123, ) result = run_monte_carlo(specs, config) summary = result.summaries[SimulationMetric.NPV] expected = net_present_value(0.1, base_flows) assert summary.sample_size == config.iterations assert summary.failed_runs == 0 assert summary.mean == pytest.approx(expected, rel=1e-6) assert summary.std_dev == 0.0 assert summary.percentiles[50] == pytest.approx(expected, rel=1e-6) def test_run_monte_carlo_normal_distribution_uses_seed_for_reproducibility() -> None: base_flows = [ CashFlow(amount=-100.0, period_index=0), CashFlow(amount=0.0, period_index=1), CashFlow(amount=0.0, period_index=2), ] revenue_flow = CashFlowSpec( cash_flow=CashFlow(amount=120.0, period_index=1), distribution=DistributionSpec( type=DistributionType.NORMAL, parameters={"mean": 120.0, "std_dev": 10.0}, ), ) specs = [CashFlowSpec(cash_flow=base_flows[0]), revenue_flow] config = SimulationConfig( iterations=1000, discount_rate=0.0, percentiles=(5.0, 50.0, 95.0), seed=42, ) result = run_monte_carlo(specs, config) summary = result.summaries[SimulationMetric.NPV] assert summary.sample_size == config.iterations assert summary.failed_runs == 0 # With zero discount rate the expected mean NPV equals mean sampled value minus investment. assert summary.mean == pytest.approx(20.0, abs=1.0) assert summary.std_dev == pytest.approx(10.0, abs=1.0) assert summary.percentiles[50.0] == pytest.approx(summary.mean, abs=1.0) def test_run_monte_carlo_supports_scenario_field_source() -> None: base_flow = CashFlow(amount=0.0, period_index=1) spec = CashFlowSpec( cash_flow=base_flow, distribution=DistributionSpec( type=DistributionType.NORMAL, parameters={"std_dev": 0.0}, source=DistributionSource.SCENARIO_FIELD, source_key="salvage_mean", ), ) config = SimulationConfig(iterations=1, discount_rate=0.0, seed=7) result = run_monte_carlo( [CashFlowSpec(cash_flow=CashFlow( amount=-100.0, period_index=0)), spec], config, scenario_context={"salvage_mean": 150.0}, ) summary = result.summaries[SimulationMetric.NPV] assert summary.sample_size == 1 assert summary.mean == pytest.approx(50.0) def test_run_monte_carlo_records_failed_metrics_when_not_defined() -> None: base_flows = [CashFlow(amount=100.0, period_index=0)] specs = [CashFlowSpec(cash_flow=flow) for flow in base_flows] config = SimulationConfig( iterations=5, discount_rate=0.1, metrics=(SimulationMetric.IRR,), seed=5, ) result = run_monte_carlo(specs, config) summary = result.summaries[SimulationMetric.IRR] assert summary.sample_size == 0 assert summary.failed_runs == config.iterations assert math.isnan(summary.mean) def test_run_monte_carlo_distribution_missing_context_raises() -> None: spec = DistributionSpec( type=DistributionType.NORMAL, parameters={"std_dev": 1.0}, source=DistributionSource.SCENARIO_FIELD, source_key="unknown", ) cash_flow_spec = CashFlowSpec( cash_flow=CashFlow(amount=0.0, period_index=0), distribution=spec, ) config = SimulationConfig(iterations=1, discount_rate=0.0) with pytest.raises(DistributionConfigError): run_monte_carlo([cash_flow_spec], config, scenario_context={}) def test_run_monte_carlo_can_return_samples() -> None: base_flow = CashFlow(amount=50.0, period_index=1) specs = [ CashFlowSpec(cash_flow=CashFlow(amount=-40.0, period_index=0)), CashFlowSpec(cash_flow=base_flow), ] config = SimulationConfig( iterations=3, discount_rate=0.0, metrics=(SimulationMetric.NPV,), return_samples=True, seed=11, ) result = run_monte_carlo(specs, config) assert result.samples is not None assert SimulationMetric.NPV in result.samples samples = result.samples[SimulationMetric.NPV] assert isinstance(samples, np.ndarray) assert samples.shape == (config.iterations,)