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:
2025-11-11 18:29:59 +01:00
parent 032e6d2681
commit 795a9f99f4
50 changed files with 5110 additions and 81 deletions

View File

@@ -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")

View File

@@ -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
View 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")

View File

@@ -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
View 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)

View File

@@ -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"]

View File

@@ -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
View 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

View 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

View File

@@ -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)

View 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

View File

@@ -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
View 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,)