feat: Enhance currency handling and validation across scenarios
- Updated form template to prefill currency input with default value and added help text for clarity. - Modified integration tests to assert more descriptive error messages for invalid currency codes. - Introduced new tests for currency normalization and validation in various scenarios, including imports and exports. - Added comprehensive tests for pricing calculations, ensuring defaults are respected and overrides function correctly. - Implemented unit tests for pricing settings repository, ensuring CRUD operations and default settings are handled properly. - Enhanced scenario pricing evaluation tests to validate currency handling and metadata defaults. - Added simulation tests to ensure Monte Carlo runs are accurate and handle various distribution scenarios.
This commit is contained in:
@@ -78,10 +78,8 @@ class TestScenarioLifecycle:
|
||||
json={"currency": "ca"},
|
||||
)
|
||||
assert invalid_update.status_code == 422
|
||||
assert (
|
||||
invalid_update.json()["detail"][0]["msg"]
|
||||
== "Value error, Currency code must be a 3-letter ISO value"
|
||||
)
|
||||
assert "Invalid currency code" in invalid_update.json()[
|
||||
"detail"][0]["msg"]
|
||||
|
||||
# Scenario detail should still show the previous (valid) currency
|
||||
scenario_detail = client.get(f"/scenarios/{scenario_id}/view")
|
||||
|
||||
@@ -10,7 +10,15 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from config.database import Base
|
||||
from config.settings import AdminBootstrapSettings
|
||||
from services.bootstrap import AdminBootstrapResult, RoleBootstrapResult, bootstrap_admin
|
||||
from models import MiningOperationType, Project
|
||||
from services.bootstrap import (
|
||||
AdminBootstrapResult,
|
||||
PricingBootstrapResult,
|
||||
RoleBootstrapResult,
|
||||
bootstrap_admin,
|
||||
bootstrap_pricing_settings,
|
||||
)
|
||||
from services.pricing import PricingMetadata
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
|
||||
@@ -114,3 +122,86 @@ def test_bootstrap_respects_force_reset(unit_of_work_factory: Callable[[], UnitO
|
||||
user = users_repo.get_by_email(rotated_settings.email)
|
||||
assert user is not None
|
||||
assert user.verify_password("rotated")
|
||||
|
||||
|
||||
def test_bootstrap_pricing_creates_defaults(unit_of_work_factory: Callable[[], UnitOfWork]) -> None:
|
||||
metadata = PricingMetadata(
|
||||
default_payable_pct=95.0,
|
||||
default_currency="CAD",
|
||||
moisture_threshold_pct=3.0,
|
||||
moisture_penalty_per_pct=1.25,
|
||||
)
|
||||
|
||||
result = bootstrap_pricing_settings(
|
||||
metadata=metadata,
|
||||
unit_of_work_factory=unit_of_work_factory,
|
||||
)
|
||||
|
||||
assert isinstance(result, PricingBootstrapResult)
|
||||
assert result.seed.created is True
|
||||
assert result.projects_assigned == 0
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
settings_repo = uow.pricing_settings
|
||||
assert settings_repo is not None
|
||||
stored = settings_repo.get_by_slug("default")
|
||||
assert stored.default_currency == "CAD"
|
||||
assert float(stored.default_payable_pct) == pytest.approx(95.0)
|
||||
assert float(stored.moisture_threshold_pct) == pytest.approx(3.0)
|
||||
assert float(stored.moisture_penalty_per_pct) == pytest.approx(1.25)
|
||||
|
||||
|
||||
def test_bootstrap_pricing_assigns_projects(unit_of_work_factory: Callable[[], UnitOfWork]) -> None:
|
||||
metadata = PricingMetadata(
|
||||
default_payable_pct=90.0,
|
||||
default_currency="USD",
|
||||
moisture_threshold_pct=5.0,
|
||||
moisture_penalty_per_pct=0.5,
|
||||
)
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
projects_repo = uow.projects
|
||||
assert projects_repo is not None
|
||||
project = Project(
|
||||
name="Project Alpha",
|
||||
operation_type=MiningOperationType.OPEN_PIT,
|
||||
)
|
||||
created = projects_repo.create(project)
|
||||
project_id = created.id
|
||||
|
||||
result = bootstrap_pricing_settings(
|
||||
metadata=metadata,
|
||||
unit_of_work_factory=unit_of_work_factory,
|
||||
)
|
||||
|
||||
assert result.projects_assigned == 1
|
||||
assert result.seed.created is True
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
projects_repo = uow.projects
|
||||
assert projects_repo is not None
|
||||
stored = projects_repo.get(project_id, with_pricing=True)
|
||||
assert stored.pricing_settings is not None
|
||||
assert stored.pricing_settings.default_currency == "USD"
|
||||
|
||||
|
||||
def test_bootstrap_pricing_is_idempotent(unit_of_work_factory: Callable[[], UnitOfWork]) -> None:
|
||||
metadata = PricingMetadata(
|
||||
default_payable_pct=92.5,
|
||||
default_currency="EUR",
|
||||
moisture_threshold_pct=4.5,
|
||||
moisture_penalty_per_pct=0.75,
|
||||
)
|
||||
|
||||
first = bootstrap_pricing_settings(
|
||||
metadata=metadata,
|
||||
unit_of_work_factory=unit_of_work_factory,
|
||||
)
|
||||
second = bootstrap_pricing_settings(
|
||||
metadata=metadata,
|
||||
unit_of_work_factory=unit_of_work_factory,
|
||||
)
|
||||
|
||||
assert first.seed.created is True
|
||||
assert second.seed.created is False
|
||||
assert second.projects_assigned == 0
|
||||
|
||||
42
tests/test_currency.py
Normal file
42
tests/test_currency.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from services.currency import CurrencyValidationError, normalise_currency, require_currency
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
("usd", "USD"),
|
||||
(" Eur ", "EUR"),
|
||||
("JPY", "JPY"),
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
def test_normalise_currency_valid_inputs(raw: str | None, expected: str | None) -> None:
|
||||
assert normalise_currency(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["usd1", "us", "", "12", "X Y Z"])
|
||||
def test_normalise_currency_invalid_inputs(raw: str) -> None:
|
||||
with pytest.raises(CurrencyValidationError):
|
||||
normalise_currency(raw)
|
||||
|
||||
|
||||
def test_require_currency_with_value() -> None:
|
||||
assert require_currency("gbp", default="usd") == "GBP"
|
||||
|
||||
|
||||
def test_require_currency_with_default() -> None:
|
||||
assert require_currency(None, default="cad") == "CAD"
|
||||
|
||||
|
||||
def test_require_currency_missing_default() -> None:
|
||||
with pytest.raises(CurrencyValidationError):
|
||||
require_currency(None)
|
||||
|
||||
|
||||
def test_require_currency_invalid_default() -> None:
|
||||
with pytest.raises(CurrencyValidationError):
|
||||
require_currency(None, default="invalid")
|
||||
@@ -128,3 +128,17 @@ def test_scenario_export_excel(client: TestClient, unit_of_work_factory) -> None
|
||||
|
||||
with ZipFile(BytesIO(response.content)) as archive:
|
||||
assert "xl/workbook.xml" in archive.namelist()
|
||||
|
||||
|
||||
def test_scenario_export_rejects_invalid_currency_filter(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/exports/scenarios",
|
||||
json={
|
||||
"format": "csv",
|
||||
"filters": {"currencies": ["USD", "XX"]},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
detail = response.json()["detail"]
|
||||
assert "Invalid currency code" in detail
|
||||
|
||||
162
tests/test_financial.py
Normal file
162
tests/test_financial.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from services.financial import (
|
||||
CashFlow,
|
||||
PaybackNotReachedError,
|
||||
internal_rate_of_return,
|
||||
net_present_value,
|
||||
normalize_cash_flows,
|
||||
payback_period,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_cash_flows_with_dates() -> None:
|
||||
base = date(2025, 1, 1)
|
||||
period_length = 365.0 / 4
|
||||
flows = [
|
||||
CashFlow(amount=-1_000_000, date=base),
|
||||
CashFlow(amount=350_000, date=date(2025, 4, 1)),
|
||||
CashFlow(amount=420_000, date=date(2025, 7, 1)),
|
||||
]
|
||||
|
||||
normalised = normalize_cash_flows(flows, compounds_per_year=4)
|
||||
|
||||
assert normalised[0] == (-1_000_000.0, 0.0)
|
||||
expected_second = (date(2025, 4, 1) - base).days / period_length
|
||||
expected_third = (date(2025, 7, 1) - base).days / period_length
|
||||
|
||||
assert normalised[1][1] == pytest.approx(expected_second, rel=1e-6)
|
||||
assert normalised[2][1] == pytest.approx(expected_third, rel=1e-6)
|
||||
|
||||
|
||||
def test_net_present_value_with_period_indices() -> None:
|
||||
rate = 0.10
|
||||
flows = [
|
||||
CashFlow(amount=-1_000, period_index=0),
|
||||
CashFlow(amount=500, period_index=1),
|
||||
CashFlow(amount=500, period_index=2),
|
||||
CashFlow(amount=500, period_index=3),
|
||||
]
|
||||
|
||||
expected = -1_000 + sum(500 / (1 + rate) **
|
||||
period for period in range(1, 4))
|
||||
|
||||
result = net_present_value(rate, flows)
|
||||
|
||||
assert result == pytest.approx(expected, rel=1e-9)
|
||||
|
||||
|
||||
def test_net_present_value_with_residual_value() -> None:
|
||||
rate = 0.08
|
||||
flows = [
|
||||
CashFlow(amount=-100_000, period_index=0),
|
||||
CashFlow(amount=30_000, period_index=1),
|
||||
CashFlow(amount=35_000, period_index=2),
|
||||
]
|
||||
|
||||
expected = (
|
||||
-100_000
|
||||
+ 30_000 / (1 + rate)
|
||||
+ 35_000 / (1 + rate) ** 2
|
||||
+ 25_000 / (1 + rate) ** 3
|
||||
)
|
||||
|
||||
result = net_present_value(rate, flows, residual_value=25_000)
|
||||
|
||||
assert result == pytest.approx(expected, rel=1e-9)
|
||||
|
||||
|
||||
def test_internal_rate_of_return_simple_case() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=-1_000, period_index=0),
|
||||
CashFlow(amount=1_210, period_index=1),
|
||||
]
|
||||
|
||||
irr = internal_rate_of_return(flows, guess=0.05)
|
||||
|
||||
assert irr == pytest.approx(0.21, rel=1e-9)
|
||||
|
||||
|
||||
def test_internal_rate_of_return_multiple_sign_changes() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=-500_000, period_index=0),
|
||||
CashFlow(amount=250_000, period_index=1),
|
||||
CashFlow(amount=-100_000, period_index=2),
|
||||
CashFlow(amount=425_000, period_index=3),
|
||||
]
|
||||
|
||||
irr = internal_rate_of_return(flows, guess=0.2)
|
||||
|
||||
npv = net_present_value(irr, flows)
|
||||
|
||||
assert npv == pytest.approx(0.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_internal_rate_of_return_requires_mixed_signs() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=100_000, period_index=0),
|
||||
CashFlow(amount=150_000, period_index=1),
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
internal_rate_of_return(flows)
|
||||
|
||||
|
||||
def test_payback_period_exact_period() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=-120_000, period_index=0),
|
||||
CashFlow(amount=40_000, period_index=1),
|
||||
CashFlow(amount=40_000, period_index=2),
|
||||
CashFlow(amount=40_000, period_index=3),
|
||||
]
|
||||
|
||||
period = payback_period(flows, allow_fractional=False)
|
||||
|
||||
assert period == pytest.approx(3.0)
|
||||
|
||||
|
||||
def test_payback_period_fractional_period() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=-100_000, period_index=0),
|
||||
CashFlow(amount=80_000, period_index=1),
|
||||
CashFlow(amount=30_000, period_index=2),
|
||||
]
|
||||
|
||||
fractional = payback_period(flows)
|
||||
whole = payback_period(flows, allow_fractional=False)
|
||||
|
||||
assert fractional == pytest.approx(1 + 20_000 / 30_000, rel=1e-9)
|
||||
assert whole == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_payback_period_raises_when_never_recovered() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=-250_000, period_index=0),
|
||||
CashFlow(amount=50_000, period_index=1),
|
||||
CashFlow(amount=60_000, period_index=2),
|
||||
CashFlow(amount=70_000, period_index=3),
|
||||
]
|
||||
|
||||
with pytest.raises(PaybackNotReachedError):
|
||||
payback_period(flows)
|
||||
|
||||
|
||||
def test_payback_period_with_quarterly_compounding() -> None:
|
||||
base = date(2025, 1, 1)
|
||||
flows = [
|
||||
CashFlow(amount=-120_000, date=base),
|
||||
CashFlow(amount=35_000, date=date(2025, 4, 1)),
|
||||
CashFlow(amount=35_000, date=date(2025, 7, 1)),
|
||||
CashFlow(amount=50_000, date=date(2025, 10, 1)),
|
||||
]
|
||||
|
||||
period = payback_period(flows, compounds_per_year=4)
|
||||
|
||||
period_length = 365.0 / 4
|
||||
expected_period = (date(2025, 10, 1) - base).days / period_length
|
||||
|
||||
assert period == pytest.approx(expected_period, abs=1e-6)
|
||||
@@ -68,3 +68,35 @@ def test_scenario_import_commit_invalid_token_returns_404(
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Unknown scenario import token" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_scenario_import_preview_rejects_invalid_currency(
|
||||
client: TestClient,
|
||||
unit_of_work_factory,
|
||||
) -> None:
|
||||
with unit_of_work_factory() as uow:
|
||||
assert uow.projects is not None
|
||||
project = Project(
|
||||
name="Import Currency Project",
|
||||
operation_type=MiningOperationType.OPEN_PIT,
|
||||
)
|
||||
uow.projects.create(project)
|
||||
|
||||
csv_content = (
|
||||
"project_name,name,currency\n"
|
||||
"Import Currency Project,Invalid Currency,US\n"
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/imports/scenarios/preview",
|
||||
files={"file": ("scenarios.csv", csv_content, "text/csv")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["summary"]["accepted"] == 0
|
||||
assert payload["summary"]["errored"] == 1
|
||||
assert payload["parser_errors"]
|
||||
parser_error = payload["parser_errors"][0]
|
||||
assert parser_error["field"] == "currency"
|
||||
assert "Invalid currency code" in parser_error["message"]
|
||||
|
||||
@@ -140,3 +140,22 @@ def test_scenario_import_handles_large_dataset() -> None:
|
||||
|
||||
assert len(result.rows) == 500
|
||||
assert len(result.rows) == 500
|
||||
|
||||
|
||||
def test_scenario_import_rejects_invalid_currency() -> None:
|
||||
csv_content = dedent(
|
||||
"""
|
||||
project_name,name,currency
|
||||
Project A,Scenario Invalid,US
|
||||
"""
|
||||
).strip()
|
||||
stream = BytesIO(csv_content.encode("utf-8"))
|
||||
|
||||
result = load_scenario_imports(stream, "scenarios.csv")
|
||||
|
||||
assert not result.rows
|
||||
assert result.errors
|
||||
error = result.errors[0]
|
||||
assert error.row_number == 2
|
||||
assert error.field == "currency"
|
||||
assert "Invalid currency code" in error.message
|
||||
|
||||
155
tests/test_pricing.py
Normal file
155
tests/test_pricing.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from services.pricing import (
|
||||
PricingInput,
|
||||
PricingMetadata,
|
||||
PricingResult,
|
||||
calculate_pricing,
|
||||
)
|
||||
|
||||
|
||||
def test_calculate_pricing_with_explicit_penalties() -> None:
|
||||
pricing_input = PricingInput(
|
||||
metal="copper",
|
||||
ore_tonnage=100_000,
|
||||
head_grade_pct=1.2,
|
||||
recovery_pct=90,
|
||||
payable_pct=96,
|
||||
reference_price=8_500,
|
||||
treatment_charge=100_000,
|
||||
smelting_charge=0,
|
||||
moisture_pct=10,
|
||||
moisture_threshold_pct=8,
|
||||
moisture_penalty_per_pct=3_000,
|
||||
impurity_ppm={"As": 100},
|
||||
impurity_thresholds={"As": 0},
|
||||
impurity_penalty_per_ppm={"As": 2},
|
||||
premiums=50_000,
|
||||
fx_rate=1.0,
|
||||
currency_code="usd",
|
||||
)
|
||||
|
||||
result = calculate_pricing(pricing_input)
|
||||
|
||||
assert isinstance(result, PricingResult)
|
||||
assert math.isclose(result.payable_metal_tonnes, 1_036.8, rel_tol=1e-6)
|
||||
assert math.isclose(result.gross_revenue, 1_036.8 * 8_500, rel_tol=1e-6)
|
||||
assert math.isclose(result.moisture_penalty, 6_000, rel_tol=1e-6)
|
||||
assert math.isclose(result.impurity_penalty, 200, rel_tol=1e-6)
|
||||
assert math.isclose(result.net_revenue, 8_756_600, rel_tol=1e-6)
|
||||
assert result.treatment_smelt_charges == pytest.approx(100_000)
|
||||
assert result.currency == "USD"
|
||||
|
||||
|
||||
def test_calculate_pricing_with_metadata_defaults() -> None:
|
||||
metadata = PricingMetadata(
|
||||
default_payable_pct=95,
|
||||
default_currency="EUR",
|
||||
moisture_threshold_pct=7,
|
||||
moisture_penalty_per_pct=2_000,
|
||||
impurity_thresholds={"Pb": 50},
|
||||
impurity_penalty_per_ppm={"Pb": 1.5},
|
||||
)
|
||||
|
||||
pricing_input = PricingInput(
|
||||
metal="lead",
|
||||
ore_tonnage=50_000,
|
||||
head_grade_pct=5,
|
||||
recovery_pct=85,
|
||||
payable_pct=None,
|
||||
reference_price=2_000,
|
||||
treatment_charge=30_000,
|
||||
smelting_charge=20_000,
|
||||
moisture_pct=9,
|
||||
moisture_threshold_pct=None,
|
||||
moisture_penalty_per_pct=None,
|
||||
impurity_ppm={"Pb": 120},
|
||||
premiums=12_000,
|
||||
fx_rate=1.2,
|
||||
currency_code=None,
|
||||
)
|
||||
|
||||
result = calculate_pricing(pricing_input, metadata=metadata)
|
||||
|
||||
expected_payable = 50_000 * 0.05 * 0.85 * 0.95
|
||||
assert math.isclose(result.payable_metal_tonnes,
|
||||
expected_payable, rel_tol=1e-6)
|
||||
assert result.moisture_penalty == pytest.approx((9 - 7) * 2_000)
|
||||
assert result.impurity_penalty == pytest.approx((120 - 50) * 1.5)
|
||||
assert result.treatment_smelt_charges == pytest.approx(50_000)
|
||||
assert result.currency == "EUR"
|
||||
assert result.net_revenue > 0
|
||||
|
||||
|
||||
def test_calculate_pricing_currency_override() -> None:
|
||||
pricing_input = PricingInput(
|
||||
metal="gold",
|
||||
ore_tonnage=10_000,
|
||||
head_grade_pct=2.5,
|
||||
recovery_pct=92,
|
||||
payable_pct=98,
|
||||
reference_price=60_000,
|
||||
treatment_charge=40_000,
|
||||
smelting_charge=10_000,
|
||||
moisture_pct=5,
|
||||
moisture_threshold_pct=7,
|
||||
moisture_penalty_per_pct=1_000,
|
||||
premiums=25_000,
|
||||
fx_rate=1.0,
|
||||
currency_code="cad",
|
||||
)
|
||||
|
||||
metadata = PricingMetadata(default_currency="USD")
|
||||
|
||||
result = calculate_pricing(
|
||||
pricing_input, metadata=metadata, currency="CAD")
|
||||
|
||||
assert result.currency == "CAD"
|
||||
assert result.net_revenue > 0
|
||||
|
||||
|
||||
def test_calculate_pricing_multiple_inputs_aggregate() -> None:
|
||||
metadata = PricingMetadata(default_currency="USD")
|
||||
inputs = [
|
||||
PricingInput(
|
||||
metal="copper",
|
||||
ore_tonnage=10_000,
|
||||
head_grade_pct=1.5,
|
||||
recovery_pct=88,
|
||||
payable_pct=95,
|
||||
reference_price=8_000,
|
||||
treatment_charge=20_000,
|
||||
smelting_charge=5_000,
|
||||
moisture_pct=7,
|
||||
moisture_threshold_pct=8,
|
||||
moisture_penalty_per_pct=1_000,
|
||||
premiums=0,
|
||||
fx_rate=1.0,
|
||||
currency_code=None,
|
||||
),
|
||||
PricingInput(
|
||||
metal="copper",
|
||||
ore_tonnage=8_000,
|
||||
head_grade_pct=1.1,
|
||||
recovery_pct=90,
|
||||
payable_pct=96,
|
||||
reference_price=8_000,
|
||||
treatment_charge=18_000,
|
||||
smelting_charge=4_000,
|
||||
moisture_pct=9,
|
||||
moisture_threshold_pct=8,
|
||||
moisture_penalty_per_pct=1_000,
|
||||
premiums=0,
|
||||
fx_rate=1.0,
|
||||
currency_code="usd",
|
||||
),
|
||||
]
|
||||
|
||||
results = [calculate_pricing(i, metadata=metadata) for i in inputs]
|
||||
|
||||
assert all(result.currency == "USD" for result in results)
|
||||
assert sum(result.net_revenue for result in results) > 0
|
||||
209
tests/test_pricing_settings_repository.py
Normal file
209
tests/test_pricing_settings_repository.py
Normal file
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from config.database import Base
|
||||
from models import PricingImpuritySettings, PricingMetalSettings, PricingSettings
|
||||
from services.pricing import PricingMetadata
|
||||
from services.repositories import (
|
||||
PricingSettingsRepository,
|
||||
ensure_default_pricing_settings,
|
||||
)
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine() -> Iterator:
|
||||
engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session(engine) -> Iterator[Session]:
|
||||
TestingSession = sessionmaker(
|
||||
bind=engine, expire_on_commit=False, future=True)
|
||||
db = TestingSession()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_pricing_settings_repository_crud(session: Session) -> None:
|
||||
repo = PricingSettingsRepository(session)
|
||||
|
||||
settings = PricingSettings(
|
||||
name="Contract A",
|
||||
slug="Contract-A",
|
||||
default_currency="usd",
|
||||
default_payable_pct=95.0,
|
||||
moisture_threshold_pct=7.5,
|
||||
moisture_penalty_per_pct=1500.0,
|
||||
)
|
||||
repo.create(settings)
|
||||
|
||||
metal_override = PricingMetalSettings(
|
||||
metal_code="Copper",
|
||||
payable_pct=96.0,
|
||||
moisture_threshold_pct=None,
|
||||
moisture_penalty_per_pct=None,
|
||||
)
|
||||
repo.attach_metal_override(settings, metal_override)
|
||||
|
||||
impurity_override = PricingImpuritySettings(
|
||||
impurity_code="as",
|
||||
threshold_ppm=100.0,
|
||||
penalty_per_ppm=3.5,
|
||||
)
|
||||
repo.attach_impurity_override(settings, impurity_override)
|
||||
|
||||
retrieved = repo.get_by_slug("CONTRACT-A", include_children=True)
|
||||
assert retrieved.slug == "contract-a"
|
||||
assert retrieved.default_currency == "USD"
|
||||
assert len(retrieved.metal_overrides) == 1
|
||||
assert retrieved.metal_overrides[0].metal_code == "copper"
|
||||
assert len(retrieved.impurity_overrides) == 1
|
||||
assert retrieved.impurity_overrides[0].impurity_code == "AS"
|
||||
|
||||
listed = repo.list(include_children=True)
|
||||
assert len(listed) == 1
|
||||
assert listed[0].id == settings.id
|
||||
|
||||
|
||||
def test_ensure_default_pricing_settings_creates_and_updates(session: Session) -> None:
|
||||
repo = PricingSettingsRepository(session)
|
||||
|
||||
metadata_initial = PricingMetadata(
|
||||
default_payable_pct=100.0,
|
||||
default_currency="USD",
|
||||
moisture_threshold_pct=8.0,
|
||||
moisture_penalty_per_pct=0.0,
|
||||
impurity_thresholds={"As": 50.0},
|
||||
impurity_penalty_per_ppm={"As": 2.0},
|
||||
)
|
||||
|
||||
result_create = ensure_default_pricing_settings(
|
||||
repo,
|
||||
metadata=metadata_initial,
|
||||
name="Seeded Pricing",
|
||||
description="Seeded from defaults",
|
||||
)
|
||||
|
||||
assert result_create.created is True
|
||||
assert result_create.settings.slug == "default"
|
||||
assert result_create.settings.default_currency == "USD"
|
||||
assert len(result_create.settings.impurity_overrides) == 1
|
||||
assert result_create.settings.impurity_overrides[0].penalty_per_ppm == 2.0
|
||||
|
||||
metadata_update = PricingMetadata(
|
||||
default_payable_pct=97.0,
|
||||
default_currency="EUR",
|
||||
moisture_threshold_pct=6.5,
|
||||
moisture_penalty_per_pct=250.0,
|
||||
impurity_thresholds={"As": 45.0, "Pb": 12.0},
|
||||
impurity_penalty_per_ppm={"As": 3.0, "Pb": 1.25},
|
||||
)
|
||||
|
||||
result_update = ensure_default_pricing_settings(
|
||||
repo,
|
||||
metadata=metadata_update,
|
||||
name="Seeded Pricing",
|
||||
description="Seeded from defaults",
|
||||
)
|
||||
|
||||
assert result_update.created is False
|
||||
assert result_update.updated_fields > 0
|
||||
assert result_update.impurity_upserts >= 1
|
||||
|
||||
updated = repo.get_by_slug("default", include_children=True)
|
||||
assert updated.default_currency == "EUR"
|
||||
as_override = {
|
||||
item.impurity_code: item for item in updated.impurity_overrides}["AS"]
|
||||
assert float(as_override.threshold_ppm) == 45.0
|
||||
assert float(as_override.penalty_per_ppm) == 3.0
|
||||
pb_override = {
|
||||
item.impurity_code: item for item in updated.impurity_overrides}["PB"]
|
||||
assert float(pb_override.threshold_ppm) == 12.0
|
||||
|
||||
|
||||
def test_unit_of_work_exposes_pricing_settings(engine) -> None:
|
||||
TestingSession = sessionmaker(
|
||||
bind=engine, expire_on_commit=False, future=True)
|
||||
metadata = PricingMetadata(
|
||||
default_payable_pct=99.0,
|
||||
default_currency="USD",
|
||||
moisture_threshold_pct=7.0,
|
||||
moisture_penalty_per_pct=125.0,
|
||||
impurity_thresholds={"Zn": 80.0},
|
||||
impurity_penalty_per_ppm={"Zn": 0.5},
|
||||
)
|
||||
|
||||
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||
assert uow.pricing_settings is not None
|
||||
result = uow.ensure_default_pricing_settings(
|
||||
metadata=metadata,
|
||||
slug="contract-core",
|
||||
name="Contract Core",
|
||||
)
|
||||
assert result.settings.slug == "contract-core"
|
||||
assert result.created is True
|
||||
|
||||
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||
assert uow.pricing_settings is not None
|
||||
stored = uow.pricing_settings.get_by_slug(
|
||||
"contract-core", include_children=True)
|
||||
assert stored.default_payable_pct == 99.0
|
||||
assert stored.impurity_overrides[0].impurity_code == "ZN"
|
||||
|
||||
|
||||
def test_unit_of_work_get_pricing_metadata_returns_defaults(engine) -> None:
|
||||
TestingSession = sessionmaker(
|
||||
bind=engine, expire_on_commit=False, future=True)
|
||||
seeded_metadata = PricingMetadata(
|
||||
default_payable_pct=96.5,
|
||||
default_currency="aud",
|
||||
moisture_threshold_pct=6.25,
|
||||
moisture_penalty_per_pct=210.0,
|
||||
impurity_thresholds={"As": 45.0, "Pb": 15.0},
|
||||
impurity_penalty_per_ppm={"As": 1.75, "Pb": 0.9},
|
||||
)
|
||||
|
||||
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||
result = uow.ensure_default_pricing_settings(
|
||||
metadata=seeded_metadata,
|
||||
slug="default",
|
||||
name="Default Contract",
|
||||
description="Primary contract defaults",
|
||||
)
|
||||
assert result.created is True
|
||||
|
||||
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||
retrieved = uow.get_pricing_metadata()
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.default_currency == "AUD"
|
||||
assert retrieved.default_payable_pct == 96.5
|
||||
assert retrieved.moisture_threshold_pct == 6.25
|
||||
assert retrieved.moisture_penalty_per_pct == 210.0
|
||||
assert retrieved.impurity_thresholds["AS"] == 45.0
|
||||
assert retrieved.impurity_thresholds["PB"] == 15.0
|
||||
assert retrieved.impurity_penalty_per_ppm["AS"] == 1.75
|
||||
assert retrieved.impurity_penalty_per_ppm["PB"] == 0.9
|
||||
|
||||
|
||||
def test_unit_of_work_get_pricing_metadata_returns_none_when_missing(engine) -> None:
|
||||
TestingSession = sessionmaker(
|
||||
bind=engine, expire_on_commit=False, future=True)
|
||||
|
||||
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||
missing = uow.get_pricing_metadata(slug="non-existent")
|
||||
|
||||
assert missing is None
|
||||
@@ -13,6 +13,7 @@ from models import (
|
||||
FinancialCategory,
|
||||
FinancialInput,
|
||||
MiningOperationType,
|
||||
PricingSettings,
|
||||
Project,
|
||||
Scenario,
|
||||
ScenarioStatus,
|
||||
@@ -147,6 +148,30 @@ def test_unit_of_work_commit_and_rollback(engine) -> None:
|
||||
projects = ProjectRepository(session).list()
|
||||
assert len(projects) == 1
|
||||
|
||||
|
||||
def test_unit_of_work_set_project_pricing_settings(engine) -> None:
|
||||
TestingSession = sessionmaker(bind=engine, expire_on_commit=False, future=True)
|
||||
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||
assert uow.projects is not None and uow.pricing_settings is not None
|
||||
project = Project(name="Project Pricing", operation_type=MiningOperationType.OTHER)
|
||||
uow.projects.create(project)
|
||||
pricing_settings = PricingSettings(
|
||||
name="Default Pricing",
|
||||
slug="default",
|
||||
default_currency="usd",
|
||||
default_payable_pct=100.0,
|
||||
moisture_threshold_pct=8.0,
|
||||
moisture_penalty_per_pct=0.0,
|
||||
)
|
||||
uow.pricing_settings.create(pricing_settings)
|
||||
uow.set_project_pricing_settings(project, pricing_settings)
|
||||
|
||||
with TestingSession() as session:
|
||||
repo = ProjectRepository(session)
|
||||
stored = repo.get(1, with_pricing=True)
|
||||
assert stored.pricing_settings is not None
|
||||
assert stored.pricing_settings.slug == "default"
|
||||
|
||||
# Rollback path
|
||||
with pytest.raises(RuntimeError):
|
||||
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||
@@ -302,6 +327,44 @@ def test_project_repository_filtered_for_export(session: Session) -> None:
|
||||
assert results[0].scenarios[0].name == "Alpha Scenario"
|
||||
|
||||
|
||||
def test_project_repository_with_pricing_settings(session: Session) -> None:
|
||||
repo = ProjectRepository(session)
|
||||
settings = PricingSettings(
|
||||
name="Contract Core",
|
||||
slug="contract-core",
|
||||
default_currency="usd",
|
||||
default_payable_pct=95.0,
|
||||
moisture_threshold_pct=7.5,
|
||||
moisture_penalty_per_pct=100.0,
|
||||
)
|
||||
project = Project(
|
||||
name="Project Pricing",
|
||||
operation_type=MiningOperationType.OPEN_PIT,
|
||||
pricing_settings=settings,
|
||||
)
|
||||
session.add(project)
|
||||
session.flush()
|
||||
|
||||
fetched = repo.get(project.id, with_pricing=True)
|
||||
assert fetched.pricing_settings is not None
|
||||
assert fetched.pricing_settings.slug == "contract-core"
|
||||
assert fetched.pricing_settings.default_currency == "USD"
|
||||
|
||||
listed = repo.list(with_pricing=True)
|
||||
assert listed[0].pricing_settings is not None
|
||||
|
||||
repo.set_pricing_settings(project, None)
|
||||
session.refresh(project)
|
||||
assert project.pricing_settings is None
|
||||
|
||||
repo.set_pricing_settings(project, settings)
|
||||
session.refresh(project)
|
||||
assert project.pricing_settings is settings
|
||||
|
||||
export_results = repo.filtered_for_export(None, include_pricing=True)
|
||||
assert export_results[0].pricing_settings is not None
|
||||
|
||||
|
||||
def test_scenario_repository_filtered_for_export(session: Session) -> None:
|
||||
repo = ScenarioRepository(session)
|
||||
|
||||
|
||||
120
tests/test_scenario_pricing.py
Normal file
120
tests/test_scenario_pricing.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from models import MiningOperationType, Project, Scenario, ScenarioStatus
|
||||
from services.pricing import PricingInput, PricingMetadata
|
||||
from services.scenario_evaluation import ScenarioPricingConfig, ScenarioPricingEvaluator
|
||||
|
||||
|
||||
def build_scenario() -> Scenario:
|
||||
project = Project(name="Test Project",
|
||||
operation_type=MiningOperationType.OPEN_PIT)
|
||||
scenario = Scenario(
|
||||
project=project,
|
||||
project_id=1,
|
||||
name="Scenario A",
|
||||
status=ScenarioStatus.ACTIVE,
|
||||
currency="USD",
|
||||
)
|
||||
scenario.id = 1 # simulate persisted entity
|
||||
return scenario
|
||||
|
||||
|
||||
def test_scenario_pricing_evaluator_uses_metadata_defaults() -> None:
|
||||
scenario = build_scenario()
|
||||
evaluator = ScenarioPricingEvaluator(
|
||||
ScenarioPricingConfig(
|
||||
metadata=PricingMetadata(
|
||||
default_currency="USD", default_payable_pct=95)
|
||||
)
|
||||
)
|
||||
inputs = [
|
||||
PricingInput(
|
||||
metal="copper",
|
||||
ore_tonnage=50_000,
|
||||
head_grade_pct=1.0,
|
||||
recovery_pct=90,
|
||||
payable_pct=None,
|
||||
reference_price=9_000,
|
||||
treatment_charge=50_000,
|
||||
smelting_charge=10_000,
|
||||
moisture_pct=9,
|
||||
moisture_threshold_pct=None,
|
||||
moisture_penalty_per_pct=None,
|
||||
impurity_ppm={"As": 120},
|
||||
premiums=10_000,
|
||||
fx_rate=1.0,
|
||||
currency_code=None,
|
||||
)
|
||||
]
|
||||
|
||||
snapshot = evaluator.evaluate(scenario, inputs=inputs)
|
||||
|
||||
assert snapshot.scenario_id == scenario.id
|
||||
assert len(snapshot.results) == 1
|
||||
result = snapshot.results[0]
|
||||
assert result.currency == "USD"
|
||||
assert result.net_revenue > 0
|
||||
|
||||
|
||||
def test_scenario_pricing_evaluator_override_metadata() -> None:
|
||||
scenario = build_scenario()
|
||||
evaluator = ScenarioPricingEvaluator(ScenarioPricingConfig())
|
||||
metadata_override = PricingMetadata(
|
||||
default_currency="CAD",
|
||||
default_payable_pct=90,
|
||||
moisture_threshold_pct=5,
|
||||
moisture_penalty_per_pct=500,
|
||||
)
|
||||
|
||||
inputs = [
|
||||
PricingInput(
|
||||
metal="copper",
|
||||
ore_tonnage=20_000,
|
||||
head_grade_pct=1.2,
|
||||
recovery_pct=88,
|
||||
payable_pct=None,
|
||||
reference_price=8_200,
|
||||
treatment_charge=15_000,
|
||||
smelting_charge=6_000,
|
||||
moisture_pct=6,
|
||||
moisture_threshold_pct=None,
|
||||
moisture_penalty_per_pct=None,
|
||||
premiums=5_000,
|
||||
fx_rate=1.0,
|
||||
currency_code="cad",
|
||||
),
|
||||
PricingInput(
|
||||
metal="gold",
|
||||
ore_tonnage=5_000,
|
||||
head_grade_pct=2.0,
|
||||
recovery_pct=90,
|
||||
payable_pct=None,
|
||||
reference_price=60_000,
|
||||
treatment_charge=10_000,
|
||||
smelting_charge=5_000,
|
||||
moisture_pct=4,
|
||||
moisture_threshold_pct=None,
|
||||
moisture_penalty_per_pct=None,
|
||||
premiums=15_000,
|
||||
fx_rate=1.0,
|
||||
currency_code="cad",
|
||||
),
|
||||
]
|
||||
|
||||
snapshot = evaluator.evaluate(
|
||||
scenario,
|
||||
inputs=inputs,
|
||||
metadata_override=metadata_override,
|
||||
)
|
||||
|
||||
assert len(snapshot.results) == 2
|
||||
assert all(result.currency ==
|
||||
scenario.currency for result in snapshot.results)
|
||||
|
||||
copper_result = snapshot.results[0]
|
||||
expected_payable = 20_000 * 0.012 * 0.88 * 0.90
|
||||
assert copper_result.payable_metal_tonnes == pytest.approx(
|
||||
expected_payable)
|
||||
assert sum(result.net_revenue for result in snapshot.results) > 0
|
||||
@@ -278,3 +278,70 @@ class TestScenarioComparisonEndpoint:
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "SCENARIO_PROJECT_MISMATCH"
|
||||
assert project_a_id != project_b_id
|
||||
|
||||
|
||||
class TestScenarioApiCurrencyValidation:
|
||||
def test_create_api_rejects_invalid_currency(
|
||||
self,
|
||||
api_client: TestClient,
|
||||
session_factory: sessionmaker,
|
||||
) -> None:
|
||||
with UnitOfWork(session_factory=session_factory) as uow:
|
||||
assert uow.projects is not None
|
||||
assert uow.scenarios is not None
|
||||
project = Project(
|
||||
name="Currency Validation Project",
|
||||
operation_type=MiningOperationType.OPEN_PIT,
|
||||
)
|
||||
uow.projects.create(project)
|
||||
project_id = project.id
|
||||
|
||||
response = api_client.post(
|
||||
f"/projects/{project_id}/scenarios",
|
||||
json={
|
||||
"name": "Invalid Currency Scenario",
|
||||
"currency": "US",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
detail = response.json().get("detail", [])
|
||||
assert any(
|
||||
"Invalid currency code" in item.get("msg", "") for item in detail
|
||||
), detail
|
||||
|
||||
with UnitOfWork(session_factory=session_factory) as uow:
|
||||
assert uow.scenarios is not None
|
||||
scenarios = uow.scenarios.list_for_project(project_id)
|
||||
assert scenarios == []
|
||||
|
||||
def test_create_api_normalises_currency(
|
||||
self,
|
||||
api_client: TestClient,
|
||||
session_factory: sessionmaker,
|
||||
) -> None:
|
||||
with UnitOfWork(session_factory=session_factory) as uow:
|
||||
assert uow.projects is not None
|
||||
assert uow.scenarios is not None
|
||||
project = Project(
|
||||
name="Currency Normalisation Project",
|
||||
operation_type=MiningOperationType.OPEN_PIT,
|
||||
)
|
||||
uow.projects.create(project)
|
||||
project_id = project.id
|
||||
|
||||
response = api_client.post(
|
||||
f"/projects/{project_id}/scenarios",
|
||||
json={
|
||||
"name": "Normalised Currency Scenario",
|
||||
"currency": "cad",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
with UnitOfWork(session_factory=session_factory) as uow:
|
||||
assert uow.scenarios is not None
|
||||
scenarios = uow.scenarios.list_for_project(project_id)
|
||||
assert len(scenarios) == 1
|
||||
assert scenarios[0].currency == "CAD"
|
||||
|
||||
158
tests/test_simulation.py
Normal file
158
tests/test_simulation.py
Normal file
@@ -0,0 +1,158 @@
|
||||
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,)
|
||||
Reference in New Issue
Block a user